1/// The screens, painted.2///3/// Every function here is pure: layout and state in, a list of scene ops out.4/// Nothing reaches for a canvas, a document or a clock. That is what lets the5/// same measurements be asserted by a test and drawn by the renderer, and it is6/// the reason the paced screens — a title that has to be touched, a sweep you7/// watch happen, a card naming the set and its optimal length, the board rising8/// into place — are descriptions of a moment rather than animations that own9/// one.10///11/// The numbers are the proof of concept's, transcribed. Where one looks12/// arbitrary it is usually load-bearing, and the comment beside it says why.13import gleam/float14import gleam/int15import gleam/list16import gleam/option17import gleam/result18import gleam/string19import klotski/domain20import klotski/families.{type Family}21import klotski/layout.{type BoardLayout, type Layout, type Rect, Rect, Split}22import klotski/progress.{type Level}23import klotski/scene.{24 type BlockStyle, type Op, type Screen, Block, BlockStyle, Center, Clip,25 DevLogo, Display, End, Fill, GoldLine, Label, Screen, Start, Text, Trophy,26 Unclip,27}28import klotski/shading.{type Pattern, type Ruling}29import klotski/store.{type Entry, type FamilyRecord, type Hints, type Totals}30import klotski/theme.{type Rgb, type Theme}31import klotski/typeface3233/// The name, set the way the address bar spells it.34///35/// It appears twice and in two registers — struck large on the title window,36/// and small beside the maker's mark where a Macintosh carried the name of the37/// application — so it is written once here rather than typed out at each. The38/// stop is part of the word, not punctuation between two of them.39pub const brand = "KLOT.SKI"4041/// A screen, resolved from a shape and a ruling.42pub fn screen_for(pattern: Pattern, ruling: Ruling) -> Screen {43 Screen(44 key: shading.pattern_id(pattern) <> "@" <> shading.ruling_id(ruling),45 diffuse: pattern == shading.Atkinson,46 table: shading.table(pattern),47 overprint: shading.overprint_table(pattern) |> result.unwrap([]),48 cell: shading.cell(ruling),49 )50}5152/// One block, ready to draw.53pub type Placed {54 Placed(55 x: Int,56 y: Int,57 w: Int,58 h: Int,59 class: Int,60 /// Where the block *rests*, which is what its ink is cut at.61 rest_x: Int,62 rest_y: Int,63 /// A doubled outline, for the piece under a thumb.64 thick: Bool,65 )66}6768/// One row of the atlas.69pub type Row {70 Row(71 family: Family,72 rung: Int,73 /// The configuration shown on the left: the rung about to be played, or the74 /// family's own packing if it has not been swept yet.75 grid: List(Int),76 surveyed: Bool,77 optimal: Int,78 grade: Int,79 ecc: Int,80 at_ceiling: Bool,81 depth: Int,82 /// The screen that rung will actually be played in, so a card previews the83 /// board *and* its look rather than the board alone.84 look: #(Pattern, Ruling),85 )86}8788fn block_style(89 class: Int,90 screen: Screen,91 theme: Theme,92 gap: Int,93 drop: Int,94 shadow: Bool,95 thick: Bool,96 device: Bool,97) -> BlockStyle {98 let ink = theme.piece_colour(theme, class)99 BlockStyle(100 gap:,101 drop:,102 tone: scene.tone_of(class),103 shadow:,104 thick:,105 device:,106 screen:,107 ink:,108 // A second plate only where the screen carries one: a comic's ink is split109 // between the piece colour and the same hue at full strength on a rotated110 // screen, and every other screen prints one ink.111 plate: case screen.overprint {112 [] -> Error(Nil)113 _ -> result.map(ink, theme.plate_two)114 },115 )116}117118/// Lay a whole configuration out as blocks. The mark goes on the 2x2 and119/// nowhere else — it is the one piece whose identity matters.120fn grid_blocks(121 family: Family,122 grid: List(Int),123 bx: Int,124 by: Int,125 cell: Int,126 screen: Screen,127 theme: Theme,128 gap: Int,129 drop: Int,130 shadow: Bool,131 device: Bool,132) -> List(Op) {133 // The same scan the board in play uses. Two pieces of one shape can touch —134 // the classic packing stacks two uprights in each outer column — so a corner135 // is the first *unclaimed* cell of a piece and never merely a cell whose136 // neighbour holds a different number.137 domain.pieces_from_grid(families.board(family), grid)138 |> list.map(fn(piece) {139 let class = domain.shape_class(piece.shape)140 Block(141 x: bx + piece.x * cell,142 y: by + piece.y * cell,143 w: piece.width * cell,144 h: piece.height * cell,145 class:,146 rest_x: bx + piece.x * cell,147 rest_y: by + piece.y * cell,148 style: block_style(149 class,150 screen,151 theme,152 gap,153 drop,154 shadow,155 False,156 device && class == 4,157 ),158 )159 })160}161162// ------------------------------------------------------------------- title163//164// One centred window holding the title, the specimen board and the prompt — an165// About box, essentially. Nothing is set on the desktop pattern: a subhead at166// that weight sitting straight on a dithered ground is exactly the fight that167// rule exists to avoid.168169pub fn boot(l: Layout, hero: Family, screen: Screen, theme: Theme) -> List(Op) {170 // Sized off the column rather than the picture: on a wide screen the picture171 // is mostly desktop, and type scaled to it would be set for a window twice172 // the size of the one it is actually in.173 let u = int.to_float(l.col.w)174 let cx = l.col.cx175 let win = l.boot_win176177 // The title is sized off the column and set by a fraction of the window — two178 // measurements that agree on a tall phone and pull apart on a short wide one,179 // where the window is wide enough to ask for big type and not tall enough to180 // have anywhere to put it. So the size is capped by the height as well, and181 // the baseline is never nearer the top of the window than the type is tall.182 // Left to the fraction alone, the name is printed through the frame.183 let title_size =184 typeface.fit_size(185 brand,186 float.min(u *. 0.14, int.to_float(win.h) *. 0.19),187 Display,188 u *. 0.02,189 int.to_float(win.w - 16),190 )191 let title_y =192 win.y193 + int.max(194 float.round(int.to_float(win.h) *. 0.15),195 float.round(title_size) + 4,196 )197 let rule_y = title_y + float.round(title_size *. 0.95)198199 // The specimen takes what is left under the rule rather than its own fraction200 // of the window, which is the same trap the title fell into.201 let top = rule_y + float.round(int.to_float(win.h) *. 0.06)202 let room = win.y + win.h - top - float.round(int.to_float(win.h) *. 0.22)203 let cell =204 int.max(8, int.min({ win.w - 52 } / hero.width, room / hero.height - 2))205 let tp = int.max(3, float.round(int.to_float(cell) *. 0.16))206 let bx = float.round(cx -. int.to_float(cell * hero.width) /. 2.0)207 let by = top + tp208 let tray =209 Rect(210 x: bx - tp,211 y: by - tp,212 w: cell * hero.width + tp * 2,213 h: cell * hero.height + tp * 2,214 )215216 list.flatten([217 scene.panel(win),218 [219 Label(220 text: brand,221 x: cx,222 y: int.to_float(title_y),223 size: title_size,224 face: Display,225 track: u *. 0.02,226 align: Center,227 alpha: 1.0,228 max_w: 0.0,229 ),230 Label(231 text: "SLIDING BLOCK PUZZLES",232 x: cx,233 y: int.to_float(title_y + float.round(title_size *. 0.55)),234 size: u *. 0.05,235 face: Text,236 track: u *. 0.006,237 align: Center,238 alpha: 1.0,239 max_w: int.to_float(win.w - 16),240 ),241 scene.rule(win.x + 10, rule_y, win.w - 20, 1),242 // The tray is ruled but not filled: the window under it is already paper,243 // and a second plate of it would only be a lighter rectangle.244 scene.rule(tray.x - 1, tray.y - 1, tray.w + 2, 1),245 scene.rule(tray.x - 1, tray.y + tray.h, tray.w + 2, 1),246 scene.rule(tray.x - 1, tray.y - 1, 1, tray.h + 2),247 scene.rule(tray.x + tray.w, tray.y - 1, 1, tray.h + 2),248 ],249 // Drawn exactly as the board in play is — same gap, same shadow, same mark250 // on the goal block. It is the same object; it should not be a lookalike.251 grid_blocks(252 hero,253 families.grid(hero),254 bx,255 by,256 cell,257 screen,258 theme,259 2,260 int.max(2, float.round(int.to_float(cell) *. 0.09)),261 True,262 True,263 ),264 ])265}266267// ------------------------------------------------------- the sweep, visible268269pub fn survey(270 l: Layout,271 label: String,272 count: Int,273 phase: Int,274 measured: Float,275) -> List(Op) {276 let u = int.to_float(l.col.w)277 let cx = l.col.cx278 let vh = int.to_float(l.vh)279280 let bar_w = float.round(u *. 0.5)281 let bar_x = float.round(cx -. int.to_float(bar_w) /. 2.0)282 let bar_y = float.round(vh *. 0.7)283 // The sweep still counts up under reduced motion, because that is a progress284 // bar and hiding it would leave a still screen during a second and a half of285 // work. It was never motion for its own sake.286 let done = case phase {287 2 -> 0.55 +. 0.45 *. float.min(1.0, measured)288 _ -> 0.35289 }290 let filled =291 float.round(int.to_float(bar_w) *. done /. int.to_float(shading.period))292 * shading.period293294 [295 Label(296 text: "MAPPING THE STATE SPACE",297 x: cx,298 y: vh *. 0.42,299 size: u *. 0.05,300 face: Text,301 track: u *. 0.006,302 align: Center,303 alpha: 1.0,304 max_w: u *. 0.84,305 ),306 Label(307 text: thousands(count),308 x: cx,309 y: vh *. 0.53,310 size: u *. 0.1,311 face: Display,312 track: u *. 0.008,313 align: Center,314 alpha: 1.0,315 max_w: 0.0,316 ),317 Label(318 text: case phase {319 2 -> "MEASURING DISTANCE TO GOAL"320 _ -> "CONFIGURATIONS REACHED"321 },322 x: cx,323 y: vh *. 0.575,324 size: u *. 0.038,325 face: Text,326 track: 0.0,327 align: Center,328 alpha: 0.75,329 max_w: u *. 0.84,330 ),331 Label(332 text: label,333 x: cx,334 y: vh *. 0.645,335 size: u *. 0.05,336 face: Display,337 track: u *. 0.01,338 align: Center,339 alpha: 1.0,340 max_w: 0.0,341 ),342 Fill(bar_x, bar_y, bar_w, 4, scene.paper_tone),343 scene.rule(bar_x - 1, bar_y - 1, bar_w + 2, 1),344 scene.rule(bar_x - 1, bar_y + 4, bar_w + 2, 1),345 scene.rule(bar_x - 1, bar_y - 1, 1, 6),346 scene.rule(bar_x + bar_w, bar_y - 1, 1, 6),347 Fill(bar_x, bar_y, filled, 4, scene.rule_tone),348 ]349}350351// ------------------------------------------------------- choosing a board352//353// A vertical list, one card per family, because a grid of three tiers no longer354// describes anything: a family is an open ladder, not three plates. The card355// shows where you are on that ladder and what the next board costs.356357pub type AtlasGeometry {358 AtlasGeometry(359 top: Int,360 row_h: Int,361 gap: Int,362 margin: Int,363 width: Int,364 board_col: Int,365 tx: Int,366 /// What the text column has, from `tx` to the card's right inset.367 text_w: Int,368 name_size: Float,369 stat_size: Float,370 foot_size: Float,371 /// One difficulty mark, square.372 mark: Int,373 /// The meter's end stops.374 tick: Int,375 cols: Int,376 gap_x: Int,377 rows: Int,378 u: Float,379 )380}381382pub fn atlas_geometry(l: Layout, count: Int) -> AtlasGeometry {383 // Two columns of cards on a wide screen. Eight sets in a single column of a384 // landscape picture is three of them visible and five below the fold, which385 // makes a list of eight things read as a list of three — and the scroll that386 // reveals the rest is the one gesture this app otherwise never asks for.387 // Filled down the first column and then the second, so it still reads as one388 // ordered ladder rather than as two lists.389 let cols = case l.mode == Split && count > 3 {390 True -> 2391 False -> 1392 }393 let gap_x = 8394 let margin = l.col.x + 7395 let width = { l.col.w - 14 - { cols - 1 } * gap_x } / cols396 let rows = { count + cols - 1 } / cols397398 // The board column is a fixed width and the text column starts at a fixed x,399 // whatever shape the board is. Sizing the column to each board made every row400 // start its name in a different place and at a different size, which is the401 // one thing a list of like items must not do.402 let board_col = float.round(int.to_float(width) *. 0.33)403 let tx = 8 + board_col + 10404405 // One column is a list and always scrolls — eight sets never fit a phone. Two406 // columns usually can fit, so they are sized to, and the scroll goes away on407 // anything with the room for it.408 let top = l.bar_h + 8409 let gap = 6410 let row_h = case cols > 1 {411 True ->412 int.min(72, int.max(46, { l.vh - top - 8 } / int.max(1, rows) - gap))413 False -> int.max(52, float.round(int.to_float(l.vh) *. 0.155))414 }415416 // Type is sized off the card rather than off the picture, so a pair of417 // half-width cards sets its names for the space a half-width card has — and418 // off *both* of the card's dimensions, not just its width. A card's height is419 // clamped: between 46 and 72 units in two columns, and never under 52 in one.420 // Its width is not clamped at all. Sizing type off the width alone therefore421 // asked a 54-unit card to hold four lines cut for a 72-unit one, and the422 // meter printed straight through the difficulty marks above it.423 let u =424 float.min(int.to_float(width) *. 1.08, int.to_float(row_h - 10) *. 6.7)425426 // One size for every name, and the name gets the whole text column. Sharing427 // that line with the stage number left so little room that the names came out428 // smaller than the numbers beside them, which inverts the hierarchy of the429 // card: the set is the subject, the stage is a detail about it.430 let text_w = width - 8 - tx431 let name_size =432 list.fold(families.all(), scene.snap(u *. 0.052), fn(size, family) {433 float.min(434 size,435 typeface.fit_size(436 family.label,437 size,438 Display,439 u *. 0.006,440 int.to_float(text_w),441 ),442 )443 })444445 // The marks and the meter are drawn rather than set, so they need sizes of446 // their own — and they have to come off the same unit as the type, or a card447 // whose type shrank keeps five-unit marks and a six-unit meter and the stack448 // stops being a stack.449 let stat_size = scene.snap(u *. 0.038)450 let mark = int.max(3, float.round(stat_size *. 0.5))451452 AtlasGeometry(453 top:,454 row_h:,455 gap:,456 margin:,457 width:,458 board_col:,459 tx:,460 text_w:,461 name_size:,462 stat_size:,463 foot_size: scene.snap(u *. 0.03),464 mark:,465 tick: mark + 1,466 cols:,467 gap_x:,468 rows:,469 u:,470 )471}472473/// Where the four lines of a card sit, in absolute units.474///475/// A card is a stack — a name, a line of numbers, the difficulty marks, then476/// the meter — and each of those claims exactly the height of the type or the477/// marks it is made of. What is left over is the air between them, shared out478/// evenly and then capped, so a tall card sets its lines apart instead of479/// drifting them into its corners.480///481/// The marks keep their slot on a card that has none. A played set and an482/// unplayed one line their names up, which is the one thing a list of like483/// items must not get wrong.484type Lines {485 Lines(name: Int, stat: Int, marks: Int, meter: Int)486}487488fn atlas_lines(g: AtlasGeometry, r: Rect) -> Lines {489 let name_h = typeface.cap_height(g.name_size, Display)490 let stat_h = typeface.cap_height(g.stat_size, Text)491 let mark_h = int.to_float(g.mark)492 let meter_h =493 float.max(typeface.cap_height(g.foot_size, Text), int.to_float(g.tick))494 let content = name_h +. stat_h +. mark_h +. meter_h495496 let air = int.to_float(r.h - 10) -. content497 let lead = float.max(2.0, float.min(air /. 3.0, g.name_size *. 0.6))498 let top =499 int.to_float(r.y) +. { int.to_float(r.h) -. content -. 3.0 *. lead } /. 2.0500501 let stat_top = top +. name_h +. lead502 let marks_top = stat_top +. stat_h +. lead503 let meter_top = marks_top +. mark_h +. lead504 Lines(505 // Names and numbers are placed by the bottom of their capitals; everything506 // in this app is set in capitals, so that is the line they sit on.507 name: float.round(top +. name_h),508 stat: float.round(stat_top +. stat_h),509 marks: float.round(marks_top),510 // The meter's rule, which its own caption is then centred against.511 meter: float.round(meter_top +. meter_h /. 2.0),512 )513}514515pub fn family_row_rect(g: AtlasGeometry, index: Int, scroll: Int) -> Rect {516 Rect(517 x: g.margin + index / g.rows * { g.width + g.gap_x },518 y: g.top + index % g.rows * { g.row_h + g.gap } + scroll,519 w: g.width,520 h: g.row_h,521 )522}523524pub fn atlas_content_height(g: AtlasGeometry) -> Int {525 g.rows * { g.row_h + g.gap } + 8526}527528pub fn atlas(529 l: Layout,530 rows: List(Row),531 scroll: Int,532 theme: Theme,533 logo: Rgb,534 density: Int,535) -> List(Op) {536 let g = atlas_geometry(l, list.length(rows))537 let u = g.u538539 let cards =540 rows541 |> list.index_map(fn(row, index) { #(index, row) })542 |> list.flat_map(fn(entry) {543 let #(index, row) = entry544 let r = family_row_rect(g, index, scroll)545 case r.y + r.h < l.bar_h || r.y > l.vh {546 // Off-screen: not drawn at all rather than drawn and clipped.547 True -> []548 False -> atlas_row(g, r, row, theme, u, density)549 }550 })551552 let logo_size = float.round(int.to_float(l.bar_h) *. 0.62)553 list.flatten([554 [Clip(Rect(0, l.bar_h + 1, l.vw, l.vh - l.bar_h - 1))],555 cards,556 [Unclip],557 scene.menu_bar(l.vw, l.bar_h),558 [559 // The maker's mark sits at the far left of the menu bar and the title560 // follows it — which is where a Macintosh put the Apple, and the reason561 // that position reads as a signature rather than as an icon someone left562 // lying there.563 DevLogo(564 x: 8.0 +. int.to_float(logo_size) /. 2.0,565 y: int.to_float(l.bar_h) /. 2.0,566 size: int.to_float(logo_size),567 ink: logo,568 ),569 Label(570 text: brand,571 x: int.to_float(8 + logo_size + 6),572 y: int.to_float(l.bar_h - float.round(int.to_float(l.bar_h) *. 0.28)),573 size: int.to_float(l.bar_h) *. 0.5,574 face: Display,575 track: int.to_float(l.bar_h) *. 0.08,576 align: Start,577 alpha: 1.0,578 max_w: 0.0,579 ),580 ],581 ])582}583584fn atlas_row(585 g: AtlasGeometry,586 r: Rect,587 row: Row,588 theme: Theme,589 u: Float,590 density: Int,591) -> List(Op) {592 let family = row.family593 let inner = r.h - 12594 let mini =595 int.max(4, int.min(g.board_col / family.width, inner / family.height))596 // Centred in its fixed column, so boards of different shapes still line up.597 let bx = r.x + 8 + { g.board_col - mini * family.width } / 2598 let by = r.y + { r.h - mini * family.height } / 2599 let #(pattern, ruling) = row.look600 // Stepped down to what a third-size cell can hold two of, which is the same601 // rule the board itself is printed under, just against a smaller cell. No602 // register check: nothing on a preview moves, so there is nothing to hold603 // register with.604 let preview_screen =605 screen_for(pattern, shading.fit_ruling(ruling, { mini - 2 } * density, 0))606607 let tx = r.x + g.tx608 let lines = atlas_lines(g, r)609610 // The stage number is the fixed end of the line it shares, so it is measured611 // first and the steps count is given what is left. Both used to run to the612 // full width of the text column from opposite ends, and on a narrow card they613 // met in the middle and overprinted each other.614 let stage = "STAGE " <> int.to_string(row.rung)615 let stage_track = u *. 0.004616 let stage_w = typeface.measure(stage, g.stat_size, Text, stage_track)617 let gutter = int.max(6, g.mark * 3)618 let steps_w = int.to_float(g.text_w) -. stage_w -. int.to_float(gutter)619620 let body = case row.surveyed {621 False -> [622 // On the meter's line, so a set you have not opened is the same card as623 // one you have with its last line swapped, rather than a different shape.624 Label(625 text: "TAP TO BEGIN",626 x: int.to_float(tx),627 y: int.to_float(lines.meter)628 +. typeface.cap_height(g.foot_size, Text)629 /. 2.0,630 size: g.foot_size,631 face: Text,632 track: u *. 0.004,633 align: Start,634 alpha: 0.65,635 max_w: int.to_float(g.text_w),636 ),637 ]638 True -> {639 let foot = case row.at_ceiling {640 True -> "AT THE CEILING"641 False -> "TO " <> int.to_string(row.ecc)642 }643 // The meter is what the caption beside it leaves: measured rather than644 // guessed, because "AT THE CEILING" is four times the width of "TO 126"645 // and a fixed seventy-two units had them fighting over the same space.646 let foot_w = typeface.measure(foot, g.foot_size, Text, 0.0)647 let bar_w =648 int.max(g.mark * 4, g.text_w - float.round(foot_w) - int.max(4, g.mark))649 let bar_y = lines.meter650 let stop = g.tick651 // How much of this family's depth you have walked. It fills as the rungs652 // climb and stops when the ladder reaches the family's ceiling — which is653 // the honest end of "deeper", not the end of the family.654 let walked =655 clamp01(int.to_float(row.depth) /. int.to_float(int.max(1, row.ecc)))656 let head = int.max(2, g.mark - 1)657 list.flatten([658 [659 Label(660 text: int.to_string(row.optimal) <> " STEPS",661 x: int.to_float(tx),662 y: int.to_float(lines.stat),663 size: g.stat_size,664 face: Text,665 track: 0.0,666 align: Start,667 alpha: 0.85,668 max_w: float.max(steps_w, 0.0),669 ),670 ],671 marks(tx, lines.marks, g.mark, row.grade),672 [673 scene.rule(tx, bar_y, bar_w, 1),674 scene.rule(tx, bar_y - stop / 2, 1, stop),675 scene.rule(tx + bar_w - 1, bar_y - stop / 2, 1, stop),676 scene.rule(677 tx + float.round(walked *. int.to_float(bar_w - head)),678 bar_y - { stop + 2 } / 2,679 head,680 stop + 2,681 ),682 Label(683 text: foot,684 x: int.to_float(tx + bar_w + int.max(4, g.mark)),685 y: int.to_float(bar_y)686 +. typeface.cap_height(g.foot_size, Text)687 /. 2.0,688 size: g.foot_size,689 face: Text,690 track: 0.0,691 align: Start,692 alpha: 0.6,693 max_w: 0.0,694 ),695 ],696 ])697 }698 }699700 list.flatten([701 scene.panel(r),702 grid_blocks(703 family,704 row.grid,705 bx,706 by,707 mini,708 preview_screen,709 theme,710 1,711 1,712 False,713 False,714 ),715 [716 Label(717 text: family.label,718 x: int.to_float(tx),719 y: int.to_float(lines.name),720 size: g.name_size,721 face: Display,722 track: u *. 0.006,723 align: Start,724 alpha: 1.0,725 max_w: int.to_float(g.text_w),726 ),727 Label(728 text: stage,729 x: int.to_float(r.x + r.w - 8),730 y: int.to_float(lines.stat),731 size: g.stat_size,732 face: Text,733 track: stage_track,734 align: End,735 alpha: 0.85,736 max_w: int.to_float(g.text_w) *. 0.5,737 ),738 ],739 body,740 ])741}742743/// Five marks, so a plate can wear its difficulty without a number: filled for744/// what it has, hollow for what it has not.745fn marks(x: Int, y: Int, size: Int, grade: Int) -> List(Op) {746 let pitch = size + int.max(1, size / 3)747 list.flat_map([0, 1, 2, 3, 4], fn(i) {748 let mx = x + i * pitch749 case i < grade {750 True -> [scene.rule(mx, y, size, size)]751 False -> [752 scene.rule(mx, y, size, 1),753 scene.rule(mx, y + size - 1, size, 1),754 scene.rule(mx, y, 1, size),755 scene.rule(mx + size - 1, y, 1, size),756 ]757 }758 })759}760761// -------------------------------------------------- the card between boards762763pub fn card(764 l: Layout,765 label: String,766 rung: Int,767 optimal: Int,768 step: Int,769 past_pause: Bool,770) -> List(Op) {771 let u = int.to_float(l.col.w)772 let cx = l.col.cx773 let win =774 Rect(775 x: l.col.x + 14,776 y: float.round(int.to_float(l.vh) *. 0.37),777 w: l.col.w - 28,778 h: float.round(int.to_float(l.vh) *. 0.25),779 )780781 let rest = case past_pause {782 False -> []783 True -> [784 Label(785 text: "STAGE " <> int.to_string(rung),786 x: cx,787 y: int.to_float(win.y + float.round(int.to_float(win.h) *. 0.66)),788 size: u *. 0.05,789 face: Text,790 track: u *. 0.008,791 align: Center,792 alpha: 1.0,793 max_w: 0.0,794 ),795 Label(796 text: int.to_string(optimal) <> " STEPS MINIMUM",797 x: cx,798 y: int.to_float(win.y + float.round(int.to_float(win.h) *. 0.87)),799 size: u *. 0.042,800 face: Text,801 track: u *. 0.006,802 align: Center,803 alpha: 0.75,804 max_w: 0.0,805 ),806 ]807 }808809 list.flatten([810 scene.panel(win),811 [812 // Arrives in discrete steps, never a smooth fade: a continuous ramp would813 // put in-between frames in the middle of a one-bit game.814 Label(815 text: label,816 x: cx,817 y: int.to_float(win.y + float.round(int.to_float(win.h) *. 0.42)),818 size: u *. 0.11,819 face: Display,820 track: u *. 0.016,821 align: Center,822 alpha: int.to_float(step) /. 4.0,823 max_w: int.to_float(win.w - 18),824 ),825 ],826 rest,827 ])828}829830// ------------------------------------------------------------- the board831832pub fn play(833 l: Layout,834 b: BoardLayout,835 family: Family,836 blocks: List(Placed),837 screen: Screen,838 theme: Theme,839 rise: Int,840 hints: Hints,841 distance: Int,842 reference: Int,843) -> List(Op) {844 let cell = b.cell845 let plate = Rect(..b.plate, y: b.plate.y - rise)846 let bx = b.board.x847 let by = b.board.y - rise848 let tp = b.tray_pad849 let tray =850 Rect(x: bx - tp, y: by - tp, w: b.board.w + tp * 2, h: b.board.h + tp * 2)851852 let placed =853 list.map(blocks, fn(p) {854 Block(855 x: p.x,856 y: p.y,857 w: p.w,858 h: p.h,859 class: p.class,860 rest_x: p.rest_x,861 rest_y: p.rest_y,862 style: block_style(863 p.class,864 screen,865 theme,866 2,867 int.max(2, float.round(int.to_float(cell) *. 0.09)),868 True,869 p.thick,870 p.class == 4,871 ),872 )873 })874875 list.flatten([876 scene.menu_bar(l.vw, l.bar_h),877 scene.panel(Rect(b.panel.x, b.panel.y, b.panel.w, b.panel.h)),878 scene.plate(plate),879 scene.tray(tray),880 scene.aperture(881 Rect(bx, by, b.board.w, b.board.h),882 cell,883 family.goal_x,884 tray.y + tray.h,885 // What the doorway has to itself: the tray's rule down to the top of the886 // name set under the plate.887 plate.y888 + plate.h889 - float.round(int.to_float(b.pad_bottom) *. 0.34)890 - float.round(typeface.cap_height(int.to_float(cell) *. 0.44, Display))891 - { tray.y + tray.h },892 theme.piece_colour(theme, 4),893 ),894 placed,895 [896 // Centred on the plate it is printed on, not on the picture: beside a897 // panel the plate is no longer in the middle of the screen.898 Label(899 text: family.label,900 x: int.to_float(plate.x) +. int.to_float(plate.w) /. 2.0,901 y: int.to_float(902 plate.y + plate.h - float.round(int.to_float(b.pad_bottom) *. 0.34),903 ),904 size: int.to_float(cell) *. 0.44,905 face: Display,906 track: 1.5,907 align: Center,908 alpha: 1.0,909 max_w: int.to_float(b.plate.w - 18),910 ),911 ],912 gauge(913 l,914 b,915 hints,916 distance,917 reference,918 b.gauge.y919 - case l.mode == Split {920 True -> 0921 False -> rise922 },923 ),924 ])925}926927/// How far you actually are, against how far you began — and the reason a board928/// that looks tidy can read as further out than the mess you started from. The929/// marker travels left to right as the distance falls.930///931/// What it is measured against depends on the hint level. FULL scales to the932/// distance you started at, which together with the TO GOAL readout beside it933/// tells you exactly where you stand. BEST scales to the furthest out you have934/// ever been on this board instead: the number is gone, so the gauge is all935/// there is, and anchoring it to your own worst point makes it a reading of936/// your progress rather than a disguised step count — you cannot work backwards937/// from it to the solver's answer.938fn gauge(939 _l: Layout,940 b: BoardLayout,941 hints: Hints,942 distance: Int,943 reference: Int,944 y: Int,945) -> List(Op) {946 case hints {947 store.OffHints -> []948 _ -> {949 let w = b.gauge.w950 let x = b.gauge.x951 let k =952 clamp01(953 1.0 -. int.to_float(distance) /. int.to_float(int.max(1, reference)),954 )955 list.flatten([956 [scene.rule(x, y, w, 1)],957 list.map([0, 1, 2, 3, 4], fn(i) {958 scene.rule(x + i * w / 4, y - 3, 1, 4)959 }),960 [scene.rule(x + float.round(k *. int.to_float(w)) - 2, y - 7, 5, 12)],961 ])962 }963 }964}965966// ------------------------------------------------------------- the record967968pub fn won(969 l: Layout,970 b: BoardLayout,971 family: Family,972 trace: List(Int),973) -> List(Op) {974 let plate = b.plate975 let cx = int.to_float(plate.x) +. int.to_float(plate.w) /. 2.0976 // Everything here is set inside the plate, so it is measured off the plate.977 // It used to be measured off the picture, which was the same number while the978 // picture was a phone-wide column and is not one beside a panel — and a979 // three-wide board's plate is narrow enough that it was never quite the same980 // number anyway. The 1.15 is what keeps the tall-phone sizes exactly where981 // they were.982 let u = int.to_float(plate.w) *. 1.15983984 // Stacked down the centre, and spaced from the trophy's actual height rather985 // than by fractions of the plate — on a short board the fractions closed up986 // until the cup was resting on the S of SOLVED.987 let cup_size = int.to_float(b.cell) *. 0.62988 let cup_y = plate.y + float.round(int.to_float(plate.h) *. 0.085)989 let solved_y =990 float.round(991 int.to_float(cup_y) +. cup_size *. 0.55 +. scene.snap(u *. 0.045) +. 6.0,992 )993994 // The caption is measured before the chart is placed, because the chart is995 // given the room rather than a fraction of it.996 let cap_size =997 typeface.fit_size(998 "DISTANCE TO GOAL",999 int.to_float(b.cell) *. 0.36,1000 Text,1001 1.0,1002 int.to_float(plate.w - 18),1003 )1004 let cap_y =1005 plate.y + plate.h - float.round(int.to_float(b.pad_bottom) *. 0.34)10061007 // The chart takes everything between the set's name and that caption.1008 //1009 // Its height used to be a fraction of the plate, and the plate is the1010 // *board's* — a five-wide set gets a wide short one and a three-wide set a1011 // narrow tall one, so the same run of play was drawn at a different size on1012 // every family. There is nothing else in this window: the chart is what the1013 // screen is for, and it should be as big as the frame allows on every board.1014 let gx = plate.x + float.round(int.to_float(plate.w) *. 0.13)1015 let gw = float.round(int.to_float(plate.w) *. 0.74)1016 let gy = solved_y + float.round(u *. 0.13)1017 // Room for the baseline rule and a breath under it before the caption's caps.1018 let gh =1019 int.max(24, cap_y - float.round(cap_size) - float.round(u *. 0.075) - gy)10201021 let n = int.max(1, list.length(trace))1022 let start = int.max(1, trace |> list.first |> result.unwrap(1))1023 // Scaled to the furthest you ever got, not to where you started. Going1024 // *backwards* is the whole point of the chart — a board can leave you further1025 // out than it found you — and scaling to the start meant those bars ran off1026 // the top of the panel and through the title.1027 let peak = int.max(1, list.fold(trace, start, int.max))1028 let bar_w = int.to_float(gw) /. int.to_float(n)1029 let top_of = fn(v: Int) {1030 gy1031 + gh1032 - float.round(int.to_float(gh) *. int.to_float(v) /. int.to_float(peak))1033 }10341035 // Drawn as one silhouette, never as separate bars.1036 //1037 // The columns used to be given a pixel of daylight between them once they1038 // were wide enough to spare it, which meant the chart was two different1039 // pictures depending on how long the run was: a hundred-step descent packed1040 // to a pixel a column read as a solid falling mass, and a fourteen-step one1041 // opened out into fourteen tally marks. The mass is the one worth having.1042 //1043 // So the columns meet. What separates them is the step in the top edge where1044 // the distance changed, which is the only separation the data actually has,1045 // and it survives at any width.1046 let ridge =1047 trace1048 |> list.index_map(fn(value, i) {1049 let h =1050 int.max(1051 1,1052 float.round(1053 int.to_float(gh) *. int.to_float(value) /. int.to_float(peak),1054 ),1055 )1056 let x0 = gx + float.round(int.to_float(i) *. bar_w)1057 let x1 = gx + float.round(int.to_float(i + 1) *. bar_w)1058 scene.rule(x0, gy + gh - h, int.max(1, x1 - x0), h)1059 })10601061 list.flatten([1062 scene.menu_bar(l.vw, l.bar_h),1063 scene.panel(Rect(b.panel.x, b.panel.y, b.panel.w, b.panel.h)),1064 scene.plate(plate),1065 [1066 Trophy(x: cx, y: int.to_float(cup_y), size: cup_size),1067 Label(1068 text: "SOLVED",1069 x: cx,1070 y: int.to_float(solved_y),1071 size: u *. 0.045,1072 face: Text,1073 track: u *. 0.018,1074 align: Center,1075 alpha: 1.0,1076 max_w: 0.0,1077 ),1078 Label(1079 text: family.label,1080 x: cx,1081 y: int.to_float(solved_y + float.round(u *. 0.085)),1082 size: u *. 0.075,1083 face: Display,1084 track: u *. 0.01,1085 align: Center,1086 alpha: 1.0,1087 max_w: int.to_float(plate.w - 18),1088 ),1089 // The dotted rule is the line a perfect solver draws — straight down. The1090 // ridge is what actually happened, and every place it climbs is a stretch1091 // where the board took you further from the answer than you already were.1092 GoldLine(1093 x1: int.to_float(gx),1094 y1: int.to_float(top_of(start)),1095 x2: int.to_float(gx + gw),1096 y2: int.to_float(gy + gh),1097 ),1098 ],1099 ridge,1100 [1101 scene.rule(gx, gy + gh + 1, gw, 1),1102 // Sized off the plate, not off the cell: a three-wide board gets big1103 // cells, and a caption scaled to them runs straight out through the frame.1104 Label(1105 text: "DISTANCE TO GOAL",1106 x: cx,1107 y: int.to_float(cap_y),1108 size: cap_size,1109 face: Text,1110 track: 1.0,1111 align: Center,1112 alpha: 1.0,1113 max_w: 0.0,1114 ),1115 ],1116 ])1117}11181119// -------------------------------------------------- the record of everything1120//1121// Built from the save, not from the session: totals are counted as they happen1122// and the log is a capped ring buffer, so this reads the same after a restart as1123// it did before one.11241125pub fn stats(1126 l: Layout,1127 totals: Totals,1128 level: Level,1129 streak: Int,1130 records: List(#(Family, FamilyRecord)),1131 log: List(Entry),1132 swept: Int,1133 mapped: Int,1134) -> List(Op) {1135 // Two columns when the screen is wide, because the alternative is a stack of1136 // panels taller than the picture with the chart — the one thing on here worth1137 // looking at — squeezed to a strip at the bottom. The per-family table is the1138 // tall item and it is also the one that does not want to be cut, so it takes1139 // the second column whole.1140 let two = l.mode == Split1141 let gap_x = 81142 let m = l.col.x + 71143 let w = case two {1144 True -> { l.col.w - 14 - gap_x } / 21145 False -> l.col.w - 141146 }1147 let m2 = m + w + gap_x1148 // Type is sized off the panel it is in.1149 let u = int.to_float(w) *. 1.081150 let cx = int.to_float(m) +. int.to_float(w) /. 2.01151 let top = l.bar_h + 811521153 let lvl_h = int.max(48, float.round(int.to_float(l.vh) *. 0.13))1154 let level_ops = level_panel(m, top, w, lvl_h, u, level, totals.xp)1155 let y = top + lvl_h + 611561157 let life = lifetime_panel(m, y, w, u, l.vh, totals, streak)1158 let y = y + life.1 + 611591160 let fam_x = case two {1161 True -> m21162 False -> m1163 }1164 let fam_y = case two {1165 True -> top1166 False -> y1167 }1168 let table = family_table(fam_x, fam_y, w, u, records)1169 let y = case two {1170 True -> y1171 False -> y + table.1 + 61172 }11731174 // The chart takes whatever the panels above did not: it is the most1175 // interesting thing on the screen and was previously a strip with a third of1176 // the page empty beneath it.1177 let chart_h = int.max(38, l.vh - y - 30)1178 let chart = recent_panel(m, y, w, chart_h, u, cx, log)1179 let y = y + chart_h + 611801181 // The one line that says why this puzzle is worth the trouble — in a panel of1182 // its own, because nothing small is ever set on the desktop pattern.1183 let foot_h = 181184 let foot = case y + foot_h < l.vh - 4 {1185 False -> []1186 True ->1187 list.flatten([1188 scene.panel(Rect(m, y, w, foot_h)),1189 [1190 Label(1191 text: case swept > 0 {1192 True -> thousands(mapped) <> " CONFIGURATIONS MAPPED"1193 False ->1194 int.to_string(families.count())1195 <> " PIECE SETS, NONE MAPPED YET"1196 },1197 x: cx,1198 y: int.to_float(y + 12),1199 size: scene.snap(u *. 0.03),1200 face: Text,1201 track: 0.0,1202 align: Center,1203 alpha: 0.6,1204 max_w: int.to_float(w - 12),1205 ),1206 ],1207 ])1208 }12091210 list.flatten([1211 level_ops,1212 life.0,1213 table.0,1214 chart,1215 foot,1216 scene.menu_bar(l.vw, l.bar_h),1217 [1218 // Centred, because unlike the atlas this screen has a BACK button sitting1219 // in the left of the bar — and centred on the bar, which spans the screen.1220 Label(1221 text: "RECORD",1222 x: int.to_float(l.vw) /. 2.0,1223 y: int.to_float(l.bar_h - float.round(int.to_float(l.bar_h) *. 0.28)),1224 size: int.to_float(l.bar_h) *. 0.5,1225 face: Display,1226 track: int.to_float(l.bar_h) *. 0.08,1227 align: Center,1228 alpha: 1.0,1229 max_w: 0.0,1230 ),1231 ],1232 ])1233}12341235fn level_panel(1236 m: Int,1237 y: Int,1238 w: Int,1239 h: Int,1240 u: Float,1241 level: Level,1242 xp: Int,1243) -> List(Op) {1244 let bx = m + float.round(int.to_float(w) *. 0.38)1245 let bw = float.round(int.to_float(w) *. 0.52)1246 let by = y + float.round(int.to_float(h) *. 0.58)1247 let k =1248 clamp01(int.to_float(level.into) /. int.to_float(int.max(1, level.span)))12491250 list.flatten([1251 scene.panel(Rect(m, y, w, h)),1252 [1253 Label(1254 text: "LEVEL",1255 x: int.to_float(m + 10),1256 y: int.to_float(y + 16),1257 size: scene.snap(u *. 0.036),1258 face: Text,1259 track: u *. 0.01,1260 align: Start,1261 alpha: 0.7,1262 max_w: 0.0,1263 ),1264 Label(1265 text: int.to_string(level.level),1266 x: int.to_float(m + 10),1267 y: int.to_float(y + float.round(int.to_float(h) *. 0.72)),1268 size: scene.snap(u *. 0.11),1269 face: Display,1270 track: u *. 0.006,1271 align: Start,1272 alpha: 1.0,1273 max_w: 0.0,1274 ),1275 Label(1276 text: int.to_string(xp) <> " XP",1277 x: int.to_float(m + w - 10),1278 y: int.to_float(y + 16),1279 size: scene.snap(u *. 0.036),1280 face: Text,1281 track: 0.0,1282 align: End,1283 alpha: 0.7,1284 max_w: 0.0,1285 ),1286 scene.rule(bx - 1, by - 1, bw + 2, 1),1287 scene.rule(bx - 1, by + 6, bw + 2, 1),1288 scene.rule(bx - 1, by - 1, 1, 8),1289 scene.rule(bx + bw, by - 1, 1, 8),1290 Fill(1291 bx,1292 by,1293 int.max(0, float.round(int.to_float(bw) *. k)),1294 6,1295 scene.rule_tone,1296 ),1297 Label(1298 text: int.to_string(level.into)1299 <> " / "1300 <> int.to_string(level.span)1301 <> " TO "1302 <> int.to_string(level.level + 1),1303 x: int.to_float(bx + bw),1304 y: int.to_float(by + 16),1305 size: scene.snap(u *. 0.03),1306 face: Text,1307 track: 0.0,1308 align: End,1309 alpha: 0.6,1310 max_w: 0.0,1311 ),1312 ],1313 ])1314}13151316/// Lifetime, in two rows of three.1317///1318/// It was one row, and adding what a run was made of to it put five captions1319/// across a phone: EFFICIENCY and PLAYED met in the middle and the row read as1320/// one long word. Three to a row is the same information at a size that1321/// survives a 360-wide screen, and it splits along the meaning anyway — what1322/// you have solved on top, how you play underneath.1323///1324/// CLEAN is solves that never went backwards at all, and DETOURS the lifetime1325/// count of the times one did.1326fn lifetime_panel(1327 m: Int,1328 y: Int,1329 w: Int,1330 u: Float,1331 vh: Int,1332 totals: Totals,1333 streak: Int,1334) -> #(List(Op), Int) {1335 let efficiency = case totals.steps {1336 0 -> 01337 steps ->1338 float.round(int.to_float(totals.optimal * 100) /. int.to_float(steps))1339 }1340 let cells = [1341 #(int.to_string(totals.solved), "SOLVED"),1342 #(int.to_string(totals.clean), "CLEAN"),1343 #(int.to_string(totals.detours), "DETOURS"),1344 #(int.to_string(efficiency) <> "%", "EFFICIENCY"),1345 #(format_time(totals.ms), "PLAYED"),1346 #(int.to_string(streak), "DAY RUN"),1347 ]1348 let per_row = 31349 let rows = { list.length(cells) + per_row - 1 } / per_row1350 let row_h = int.max(30, float.round(int.to_float(vh) *. 0.075))1351 let h = rows * row_h + 41352 let cell_w = int.to_float(w) /. int.to_float(per_row)13531354 let body =1355 cells1356 |> list.index_map(fn(cell, i) {1357 let #(value, caption) = cell1358 let row = i / per_row1359 let col = i % per_row1360 let x =1361 int.to_float(m)1362 +. int.to_float(float.round(cell_w *. { int.to_float(col) +. 0.5 }))1363 let ry = y + 2 + row * row_h1364 [1365 Label(1366 text: value,1367 x:,1368 y: int.to_float(ry + float.round(int.to_float(row_h) *. 0.52)),1369 size: scene.snap(u *. 0.048),1370 face: Display,1371 track: 0.0,1372 align: Center,1373 alpha: 1.0,1374 max_w: 0.0,1375 ),1376 // Fitted to its own cell rather than trusted to be short enough: the1377 // widest of these is EFFICIENCY and it is the one that ran into its1378 // neighbour.1379 Label(1380 text: caption,1381 x:,1382 y: int.to_float(ry + float.round(int.to_float(row_h) *. 0.82)),1383 size: u *. 0.028,1384 face: Text,1385 track: 0.0,1386 align: Center,1387 alpha: 0.65,1388 max_w: cell_w -. 6.0,1389 ),1390 ]1391 })1392 |> list.flatten13931394 #(list.flatten([scene.panel(Rect(m, y, w, h)), body]), h)1395}13961397fn family_table(1398 x: Int,1399 y: Int,1400 w: Int,1401 u: Float,1402 records: List(#(Family, FamilyRecord)),1403) -> #(List(Op), Int) {1404 let row_h = 111405 let h = 16 + list.length(records) * row_h + 61406 let columns = [1407 x + 8,1408 x + float.round(int.to_float(w) *. 0.5),1409 x + float.round(int.to_float(w) *. 0.68),1410 x + w - 8,1411 ]1412 let at = fn(i) { columns |> list.drop(i) |> list.first |> result.unwrap(x) }1413 let head = scene.snap(u *. 0.028)1414 let heading = fn(text, i, align) {1415 Label(1416 text:,1417 x: int.to_float(at(i)),1418 y: int.to_float(y + 12),1419 size: head,1420 face: Text,1421 track: 0.0,1422 align:,1423 alpha: 0.55,1424 max_w: 0.0,1425 )1426 }14271428 let body =1429 records1430 |> list.index_map(fn(entry, i) {1431 let #(family, record) = entry1432 let ry = y + 16 + { i + 1 } * row_h - 21433 // A set nobody has opened is dimmed rather than omitted: the list is the1434 // whole ladder, and an empty row is information.1435 let dim = case record.solved > 0 {1436 True -> 1.01437 False -> 0.451438 }1439 let figure = fn(text, i, face) {1440 Label(1441 text:,1442 x: int.to_float(at(i)),1443 y: int.to_float(ry),1444 size: scene.snap(u *. 0.032),1445 face:,1446 track: 0.0,1447 align: case i {1448 0 -> Start1449 _ -> End1450 },1451 alpha: dim,1452 max_w: 0.0,1453 )1454 }1455 [1456 figure(family.label, 0, Display),1457 figure(int.to_string(record.rung), 1, Text),1458 figure(int.to_string(record.solved), 2, Text),1459 figure(int.to_string(record.deepest), 3, Text),1460 ]1461 })1462 |> list.flatten14631464 #(1465 list.flatten([1466 scene.panel(Rect(x, y, w, h)),1467 [1468 heading("SET", 0, Start),1469 heading("STAGE", 1, End),1470 heading("DONE", 2, End),1471 heading("DEEPEST", 3, End),1472 ],1473 body,1474 ]),1475 h,1476 )1477}14781479fn recent_panel(1480 m: Int,1481 y: Int,1482 w: Int,1483 h: Int,1484 u: Float,1485 cx: Float,1486 log: List(Entry),1487) -> List(Op) {1488 let shown = case list.length(log) > 40 {1489 True -> list.drop(log, list.length(log) - 40)1490 False -> log1491 }1492 let heading =1493 Label(1494 text: "RECENT DEPTH",1495 x: int.to_float(m + 8),1496 y: int.to_float(y + 12),1497 size: scene.snap(u *. 0.03),1498 face: Text,1499 track: 0.0,1500 align: Start,1501 alpha: 0.6,1502 max_w: 0.0,1503 )15041505 let body = case shown {1506 [] -> [1507 Label(1508 text: "NOTHING SOLVED YET",1509 x: cx,1510 y: int.to_float(y + float.round(int.to_float(h) *. 0.62)),1511 size: scene.snap(u *. 0.034),1512 face: Text,1513 track: 0.0,1514 align: Center,1515 alpha: 0.5,1516 max_w: 0.0,1517 ),1518 ]1519 entries -> {1520 let gx = m + 81521 let gw = w - 161522 let gy = y + 161523 let gh = h - 241524 let peak =1525 int.max(1526 1,1527 list.fold(entries, 1, fn(best, e) { int.max(best, e.optimal) }),1528 )1529 // Bars keep their own width instead of stretching, so three solves read as1530 // three solves rather than as one filled rectangle.1531 let bw =1532 float.min(int.to_float(gw) /. int.to_float(list.length(entries)), 7.0)1533 let daylight = case bw >. 3.0 {1534 True -> 11535 False -> 01536 }1537 let bars =1538 entries1539 |> list.index_map(fn(entry, i) {1540 let bar_h =1541 int.max(1542 1,1543 float.round(1544 int.to_float(gh)1545 *. int.to_float(entry.optimal)1546 /. int.to_float(peak),1547 ),1548 )1549 let x0 = gx + float.round(int.to_float(i) *. bw)1550 let x1 = gx + float.round(int.to_float(i + 1) *. bw)1551 let width = int.max(1, x1 - x0 - daylight)1552 // A pip under the rule for every board that was solved without ever1553 // going backwards. The bar says how deep the board was; the pip says1554 // the descent was a straight one — two facts about the same solve, and1555 // the second is worth about a pixel. Solves recorded before the shape1556 // of a run was kept have no answer rather than a zero, so they get no1557 // pip and are not claimed as clean.1558 case entry.detours {1559 option.Some(0) -> [1560 scene.rule(x0, gy + gh - bar_h, width, bar_h),1561 scene.rule(x0, gy + gh + 3, width, 2),1562 ]1563 _ -> [scene.rule(x0, gy + gh - bar_h, width, bar_h)]1564 }1565 })1566 |> list.flatten1567 list.flatten([1568 bars,1569 [1570 scene.rule(gx, gy + gh + 1, gw, 1),1571 Label(1572 text: int.to_string(peak),1573 x: int.to_float(m + w - 8),1574 y: int.to_float(y + 12),1575 size: scene.snap(u *. 0.03),1576 face: Text,1577 track: 0.0,1578 align: End,1579 alpha: 0.6,1580 max_w: 0.0,1581 ),1582 ],1583 ])1584 }1585 }15861587 list.flatten([scene.panel(Rect(m, y, w, h)), [heading], body])1588}15891590// ------------------------------------------------------------------ figures15911592fn clamp01(value: Float) -> Float {1593 float.max(0.0, float.min(1.0, value))1594}15951596/// Thousands separated, because a five-figure state count read as a phone1597/// number without them.1598pub fn thousands(value: Int) -> String {1599 let digits = int.to_string(int.absolute_value(value))1600 let grouped =1601 digits1602 |> string.to_graphemes1603 |> list.reverse1604 |> list.index_map(fn(digit, i) {1605 case i > 0 && i % 3 == 0 {1606 True -> digit <> ","1607 False -> digit1608 }1609 })1610 |> list.reverse1611 |> string.concat1612 case value < 0 {1613 True -> "-" <> grouped1614 False -> grouped1615 }1616}16171618pub fn format_time(ms: Int) -> String {1619 case ms < 60_000 {1620 True -> int.to_string(float.round(int.to_float(ms) /. 1000.0)) <> "S"1621 False -> {1622 let minutes = float.round(int.to_float(ms) /. 60_000.0)1623 case minutes < 60 {1624 True -> int.to_string(minutes) <> "M"1625 False ->1626 int.to_string(minutes / 60)1627 <> "H "1628 <> string.pad_start(int.to_string(minutes % 60), 2, "0")1629 <> "M"1630 }1631 }1632 }1633}