1/// Where everything goes.2///3/// A phone is no longer one shape. It is 22:9 and it is 4:3, it folds open into4/// a square, it turns on its side, and a keypad phone is a 4:3 screen the size5/// of a card. What all of those have in common is that the *composition* does6/// not want to change with them: this is a game drawn at one scale in a ~2007/// unit column, and stretching that column to fit a screen is how a designed8/// thing turns into a responsive web page.9///10/// So the column is not the picture. The picture is the screen — the canvas11/// covers the whole safe area — and the column sits in the middle of it at the12/// width it has always been. What that leaves over is not a margin to be13/// apologised for: every pixel nobody paints becomes ground, so on a wide14/// screen the leftover is the desktop, patterned, dithered, and exactly what a15/// Macintosh with a big monitor showed.16///17/// That handles every proportion in one direction. It does not handle the other18/// one: on a landscape screen height is the scarce dimension, and a column19/// stacked board-over-controls has to shrink the board to nothing to fit its20/// own chrome underneath. So there is a second arrangement, in which the same21/// two windows stop stacking and sit side by side. Two windows on a desktop is22/// not a concession to landscape — it is what this system did with a wide23/// screen.24///25/// Which arrangement, and at what upscale, is *chosen* rather than calculated.26/// Both were tried as formulas first and both kept being wrong at the edges: a27/// threshold on the proportion sends a 768x800 fold to the side-by-side28/// arrangement, where it comes out worse than stacked, and any expression that29/// gets the upscale right for a 900-tall desktop gets it wrong for a 393-tall30/// phone on its side. There are only about a dozen candidates, they can each be31/// costed exactly, and the cost is not a matter of opinion — so the search32/// below builds all of them and takes the best. The arrangement falls out of33/// that rather than being a rule anyone has to defend.34import gleam/float35import gleam/int36import gleam/list37import klotski/families.{type Family}38import klotski/shading3940/// The design width, in layout units. Every type size and offset in the app is41/// a fraction of this and always has been; it is the one number the drawing42/// code is entitled to assume.43pub const col = 2004445/// How tall a column is allowed to get before the extra goes to the desktop46/// instead. Past about this, a column stops reading as a page and starts47/// reading as a corridor with the board adrift in the middle of it.48const col_ratio = 1.94950/// Between the two windows when they sit side by side, and how wide the one51/// holding the controls is allowed to be. Its floor is three readouts abreast;52/// past its ceiling it is just a wide empty panel.53const pair_gap = 105455const panel_min = 1005657const panel_max = 1685859/// The board the picture is sized for. Every family is re-fitted afterwards — a60/// 3x5 set gets a bigger cell than a 5x4 one — but the *picture* has to be61/// decided before a family is open, and it cannot change when one is picked.62const ref_w = 46364const ref_h = 56566pub type Mode {67 Stack68 Split69}7071pub type Rect {72 Rect(x: Int, y: Int, w: Int, h: Int)73}7475pub type Box {76 Box(x: Int, y: Int, w: Int, h: Int)77}7879pub type Column {80 Column(x: Int, w: Int, cx: Float)81}8283pub type Panel {84 Panel(x: Int, y: Int, w: Int, h: Int, actions_y: Int)85}8687pub type Gauge {88 Gauge(x: Int, y: Int, w: Int)89}9091/// The picture, and everything that follows from the arrangement it was drawn92/// in. Independent of which family is open, so a resize does not need to know.93pub type Layout {94 Layout(95 mode: Mode,96 scale: Float,97 vw: Int,98 vh: Int,99 off_x: Int,100 off_y: Int,101 bar_h: Int,102 hud_gap: Int,103 head_h: Int,104 foot_h: Int,105 room_w: Int,106 room_h: Int,107 col: Column,108 boot_win: Rect,109 )110}111112/// The board's own measurements, which depend on the family in play — a 3x5 set113/// and a 5x4 set want different cells and a different plate.114///115/// Everything downstream reads the same names in either arrangement: the116/// painters and the DOM chrome are told where the plate, the panel and the117/// gauge are, and never which arrangement put them there.118pub type BoardLayout {119 BoardLayout(120 cell: Int,121 pad_x: Int,122 pad_top: Int,123 pad_bottom: Int,124 below_plate: Int,125 tray_pad: Int,126 plate: Rect,127 board: Rect,128 panel: Panel,129 gauge: Gauge,130 hud_y: Int,131 record_y: Int,132 )133}134135/// The plate's three measurements as functions of the cell, for a given board.136///137/// Shared by the fitting and the drawing so the two cannot drift apart: the138/// tray is inset from the cells because a block's drop shadow reaches past its139/// own cell, and without that margin the bottom and right rules sit directly on140/// the last row of blocks and clip their shadows.141pub fn plate_pad(cell: Int) -> Int {142 int.max(3, round(int.to_float(cell) *. 0.16))143}144145pub fn plate_w(cell: Int, board_w: Int) -> Int {146 cell * board_w + plate_pad(cell) * 2 + round(int.to_float(cell) *. 0.5) * 2147}148149pub fn plate_h(cell: Int, board_h: Int) -> Int {150 round(151 int.to_float(cell * board_h)152 +. int.to_float(plate_pad(cell) * 2)153 +. int.to_float(cell)154 *. 0.5155 +. int.to_float(cell)156 *. 1.05,157 )158}159160/// The largest cell that fits the room, in whole dither periods so an ordered161/// screen does not flash when a block slides. `Error` when even the smallest one162/// does not fit, which is how a candidate picture gets ruled out.163///164/// Stacked, the air under the plate is part of what has to fit: it is measured165/// off the cell, so leaving it out of the fitting is how a board that fits by166/// two units posts the buttons under it off the bottom of the screen.167pub fn fit_cell(168 mode: Mode,169 room_w: Int,170 room_h: Int,171 board_w: Int,172 board_h: Int,173) -> Result(Int, Nil) {174 let need = fn(cell) {175 plate_h(cell, board_h)176 + case mode {177 Split -> 0178 Stack -> round(int.to_float(cell) *. 0.3)179 }180 }181 let fits = fn(cell) {182 need(cell) <= room_h && plate_w(cell, board_w) <= room_w183 }184 let cell = shrink(40, fits)185 case fits(cell) {186 True -> Ok(cell)187 False -> Error(Nil)188 }189}190191fn shrink(cell: Int, fits: fn(Int) -> Bool) -> Int {192 case cell > 16 && !fits(cell) {193 True -> shrink(cell - shading.period, fits)194 False -> cell195 }196}197198/// Everything that follows from an arrangement and an upscale, as a pure199/// function of the two, so a candidate can be built and costed without200/// committing to it.201fn metrics_for(mode: Mode, scale: Float, box: Box) -> Layout {202 let vw = float.truncate(int.to_float(box.w) /. scale)203 let vh = float.truncate(int.to_float(box.h) /. scale)204 let split = mode == Split205206 // The column: where everything that is not the desktop is composed. It is207 // `col` units wide wherever there is room for them, which is the point — the208 // app is drawn at one scale and the screen does not get a say in it. It gives209 // way in exactly two directions. A picture narrower than the design width has210 // to be taken as it comes, and a taller one is allowed to widen a little211 // rather than leave the board stranded in a corridor, up to a limit: past212 // about a third over, the fixed things in it — the margins, the table rows,213 // the buttons — start looking small for the page they are on.214 let col_w = case split {215 True ->216 int.min(217 vw,218 int.max(219 col + 24,220 int.min(221 round(int.to_float(vh) *. 1.2),222 round(int.to_float(col) *. 1.6),223 ),224 ),225 )226 False ->227 int.min(228 vw,229 int.max(230 col,231 int.min(232 round(int.to_float(vh) /. col_ratio),233 round(int.to_float(col) *. 1.35),234 ),235 ),236 )237 }238239 // The menu bar spans the screen, not the column — it did on a Macintosh and a240 // bar that stopped short of the edges would read as a strip of window. It is241 // proportioned against the height it has: a bar taking a twentieth of a tall242 // column would take a tenth of a short one.243 //244 // The floor under the fraction is not a taste number, it is the tallest thing245 // the bar carries plus the air that thing needs. The boxed icons at the right246 // are twelve units square; they want two units of clearance above and below,247 // because both the box and the bar's rule are drawn in ink and a box that248 // comes within a pixel of a line of the same colour stops reading as a box249 // near a line and starts reading as one shape that has gone wrong; and the250 // strip the buttons sit in is inset a unit at each end so it never lands on251 // that rule. Twelve and six is eighteen. At fourteen the fraction was quietly252 // handing the icons a bar exactly as tall as they are, which is how they came253 // to sit on the rule at the upscales where the fraction gives way to the254 // floor.255 let bar_h =256 int.max(257 18,258 round(259 int.to_float(vh)260 *. case split {261 True -> 0.07262 False -> 0.048263 },264 ),265 )266267 // The head and the foot are fractions with floors under them. The fractions268 // are what a tall phone always used; the floors are what stops a short column269 // from computing a foot too small to hold the controls that go in it and270 // posting the actions row off the bottom of the screen. The foot is the271 // gauge, the readout, the buttons and the air between them, so it is stated272 // as exactly that rather than as a fraction that happens to work out.273 let hud_gap = int.min(46, int.max(26, round(int.to_float(vh) *. 0.095)))274 let head_h = int.max(bar_h + 10, round(int.to_float(vh) *. 0.14))275 let foot_h =276 int.max(hud_gap + 30 + 10 + 34 + 4, round(int.to_float(vh) *. 0.3))277278 Layout(279 mode:,280 scale:,281 vw:,282 vh:,283 off_x: 0,284 off_y: 0,285 bar_h:,286 hud_gap:,287 head_h:,288 foot_h:,289 // What the plate has to fit inside — the only thing the two arrangements290 // actually argue about. Split measures against the narrowest the control291 // panel is allowed to be, so the board gets first call on the width and the292 // panel takes what is left.293 room_w: case split {294 True -> vw - 24 - panel_min - pair_gap295 False -> col_w - 8296 },297 // Stacked, the board is fitted against the *least* head it could have — the298 // bar and a little clearance. The head above is a composition, not a299 // reservation: on a tall phone there is room for all of it and the plate300 // sits where it always did, and on a short one it gives way rather than301 // taking a step off the board to keep itself whole.302 room_h: case split {303 True -> vh - bar_h - 12304 False -> vh - foot_h - { bar_h + 10 }305 },306 col: Column(307 x: round({ int.to_float(vw) -. int.to_float(col_w) } /. 2.0),308 w: col_w,309 cx: round({ int.to_float(vw) -. int.to_float(col_w) } /. 2.0)310 |> int.to_float311 |> float.add(int.to_float(col_w) /. 2.0),312 ),313 boot_win: Rect(0, 0, 0, 0),314 )315}316317/// The upscales worth trying, in the order of how much they are worth.318///319/// A buffer pixel has to land on a whole number of *device* pixels or the320/// compositor resamples the one thing in this app that must never be resampled.321/// The picture is upscaled by S CSS pixels per layout unit and composed at322/// `density` buffer pixels per unit, so a buffer pixel covers S*dpr/density323/// device ones — and on the phone this was found on, S=2 with dpr 2.748 makes324/// that 1.832. Nearest-neighbour then keeps five buffer rows out of every six325/// and doubles the sixth, in a rhythm that walks down the screen: a ground326/// ruled every four buffer pixels came out at seven device pixels two times in327/// three and eight the rest, which is not a texture any more, it is a beat.328///329/// So the candidates are the scales where it lands whole: S = density*k/dpr for330/// integer k, which is every scale at which one buffer pixel is exactly k331/// device pixels. S is not an integer and does not need to be — what has to be332/// whole is the device mapping, and this makes a *layout unit* exactly333/// density*k device pixels, so the one-unit rules the whole design is drawn334/// with are whole pixels too, which they were not before.335///336/// The integer upscales are still offered, and rank below any crisp one at the337/// same board size. They are what a display with dpr 2 and density 3 gets:338/// there is no k there that both lands whole and leaves the column wide enough,339/// and a board at half the size is a worse answer than a board that resamples.340///341/// The density here is the *standard* one and not whatever the intensity setting342/// is on, which is a distinction that cost a whole layout. The ladder is cut343/// from it — at dpr 2 a density of 3 offers 1.5, 3 and 4.5, and a density of 2344/// offers 1, 2, 3 and 4 — so reading the setting here let a control over the345/// shading pick a different winner and move every measurement in the app with346/// it. On five of twelve ordinary window sizes, including a phone and two347/// laptops, turning the intensity down changed the upscale, and everything the348/// chrome sizes in layout units changed by a third along with it. Intensity is349/// allowed to change how the picture is *printed* and nothing about how it is350/// laid out.351fn upscales(dpr: Float, density: Int) -> List(#(Float, Bool)) {352 let crisp =353 [1, 2, 3, 4, 5, 6]354 |> list.map(fn(k) { int.to_float(density * k) /. dpr })355 |> list.filter(fn(scale) { scale >=. 1.0 && scale <=. 8.0 })356 |> list.map(fn(scale) { #(scale, True) })357 let plain =358 [1, 2, 3, 4, 5, 6, 7, 8]359 |> list.map(fn(scale) { #(int.to_float(scale), False) })360 list.append(crisp, plain)361}362363/// Build every arrangement at every upscale and take the best of them.364///365/// The cost is the size of the board in real pixels, which is the thing a366/// player is actually given, and it is not the same as the size of the picture:367/// a cell is capped at 40 units, so past a point more units stop turning into368/// more board and the upscale is what has to grow instead.369///370/// Two riders on it. A layout unit under two CSS pixels sets a six-unit label371/// at six pixels, so anything that can only be had at S=1 is a last resort372/// rather than a candidate — it is what a keypad phone gets and nothing else.373/// And where two candidates give the same board, the smaller upscale wins,374/// because it is the one with more units left over for everything around it.375///376/// Crispness is worth about an eighth of the board's size and not more. It is377/// not free: a whole mapping needs the layout unit to be a multiple of `density`378/// device pixels, and which multiples exist depends on the display. At dpr 2.748379/// the crisp scale is *larger* than the integer one and there is nothing to380/// trade. At dpr 2 with density 3 the only crisp scales are 1.5 and 3, and 3381/// leaves the column too narrow to hold, so crispness there would cost a quarter382/// of the board — which is the wrong trade, and a resampled board at full size383/// is what that display gets.384pub fn choose(box: Box, dpr: Float, standard_density: Int) -> Layout {385 let candidates =386 list.flat_map([Stack, Split], fn(mode) {387 list.filter_map(upscales(dpr, standard_density), fn(candidate) {388 let #(scale, crisp) = candidate389 let m = metrics_for(mode, scale, box)390 case m.vw < 150 || m.vw > 640 || m.vh < 120 {391 True -> Error(Nil)392 False ->393 case fit_cell(mode, m.room_w, m.room_h, ref_w, ref_h) {394 Error(_) -> Error(Nil)395 Ok(cell) -> {396 let step = case scale >. 1.0 {397 True -> 1_000_000.0398 False -> 0.0399 }400 let sharpness = case crisp {401 True -> 1.125402 False -> 1.0403 }404 let cost =405 step406 +. int.to_float(cell)407 *. scale408 *. 100.0409 *. sharpness410 -. scale411 Ok(#(cost, m))412 }413 }414 }415 })416 })417418 // Nothing fit at all: a screen this small has no good answer, only an honest419 // one.420 let best =421 list.fold(candidates, #(-1.0, metrics_for(Stack, 1.0, box)), fn(best, it) {422 case it.0 >. best.0 {423 True -> it424 False -> best425 }426 })427 finish(best.1, box)428}429430fn finish(m: Layout, box: Box) -> Layout {431 Layout(432 ..m,433 off_x: box.x434 + round({ int.to_float(box.w) -. int.to_float(m.vw) *. m.scale } /. 2.0),435 off_y: box.y436 + round({ int.to_float(box.h) -. int.to_float(m.vh) *. m.scale } /. 2.0),437 boot_win: Rect(438 x: m.col.x + 12,439 y: round(int.to_float(m.vh) *. 0.15),440 w: m.col.w - 24,441 h: round(int.to_float(m.vh) *. 0.66),442 ),443 )444}445446/// Fit a family to the picture that was already chosen.447///448/// The room was worked out when the picture was chosen; only the board it has449/// to hold changes here. A family that will not fit at the smallest cell gets450/// the smallest cell anyway — there is nothing else to give it.451pub fn board_layout(layout: Layout, family: Family) -> BoardLayout {452 let bw = family.width453 let bh = family.height454 let cell =455 fit_cell(layout.mode, layout.room_w, layout.room_h, bw, bh)456 |> unwrap_or(16)457458 let cellf = int.to_float(cell)459 let tray_pad = plate_pad(cell)460 let pad_x = round(cellf *. 0.5)461 let pad_top = round(cellf *. 0.5)462 // The caption band.463 let pad_bottom = round(cellf *. 1.05)464 // Was a drawn plinth under the plate, a leftover from an earlier direction. A465 // dithered slab under a window is not something QuickDraw would have done and466 // it read as a stray textured bar; the space it held is kept as plain467 // breathing room.468 let below_plate = round(cellf *. 0.3)469 let pw = plate_w(cell, bw)470 let ph = plate_h(cell, bh)471472 let split = layout.mode == Split473 // The panel takes what the plate leaves, between the narrowest that holds474 // three readouts abreast and the widest that is not just empty panel. The475 // board has first call because it is the thing being played; a five-wide set476 // on a square screen is the case where there is nothing spare, and there the477 // panel goes to its floor rather than the board going to its.478 let panel_w = case split {479 True ->480 int.min(panel_max, int.max(panel_min, layout.vw - 24 - pair_gap - pw))481 False -> 0482 }483 // Split centres the pair, so the desktop shows either side of both windows484 // rather than all of it landing on one edge.485 let comp_w = case split {486 True -> pw + pair_gap + panel_w487 False -> pw488 }489 let plate_x =490 round({ int.to_float(layout.vw) -. int.to_float(comp_w) } /. 2.0)491492 // Stacked, the plate takes the head it is owed and then centres itself in493 // whatever is left. On a tall phone that is the same 14% of the height it has494 // always been and the plate lands exactly where it did; on a short one the495 // head shrinks to the bar plus clearance instead of pushing the foot off the496 // bottom of the screen.497 let head_room = layout.vh - layout.foot_h - ph - below_plate498 let head = int.max(layout.bar_h + 10, int.min(layout.head_h, head_room))499 let plate_y = case split {500 True ->501 int.max(502 layout.bar_h + 6,503 round(504 int.to_float(layout.bar_h)505 +. { int.to_float(layout.vh - layout.bar_h - ph) }506 /. 2.0,507 ),508 )509 False ->510 round(511 int.to_float(head) +. int.to_float(int.max(0, head_room - head)) /. 2.0,512 )513 }514515 let foot = plate_y + ph + below_plate516517 // Split, the panel is as tall as the plate and its contents are worth less518 // than that, so they are centred in it as one group rather than pinned to its519 // two ends — pinned, the gauge and the buttons drift apart until the panel520 // reads as two things with a hole between them.521 let inner = case split {522 True -> int.min(26, int.max(8, round(int.to_float(ph) *. 0.12)))523 False -> 0524 }525 let group_h = case split {526 True -> 14 + inner + 30 + inner + 34527 False -> 0528 }529 let group_y = case split {530 True -> plate_y + int.max(4, round(int.to_float(ph - group_h) /. 2.0))531 False -> 0532 }533534 let actions_y = case split {535 True -> group_y + 14 + inner + 30 + inner536 False -> foot + layout.hud_gap + 42537 }538 let panel = case split {539 True ->540 Panel(541 x: plate_x + pw + pair_gap,542 y: plate_y,543 w: panel_w,544 h: ph,545 actions_y:,546 )547 False -> {548 let y = foot + round(int.to_float(layout.vh) *. 0.02)549 Panel(550 x: layout.col.x + 7,551 y:,552 w: layout.col.w - 14,553 h: int.min(layout.vh - 6, actions_y + 34 + 8) - y,554 actions_y:,555 )556 }557 }558559 // The gauge is measured off whatever is holding it, so it spans the column560 // when it is under the board and the panel when it is beside it.561 let gauge_w = case split {562 True -> panel.w - 24563 False -> round(int.to_float(layout.col.w) *. 0.6)564 }565 let gauge_y = case split {566 True -> group_y567 False ->568 foot + int.min(22, int.max(12, round(int.to_float(layout.vh) *. 0.045)))569 }570571 BoardLayout(572 cell:,573 pad_x:,574 pad_top:,575 pad_bottom:,576 below_plate:,577 tray_pad:,578 plate: Rect(x: plate_x, y: plate_y, w: pw, h: ph),579 board: Rect(580 x: plate_x + pad_x + tray_pad,581 y: plate_y + pad_top + tray_pad,582 w: cell * bw,583 h: cell * bh,584 ),585 panel:,586 gauge: Gauge(587 x: case split {588 True -> panel.x + 12589 False -> round(layout.col.cx -. int.to_float(gauge_w) /. 2.0)590 },591 y: gauge_y,592 w: gauge_w,593 ),594 hud_y: case split {595 True -> group_y + 14 + inner596 False -> foot + layout.hud_gap597 },598 // The record stands where the readout does — it is the same row of numbers,599 // counted up rather than counted down.600 record_y: case split {601 True -> group_y + 12 + inner602 False -> foot + round(int.to_float(layout.vh) *. 0.04)603 },604 )605}606607fn round(value: Float) -> Int {608 float.round(value)609}610611fn unwrap_or(result: Result(a, b), fallback: a) -> a {612 case result {613 Ok(value) -> value614 Error(_) -> fallback615 }616}