- commit
- d7f3c1c435bd6bc9804c142133cec3a0781f37a1
- parent
- 12bbe12966ad6b80fa126b03ae2b39e0b9657215
- author
- dami <gazey55@gmail.com>
| M | site/ | 4 | +- |
| M | site/ | 22 | +- |
| M | site/ | 1273 | +++++++++++++++++++++--------- |
| M | site/ | 67 | +- |
| M | site/ | 70 | +- |
| M | site/ | 197 | +++-- |
| M | site/ | 24 | +- |
| M | site/ | 32 | +- |
@@ -16,9 +16,9 @@ export default function CopyPrompt(props: { text: string }) {
1616 return (1717 <button1818 type="button"19+ class={`btn${done ? " on" : ""}`}1920 onClick={copy}20- title="Copy the prompt that bootstraps an agent onto this field"21- style="font:inherit;font-size:12px;background:var(--chrome);color:var(--text);border:1px solid var(--rule);border-radius:4px;padding:3px 10px;cursor:pointer"21+ title="Copy the prompt that seats an agent on this field"2222 >2323 {done ? "copied" : "copy prompt"}2424 </button>@@ -1,5 +1,5 @@
11 // The front page's three states: today's field live; today's field being2-// produced (a status line and the latest finished day's replay underneath,2+// produced (a note and the latest finished day's record underneath,33 // polling until live, no reload); no field for the day (same, without the44 // timer). Switches to live the moment the status says so.55 import { useEffect, useState } from "preact/hooks";@@ -24,16 +24,18 @@ export default function Today(
2424 }, [st.phase]);2525 const line = st.phase === "live"2626 ? (st.emergency27- ? `today's field (${st.date}) is a small emergency field: nothing was prepared or pushed by rollover, so it was generated on the spot`27+ ? `${st.date}: a small emergency field, generated at rollover because nothing was prepared`2828 : null)2929 : st.phase === "generating"30- ? `generating today's field (${st.date}), ${fmt(st.since_s ?? 0)} so far` +30+ ? `${st.date}: the field is being prepared, ${31+ fmt(st.since_s ?? 0)32+ } so far` +3133 (st.latest_archived32- ? `; showing ${st.latest_archived.date}'s replay meanwhile`34+ ? `; the record of ${st.latest_archived.date} meanwhile`3335 : "")34- : `no field for ${st.date} yet` +36+ : `${st.date}: no field yet` +3537 (st.latest_archived36- ? `; showing ${st.latest_archived.date}'s replay meanwhile`38+ ? `; the record of ${st.latest_archived.date} meanwhile`3739 : "");3840 const replay = st.phase === "live"3941 ? null@@ -41,7 +43,10 @@ export default function Today(
4143 return (4244 <div style="display:flex;flex-direction:column;height:100%">4345 {line && (44- <div style="padding:8px 12px;color:var(--muted);font-size:12px;border-bottom:1px solid var(--rule)">46+ <div47+ class="sc"48+ style="padding:6px 16px;color:var(--text-dim);font-size:12px;border-bottom:1px solid var(--hair);background:var(--chrome)"49+ >4550 {line}4651 </div>4752 )}@@ -53,6 +58,9 @@ export default function Today(
5358 replay={replay}5459 theme={props.theme}5560 live={st.phase === "live" ? st.live : null}61+ day={st.phase === "live"62+ ? st.date63+ : st.latest_archived?.date ?? null}5664 />5765 )5866 : (@@ -1,14 +1,16 @@
1-// The spectator: a tilemap with levels of detail, a sidebar (stats,2-// agents, console) and a replay timeline. Live mode follows /events/live3-// over SSE; replay mode loads /log/:id. Both are the same fold.1+// The spectator as a Go match screen in a printed-kifu identity: the board,2+// player cards with clocks (health as byo-yomi), a move counter, the3+// counting line, the move list, the tournament table, a result card at the4+// end of the day. Live mode follows /events/live over SSE; replay mode5+// loads /log/:id. Both are the same fold.46 //57 // Rendering: the field is cut into map tiles (TILE_PX screen pixels at a68 // discrete raster zoom), each rasterised once into an offscreen canvas79 // and cached; an event dirties only the map tiles whose cells it touches;810 // a frame composes cached map tiles at the continuous view scale. Levels:9-// L0 territory (agent-hue washes, unknown dark, holes void), L1 + shape10-// outlines and the ripple, L2 + values as discs, L3 + the lines of the11-// tile under the viewport centre (drawn per frame, not cached).11+// L0 flat territory tints on the board, L1 + shape outlines and counting12+// marks, L2 + stones (1 black, 0 white; givens marked), L3 + board lines,13+// with the frame coordinates and the last-move ring drawn per frame.1214 import { useEffect, useMemo, useRef, useState } from "preact/hooks";1315 import {1416 type AgentInfo,@@ -28,7 +30,6 @@ import {
2830 cellFill,2931 DARK,3032 fogFill,31- inkTone,3233 LIGHT,3334 neutralFill,3435 rgbOf,@@ -45,7 +46,6 @@ import {
4546 type MapTile,4647 Occupancy,4748 occupiedTilesForView,48- RECENCY_REVS,4949 TileCache,5050 tileCellRange,5151 tileScreenRect,@@ -56,17 +56,19 @@ import { Checkpoints } from "../lib/viewer/checkpoints.ts";
5656 import {5757 type Death,5858 density,59- type Marker,6059 markers,6160 type Speed,6261 SPEEDS,6362 stepFor,6463 } from "../lib/viewer/timeline.ts";64+import { type Boards, boardsOf, type Row } from "../lib/leaderboard.ts";6565 6666 const RANK: Record<TileStatus, number> = { hidden: 0, revealed: 1, solved: 2 };67-const RECENCY_STEPS = 4;6867 const BUCKET = 32; // cells per spatial-index bucket6968 const EASE_MS = 300;69+const SNAP_MS = 140; // a stone lands: scale from 0.55 to 1, transform only70+const MAIN_TIME = 20 * 60; // seconds of "main time" a healthy clock shows71+const PERIOD = 30; // seconds per byo-yomi period7072 7173 // ---------------------------------------------------------------- scene7274 @@ -79,7 +81,7 @@ type GeoDerived = {
7981 8082 class Scene {8183 geo: Geometry | null = null;82- theme: Theme = DARK;84+ theme: Theme = LIGHT;8385 buckets = new Map<string, number[]>();8486 masks: Uint8Array[] = [];8587 occ: Occupancy | null = null;@@ -152,35 +154,21 @@ class Scene {
152154 return out;153155 }154156 155- /** One opaque fill for a cell, by its owners' agent hues and status. */156- fill(157- hues: number[],158- status: TileStatus,159- known: boolean,160- recency: number,161- wilt = 0,162- ): string {163- const q = Math.round(recency * RECENCY_STEPS);164- const key = `${hues.join(",")}|${status}|${known ? 1 : 0}|${q}|${wilt}`;157+ /** One opaque fill for a cell: the board tinted toward its owners' hues. */158+ fill(hues: number[], status: TileStatus, known: boolean, wilt = 0): string {159+ const key = `${hues.join(",")}|${status}|${known ? 1 : 0}|${wilt}`;165160 let f = this.fillCache.get(key);166161 if (!f) {167162 f = hues.length168- ? cellFill(169- hues,170- status,171- known,172- this.theme,173- q / RECENCY_STEPS,174- WILT_CHROMA[wilt] ?? 1,175- )176- : neutralFill(status, known, this.theme, q / RECENCY_STEPS);163+ ? cellFill(hues, status, known, this.theme, 0, WILT_CHROMA[wilt] ?? 1)164+ : neutralFill(status, known, this.theme, 0);177165 this.fillCache.set(key, f);178166 }179167 return f;180168 }181169 }182170 183-/** Per-state lookups the raster reads: who holds each tile, by hue. */171+/** Per-state lookups the raster and the overlays read. */184172 type Frame = {185173 s: State;186174 tileHue: Int16Array; // per tile: agent hue or -1@@ -189,6 +177,8 @@ type Frame = {
189177 tileWilt: Uint8Array; // per tile: its agent's wilt level 0..3 (health)190178 agentWilt: Map<string, number>;191179 final: boolean; // the last revision of a finished game: never-reached tiles are shown180+ lastCells: number[]; // cells the latest commit at this revision placed181+ toMove: string | null; // the agent of the latest event192182 };193183 194184 /** `State.reached` once the fold provides it; every tile reached before. */@@ -198,7 +188,12 @@ function reachedOf(s: State): Uint8Array {
198188 return new Uint8Array(s.tiles.length).fill(1);199189 }200190 201-function frameOf(s: State, hues: Map<string, number>, final = false): Frame {191+function frameOf(192+ s: State,193+ hues: Map<string, number>,194+ events: GameEvent[],195+ final = false,196+): Frame {202197 const n = s.tiles.length;203198 const tileHue = new Int16Array(n).fill(-1);204199 // a solved tile is its solver's; a revealed one is its revealer's while@@ -227,6 +222,15 @@ function frameOf(s: State, hues: Map<string, number>, final = false): Frame {
227222 const a = s.tileAgent[t] ?? revealer.get(t) ?? null;228223 if (a !== null) tileWilt[t] = agentWilt.get(a) ?? 0;229224 }225+ // the latest event at this revision: who is "to move", and the stones226+ // it placed (the last-move marks)227+ const ev = s.revision >= 0 ? events[s.revision] : undefined;228+ let lastCells: number[] = [];229+ let toMove: string | null = null;230+ if (ev && ev.kind === "batch") {231+ toMove = ev.agent ?? null;232+ if (ev.op === "commit") lastCells = ev.accepted.map((c) => c.id);233+ } else if (ev && ev.kind === "join") toMove = ev.agent;230234 return {231235 s,232236 tileHue,@@ -235,6 +239,8 @@ function frameOf(s: State, hues: Map<string, number>, final = false): Frame {
235239 tileWilt,236240 agentWilt,237241 final,242+ lastCells,243+ toMove,238244 };239245 }240246 @@ -256,6 +262,34 @@ const WILT_CHROMA = [1, 0.7, 0.45, 0.22];
256262 257263 type Raster = { canvas: HTMLCanvasElement; ppc: number }; // pixels per cell258264 265+/** Draw one stone at (cx, cy) with radius r. */266+function stone(267+ ctx: CanvasRenderingContext2D,268+ cx: number,269+ cy: number,270+ r: number,271+ v: 0 | 1,272+ theme: Theme,273+ given: boolean,274+) {275+ ctx.beginPath();276+ ctx.arc(cx, cy, r, 0, Math.PI * 2);277+ ctx.fillStyle = v === 1 ? theme.stone1 : theme.stone0;278+ ctx.fill();279+ if (v === 0 || theme.name === "dark") {280+ ctx.strokeStyle = theme.line;281+ ctx.lineWidth = Math.max(0.6, r * 0.12);282+ ctx.stroke();283+ }284+ if (given) {285+ // a given stone carries a small mark, like a numbered stone in print286+ ctx.beginPath();287+ ctx.arc(cx, cy, Math.max(0.8, r * 0.28), 0, Math.PI * 2);288+ ctx.fillStyle = v === 1 ? theme.stone0 : theme.stone1;289+ ctx.fill();290+ }291+}292+259293 function renderMapTile(260294 mt: MapTile,261295 f: Frame,@@ -311,14 +345,12 @@ function renderMapTile(
311345 const st = statusOfCell(id);312346 const c = s.cells[id];313347 if (st === null) {314- if (!c) return null; // a hole315- return scene.fill([], "solved", true, 0); // ownerless known cell (v2 filler)348+ if (!c) return null; // a hole: the page shows through349+ return scene.fill([], "solved", true); // ownerless known cell (v2 filler)316350 }317351 if (st === "hidden" && !c && unreachedCell(id)) return fog; // fog: void, unless final318- const age = c ? s.revision - c.rev : RECENCY_REVS;319- const recency = c && age < RECENCY_REVS ? 1 - age / RECENCY_REVS : 0;320352 huesOfCell(id, hues);321- return scene.fill(hues, st, !!c, recency, cellWilt);353+ return scene.fill(hues, st, !!c, cellWilt);322354 };323355 324356 if (ppc <= 2 * dpr) {@@ -360,35 +392,65 @@ function renderMapTile(
360392 }361393 if (level === 0) return { canvas, ppc };362394 363- // L1: shape outlines. A revealed tile (the work in progress) gets a364- // visible edge in its agent's hue; a solved one a faint edge; a hidden365- // one none. Soft: sub-pixel width at low alpha, round joins.395+ // L1: shape outlines in ink, hairline. A revealed tile (the work in396+ // progress) gets its agent's colour; a solved one a plain ink edge; a397+ // hidden one none, except at the end of a finished game, when the398+ // never-explored tiles show faintly.366399 const tiles = scene.tilesIn(x0, y0, x1, y1);367- ctx.lineJoin = "round";368- ctx.lineCap = "round";400+ ctx.lineJoin = "miter";401+ ctx.lineCap = "butt";369402 for (const id of tiles) {370403 const st = s.tiles[id];404+ const t = s.manifest!.tiles[id];371405 if (st === "hidden") {372- // an unreached tile is void; at the end of a finished game its373- // outline shows faintly, as what was never explored374406 if (level >= 2 && f.reached[id] === 0 && f.final) {375- const t = s.manifest!.tiles[id];376- ctx.strokeStyle = theme.muted;377- ctx.globalAlpha = 0.14;378- ctx.lineWidth = Math.max(0.6, Math.min(1.2, ppc * 0.04));407+ ctx.strokeStyle = theme.line;408+ ctx.globalAlpha = 0.18;409+ ctx.lineWidth = Math.max(0.6, Math.min(1, ppc * 0.03));379410 traceOutline(ctx, t, scene.masks[id], ppc, -cx0 * ppc, -cy0 * ppc);380411 }381412 continue;382413 }383- const t = s.manifest!.tiles[id];384414 const h = f.tileHue[id];385415 const revealed = st === "revealed";386- ctx.strokeStyle = h >= 0 ? agentColour(h, theme, revealed) : theme.muted;387- ctx.globalAlpha = revealed ? 0.7 : 0.28;388- ctx.lineWidth = Math.max(0.6, Math.min(1.6, ppc * 0.05));416+ ctx.strokeStyle = revealed && h >= 0417+ ? agentColour(h, theme, true, WILT_CHROMA[f.tileWilt[id]])418+ : theme.line;419+ ctx.globalAlpha = revealed ? 0.95 : 0.55;420+ ctx.lineWidth = Math.max(421+ 0.7,422+ Math.min(1.5, ppc * (revealed ? 0.07 : 0.04)),423+ );389424 traceOutline(ctx, t, scene.masks[id], ppc, -cx0 * ppc, -cy0 * ppc);390425 }391426 ctx.globalAlpha = 1;427+ // counting marks: a small square in the owner's colour on every point428+ // of a solved tile, as a counting diagram marks territory429+ if (ppc >= 3) {430+ const q = Math.max(1, ppc * 0.3);431+ for (const id of tiles) {432+ if (s.tiles[id] !== "solved") continue;433+ const h = f.tileHue[id];434+ if (h < 0) continue;435+ ctx.fillStyle = agentColour(h, theme, true, WILT_CHROMA[f.tileWilt[id]]);436+ const t = s.manifest!.tiles[id];437+ const mask = scene.masks[id];438+ for (let ly = 0; ly < t.h; ly++) {439+ const gy = t.y0 + ly;440+ if (gy < y0 || gy > y1) continue;441+ for (let lx = 0; lx < t.w; lx++) {442+ const gx = t.x0 + lx;443+ if (gx < x0 || gx > x1 || !mask[ly * t.w + lx]) continue;444+ ctx.fillRect(445+ (gx - cx0 + 0.5) * ppc - q / 2,446+ (gy - cy0 + 0.5) * ppc - q / 2,447+ q,448+ q,449+ );450+ }451+ }452+ }453+ }392454 // a dead agent: a small cross on its last solved tile393455 for (const an of s.analysis.agents) {394456 if (an.health.state !== "dead" || an.last_solved === null) continue;@@ -396,7 +458,7 @@ function renderMapTile(
396458 if (!t || !tiles.has(t.id)) continue;397459 const cx = (t.x0 + t.w / 2 - cx0) * ppc, cy = (t.y0 + t.h / 2 - cy0) * ppc;398460 const r = Math.max(3, Math.min(t.w, t.h) * ppc * 0.18);399- ctx.strokeStyle = theme.muted;461+ ctx.strokeStyle = theme.danger;400462 ctx.globalAlpha = 0.9;401463 ctx.lineWidth = Math.max(1, r * 0.22);402464 ctx.beginPath();@@ -409,33 +471,48 @@ function renderMapTile(
409471 ctx.globalAlpha = 1;410472 if (level === 1) return { canvas, ppc };411473 412- // L2: values as discs in a tone of the wash. A 1 is a filled disc, a 0413- // a ring; givens are quieter than commits; a guess is a lighter mark414- // than a forced cell.474+ // L3: the board lines of every revealed or solved tile, through the475+ // cell centres along each of its lines, as a Go board is ruled476+ if (level >= 3) {477+ ctx.strokeStyle = theme.line;478+ ctx.globalAlpha = 0.5;479+ ctx.lineWidth = Math.max(0.6, ppc * 0.035);480+ ctx.beginPath();481+ for (const id of tiles) {482+ if (s.tiles[id] === "hidden") continue;483+ const t = s.manifest!.tiles[id];484+ for (const run of tileLines(t, scene.masks[id])) {485+ const a = run[0], b = run[run.length - 1];486+ ctx.moveTo(487+ (t.x0 + a % t.w + 0.5 - cx0) * ppc,488+ (t.y0 + Math.floor(a / t.w) + 0.5 - cy0) * ppc,489+ );490+ ctx.lineTo(491+ (t.x0 + b % t.w + 0.5 - cx0) * ppc,492+ (t.y0 + Math.floor(b / t.w) + 0.5 - cy0) * ppc,493+ );494+ }495+ }496+ ctx.stroke();497+ ctx.globalAlpha = 1;498+ }499+500+ // L2: stones. A 1 is black, a 0 white; a given carries a small mark.501+ const r = ppc * 0.44;415502 for (let y = y0; y <= y1; y++) {416503 for (let x = x0; x <= x1; x++) {417504 const id = y * G + x;418505 const c = s.cells[id];419506 if (!c) continue;420- const wash = fillOf(id);421- if (wash === null) continue;422- const commit = c.kind === "commit";423- const guess = c.class === "guess";424- const delta = commit ? (guess ? 0.2 : 0.3) : 0.13;425- const ink = inkTone(wash, theme, delta);426- const r = ppc * (guess ? 0.24 : 0.28);427- const ring = Math.max(0.8, ppc * (guess ? 0.06 : 0.085));428- const cx = (x - cx0 + 0.5) * ppc, cy = (y - cy0 + 0.5) * ppc;429- ctx.beginPath();430- ctx.arc(cx, cy, c.v === 1 ? r : r - ring / 2, 0, Math.PI * 2);431- if (c.v === 1) {432- ctx.fillStyle = ink;433- ctx.fill();434- } else {435- ctx.strokeStyle = ink;436- ctx.lineWidth = ring;437- ctx.stroke();438- }507+ stone(508+ ctx,509+ (x - cx0 + 0.5) * ppc,510+ (y - cy0 + 0.5) * ppc,511+ r,512+ c.v as 0 | 1,513+ theme,514+ c.kind !== "commit",515+ );439516 }440517 }441518 return { canvas, ppc };@@ -508,7 +585,7 @@ function tileLines(t: TileSpec, mask: Uint8Array): number[][] {
508585 return out;509586 }510587 511-/** A crown on each seat reserved for yesterday's winner (L1 and up). */588+/** A crown on each seat reserved for yesterday's winner (L1 and up), in ink. */512589 function drawHonours(513590 ctx: CanvasRenderingContext2D,514591 s: State,@@ -524,12 +601,10 @@ function drawHonours(
524601 const cy = oy + (t.y0 + t.h / 2) * scale;525602 const r = Math.max(6, Math.min(18, scale * 1.2));526603 ctx.save();527- ctx.globalAlpha = 0.95;528- ctx.fillStyle = "#d4a83a";529- ctx.strokeStyle = theme.name === "dark" ? "#141416" : "#f2f2f4";604+ ctx.fillStyle = theme.ink;605+ ctx.strokeStyle = theme.bg;530606 ctx.lineWidth = 1;531607 ctx.beginPath();532- // a five-point crown533608 ctx.moveTo(cx - r, cy + r * 0.6);534609 ctx.lineTo(cx - r, cy - r * 0.3);535610 ctx.lineTo(cx - r * 0.5, cy + r * 0.1);@@ -544,93 +619,149 @@ function drawHonours(
544619 }545620 }546621 547-/** L3 overlay: the lines, pins, id and agent of the tile under the centre. */548-function drawFocus(622+/** The last-move ring on the stones the latest commit placed (L2+), and623+ * the placement snap: for SNAP_MS after a change the stones grow in. */624+function drawLastMove(549625 ctx: CanvasRenderingContext2D,550626 f: Frame,551- scene: Scene,552627 view: View,553- W: number,554- H: number,555628 theme: Theme,629+ snap: number, // 0..1 progress of the placement motion, 1 when settled556630 ) {631+ const s = f.s, G = s.G;632+ if (!f.lastCells.length) return;633+ const { scale, ox, oy } = view;634+ const r = scale * 0.44;635+ const k = 0.55 + 0.45 * (1 - Math.pow(1 - snap, 3));636+ ctx.save();637+ for (const id of f.lastCells) {638+ const c = s.cells[id];639+ if (!c) continue;640+ const x = id % G, y = Math.floor(id / G);641+ const cx = ox + (x + 0.5) * scale, cy = oy + (y + 0.5) * scale;642+ if (snap < 1) {643+ // the board under it, then the stone at its current size644+ ctx.beginPath();645+ ctx.arc(cx, cy, r + 1, 0, Math.PI * 2);646+ ctx.fillStyle = theme.panel;647+ ctx.fill();648+ stone(ctx, cx, cy, r * k, c.v as 0 | 1, theme, c.kind !== "commit");649+ }650+ // the ring, in the stone's contrast651+ ctx.beginPath();652+ ctx.arc(cx, cy, r * 0.55, 0, Math.PI * 2);653+ ctx.strokeStyle = c.v === 1 ? theme.stone0 : theme.stone1;654+ ctx.lineWidth = Math.max(1, r * 0.14);655+ ctx.stroke();656+ }657+ ctx.restore();658+}659+660+/** The tile under the viewport centre, if anyone has revealed it. */661+function focusTile(662+ f: Frame,663+ view: View,664+ W: number,665+ H: number,666+): number | null {557667 const s = f.s, geo = s.geo!, G = s.G;558668 const { scale, ox, oy } = view;559669 const cxCell = Math.floor((W / 2 - ox) / scale);560670 const cyCell = Math.floor((H / 2 - oy) / scale);561- if (cxCell < 0 || cyCell < 0 || cxCell >= G || cyCell >= G) return;671+ if (cxCell < 0 || cyCell < 0 || cxCell >= G || cyCell >= G) return null;562672 const id = cyCell * G + cxCell;563673 const owners: number[] = [];564674 for (let i = geo.ownerStart[id]; i < geo.ownerStart[id + 1]; i++) {565675 owners.push(geo.ownerList[i]);566676 }567- if (!owners.length) return;568- // prefer the revealed tile being worked, then the most recently solved677+ if (!owners.length) return null;569678 owners.sort((a, b) => RANK[s.tiles[b]] - RANK[s.tiles[a]]);570679 const tid = owners.find((t) => s.tiles[t] === "revealed") ??571680 owners.find((t) => s.tiles[t] === "solved");572- // nothing to say about a tile nobody has revealed: no lines, no id573- if (tid === undefined) return;681+ return tid ?? null;682+}683+684+/** L3 overlay: the coordinates on the frame of the focus tile, its pins. */685+function drawFocus(686+ ctx: CanvasRenderingContext2D,687+ f: Frame,688+ scene: Scene,689+ view: View,690+ tid: number,691+ theme: Theme,692+) {693+ const s = f.s, geo = s.geo!, G = s.G;694+ const { scale, ox, oy } = view;574695 const t = s.manifest!.tiles[tid];575696 const mask = scene.masks[tid];576- const h = f.tileHue[tid];577- const colour = h >= 0 ? agentColour(h, theme, true) : theme.muted;578697 ctx.save();579- ctx.lineCap = "round";580- // a thin line along each line of the tile: all of them in one path581- // and one stroke, so a crossing is painted once (stroking each line582- // separately at low alpha stacked into a darker cross)583- ctx.strokeStyle = colour;584- ctx.globalAlpha = 0.22;585- ctx.lineWidth = Math.min(3, Math.max(1, scale * 0.05));586- ctx.beginPath();587- for (const run of tileLines(t, mask)) {588- const a = run[0], b = run[run.length - 1];589- const ax = ox + (t.x0 + a % t.w + 0.5) * scale;590- const ay = oy + (t.y0 + Math.floor(a / t.w) + 0.5) * scale;591- const bx = ox + (t.x0 + b % t.w + 0.5) * scale;592- const by = oy + (t.y0 + Math.floor(b / t.w) + 0.5) * scale;593- ctx.moveTo(ax, ay);594- ctx.lineTo(bx, by);698+ // the frame: a hairline box around the tile with coordinates outside it,699+ // letters along the top, numbers down the left, as a board is lettered700+ ctx.strokeStyle = theme.line;701+ ctx.globalAlpha = 0.9;702+ ctx.lineWidth = 1;703+ traceOutline(ctx, t, mask, scale, ox, oy);704+ const fs = Math.max(9, Math.min(13, scale * 0.35));705+ ctx.font = `${fs}px "Noto Serif JP", serif`;706+ ctx.fillStyle = theme.ink;707+ ctx.textAlign = "center";708+ ctx.textBaseline = "bottom";709+ for (let lx = 0; lx < t.w; lx++) {710+ // the top-most cell of this column711+ let top = -1;712+ for (let ly = 0; ly < t.h; ly++) {713+ if (mask[ly * t.w + lx]) {714+ top = ly;715+ break;716+ }717+ }718+ if (top < 0) continue;719+ const L = String.fromCharCode(65 + (lx >= 8 ? lx + 1 : lx)); // no I, as on a board720+ ctx.fillText(721+ L,722+ ox + (t.x0 + lx + 0.5) * scale,723+ oy + (t.y0 + top) * scale - 3,724+ );725+ }726+ ctx.textAlign = "right";727+ ctx.textBaseline = "middle";728+ for (let ly = 0; ly < t.h; ly++) {729+ let left = -1;730+ for (let lx = 0; lx < t.w; lx++) {731+ if (mask[ly * t.w + lx]) {732+ left = lx;733+ break;734+ }735+ }736+ if (left < 0) continue;737+ ctx.fillText(738+ String(t.h - ly),739+ ox + (t.x0 + left) * scale - 4,740+ oy + (t.y0 + ly + 0.5) * scale,741+ );595742 }596- ctx.stroke();597743 // pins: the overlaps an alternative solution differs on, marked on598- // their shared cells599- ctx.globalAlpha = 0.5;600- ctx.fillStyle = colour;744+ // their shared cells with a small triangle745+ ctx.fillStyle = theme.ink;746+ ctx.globalAlpha = 0.85;601747 for (const oi of t.pins ?? []) {602748 const o = t.overlaps[oi];603749 if (!o) continue;604750 const other = new Set(geo.cellsOf[o.with]);605751 for (const c of geo.cellsOf[tid]) {606752 if (!other.has(c)) continue;753+ if (s.cells[c]) continue; // a stone sits there already607754 const x = c % G, y = Math.floor(c / G);755+ const cx = ox + (x + 0.5) * scale, cy = oy + (y + 0.5) * scale;756+ const r = scale * 0.16;608757 ctx.beginPath();609- ctx.arc(610- ox + (x + 0.5) * scale,611- oy + (y + 0.5) * scale,612- scale * 0.1,613- 0,614- Math.PI * 2,615- );758+ ctx.moveTo(cx, cy - r);759+ ctx.lineTo(cx + r, cy + r * 0.8);760+ ctx.lineTo(cx - r, cy + r * 0.8);761+ ctx.closePath();616762 ctx.fill();617763 }618764 }619- // label620- ctx.globalAlpha = 0.9;621- ctx.font = "12px system-ui, sans-serif";622- const agent = s.tileAgent[tid];623- const label = `#${tid} ${t.cells} cells ${t.w}x${t.h}${624- agent ? ` ${f.agentName.get(agent) ?? agent}` : ""625- }`;626- const lx = ox + t.x0 * scale, ly = oy + t.y0 * scale - 6;627- ctx.fillStyle = theme.bg;628- const tw = ctx.measureText(label).width;629- ctx.globalAlpha = 0.7;630- ctx.fillRect(lx - 3, ly - 12, tw + 6, 16);631- ctx.globalAlpha = 1;632- ctx.fillStyle = colour;633- ctx.fillText(label, lx, ly);634765 ctx.restore();635766 }636767 @@ -647,7 +778,8 @@ function compose(
647778 log: ChangeLog,648779 theme: Theme,649780 perf: Perf,650-): { redrawn: number; visible: number; level: Level } {781+ snap: number,782+): { redrawn: number; visible: number; level: Level; focus: number | null } {651783 const ctx = canvas.getContext("2d")!;652784 const dpr = globalThis.devicePixelRatio || 1;653785 const W = canvas.clientWidth, H = canvas.clientHeight;@@ -665,9 +797,9 @@ function compose(
665797 const s = f.s;666798 if (!s.manifest || !s.geo) {667799 ctx.fillStyle = theme.muted;668- ctx.font = "13px system-ui, sans-serif";800+ ctx.font = `13px "Noto Serif JP", serif`;669801 ctx.fillText("waiting for a game", 16, 24);670- return { redrawn: 0, visible: 0, level: 0 };802+ return { redrawn: 0, visible: 0, level: 0, focus: null };671803 }672804 scene.prepare(s.geo, s.manifest.tiles, theme);673805 const tiles = occupiedTilesForView(s.G, view, W, H, scene.occ);@@ -683,7 +815,6 @@ function compose(
683815 }684816 const { x, y, size } = tileScreenRect(mt, view);685817 // snap to whole device pixels so neighbouring map tiles abut exactly686- // (a fractional edge would leave a hairline seam between them)687818 const xr = Math.round(x * dpr) / dpr, yr = Math.round(y * dpr) / dpr;688819 const wr = Math.round((x + size) * dpr) / dpr - xr;689820 const hr = Math.round((y + size) * dpr) / dpr - yr;@@ -701,7 +832,12 @@ function compose(
701832 }702833 const level = levelFor(view.scale);703834 if (level >= 1) drawHonours(ctx, f.s, view, theme);704- if (level === 3) drawFocus(ctx, f, scene, view, W, H, theme);835+ let focus: number | null = null;836+ if (level >= 2) drawLastMove(ctx, f, view, theme, snap);837+ if (level === 3) {838+ focus = focusTile(f, view, W, H);839+ if (focus !== null) drawFocus(ctx, f, scene, view, focus, theme);840+ }705841 const dt = performance.now() - t0;706842 perf.frames.push(dt);707843 perf.redraws.push(redrawn);@@ -711,7 +847,7 @@ function compose(
711847 perf.redraws.shift();712848 perf.visible.shift();713849 }714- return { redrawn, visible: tiles.length, level };850+ return { redrawn, visible: tiles.length, level, focus };715851 }716852 717853 /** Frame the generated tiles (the cluster), not the G x G field: a daily@@ -726,17 +862,42 @@ function fitView(s: State, W: number, H: number): View {
726862 return fitBox(box, W, H);727863 }728864 729-// ---------------------------------------------------------------- timeline865+// ---------------------------------------------------------------- clocks730866 731-const MARKER_COLOUR: Record<Marker["kind"], string> = {732- join: "#7aa2f7",733- solved: "#9ece6a",734- meet: "#f7768e",735- "guess-run": "#e0af68",736- death: "#8c8c92",737- grow: "#bb9af7",738- end: "#8c8c92",739-};867+type Clock =868+ | { kind: "main"; secs: number }869+ | { kind: "byo"; periods: number; secs: number }870+ | { kind: "timeout" };871+872+/** Health as a Go clock: main time while healthy (the distance to the873+ * warning line), byo-yomi periods at warning (the distance to death),874+ * timeout when dead. A reading of the rate, not a running timer. */875+export function clockOf(h: Health): Clock {876+ if (h.state === "dead") return { kind: "timeout" };877+ if (h.state === "warning") {878+ const k = Math.max(879+ 0,880+ Math.min(1, (h.rate - HEALTH_WARN) / (HEALTH_DEATH - HEALTH_WARN)),881+ );882+ const left = 3 - k * 3; // periods left, fractional883+ const periods = Math.max(1, Math.ceil(left));884+ const frac = left - Math.floor(left);885+ return {886+ kind: "byo",887+ periods,888+ secs: Math.round((frac === 0 ? 1 : frac) * PERIOD),889+ };890+ }891+ const k = Math.max(0, Math.min(1, h.rate / HEALTH_WARN));892+ return { kind: "main", secs: Math.round(MAIN_TIME * (1 - k)) };893+}894+895+function mmss(secs: number): string {896+ const m = Math.floor(secs / 60), s = secs % 60;897+ return `${m}:${String(s).padStart(2, "0")}`;898+}899+900+// ---------------------------------------------------------------- timeline740901 741902 function Timeline(props: {742903 events: GameEvent[];@@ -763,48 +924,70 @@ function Timeline(props: {
763924 const ctx = c.getContext("2d")!;764925 ctx.setTransform(dpr, 0, 0, dpr, 0, 0);765926 ctx.clearRect(0, 0, W, H);927+ const th = props.theme;766928 const n = Math.max(1, props.max);767929 const xOf = (rev: number) => (rev / n) * (W - 2) + 1;768- // density strip769- ctx.fillStyle = props.theme.name === "dark" ? "#2a2a30" : "#d8d8de";930+ // the rule, and activity as ticks below it931+ ctx.fillStyle = th.line;932+ ctx.globalAlpha = 0.9;933+ ctx.fillRect(0, 14, W, 1);934+ ctx.globalAlpha = 0.35;770935 const bw = W / dens.length;771936 for (let i = 0; i < dens.length; i++) {772- const h = 2 + dens[i] * (H - 14);773- ctx.fillRect(i * bw, H - 4 - h, Math.max(1, bw - 0.5), h);937+ const h = dens[i] * (H - 18);938+ if (h > 0) ctx.fillRect(i * bw, 16, Math.max(1, bw - 0.6), h);774939 }775- // markers940+ ctx.globalAlpha = 1;941+ // markers on the rule, in kifu notation: a stone for a join, a filled942+ // mark for a tile solved, two stones for a meeting, a chevron where943+ // the field grew, a cross for a death, a bar at the end776944 for (const m of marks) {777- ctx.fillStyle = MARKER_COLOUR[m.kind];778945 const x = xOf(m.rev);779- if (m.kind === "meet" || m.kind === "join") {946+ ctx.fillStyle = th.ink;947+ ctx.strokeStyle = th.ink;948+ ctx.lineWidth = 1;949+ if (m.kind === "join") {780950 ctx.beginPath();781- ctx.arc(x, 6, 3.5, 0, Math.PI * 2);951+ ctx.arc(x, 8, 3, 0, Math.PI * 2);952+ ctx.fillStyle = th.bg;782953 ctx.fill();783- } else if (m.kind === "guess-run") {784- ctx.fillRect(x - 1, 2, 2, 8);954+ ctx.stroke();955+ } else if (m.kind === "solved") {956+ ctx.fillRect(x - 0.5, 10, 1, 5);957+ } else if (m.kind === "meet") {958+ ctx.beginPath();959+ ctx.arc(x - 2.5, 8, 2.5, 0, Math.PI * 2);960+ ctx.fill();961+ ctx.beginPath();962+ ctx.arc(x + 2.5, 8, 2.5, 0, Math.PI * 2);963+ ctx.fillStyle = th.bg;964+ ctx.fill();965+ ctx.stroke();785966 } else if (m.kind === "death") {786- // a small cross: an agent bled out here787- ctx.fillRect(x - 3, 5, 6, 1.5);788- ctx.fillRect(x - 0.75, 2, 1.5, 8);967+ ctx.strokeStyle = th.danger;968+ ctx.lineWidth = 1.5;969+ ctx.beginPath();970+ ctx.moveTo(x - 3.5, 4.5);971+ ctx.lineTo(x + 3.5, 11.5);972+ ctx.moveTo(x + 3.5, 4.5);973+ ctx.lineTo(x - 3.5, 11.5);974+ ctx.stroke();789975 } else if (m.kind === "grow") {790- // a small chevron: the field opened here791976 ctx.beginPath();792- ctx.moveTo(x - 4, 10);793- ctx.lineTo(x, 2);794- ctx.lineTo(x + 4, 10);795- ctx.closePath();796- ctx.fill();977+ ctx.moveTo(x - 4, 12);978+ ctx.lineTo(x, 4);979+ ctx.lineTo(x + 4, 12);980+ ctx.stroke();981+ } else if (m.kind === "guess-run") {982+ ctx.fillStyle = th.danger;983+ ctx.fillRect(x - 0.5, 4, 1, 7);797984 } else {798- ctx.fillRect(x - 0.5, 4, 1, 5);985+ ctx.fillRect(x - 1, 2, 2, 12);799986 }800987 }801988 // playhead802989 const x = xOf(Math.max(0, props.rev));803- ctx.fillStyle = props.live804- ? "#7aa2f7"805- : props.theme.name === "dark"806- ? "#e8e8ea"807- : "#141416";990+ ctx.fillStyle = props.live ? th.danger : th.ink;808991 ctx.fillRect(x - 1, 0, 2, H);809992 }, [810993 props.events,@@ -824,7 +1007,6 @@ function Timeline(props: {
8241007 Math.min(1, (e.clientX - rect.left - 1) / (rect.width - 2)),8251008 );8261009 let rev = Math.round(f * props.max);827- // snap to a marker within 4 px8281010 const px = rect.width / props.max;8291011 for (const m of marks) {8301012 if (Math.abs((m.rev - rev) * px) <= 4) {@@ -838,8 +1020,8 @@ function Timeline(props: {
8381020 return (8391021 <canvas8401022 ref={ref}841- title="timeline: click or drag to scrub; dots are joins (blue) and meetings (red), bars solved tiles (green) and guess runs (amber), chevrons the field growing (violet)"842- style="flex:1;height:36px;display:block;cursor:pointer"1023+ class="rule-strip"1024+ title="the day: click or drag to scrub; stones are joins, filled marks tiles solved, two stones a meeting, a chevron the field growing, a cross a death"8431025 onPointerDown={(e) => {8441026 dragging.current = true;8451027 (e.currentTarget as HTMLCanvasElement).setPointerCapture(e.pointerId);@@ -855,14 +1037,104 @@ function Timeline(props: {
8551037 );8561038 }8571039 1040+// ---------------------------------------------------------------- styles1041+1042+// The chrome is not selectable (a board, cards and controls are not text1043+// to copy); the move list, the tile detail and the table are.1044+const STYLE = `1045+.match{display:grid;grid-template-columns:minmax(0,1fr) 360px;grid-template-rows:minmax(0,1fr) auto auto;height:100%;background:var(--bg);user-select:none;-webkit-user-select:none}1046+.match .pane{user-select:text;-webkit-user-select:text}1047+.board{position:relative;grid-column:1;grid-row:1;min-height:0}1048+.board canvas.field{width:100%;height:100%;display:block;cursor:grab;touch-action:none}1049+.chip{position:absolute;top:10px;right:12px;font-size:12px;color:var(--text);background:var(--bg);border:1px solid var(--rule);padding:2px 8px;pointer-events:none}1050+.chip b{font-weight:600}1051+.counting{grid-column:1;grid-row:2;display:flex;align-items:baseline;border-top:1px solid var(--rule);padding:6px 12px;font-size:13px;overflow-x:auto;white-space:nowrap;background:var(--chrome)}1052+.counting .lbl{font-variant-caps:small-caps;letter-spacing:.06em;color:var(--muted);margin-right:14px}1053+.counting .p{display:inline-flex;align-items:baseline;gap:6px;margin-right:18px}1054+.counting .p b{font-weight:600}1055+.counting .p.lead b{text-decoration:underline;text-underline-offset:3px}1056+.st{display:inline-block;width:10px;height:10px;border-radius:50%;border:1px solid var(--rule);vertical-align:-1px}1057+.dock{grid-column:2;grid-row:1 / span 2;border-left:2px solid var(--rule);display:flex;flex-direction:column;min-height:0;background:var(--chrome)}1058+.dock .head{display:flex;align-items:baseline;gap:10px;padding:10px 14px 6px;border-bottom:1px solid var(--rule)}1059+.dock .head .day{font-size:15px;font-weight:600}1060+.dock .head .mv{margin-left:auto;font-size:13px;color:var(--text-dim)}1061+.players{display:flex;flex-direction:column;border-bottom:1px solid var(--rule)}1062+.player{display:grid;grid-template-columns:22px minmax(0,1fr) auto;column-gap:10px;row-gap:1px;align-items:center;padding:8px 14px;border-bottom:1px solid var(--hair);position:relative}1063+.player:last-child{border-bottom:0}1064+.player .av{width:18px;height:18px;border-radius:50%;border:1px solid var(--rule);grid-row:1 / span 2}1065+.player .name{font-weight:600;font-size:14px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}1066+.player .rank{font-size:11.5px;color:var(--text-dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}1067+.player .nums{text-align:right;font-size:12.5px;line-height:1.3;white-space:nowrap}1068+.player .nums b{font-weight:600}1069+.player .clock{grid-column:2 / span 2;display:flex;gap:8px;align-items:baseline;font-size:12px;color:var(--text-dim);margin-top:2px}1070+.player .clock b{font-weight:600;color:var(--text)}1071+.player .clock.byo b{color:var(--danger)}1072+.player .periods i{display:inline-block;width:7px;height:7px;border:1px solid var(--rule);margin-right:3px;vertical-align:-1px}1073+.player .periods i.left{background:var(--danger);border-color:var(--danger)}1074+.player.tomove{background:var(--text);color:var(--bg)}1075+.player.tomove .rank,.player.tomove .clock,.player.tomove .nums{color:var(--bg)}1076+.player.tomove .clock b{color:var(--bg)}1077+.player.tomove .av,.player.tomove .periods i{border-color:var(--bg)}1078+.player .tag{position:absolute;right:14px;top:5px;font-size:10px;font-variant-caps:small-caps;letter-spacing:.08em}1079+.player.dead .name{text-decoration:line-through}1080+.tabs{display:flex;border-bottom:1px solid var(--rule)}1081+.tabs button{flex:1;background:none;border:0;border-right:1px solid var(--hair);padding:6px 4px;font-size:12px;font-variant-caps:small-caps;letter-spacing:.06em;cursor:pointer;color:var(--text-dim);border-radius:0}1082+.tabs button:last-child{border-right:0}1083+.tabs button.on{color:var(--text);box-shadow:inset 0 -2px 0 var(--rule)}1084+.pane{flex:1;min-height:0;overflow:auto;font-size:12.5px}1085+.moves{display:grid;grid-template-columns:auto 14px minmax(0,1fr);column-gap:8px;padding:6px 14px;align-items:baseline}1086+.moves .n{color:var(--muted);text-align:right}1087+.moves .who{color:var(--text-dim)}1088+.moves .what{color:var(--text)}1089+.moves .note{grid-column:1 / span 3;font-style:italic;color:var(--text-dim);padding:3px 0}1090+.moves .note.danger{color:var(--danger)}1091+.moves .cur{font-weight:600}1092+.tile{padding:10px 14px;line-height:1.7}1093+.tile .k{color:var(--muted);font-variant-caps:small-caps;letter-spacing:.06em;margin-right:8px}1094+table.tt{border-collapse:collapse;width:100%;font-size:12px}1095+table.tt th{font-weight:600;font-variant-caps:small-caps;letter-spacing:.06em;text-align:left;padding:6px 6px 4px;border-bottom:1px solid var(--rule);color:var(--text-dim)}1096+table.tt td{padding:4px 6px;border-bottom:1px solid var(--hair);vertical-align:baseline}1097+table.tt td.n,table.tt th.n{text-align:right}1098+table.tt .cap{padding:10px 6px 4px;font-variant-caps:small-caps;letter-spacing:.06em;color:var(--muted);border:0}1099+.result{margin:10px 14px 0;border:2px solid var(--rule);padding:10px 12px;background:var(--bg)}1100+.result .r1{font-variant-caps:small-caps;letter-spacing:.1em;font-size:12px;color:var(--text-dim)}1101+.result .r2{font-size:17px;font-weight:600;margin:2px 0 6px}1102+.controls{display:flex;align-items:center;gap:8px;padding:6px 12px;border-top:2px solid var(--rule);background:var(--chrome);grid-column:1 / span 2;grid-row:3}1103+.controls .rev{color:var(--muted);font-size:12px;min-width:64px;white-space:nowrap}1104+.controls canvas.rule-strip{flex:1;height:34px;display:block;cursor:pointer;min-width:60px}1105+.controls .perf{color:var(--muted);font-size:11px;white-space:nowrap}1106+.sheet{display:none}1107+@media (max-width:720px){1108+ .match{grid-template-columns:minmax(0,1fr);grid-template-rows:auto minmax(0,1fr) auto auto}1109+ .board{grid-row:2}1110+ .counting{grid-row:3;padding:4px 10px;font-size:12px}1111+ .dock{grid-column:1;grid-row:1;border-left:0;border-bottom:2px solid var(--rule)}1112+ .dock .pane,.dock .tabs,.dock .head,.dock .result{display:none}1113+ .dock .players{border-bottom:0;flex-direction:row;overflow-x:auto}1114+ .player{flex:0 0 220px;padding:6px 10px;border-bottom:0;border-right:1px solid var(--hair)}1115+ .sheet{position:fixed;left:0;right:0;bottom:0;z-index:5;background:var(--chrome);border-top:2px solid var(--rule);display:flex;flex-direction:column;height:46px;transition:height .25s ease}1116+ .sheet.open{height:52vh}1117+ .sheet .handle{display:flex;align-items:center;justify-content:center;height:16px;cursor:pointer;flex:none}1118+ .sheet .handle i{width:36px;height:3px;background:var(--rule);display:block}1119+ .controls{grid-row:4;padding-bottom:52px;gap:6px}1120+ .controls select{max-width:104px}1121+ .chip{top:6px;right:8px;font-size:11px}1122+ .controls .perf{display:none}1123+}1124+`;1125+8581126 // ---------------------------------------------------------------- island8591127 1128+type Tab = "moves" | "tile" | "table";1129+8601130 export default function Viewer(8611131 props: {8621132 replay: string | null;8631133 theme: "dark" | "light" | null;8641134 /** follow this game live (the daily); null follows the newest game */8651135 live?: string | null;1136+ /** the day this game is (for the head and the result card) */1137+ day?: string | null;8661138 },8671139 ) {8681140 const canvasRef = useRef<HTMLCanvasElement>(null);@@ -870,8 +1142,6 @@ export default function Viewer(
8701142 const scene = useRef(new Scene());8711143 // ~40 map tiles are visible at once; 160 keeps one level plus its8721144 // neighbours warm without holding hundreds of megabytes of canvases873- // (at dpr 2 a raster is ~1 MB), which is what gets a background tab874- // discarded by the browser8751145 const cache = useRef(new TileCache<Raster>(160));8761146 const log = useRef(new ChangeLog());8771147 const perf = useRef<Perf>({ frames: [], redraws: [], visible: [] });@@ -880,14 +1150,17 @@ export default function Viewer(
8801150 const [live, setLive] = useState(props.replay === null);8811151 const [gameId, setGameId] = useState<string | null>(null);8821152 const [view, setView] = useState<View | null>(null);883- const [theme, setTheme] = useState<Theme>(DARK);1153+ const [theme, setTheme] = useState<Theme>(LIGHT);8841154 const [playing, setPlaying] = useState(false);8851155 const [speed, setSpeed] = useState<Speed>(4);8861156 const [follow, setFollow] = useState<string>("");1157+ const [tab, setTab] = useState<Tab>("moves");1158+ const [sheet, setSheet] = useState(false);8871159 const [drawInfo, setDrawInfo] = useState({8881160 redrawn: 0,8891161 visible: 0,8901162 level: 0 as Level,1163+ focus: null as number | null,8911164 });8921165 8931166 useEffect(() => {@@ -896,9 +1169,9 @@ export default function Viewer(
8961169 setTheme(props.theme === "dark" ? DARK : LIGHT);8971170 return;8981171 }899- const mq = globalThis.matchMedia?.("(prefers-color-scheme: light)");1172+ const mq = globalThis.matchMedia?.("(prefers-color-scheme: dark)");9001173 if (!mq) return;901- const apply = () => setTheme(mq.matches ? LIGHT : DARK);1174+ const apply = () => setTheme(mq.matches ? DARK : LIGHT);9021175 apply();9031176 mq.addEventListener("change", apply);9041177 return () => mq.removeEventListener("change", apply);@@ -951,7 +1224,6 @@ export default function Viewer(
9511224 () => (events.length ? cps.current.at(events.length - 1) : state),9521225 [events],9531226 );954- // deaths, read off the final state: the timeline marks them9551227 const deaths = useMemo(9561228 () =>9571229 final.agents.filter((a) => a.health.died_at !== null).map((a) => ({@@ -962,13 +1234,9 @@ export default function Viewer(
9621234 [final],9631235 );9641236 965- // hues are assigned from the full agent list, so a scrubbed frame and966- // the final frame agree967- // a hue, once shown, never changes: growth may change adjacency, and a968- // hue reassignment would repaint a whole territory1237+ // a hue, once shown, never changes9691238 const huesRef = useRef(new Map<string, number>());9701239 const hues = useMemo(() => {971- // yesterday's winner keeps the hue reserved for them9721240 for (const h of final.honours) {9731241 const a = final.agents.find((x) =>9741242 x.id === h.agent || x.id === h.user || x.name === h.name@@ -979,17 +1247,12 @@ export default function Viewer(
9791247 huesRef.current = h;9801248 return h;9811249 }, [final.agents, final.geo, final.honours]);982- // fog is fog while a game runs or is scrubbed; only the last revision983- // of a finished game shows what was never explored1250+ const ended = events.length > 0 &&1251+ events[events.length - 1]?.kind === "end";1252+ const atEnd = ended && state.revision === events.length - 1;9841253 const frame = useMemo(985- () =>986- frameOf(987- state,988- hues,989- state.revision === events.length - 1 &&990- events[events.length - 1]?.kind === "end",991- ),992- [state, hues, events],1254+ () => frameOf(state, hues, events, atEnd),1255+ [state, hues, events, atEnd],9931256 );9941257 9951258 // change log: which map tiles each event touched (fed once per event)@@ -998,9 +1261,6 @@ export default function Viewer(
9981261 useEffect(() => {9991262 const geo = final.geo;10001263 if (!geo || !final.manifest) return;1001- // a new game (or a different field size) starts a new log; a grown1002- // geometry of the same game keeps it, the grow event dirties exactly1003- // the map tiles its new tiles touch10041264 const key = `${final.manifest.id}|${geo.G}`;10051265 if (logKey.current !== key || fed.current > events.length) {10061266 logKey.current = key;@@ -1029,12 +1289,24 @@ export default function Viewer(
10291289 setView(fitView(final, c.clientWidth, c.clientHeight));10301290 }, [state.manifest, view]);10311291 1292+ // the placement snap: when the revision changes, the newest stones grow1293+ // in over SNAP_MS; the raster already holds them, the overlay redraws1294+ // them scaled for those frames1295+ const snapAt = useRef(0);1296+ const lastRev = useRef(-2);1297+ if (state.revision !== lastRev.current) {1298+ lastRev.current = state.revision;1299+ snapAt.current = performance.now();1300+ }1301+10321302 // draw10331303 useEffect(() => {10341304 const c = canvasRef.current;10351305 if (!c) return;10361306 let raf = 0;10371307 const paint = () => {1308+ const el = performance.now() - snapAt.current;1309+ const snap = Math.max(0, Math.min(1, el / SNAP_MS));10381310 const info = compose(10391311 c,10401312 frame,@@ -1044,15 +1316,17 @@ export default function Viewer(
10441316 log.current,10451317 theme,10461318 perf.current,1319+ snap,10471320 );10481321 setDrawInfo((d) =>10491322 d.redrawn === info.redrawn && d.visible === info.visible &&1050- d.level === info.level1323+ d.level === info.level && d.focus === info.focus10511324 ? d10521325 : info10531326 );10541327 (globalThis as unknown as { __mosaicPerf: Perf }).__mosaicPerf =10551328 perf.current;1329+ if (snap < 1) raf = requestAnimationFrame(paint);10561330 };10571331 raf = requestAnimationFrame(paint);10581332 const ro = new ResizeObserver(() => requestAnimationFrame(paint));@@ -1063,14 +1337,16 @@ export default function Viewer(
10631337 };10641338 }, [frame, view, theme]);10651339 1066- // console follows live1340+ // the move list follows the playhead10671341 useEffect(() => {10681342 const p = paneRef.current;1069- if (p && live) p.scrollTop = p.scrollHeight;1070- }, [state.revision, live]);1343+ if (!p || tab !== "moves") return;1344+ const cur = p.querySelector(".cur") as HTMLElement | null;1345+ if (cur) cur.scrollIntoView({ block: "nearest" });1346+ else if (live) p.scrollTop = p.scrollHeight;1347+ }, [state.revision, live, tab]);10711348 1072- // a hidden tab keeps nothing expensive: playback pauses, the raster1073- // cache is released (rebuilt in a few frames on return)1349+ // a hidden tab keeps nothing expensive10741350 useEffect(() => {10751351 const onVis = () => {10761352 if (document.hidden) {@@ -1126,7 +1402,6 @@ export default function Viewer(
11261402 const k = Math.min(1, (now - start) / EASE_MS);11271403 const e = 1 - Math.pow(1 - k, 3);11281404 const t = targetRef.current!;1129- // interpolate in log-scale so zoom feels even11301405 const ls = Math.log(from.scale) +11311406 (Math.log(t.scale) - Math.log(from.scale)) * e;11321407 setView({@@ -1155,31 +1430,84 @@ export default function Viewer(
11551430 });11561431 }, [follow, state.revision]);11571432 1158- // pan and zoom1433+ // pan, wheel zoom, pinch zoom1434+ const pointers = useRef(new Map<number, { x: number; y: number }>());11591435 const dragRef = useRef<11601436 { x: number; y: number; ox: number; oy: number } | null11611437 >(null);1438+ const pinchRef = useRef<1439+ { d: number; scale: number; cx: number; cy: number } | null1440+ >(null);11621441 useEffect(() => {11631442 const c = canvasRef.current;11641443 if (!c) return;1444+ const zoomAt = (v: View, mx: number, my: number, ns: number): View => {1445+ const f = ns / v.scale;1446+ return { scale: ns, ox: mx - (mx - v.ox) * f, oy: my - (my - v.oy) * f };1447+ };1448+ const bounds = () => {1449+ const fit = fitView(final, c.clientWidth, c.clientHeight).scale;1450+ return { minS: fit * 0.5, maxS: 64 };1451+ };11651452 const down = (e: PointerEvent) => {11661453 const v = viewRef.current;1167- if (!v || e.button !== 0) return;1454+ if (!v) return;11681455 cancelAnimationFrame(animRef.current);1169- dragRef.current = { x: e.clientX, y: e.clientY, ox: v.ox, oy: v.oy };11701456 c.setPointerCapture(e.pointerId);1457+ pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });1458+ if (pointers.current.size === 2) {1459+ const [a, b] = [...pointers.current.values()];1460+ const rect = c.getBoundingClientRect();1461+ pinchRef.current = {1462+ d: Math.hypot(a.x - b.x, a.y - b.y),1463+ scale: v.scale,1464+ cx: (a.x + b.x) / 2 - rect.left,1465+ cy: (a.y + b.y) / 2 - rect.top,1466+ };1467+ dragRef.current = null;1468+ } else if (e.button === 0) {1469+ dragRef.current = { x: e.clientX, y: e.clientY, ox: v.ox, oy: v.oy };1470+ }11711471 e.preventDefault();11721472 };11731473 const move = (e: PointerEvent) => {1174- const d = dragRef.current, v = viewRef.current;1175- if (!d || !v) return;1474+ const v = viewRef.current;1475+ if (!v) return;1476+ if (pointers.current.has(e.pointerId)) {1477+ pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });1478+ }1479+ const p = pinchRef.current;1480+ if (p && pointers.current.size >= 2) {1481+ const [a, b] = [...pointers.current.values()];1482+ const d = Math.hypot(a.x - b.x, a.y - b.y);1483+ const { minS, maxS } = bounds();1484+ const ns = Math.max(minS, Math.min(maxS, p.scale * (d / p.d)));1485+ const rect = c.getBoundingClientRect();1486+ const mx = (a.x + b.x) / 2 - rect.left,1487+ my = (a.y + b.y) / 2 - rect.top;1488+ const base = zoomAt(v, p.cx, p.cy, ns);1489+ setView({1490+ scale: ns,1491+ ox: base.ox + (mx - p.cx),1492+ oy: base.oy + (my - p.cy),1493+ });1494+ p.cx = mx;1495+ p.cy = my;1496+ p.scale = ns;1497+ p.d = d;1498+ return;1499+ }1500+ const d = dragRef.current;1501+ if (!d) return;11761502 setView({11771503 scale: v.scale,11781504 ox: d.ox + e.clientX - d.x,11791505 oy: d.oy + e.clientY - d.y,11801506 });11811507 };1182- const up = () => {1508+ const up = (e: PointerEvent) => {1509+ pointers.current.delete(e.pointerId);1510+ if (pointers.current.size < 2) pinchRef.current = null;11831511 dragRef.current = null;11841512 };11851513 const wheel = (e: WheelEvent) => {@@ -1190,17 +1518,10 @@ export default function Viewer(
11901518 e.preventDefault();11911519 const rect = c.getBoundingClientRect();11921520 const mx = e.clientX - rect.left, my = e.clientY - rect.top;1193- const fit = fitView(final, c.clientWidth, c.clientHeight).scale;1194- const minS = fit * 0.5, maxS = 64;1521+ const { minS, maxS } = bounds();11951522 const k = Math.exp(-e.deltaY * 0.0015);11961523 const ns = Math.max(minS, Math.min(maxS, v.scale * k));1197- const f = ns / v.scale;1198- const target = {1199- scale: ns,1200- ox: mx - (mx - v.ox) * f,1201- oy: my - (my - v.oy) * f,1202- };1203- // ease only across a level boundary; otherwise track the wheel1524+ const target = zoomAt(v, mx, my, ns);12041525 if (levelFor(ns) !== levelFor(v.scale)) easeTo(target);12051526 else {12061527 cancelAnimationFrame(animRef.current);@@ -1224,180 +1545,254 @@ export default function Viewer(
12241545 12251546 const max = Math.max(0, events.length - 1);12261547 const an = state.analysis;1227- const solved = state.tiles.filter((t) => t === "solved").length;1228- let reachedCount = 0;1229- for (const r of frame.reached) reachedCount += r;1230- const grown = (state as unknown as { grown?: number }).grown ?? 0;1548+ const boards = useMemo(() => boardsOf(state), [state]);12311549 const honoured = (a: AgentInfo) =>12321550 final.honours.some((h) =>12331551 h.agent === a.id || h.user === a.id || h.name === a.name12341552 );1235- const agentRows = final.agents.map((a: AgentInfo) => {1553+ const players = final.agents.map((a: AgentInfo) => {12361554 const s = an.agents.find((x) => x.agent === a.id);1555+ const health = s?.health ?? a.health;12371556 return {12381557 a,12391558 s,12401559 hue: hues.get(a.id) ?? 0,12411560 crowned: honoured(a),1242- wilt: wiltOf(s?.health ?? a.health),1243- health: s?.health ?? a.health,1561+ wilt: wiltOf(health),1562+ health,1563+ clock: clockOf(health),1564+ toMove: frame.toMove === a.id,12441565 };1245- }).sort((x, y) => Number(y.crowned) - Number(x.crowned));1566+ }).sort((x, y) =>1567+ Number(y.crowned) - Number(x.crowned) ||1568+ (y.s?.cells ?? 0) - (x.s?.cells ?? 0)1569+ );1570+ const leader = players.reduce(1571+ (best, p) => ((p.s?.cells ?? 0) > (best?.s?.cells ?? -1) ? p : best),1572+ null as (typeof players)[number] | null,1573+ );1574+ const day = props.day ?? state.day ?? null;1575+ const focusTileSpec = drawInfo.focus !== null && state.manifest1576+ ? state.manifest.tiles[drawInfo.focus]1577+ : null;1578+ const solvedCount = state.tiles.filter((t) => t === "solved").length;12461579 1247- return (1248- <div style="display:flex;flex-direction:column;height:100%">1249- <div style="display:flex;flex:1;min-height:0">1250- <canvas1251- ref={canvasRef}1252- style="flex:1;min-width:0;display:block;cursor:grab;touch-action:none"1253- />1254- <div style="width:340px;border-left:1px solid var(--rule);display:flex;flex-direction:column;background:var(--chrome)">1255- <div style="padding:10px 12px;border-bottom:1px solid var(--rule);color:var(--text-dim);font-size:12px;line-height:1.6">1256- <div>1257- game {gameId ? gameId.slice(0, 8) : "-"}{" "}1258- {props.replay ? "replay" : live ? "live" : "scrubbing"}{" "}1259- L{drawInfo.level}1260- </div>1261- <div>1262- revision {state.revision < 0 ? "-" : state.revision} of {max}1263- </div>1264- <div>1265- {state.manifest1266- ? `${state.manifest.tiles.length} tiles (${state.manifest.packer.kind}), ${state.G}x${state.G} field, ${1267- state.geo?.holes ?? 01268- } hole cells`1269- : "no manifest"}1270- </div>1271- <div>1272- solved {solved}/{state.tiles.length} tiles, status {state.status}1273- </div>1274- <div>1275- reached {reachedCount} of {state.tiles.length} tiles, grown{" "}1276- {grown} {grown === 1 ? "time" : "times"}1277- </div>1278- {state.day && <div>day {state.day}</div>}1279- {state.honours.map((h) => (1280- <div key={h.user} style="color:#d4a83a">1281- ♕ {h.name} ({h.model ?? "model unreported"}) won {h.day}1282- {" "}1283- with {h.reward} cells; their seat is tile #{h.seed_tile}1284- </div>1285- ))}1286- <div>1287- forced {state.totals.forced}, guess {state.totals.guess}, wrong1288- {" "}1289- {state.totals.wrong}, retracted {state.totals.retracted}1290- </div>1291- <div>1292- guess rate {(an.guess_rate * 100).toFixed(0)}%, reveal:commit{" "}1293- {an.reveal_commit.toFixed(2)}, batch{" "}1294- {an.batch_size_mean.toFixed(1)}{" "}1295- ({an.batch_size_drift >= 0 ? "+" : ""}1296- {an.batch_size_drift.toFixed(1)}), gap {an.mean_gap_ms === null1297- ? "-"1298- : `${(an.mean_gap_ms / 1000).toFixed(1)}s`}1299- </div>1300- <div>1301- reward {state.totals.reward} ({an.reward_per_batch.toFixed(1)}1302- {" "}1303- per batch), meets {an.meets}1304- </div>1305- <div style="color:var(--muted)">1306- draw: {drawInfo.redrawn}/{drawInfo.visible}{" "}1307- map tiles redrawn, cache {cache.current.size()}1308- </div>1309- </div>1310- <div style="padding:8px 12px;border-bottom:1px solid var(--rule);font-size:12px;line-height:1.5">1311- {agentRows.length === 01312- ? <div style="color:var(--muted)">no agents yet</div>1313- : null}1314- {agentRows.map(({ a, s, hue, crowned, wilt, health }) => (1315- <div1316- key={a.id}1317- style="display:flex;align-items:baseline;gap:8px;padding:2px 0"1318- >1580+ const dockBody = (1581+ <>1582+ {atEnd && <ResultCard boards={boards} day={day} />}1583+ <div class="tabs">1584+ {(["moves", "tile", "table"] as Tab[]).map((t) => (1585+ <button1586+ key={t}1587+ type="button"1588+ class={tab === t ? "on" : ""}1589+ onClick={() => setTab(t)}1590+ >1591+ {t}1592+ </button>1593+ ))}1594+ </div>1595+ <div class="pane" ref={paneRef}>1596+ {tab === "moves" && (1597+ <div class="moves">1598+ {state.console.map((e) => {1599+ const cur = e.rev === state.revision;1600+ const name = e.agent1601+ ? frame.agentName.get(e.agent) ?? e.agent1602+ : "";1603+ const h = e.agent ? hues.get(e.agent) : undefined;1604+ if (1605+ e.op === "join" || e.op === "grow" || e.op === "end" ||1606+ e.op === "start"1607+ ) {1608+ return (1609+ <div key={e.rev} class={`note${cur ? " cur" : ""}`}>1610+ {e.rev} {noteLine(e, name)}1611+ </div>1612+ );1613+ }1614+ return [1615+ <span key={`${e.rev}n`} class={`n${cur ? " cur" : ""}`}>1616+ {e.rev}1617+ </span>,13191618 <span1320- style={`display:inline-block;width:10px;height:10px;border-radius:50%;background:${1321- agentColour(hue, theme, true, WILT_CHROMA[wilt])1322- };flex:none;align-self:center`}1323- title={`health ${health.state}: ${1324- (health.rate * 100).toFixed(1)1325- }% wrong over ${health.committed} cells`}1326- />1327- <span style="color:var(--text-strong);min-width:70px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">1328- {crowned && <span style="color:#d4a83a">♕</span>}1329- {health.state === "dead" && (1330- <span style="color:var(--muted)">✕</span>1331- )}1332- {a.name || a.id}1333- </span>1334- <span style="color:var(--muted);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">1335- {a.harness}1336- {a.model ? ` ${a.model}` : ""}1337- </span>1338- <span style="color:var(--text-dim);white-space:nowrap">1339- {s1340- ? `${s.tiles}t ${s.cells}c ${1341- (s.territory * 100).toFixed(0)1342- }% ${s.strategy}${s.meets ? ` m${s.meets}` : ""} ${1343- health.state === "dead"1344- ? "dead"1345- : `${(health.rate * 100).toFixed(1)}%${1346- health.state === "warning" ? "!" : ""1347- }`1348- }`1349- : "-"}1350- </span>1619+ key={`${e.rev}s`}1620+ class="st"1621+ style={h !== undefined1622+ ? `background:${agentColour(h, theme, true)}`1623+ : ""}1624+ />,1625+ <span key={`${e.rev}w`} class={`what${cur ? " cur" : ""}`}>1626+ <span class="who">{name}</span>1627+ {moveLine(e)}1628+ </span>,1629+ ];1630+ })}1631+ {deaths.filter((d) => d.rev <= state.revision).map((d) => (1632+ <div key={`d${d.rev}`} class="note danger">1633+ {d.rev} {d.name} resigns (timeout)13511634 </div>13521635 ))}13531636 </div>1354- <div1355- ref={paneRef}1356- style="flex:1;overflow:auto;padding:8px 12px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:11.5px"1357- >1358- {state.console.map((e) => (1359- <div1360- key={e.rev}1361- style="padding:4px 0;border-bottom:1px solid var(--rule)"1362- >1637+ )}1638+ {tab === "tile" && (1639+ <div class="tile">1640+ {focusTileSpec1641+ ? (1642+ <>1643+ <div>1644+ <span class="k">tile</span>#{focusTileSpec.id}{" "}1645+ <span class="k">status</span>1646+ {state.tiles[focusTileSpec.id]}1647+ </div>1648+ <div>1649+ <span class="k">shape</span>1650+ {focusTileSpec.w}x{focusTileSpec.h}, {focusTileSpec.cells}1651+ {" "}1652+ cells, {focusTileSpec.rects.length}{" "}1653+ {focusTileSpec.rects.length === 1 ? "rect" : "rects"}1654+ </div>1655+ <div>1656+ <span class="k">m</span>1657+ {focusTileSpec.m} solutions alone1658+ </div>1659+ <div>1660+ <span class="k">by</span>1661+ {state.tileAgent[focusTileSpec.id]1662+ ? frame.agentName.get(1663+ state.tileAgent[focusTileSpec.id]!,1664+ )1665+ : "-"}1666+ </div>1667+ <div>1668+ <span class="k">neighbours</span>1669+ {focusTileSpec.overlaps.map((o) => `#${o.with}`).join(" ")}1670+ </div>1671+ <div>1672+ <span class="k">pins</span>1673+ {(focusTileSpec.pins ?? []).length1674+ ? (focusTileSpec.pins ?? []).map((i) =>1675+ `#${focusTileSpec.overlaps[i]?.with}`1676+ ).join(" ")1677+ : "-"}1678+ </div>1679+ </>1680+ )1681+ : (13631682 <div style="color:var(--muted)">1364- #{e.rev} {e.op}1365- {e.agent ? ` ${frame.agentName.get(e.agent) ?? e.agent}` : ""}1366- {e.dt_ms !== null ? ` +${(e.dt_ms / 1000).toFixed(1)}s` : ""}1683+ zoom in until the board is lettered; the tile under the centre1684+ is described here13671685 </div>1368- <div style="color:var(--text)">{entryLine(e)}</div>1369- {e.intent && e.op !== "start"1370- ? (1371- <div style="color:var(--text-strong);white-space:pre-wrap">1372- {e.intent}1373- </div>1374- )1375- : null}1376- </div>1377- ))}1686+ )}13781687 </div>1688+ )}1689+ {tab === "table" && <TournamentTable boards={boards} />}1690+ </div>1691+ </>1692+ );1693+1694+ return (1695+ <div class="match">1696+ <style>{STYLE}</style>1697+ <div class="board">1698+ <canvas ref={canvasRef} class="field" />1699+ <div class="chip">1700+ move <b>{state.revision < 0 ? "-" : state.revision}</b>1701+ {!live && props.replay === null ? " (scrubbing)" : ""}1702+ {props.replay ? " (replay)" : ""}13791703 </div>13801704 </div>1381- <div style="display:flex;align-items:center;gap:10px;padding:6px 12px;border-top:1px solid var(--rule);background:var(--chrome)">1705+ <div class="counting">1706+ <span class="lbl">counting</span>1707+ {players.length === 0 && (1708+ <span style="color:var(--muted)">no players yet</span>1709+ )}1710+ {players.map((p) => (1711+ <span1712+ key={p.a.id}1713+ class={`p${leader && leader.a.id === p.a.id ? " lead" : ""}`}1714+ >1715+ <span1716+ class="st"1717+ style={`background:${1718+ agentColour(p.hue, theme, true, WILT_CHROMA[p.wilt])1719+ }`}1720+ />1721+ <span class={p.health.state === "dead" ? "dead" : ""}>1722+ {p.a.name || p.a.id}1723+ </span>1724+ <b>{p.s?.cells ?? 0}</b>1725+ </span>1726+ ))}1727+ </div>1728+ <div class="dock">1729+ <div class="head">1730+ <span class="day">{day ?? gameId?.slice(0, 8) ?? "mosaic"}</span>1731+ <span class="sc" style="color:var(--text-dim);font-size:12px">1732+ {ended ? "finished" : live ? "live" : "replay"}1733+ </span>1734+ <span class="mv">{solvedCount}/{state.tiles.length} tiles</span>1735+ </div>1736+ <div class="players">1737+ {players.length === 0 && (1738+ <div style="padding:10px 14px;color:var(--muted)">1739+ waiting for players1740+ </div>1741+ )}1742+ {players.map((p) => (1743+ <div1744+ key={p.a.id}1745+ class={`player${p.toMove ? " tomove" : ""}${1746+ p.health.state === "dead" ? " dead" : ""1747+ }`}1748+ >1749+ <span1750+ class="av"1751+ style={`background:${1752+ agentColour(p.hue, theme, true, WILT_CHROMA[p.wilt])1753+ }`}1754+ title={`health ${p.health.state}: ${1755+ (p.health.rate * 100).toFixed(1)1756+ }% wrong over ${p.health.committed} cells`}1757+ />1758+ <span class="name">1759+ {p.crowned ? "♕ " : ""}1760+ {p.a.name || p.a.id}1761+ </span>1762+ <span class="nums">1763+ <b>{p.s?.tiles ?? 0}</b> tiles <b>{p.s?.cells ?? 0}</b> pts1764+ </span>1765+ <span class="rank">1766+ {p.a.model ?? "model unreported"}1767+ {p.a.harness ? ` — ${p.a.harness}` : ""}1768+ </span>1769+ <ClockView clock={p.clock} />1770+ {p.toMove && <span class="tag">to move</span>}1771+ </div>1772+ ))}1773+ </div>1774+ {dockBody}1775+ </div>1776+ <div class="controls">13821777 <button13831778 type="button"1779+ class="btn"13841780 disabled={events.length === 0}13851781 onClick={() => {13861782 if (!playing && rev >= max) setRev(0);13871783 setLive(false);13881784 setPlaying(!playing);13891785 }}1390- style="font:inherit;background:none;border:1px solid var(--rule);color:var(--text);border-radius:4px;padding:2px 8px;cursor:pointer"13911786 >13921787 {playing ? "pause" : "play"}13931788 </button>13941789 <select1790+ class="btn"13951791 value={String(speed)}13961792 onChange={(e) => {13971793 const v = (e.target as HTMLSelectElement).value;13981794 setSpeed(v === "max" ? "max" : Number(v) as Speed);13991795 }}1400- style="font:inherit;background:var(--chrome);color:var(--text);border:1px solid var(--rule);border-radius:4px"14011796 >14021797 {SPEEDS.map((sp) => (14031798 <option key={String(sp)} value={String(sp)}>@@ -1406,18 +1801,16 @@ export default function Viewer(
14061801 ))}14071802 </select>14081803 <select1804+ class="btn"14091805 value={follow}14101806 onChange={(e) => setFollow((e.target as HTMLSelectElement).value)}1411- style="font:inherit;background:var(--chrome);color:var(--text);border:1px solid var(--rule);border-radius:4px;max-width:140px"14121807 >14131808 <option value="">follow: none</option>14141809 {final.agents.map((a) => (14151810 <option key={a.id} value={a.id}>follow: {a.name || a.id}</option>14161811 ))}14171812 </select>1418- <span style="color:var(--muted);font-size:12px;min-width:56px">1419- rev {rev < 0 ? "-" : rev}1420- </span>1813+ <span class="rev">{rev < 0 ? "-" : rev} / {max}</span>14211814 <Timeline14221815 events={events}14231816 deaths={deaths}@@ -1431,50 +1824,172 @@ export default function Viewer(
14311824 setLive(props.replay === null && v >= max);14321825 }}14331826 />1434- <span style="color:var(--muted);font-size:12px;min-width:36px;text-align:right">1435- {max}1436- </span>14371827 {props.replay === null && !live14381828 ? (14391829 <button14401830 type="button"1831+ class="btn"14411832 onClick={() => {14421833 setPlaying(false);14431834 setLive(true);14441835 setRev(max);14451836 }}1446- style="font:inherit;background:none;border:1px solid var(--rule);color:var(--text);border-radius:4px;padding:2px 8px;cursor:pointer"14471837 >14481838 live14491839 </button>14501840 )14511841 : null}1842+ <span1843+ class="perf"1844+ title="level; map tiles redrawn this frame / visible; raster cache size"1845+ >1846+ L{drawInfo.level} {drawInfo.redrawn}/{drawInfo.visible} c{cache1847+ .current.size()}1848+ </span>1849+ </div>1850+ {/* phone: the tabs and pane as a bottom sheet */}1851+ <div class={`sheet${sheet ? " open" : ""}`}>1852+ <div class="handle" onClick={() => setSheet(!sheet)}>1853+ <i />1854+ </div>1855+ {dockBody}14521856 </div>14531857 </div>14541858 );14551859 }14561860 1457-function entryLine(e: State["console"][number]): string {1861+function ClockView(props: { clock: Clock }) {1862+ const c = props.clock;1863+ if (c.kind === "timeout") {1864+ return (1865+ <span class="clock byo">1866+ <span class="sc">clock</span>1867+ <b>timeout</b>1868+ </span>1869+ );1870+ }1871+ if (c.kind === "main") {1872+ return (1873+ <span class="clock">1874+ <span class="sc">main time</span>1875+ <b>{mmss(c.secs)}</b>1876+ </span>1877+ );1878+ }1879+ return (1880+ <span class="clock byo">1881+ <span class="sc">byo-yomi</span>1882+ <span class="periods">1883+ {[3, 2, 1].map((k) => (1884+ <i1885+ key={k}1886+ class={k <= c.periods ? "left" : ""}1887+ />1888+ ))}1889+ </span>1890+ <b>{mmss(c.secs)}</b>1891+ </span>1892+ );1893+}1894+1895+function ResultCard(props: { boards: Boards; day: string | null }) {1896+ const rows = props.boards.by_user.filter((r) => r.reward > 0);1897+ const first = rows[0], second = rows[1];1898+ let line = "no result";1899+ if (first && second) {1900+ line = `${first.key} wins by ${first.reward - second.reward} points`;1901+ } else if (first) line = `${first.key} wins with ${first.reward} points`;1902+ const top = props.boards.by_model[0];1903+ return (1904+ <div class="result">1905+ <div class="r1">result {props.day ?? ""}</div>1906+ <div class="r2">{line}</div>1907+ {top ? <div class="r1">top model {top.key}</div> : null}1908+ </div>1909+ );1910+}1911+1912+function TournamentTable(props: { boards: Boards }) {1913+ const rows = (rs: Row[], cap: string) => (1914+ <>1915+ <tr>1916+ <td class="cap" colSpan={5}>{cap}</td>1917+ </tr>1918+ {rs.length === 0 && (1919+ <tr>1920+ <td colSpan={5} style="color:var(--muted)">nobody yet</td>1921+ </tr>1922+ )}1923+ {rs.map((r, i) => (1924+ <tr key={r.key}>1925+ <td class="n">{i + 1}</td>1926+ <td class={r.dead > 0 && r.dead >= r.agents ? "dead" : ""}>1927+ {r.key}1928+ {r.models.length > 11929+ ? (1930+ <span style="color:var(--muted)">1931+ {` (${r.models.join(", ")})`}1932+ </span>1933+ )1934+ : null}1935+ {r.dead > 0 ? <span class="danger"> (timeout)</span> : null}1936+ </td>1937+ <td class="n">{r.tiles}</td>1938+ <td class="n">{r.reward}</td>1939+ <td class="n">{(r.wrong_rate * 100).toFixed(1)}%</td>1940+ </tr>1941+ ))}1942+ </>1943+ );1944+ return (1945+ <table class="tt">1946+ <thead>1947+ <tr>1948+ <th class="n">#</th>1949+ <th>player</th>1950+ <th class="n">tiles</th>1951+ <th class="n">points</th>1952+ <th class="n">wrong</th>1953+ </tr>1954+ </thead>1955+ <tbody>1956+ {rows(props.boards.by_user, "by user")}1957+ {rows(props.boards.by_model, "by model")}1958+ </tbody>1959+ </table>1960+ );1961+}1962+1963+function noteLine(e: State["console"][number], name: string): string {14581964 switch (e.op) {14591965 case "start":1460- return "start";1966+ return "the field opens";14611967 case "join":1462- return `join ${e.agent} at #${e.tile}`;1968+ return `${name} joins at #${e.tile}`;1969+ case "grow":1970+ return "the field grows";1971+ case "end":1972+ return "end of the day";1973+ default:1974+ return e.op;1975+ }1976+}1977+1978+function moveLine(e: State["console"][number]): string {1979+ switch (e.op) {14631980 case "reveal":1464- return `reveal #${e.tile} ${e.revealed_cells} cells`;1981+ return `reveals #${e.tile} (${e.revealed_cells} cells)`;14651982 case "commit":1466- return `commit ${e.accepted} ok (${e.forced} forced, ${e.guess} guess), ${e.rejected} rejected, ${e.ignored} ignored${1983+ return `${e.accepted} stones${e.rejected ? `, ${e.rejected} wrong` : ""}${14671984 e.solved.length1468- ? `, solved ${e.solved.map((i) => `#${i}`).join(" ")} +${e.reward}`1985+ ? `, solves ${e.solved.map((i) => `#${i}`).join(" ")} +${e.reward}`14691986 : ""14701987 }${14711988 e.meets?.length ? `, meets ${e.meets.map((m) => m.with).join(" ")}` : ""14721989 }`;14731990 case "retract":1474- return `retract ${e.retracted}`;1475- case "grow":1476- return "the field grows";1477- case "end":1478- return "end";1991+ return `retracts ${e.retracted}`;1992+ default:1993+ return e.op;14791994 }14801995 }@@ -12,41 +12,58 @@ import { HUES, type TileStatus } from "./fold.ts";
1212 */1313 export type Theme = {1414 name: "dark" | "light";15- bg: string;16- panel: string;17- rule: string;18- ink: string;19- inkGiven: string; // ink with a lightness bump: lighter on dark, darker on light15+ bg: string; // the page: printed paper (light) or the night page (dark)16+ panel: string; // the board ground under a tile: flat kaya, no grain17+ rule: string; // hairlines18+ ink: string; // stone black, text19+ inkGiven: string; // the mark on a given stone2020 muted: string;2121 unknownShift: number; // OKLab lightness shift for cells not yet known22+ stone1: string; // a 1: the black stone23+ stone0: string; // a 0: the white stone24+ line: string; // board lines and stone outlines25+ danger: string; // wrong, dead, byo-yomi26+ chrome: string; // the dock2227 };28+/** Kifu: the printed page and a flat kaya board. */29+export const LIGHT: Theme = {30+ name: "light",31+ bg: "#f4f1e8",32+ panel: "#e6c98a",33+ rule: "#1a1a1a",34+ ink: "#1a1a1a",35+ inkGiven: "#f4f1e8",36+ muted: "#6f6a5e",37+ unknownShift: 0.04,38+ stone1: "#1a1a1a",39+ stone0: "#fbfaf6",40+ line: "#1a1a1a",41+ danger: "#b0231c",42+ chrome: "#f4f1e8",43+};44+/** Kifu at night: the same structure on an ink page, the board darkened. */2345 export const DARK: Theme = {2446 name: "dark",25- bg: "#131316",26- panel: "#1a1a1e",27- rule: "#333336",28- ink: "#0b0b0e",29- inkGiven: "#3e3e46",30- muted: "#8c8c92",47+ bg: "#171613",48+ panel: "#4a3d27",49+ rule: "#d8d2c2",50+ ink: "#e9e4d6",51+ inkGiven: "#171613",52+ muted: "#9a9384",3153 unknownShift: -0.05,32-};33-export const LIGHT: Theme = {34- name: "light",35- bg: "#f3f3f5",36- panel: "#e7e7ea",37- rule: "#cfcfd4",38- ink: "#ffffff",39- inkGiven: "#d2d2d8",40- muted: "#6e6e76",41- unknownShift: 0.045,54+ stone1: "#101010",55+ stone0: "#efeade",56+ line: "#d8d2c2",57+ danger: "#d94a3d",58+ chrome: "#1c1b17",4259 };4360 export const PANEL = DARK.panel;4461 export const RULE = DARK.rule;4562 export const INK = DARK.ink;4663 export const MUTED = DARK.muted;4764 48-const L = 0.6;49-const C = 0.095;65+const L = 0.58;66+const C = 0.13;5067 5168 /**5269 * Status strength of the wash, for a cell whose value is known. A cell@@ -172,9 +189,9 @@ export function cellFill(
172189 const [l, a0, b0] = meanHueLab(hues);173190 // chroma < 1 wilts the hue toward grey (an agent's health)174191 const a = a0 * chroma, b = b0 * chroma;175- const lift = recency * 0.07 * (theme.name === "dark" ? 1 : -1);192+ void recency; // placement is a motion on the board, not a lift of the wash176193 return toHex(oklabToSrgb(177- panelLab[0] + (l - panelLab[0]) * k + shift + lift,194+ panelLab[0] + (l - panelLab[0]) * k + shift,178195 panelLab[1] + (a - panelLab[1]) * k,179196 panelLab[2] + (b - panelLab[2]) * k,180197 ));@@ -1,21 +1,73 @@
11 import { define } from "../utils.ts";22 import WebMcp from "../islands/WebMcp.tsx";33 4+// The Kifu identity: a printed Go record. Paper, ink, hairlines, one serif5+// with tabular numerals; a flat kaya board; hue only where it is data6+// (territories, health, markers). A night variant keeps every structure7+// and swaps the page for ink.8+const CSS = `9+:root{color-scheme:light dark;10+--bg:#f4f1e8;--chrome:#f4f1e8;--rule:#1a1a1a;--hair:rgba(26,26,26,.35);11+--text:#1a1a1a;--text-dim:#555148;--text-strong:#1a1a1a;--muted:#6f6a5e;12+--board:#e6c98a;--danger:#b0231c;--stone-1:#1a1a1a;--stone-0:#fbfaf6;13+--font:"Noto Serif JP","Hiragino Mincho ProN","Yu Mincho",Georgia,serif;14+--mono:"Noto Serif JP",Georgia,serif}15+@media (prefers-color-scheme: dark){:root{16+--bg:#171613;--chrome:#1c1b17;--rule:#d8d2c2;--hair:rgba(216,210,194,.3);17+--text:#e9e4d6;--text-dim:#bdb6a5;--text-strong:#f2eee3;--muted:#9a9384;18+--board:#4a3d27;--danger:#d94a3d;--stone-1:#101010;--stone-0:#efeade}}19+:root[data-theme=light]{20+--bg:#f4f1e8;--chrome:#f4f1e8;--rule:#1a1a1a;--hair:rgba(26,26,26,.35);21+--text:#1a1a1a;--text-dim:#555148;--text-strong:#1a1a1a;--muted:#6f6a5e;22+--board:#e6c98a;--danger:#b0231c;--stone-1:#1a1a1a;--stone-0:#fbfaf6}23+:root[data-theme=dark]{24+--bg:#171613;--chrome:#1c1b17;--rule:#d8d2c2;--hair:rgba(216,210,194,.3);25+--text:#e9e4d6;--text-dim:#bdb6a5;--text-strong:#f2eee3;--muted:#9a9384;26+--board:#4a3d27;--danger:#d94a3d;--stone-1:#101010;--stone-0:#efeade}27+html,body{margin:0;height:100%;background:var(--bg);color:var(--text);28+font:14px/1.45 var(--font);font-variant-numeric:tabular-nums}29+*{box-sizing:border-box}30+a{color:var(--text);text-decoration:none}31+a:hover{text-decoration:underline;text-underline-offset:3px}32+button,select{font:inherit;font-variant-numeric:tabular-nums}33+.sc{font-variant-caps:small-caps;letter-spacing:.06em;font-weight:600}34+.num{font-variant-numeric:tabular-nums}35+.rule{border-top:1px solid var(--rule)}36+.hair{border-top:1px solid var(--hair)}37+.dead{text-decoration:line-through;color:var(--muted)}38+.danger{color:var(--danger)}39+.nav{display:flex;gap:18px;align-items:baseline;padding:10px 16px 8px;border-bottom:2px solid var(--rule);background:var(--chrome);font-size:13px}40+.nav .brand{font-size:17px;font-weight:600;letter-spacing:.02em}41+.nav .spacer{flex:1}42+.btn{background:none;border:1px solid var(--rule);color:var(--text);padding:3px 10px;cursor:pointer;border-radius:0}43+.btn:hover{background:var(--text);color:var(--bg)}44+.btn.on{background:var(--text);color:var(--bg)}45+select.btn{padding:2px 6px;background:var(--chrome)}46+select.btn:hover{background:var(--chrome);color:var(--text)}47+@media (max-width:720px){.nav{padding:8px 12px;gap:12px;font-size:12px}.nav .brand{font-size:15px}}48+`;49+450 export default define.page(function App({ Component }) {551 return (652 <html lang="en">753 <head>854 <meta charset="utf-8" />9- <meta name="viewport" content="width=device-width, initial-scale=1" />55+ <meta56+ name="viewport"57+ content="width=device-width, initial-scale=1, viewport-fit=cover"58+ />1059 <title>mosaic</title>11- <style>12- {`:root{color-scheme:dark light;--bg:#141416;--chrome:#161618;--rule:#2a2a2e;--text:#d8d8dc;--text-dim:#b9b9c0;--text-strong:#e8e8ea;--muted:#8c8c92}13-@media (prefers-color-scheme: light){:root{--bg:#f2f2f4;--chrome:#ebebee;--rule:#d4d4d9;--text:#2a2a2e;--text-dim:#4a4a52;--text-strong:#141416;--muted:#6e6e76}}14-:root[data-theme=dark]{--bg:#141416;--chrome:#161618;--rule:#2a2a2e;--text:#d8d8dc;--text-dim:#b9b9c0;--text-strong:#e8e8ea;--muted:#8c8c92}15-:root[data-theme=light]{--bg:#f2f2f4;--chrome:#ebebee;--rule:#d4d4d9;--text:#2a2a2e;--text-dim:#4a4a52;--text-strong:#141416;--muted:#6e6e76}16-html,body{margin:0;height:100%;background:var(--bg);color:var(--text-strong);font:13px/1.4 system-ui,-apple-system,Segoe UI,Roboto,sans-serif}17-*{box-sizing:border-box}`}18- </style>60+ <link rel="preconnect" href="https://fonts.googleapis.com" />61+ <link62+ rel="preconnect"63+ href="https://fonts.gstatic.com"64+ crossOrigin="anonymous"65+ />66+ <link67+ rel="stylesheet"68+ href="https://fonts.googleapis.com/css2?family=Noto+Serif+JP:wght@400;600;700&display=swap"69+ />70+ <style>{CSS}</style>1971 </head>2072 <body>2173 <WebMcp />@@ -1,5 +1,6 @@
1-// The calendar: a month grid, one tile per day with its winners and stats;2-// today links to the live field, past days to their replay.1+// The calendar: a month grid in the kifu identity, one cell per day with2+// its winner bar and stats; today links to the live field, past days to3+// their record. The cumulative tournament tables sit below.34 import { define } from "../utils.ts";45 import { daily, type DayRow } from "../lib/daily.ts";56 @@ -13,54 +14,78 @@ function shiftMonth(m: string, by: number): string {
1314 return d.toISOString().slice(0, 7);1415 }1516 17+const CSS = `18+.cal{padding:18px 20px 40px;max-width:1120px;margin:0 auto}19+.cal nav.m{display:flex;gap:14px;align-items:baseline;margin:10px 0 14px}20+.cal nav.m .month{font-size:18px;font-weight:600}21+.cal .grid{display:grid;grid-template-columns:repeat(7,minmax(0,1fr));border-top:2px solid var(--rule);border-left:1px solid var(--rule)}22+.cal .dow{font-variant-caps:small-caps;letter-spacing:.06em;color:var(--muted);font-size:11px;padding:4px 8px;border-right:1px solid var(--hair);border-bottom:1px solid var(--rule)}23+.cal .day{display:block;min-height:104px;border-right:1px solid var(--hair);border-bottom:1px solid var(--hair);padding:6px 8px;font-size:11.5px;color:var(--text-dim);position:relative;text-decoration:none}24+.cal .day:hover{background:var(--board);text-decoration:none}25+.cal .day .n{font-size:13px;color:var(--text);font-weight:600}26+.cal .day.today .n{text-decoration:underline;text-underline-offset:3px}27+.cal .day .win{display:flex;align-items:center;gap:6px;margin:6px 0 4px;color:var(--text);font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}28+.cal .day .bar{height:4px;background:var(--rule);margin:2px 0 6px}29+.cal .day .row{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}30+.cal .day.empty{color:var(--muted)}31+.cal .boards{display:grid;grid-template-columns:1fr 1fr;gap:32px;margin-top:28px}32+table.tt{border-collapse:collapse;width:100%;font-size:12.5px}33+table.tt caption{text-align:left;font-variant-caps:small-caps;letter-spacing:.08em;font-weight:600;padding:0 0 6px;color:var(--text-dim)}34+table.tt th{font-weight:600;font-variant-caps:small-caps;letter-spacing:.06em;text-align:left;padding:6px 6px 4px;border-bottom:1px solid var(--rule);color:var(--text-dim)}35+table.tt td{padding:4px 6px;border-bottom:1px solid var(--hair)}36+table.tt .n{text-align:right}37+@media (max-width:720px){.cal{padding:12px 10px 40px}.cal .day{min-height:74px;padding:4px 5px;font-size:10.5px}.cal .day .row{display:none}.cal .boards{grid-template-columns:1fr;gap:18px}}38+`;39+1640 function cell(row: DayRow | undefined, date: string, today: string | null) {1741 const isToday = date === today;1842 const href = isToday ? "/" : row ? `/day/${date}` : null;19- const box =20- "min-height:96px;border:1px solid var(--rule);padding:6px;font-size:11px;color:var(--text-dim);" +21- (isToday ? "background:var(--chrome);" : "");43+ const cls = `day${isToday ? " today" : ""}${row ? "" : " empty"}`;2244 const inner = (2345 <>24- <div style="color:var(--text-strong);font-size:12px">25- {date.slice(8)}26- {isToday && <span style="color:var(--muted)">today, live</span>}46+ <div class="n">47+ {Number(date.slice(8))}48+ {isToday && (49+ <span class="sc" style="color:var(--muted);font-size:10px">50+ {" "}live51+ </span>52+ )}2753 </div>2854 {row && (2955 <>30- <div>31- {row.winner_user32- ? <span title="top user">♕ {row.winner_user}</span>33- : <span style="color:var(--muted)">no winner</span>}56+ <div class="win" title="the day's winner, by user">57+ {row.winner_user ? `♕ ${row.winner_user}` : "no winner"}3458 </div>59+ <div60+ class="bar"61+ style={`width:${62+ Math.max(63+ 6,64+ Math.round(65+ 100 * row.stats.solved / Math.max(1, row.stats.tiles),66+ ),67+ )68+ }%`}69+ title={`${row.stats.solved} of ${row.stats.tiles} tiles solved`}70+ />3571 {row.winner_model && (36- <div title="top model">model: {row.winner_model}</div>72+ <div class="row" title="top model">{row.winner_model}</div>3773 )}38- <div>74+ <div class="row num">3975 {row.stats.solved}/{row.stats.tiles} tiles, {row.stats.agents}{" "}40- agents41- </div>42- <div>43- {row.stats.completed ? "completed" : "unfinished"}, guess{" "}44- {(row.stats.guess_rate * 100).toFixed(0)}%, grown {row.stats.grown}x76+ players4577 </div>46- <div style="color:var(--muted)">47- {row.boards.by_model.slice(0, 3).map((m) => m.key).join(", ")}78+ <div class="row num">79+ {row.stats.completed ? "complete" : "unfinished"}, guess{" "}80+ {(row.stats.guess_rate * 100).toFixed(0)}%, grown {row.stats.grown}4881 </div>4982 </>5083 )}5184 </>5285 );5386 return href54- ? (55- <a56- key={date}57- href={href}58- style={box + "display:block;text-decoration:none"}59- >60- {inner}61- </a>62- )63- : <div key={date} style={box}>{inner}</div>;87+ ? <a key={date} href={href} class={cls}>{inner}</a>88+ : <div key={date} class={cls}>{inner}</div>;6489 }6590 6691 export default define.page(function Calendar(ctx) {@@ -76,38 +101,38 @@ export default define.page(function Calendar(ctx) {
76101 const days = new Date(Date.UTC(y, mo, 0)).getUTCDate();77102 const lead = (first.getUTCDay() + 6) % 7; // Monday first78103 const cells = [];79- for (let i = 0; i < lead; i++) cells.push(<div key={"lead" + i} />);104+ for (let i = 0; i < lead; i++) {105+ cells.push(<div key={"lead" + i} class="day empty" />);106+ }80107 for (let day = 1; day <= days; day++) {81108 const date = `${month}-${String(day).padStart(2, "0")}`;82109 cells.push(cell(rows.get(date), date, today));83110 }84111 const cumulative = d?.cumulative();85112 return (86- <div style="padding:16px 20px;max-width:1100px;margin:0 auto;font-size:13px">87- <nav style="display:flex;gap:14px;align-items:center;margin-bottom:12px;color:var(--text-dim)">88- <a href="/" style="color:var(--text-strong);text-decoration:none">89- mosaic90- </a>113+ <div class="cal">114+ <style>{CSS}</style>115+ <nav class="nav" style="padding-left:0;padding-right:0">116+ <a href="/" class="brand">mosaic</a>117+ <span class="sc" style="color:var(--text-dim)">calendar</span>118+ <span class="spacer" />119+ {!d && <span style="color:var(--muted)">no daily on this server</span>}120+ </nav>121+ <nav class="m">91122 <a href={`/calendar?month=${shiftMonth(month, -1)}`}>←</a>92- <span style="color:var(--text-strong)">{month}</span>123+ <span class="month">{month}</span>93124 <a href={`/calendar?month=${shiftMonth(month, 1)}`}>→</a>94- {!d && <span style="color:var(--muted)">no daily on this server</span>}95125 </nav>96- <div style="display:grid;grid-template-columns:repeat(7,1fr);gap:4px">126+ <div class="grid">97127 {["mon", "tue", "wed", "thu", "fri", "sat", "sun"].map((n) => (98- <div99- key={n}100- style="color:var(--muted);font-size:11px;padding:2px 6px"101- >102- {n}103- </div>128+ <div key={n} class="dow">{n}</div>104129 ))}105130 {cells}106131 </div>107132 {cumulative && cumulative.days > 0 && (108- <div style="display:flex;gap:32px;margin-top:20px">109- <Board title="models, all days" rows={cumulative.by_model} />110- <Board title="users, all days" rows={cumulative.by_user} />133+ <div class="boards">134+ <Board title="by user, all days" rows={cumulative.by_user} />135+ <Board title="by model, all days" rows={cumulative.by_model} />111136 </div>112137 )}113138 </div>@@ -123,49 +148,49 @@ function Board(
123148 tiles: number;124149 guess: number;125150 forced: number;151+ wrong: number;126152 days: number;127153 models: string[];128154 }[];129155 },130156 ) {131157 return (132- <div style="flex:1;min-width:0">133- <div style="color:var(--text-strong);margin-bottom:6px">134- {props.title}135- </div>136- <table style="border-collapse:collapse;width:100%;font-size:12px">137- <thead>138- <tr style="color:var(--muted);text-align:left">139- <th>who</th>140- <th>cells</th>141- <th>tiles</th>142- <th>guess %</th>143- <th>days</th>158+ <table class="tt">159+ <caption>{props.title}</caption>160+ <thead>161+ <tr>162+ <th class="n">#</th>163+ <th>player</th>164+ <th class="n">points</th>165+ <th class="n">tiles</th>166+ <th class="n">wrong</th>167+ <th class="n">days</th>168+ </tr>169+ </thead>170+ <tbody>171+ {props.rows.slice(0, 20).map((r, i) => (172+ <tr key={r.key}>173+ <td class="n">{i + 1}</td>174+ <td>175+ {r.key}176+ {r.models.length > 1 && (177+ <span style="color:var(--muted)">178+ {` (${r.models.join(", ")})`}179+ </span>180+ )}181+ </td>182+ <td class="n">{r.reward}</td>183+ <td class="n">{r.tiles}</td>184+ <td class="n">185+ {r.forced + r.guess + r.wrong > 0186+ ? (100 * r.wrong / (r.forced + r.guess + r.wrong)).toFixed(1) +187+ "%"188+ : "-"}189+ </td>190+ <td class="n">{r.days}</td>144191 </tr>145- </thead>146- <tbody>147- {props.rows.slice(0, 20).map((r) => (148- <tr key={r.key}>149- <td>150- {r.key}151- {r.models.length > 1 && (152- <span style="color:var(--muted);margin-left:4px">153- ({r.models.join(", ")})154- </span>155- )}156- </td>157- <td>{r.reward}</td>158- <td>{r.tiles}</td>159- <td>160- {r.forced + r.guess > 0161- ? (100 * r.guess / (r.forced + r.guess)).toFixed(0)162- : "-"}163- </td>164- <td>{r.days}</td>165- </tr>166- ))}167- </tbody>168- </table>169- </div>192+ ))}193+ </tbody>194+ </table>170195 );171196 }@@ -27,23 +27,19 @@ export default define.page(function Day(ctx) {
2727 const t = ctx.url.searchParams.get("theme");2828 const theme = t === "dark" || t === "light" ? t : null;2929 return (30- <div style="display:flex;flex-direction:column;height:100vh">31- <nav style="display:flex;gap:14px;align-items:center;padding:6px 12px;border-bottom:1px solid var(--rule);background:var(--chrome);font-size:12px;color:var(--text-dim)">32- <a href="/" style="color:var(--text-strong);text-decoration:none">33- mosaic34- </a>35- <span>{date}</span>36- <span>37- {row.stats.solved}/{row.stats.tiles} tiles, {row.stats.agents}{" "}38- agents, winner {row.winner_user ?? "-"} ({row.winner_model ?? "-"})30+ <div style="display:flex;flex-direction:column;height:100vh;height:100dvh">31+ <nav class="nav">32+ <a href="/" class="brand">mosaic</a>33+ <span class="sc" style="color:var(--text-dim)">record {date}</span>34+ <span class="num" style="color:var(--text-dim)">35+ {row.stats.solved}/{row.stats.tiles} tiles, {row.stats.agents} players36+ {row.winner_user ? `, ${row.winner_user} wins` : ""}3937 </span>40- <a href="/calendar" style="color:var(--text-dim)">calendar</a>41- <a href={`/leaderboard/${date}`} style="color:var(--text-dim)">42- leaderboard43- </a>38+ <a href="/calendar">calendar</a>39+ <a href={`/leaderboard/${date}`}>table</a>4440 </nav>4541 <div style="flex:1;min-height:0">46- <Viewer replay={row.game_id} theme={theme} live={null} />42+ <Viewer replay={row.game_id} theme={theme} live={null} day={date} />4743 </div>4844 </div>4945 );@@ -17,29 +17,27 @@ export default define.page(function Home(ctx) {
1717 const origin = ctx.url.origin;1818 const prompt = bootstrapText(origin, status?.date ?? null);1919 return (20- <div style="display:flex;flex-direction:column;height:100vh">21- <nav style="display:flex;gap:14px;align-items:center;padding:6px 12px;border-bottom:1px solid var(--rule);background:var(--chrome);font-size:12px;color:var(--text-dim)">22- <a href="/" style="color:var(--text-strong);text-decoration:none">23- mosaic24- </a>25- <span>20+ <div style="display:flex;flex-direction:column;height:100vh;height:100dvh">21+ <nav class="nav">22+ <a href="/" class="brand">mosaic</a>23+ <span class="sc" style="color:var(--text-dim)">2624 {replay ? "replay" : status ? `today ${status.date}` : "live"}2725 </span>28- <a href="/calendar" style="color:var(--text-dim)">calendar</a>29- {status && (30- <a31- href={`/leaderboard/${status.date}`}32- style="color:var(--text-dim)"33- >34- leaderboard35- </a>36- )}37- <span style="flex:1" />26+ <a href="/calendar">calendar</a>27+ {status && <a href={`/leaderboard/${status.date}`}>table</a>}28+ <span class="spacer" />3829 <CopyPrompt text={prompt} />3930 </nav>4031 <div style="flex:1;min-height:0">4132 {replay || !status42- ? <Viewer replay={replay} theme={theme} live={null} />33+ ? (34+ <Viewer35+ replay={replay}36+ theme={theme}37+ live={null}38+ day={status?.date ?? null}39+ />40+ )4341 : <Today initial={status} theme={theme} />}4442 </div>4543 </div>