Public: anyone can read this; the crew can change it.

The map is a space: full screen, pan and pinch-zoom, semantic zoom from pillars to pulls, time as a dimension #10655

Merged opened this in answer to

Check · Review · Land · Live done — Landed and live in production.

Merged

torque/forge:torque/10444-the-map-is-a-space-full-screen-pan-and-p → main 3 commits fork 3 threads · 3 unresolved

  • Merged. torque/10444-the-map-is-a-space-full-screen-pan-and-p was merged into main as ba40bde.

Review

Sign in to review this change.
18 files changed +1596 −3
docs/design/areas/space.md added +9
@@ -0,0 +1,9 @@
1 # Space
2
3 The repository space (`/:owner/:name/space`, `docs/features/space.md`): one full-screen canvas with a few controls over it. Buttons are the forge's `btn()`; the panel's facts are a `status-rows` `dl`.
4
5 - `.space` — the full-screen surface the canvas, bar, panel and time controls sit on.
6 - `.space-bar` — the row of buttons at the top; shown only when the script runs.
7 - `.space-time` — the scrubber along the bottom; shown only when the script runs.
8 - `.space-panel` — what a tapped issue or crew member is on: a heading and rows, on the page background with one hairline.
9 - `.space-list` — the pillar list the server draws; hidden when the script runs.
docs/features/space.md added +29
@@ -0,0 +1,29 @@
1 # The space
2
3 `/:owner/:name/space` is the map as a plane you move through (#10444). It is
4 linked from the map page ("The same work as a space to move through"). Its data
5 is `/:owner/:name/space.json`, the same plane for an agent. In the terminal it is the `space` entry in the palette (`src/tui/screens/space.ts`): pillars with their counts, issues by state, who of the crew is on what, and the week's landings, from the same `spaceOf` and so the same gate as the page (a guest sees what is public, an admin everything).
6
7 ## Use
8
9 - **Move.** Drag (or two fingers) to pan; pinch, wheel or `+`/`-` (also with ⌘/Ctrl) to zoom; arrows pan; `0` or **Fit** shows the whole plane; `n` or **Now** returns to live. No page scroll fights it (`touch-action: none`).
10 - **Semantic zoom.** Out: pillars are regions whose area follows open work, with a progress bar (landed ÷ open + landed over the week) and whether they moved. In: each region's issues sit in clusters by state (ready, building, in review, waiting, landed). Further in: an issue is a card with its title, then its pull, checks and the crew member on it. Words are set at a fixed pixel size at every level; detail is added, nothing shrinks.
11 - **Time.** The scrubber replays the last 7 days: issues appear when opened, slide from "in review" to "landed" when they close, and crew members sit on what they were running then. **Play** runs the week in 20 seconds. At **Now** the view is live: any run, issue or pull event of the repository fetches the plane again.
12 - **Crew.** Their avatars sit on the issue they are working; tap one (or its name in the bar) to see what they are on.
13 - **Share.** The view is in the address hash: `#x=…&y=…&z=…` (centre in world units, zoom relative to Fit) and `&t=` (minutes since the epoch) when scrubbed back.
14
15 ## How it is built
16
17 - `src/web/space_model.ts` — pure placement: treemap of regions, grid of cells per cluster. Plain data in, compact arrays out.
18 - `src/web/space_read.ts` — reads what the map page reads (graph, words, pillars, runs) and keeps the answer by the write clock (`map_snapshot.ts`).
19 - `src/web/space_view.ts` — the client's arithmetic (placement over time, crew at a time, address hash, tap picking) as plain functions; the page ships their source, the tests call them.
20 - `src/web/space_client.ts` — one 2D canvas, drawn only when the view changed; culls to the viewport.
21 - `src/web/pages/space.ts` — the page (server-rendered pillar list under the canvas for no-JS readers) and the JSON.
22
23 `space.json` carries an ETag (the body's hash): a refetch after an event whose change the plane does not draw is answered 304, and the body is encoded by the server (`http/compress.ts`) otherwise. About 1.5 MB at forge scale before encoding.
24
25 Without script the page is the pillar list alone: the canvas, bars and scrubber show only once the script sets `space-on`. If the first load fails the list stays and says so. State is told by a cluster's label and position and by weight (muted as work ages), not by hue; the accent is for the crew and the controls.
26
27 Assumptions shown: the week's landed work is the closed issues the map's graph still carries (those on an edge); issues closed more than 7 days ago are not drawn. Before an issue landed, it is drawn in review, its slot there reserved by the layout (history of earlier states is not stored).
28
29 Test: `test/space-model.test.ts` places 12,000 issues and checks the size of the JSON (under 3 MB; the time is logged, not asserted); `test/space-view.test.ts` tests the client's placement, crew, hash and tap functions, which the page ships from `src/web/space_view.ts`; the canvas's own paint is measured by hand on a device, not in a test.
src/http/server.ts +3
@@ -231,6 +231,7 @@ import { issuePicture } from "../web/studio/picture.ts";
231231 import { tellHerePage } from "../web/studio/tell_here.ts";
232232 import { globalMapPage } from "../web/pages/global-map.ts";
233233 import { mapPage } from "../web/pages/map.ts";
234 import { spaceJson, spacePage } from "../web/pages/space.ts";
234235 import { firstSubmit } from "../web/pages/first.ts";
235236 import { nextPage, nextSubmit } from "../web/pages/next.ts";
236237 import { weekPage, weekSubmit } from "../web/pages/week.ts";
@@ -1194,6 +1195,8 @@ export function buildRouter(): Router<Handler> {
11941195 router.add("GET", "/:owner/:name/held", heldPage);
11951196 router.add("POST", "/:owner/:name/held/apply", heldApply);
11961197 router.add("GET", "/:owner/:name/map", mapPage);
1198 router.add("GET", "/:owner/:name/space", spacePage);
1199 router.add("GET", "/:owner/:name/space.json", spaceJson);
11971200 router.add("GET", "/:owner/:name/next", nextPage);
11981201 router.add("POST", "/:owner/:name/next", nextSubmit);
11991202 router.add("GET", "/:owner/:name/week", weekPage);
src/tui/forge.ts +6
@@ -20,6 +20,7 @@ import { packsCommand } from "./screens/packs.ts";
2020 import { shellsScreen } from "./screens/shells.ts";
2121 import { forkTreeScreen } from "./screens/fork_tree.ts";
2222 import { roadmapScreen } from "./screens/roadmap.ts";
23 import { spaceScreen } from "./screens/space.ts";
2324 import { App, type Ctx, type Screen } from "./app/app.ts";
2425 import type { Command, PaletteItem, PaletteSearch } from "./app/palette.ts";
2526 import { G } from "./term/screen.ts";
@@ -141,6 +142,11 @@ const REPO_VERBS: readonly {
141142 title: "Roadmap of a repository",
142143 open: (f, ctx, repo) => roadmapScreen(f, ctx, repo),
143144 },
145 {
146 id: "space",
147 title: "Space of a repository",
148 open: (f, ctx, repo) => spaceScreen(f, ctx, repo),
149 },
144150 {
145151 id: "pulls",
146152 title: "Pull requests in a repository",
src/tui/screens/space.ts added +80
@@ -0,0 +1,80 @@
1 /**
2 * The space in the terminal (#10444): the plane the page draws, as text. The
3 * same `spaceOf` the page and `space.json` read, so a guest sees what is
4 * public and an admin everything. Per pillar: its counts, its issues by state,
5 * then who of the crew is on what, and the week's landings.
6 */
7
8 import type { Repo } from "../../core/repos.ts";
9 import { spaceOf } from "../../web/space_read.ts";
10 import type { ClusterRow, IssueRow, Region, State } from "../../web/space_model.ts";
11 import type { Ctx, Screen } from "../app/app.ts";
12 import { binding } from "../app/keys.ts";
13 import type { Session } from "../session.ts";
14 import { style } from "../term/style.ts";
15 import { wrap } from "../term/width.ts";
16 import { Scroll } from "./common.ts";
17
18 const NAMES: Record<State, string> = { ready: "Ready", building: "Building", review: "In review", waiting: "Waiting", landed: "Landed" };
19
20 /** The space as text lines, wrapped to `width`. */
21 export function spaceLines(space: Record<string, unknown>, width = 80): string[] {
22 const w = Math.max(20, width);
23 const regions = space.regions as Region[];
24 const clusters = space.clusters as ClusterRow[];
25 const states = space.states as State[];
26 const issues = space.issues as IssueRow[];
27 const crew = space.crew as { name: string; runs: [number, number, number][] }[];
28 const now = space.now as number;
29 const out: string[] = [];
30 regions.forEach((r, ri) => {
31 out.push(`${r.name} ${r.open} open, ${r.ready} ready, ${r.landed} landed this week${r.moving ? "" : ", still"}`);
32 const by = new Map<State, IssueRow[]>();
33 for (const row of issues) {
34 const c = clusters[row[1]];
35 if (c === undefined || c[0] !== ri) continue;
36 const st = states[c[1]!]!;
37 by.set(st, [...(by.get(st) ?? []), row]);
38 }
39 for (const st of states) {
40 const rows = by.get(st);
41 if (rows === undefined) continue;
42 out.push(` ${NAMES[st]} (${rows.length})`);
43 for (const row of rows) out.push(...wrap(` #${row[0]} ${row[6]}${row[7] ? ` (pull #${row[7]}${row[12] ? `, checks ${row[12]}` : ""})` : ""}`, w));
44 }
45 out.push("");
46 });
47 const on = crew.flatMap((m) => {
48 const cur = m.runs.find((run) => run[1] <= now && (run[2] === 0 || run[2] > now));
49 return cur === undefined ? [] : [` ${m.name} is on #${cur[0]}`];
50 });
51 out.push("The crew", ...(on.length === 0 ? [" Nobody is building right now."] : on), "");
52 const landed = issues.filter((row) => row[5] > 0).sort((a, b) => b[5] - a[5]);
53 out.push(`Landed this week (${landed.length})`, ...landed.flatMap((row) => wrap(` #${row[0]} ${row[6]}`, w)));
54 return out;
55 }
56
57 export function spaceScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
58 const scroll = new Scroll();
59 let lines: string[] = [];
60 return {
61 title: `Space · ${repo.ownerName}/${repo.name}`,
62 keys: [binding(["j", "k"], "scroll"), binding(["esc"], "back")],
63 render(area, s) {
64 const c = ctx.theme.c;
65 lines = spaceLines(spaceOf(f.app, repo, f.user), area.w - 4);
66 const from = scroll.window(area.h, lines.length);
67 for (let i = 0; i < area.h; i++) {
68 const line = lines[from + i];
69 if (line !== undefined) s.text(area.x + 1, area.y + i, line, style(c.fg, c.bg), area.x + area.w);
70 }
71 },
72 key(e) {
73 if (e.key === "esc" || e.key === "q") {
74 ctx.pop();
75 return true;
76 }
77 return scroll.key(e, lines.length);
78 },
79 };
80 }
src/web/map_snapshot.ts +2 −2
@@ -28,14 +28,14 @@ export function mapSnapshots(db: Database): Stamped<Record<string, unknown>> {
2828 /** `build()` once per repository, scope and minute while nothing has been written. */
2929 export function cachedMap(
3030 db: Database,
31 key: { repoId: string; scope: { sealed?: true; sealedBy?: string }; now: Date },
31 key: { repoId: string; scope: { sealed?: true; sealedBy?: string }; now: Date; kind?: string },
3232 build: () => Record<string, unknown>,
3333 ): { value: Record<string, unknown>; hit: boolean } {
3434 const stamp = writeStamp(db);
3535 if (stamp === undefined) return { value: build(), hit: false };
3636 const cache = mapSnapshots(db);
3737 const before = cache.hits;
38 const k = `map:${key.repoId}:${scopeKey(key.scope)}:${key.now.toISOString().slice(0, 16)}`;
38 const k = `${key.kind ?? "map"}:${key.repoId}:${scopeKey(key.scope)}:${key.now.toISOString().slice(0, 16)}`;
3939 const value = cache.get(k, stamp, build);
4040 return { value, hit: cache.hits > before };
4141 }
src/web/pages/map.ts +2 −1
@@ -1439,7 +1439,7 @@ export async function mapPage(ctx: Ctx): Promise<Response> {
14391439 body: html`${repoHeader(ctx, repo, "map")}
14401440 <div class="page-head mt">
14411441 <h1>Roadmap</h1>
1442 <p class="muted small"><a href="${base}/map?view=graph">The graph</a></p>
1442 <p class="muted small"><a href="${base}/map?view=graph">The graph</a> · <a href="${base}/space">The space</a></p>
14431443 </div>
14441444 <div
14451445 class="rm"
@@ -1500,6 +1500,7 @@ export async function mapPage(ctx: Ctx): Promise<Response> {
15001500 <p class="muted">
15011501 What waits for what. The top edge is ready now; the chain in the accent
15021502 is the least work that still has to happen in order.
1503 <a href="${repoBase(repo)}/space">The same work as a space to move through</a>.
15031504 <a href="${repoBase(repo)}/milestones">How far each milestone is</a>.
15041505 <a href="${repoBase(repo)}/next">Next up</a> is the one ranked list the crew builds from.
15051506 </p>
src/web/pages/space.ts added +92
@@ -0,0 +1,92 @@
1 /**
2 * The space (#10444): `/:owner/:name/space`, the map as a full-screen plane
3 * you move through, and `/:owner/:name/space.json`, the same plane as data.
4 * The page server-renders the pillars as a list (what you see before the
5 * script runs, and what a reader without it gets), then the canvas
6 * (`space_client.ts`) takes the screen. The view is in the address hash, so
7 * a link reproduces it.
8 */
9
10 import type { Ctx } from "../../http/context.ts";
11 import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts";
12 import { holds, weakEtag } from "../../http/etag.ts";
13 import { loadRepo, notFoundPage, repoBase } from "../chrome.ts";
14 import { html } from "../html.ts";
15 import { page } from "../layout.ts";
16 import { btn } from "../ui.ts";
17 import { spaceCss, spaceJs } from "../space_client.ts";
18 import type { Region } from "../space_model.ts";
19 import { spaceOf } from "../space_read.ts";
20
21 /** The plane as JSON: for the canvas, and for an agent that wants the numbers. */
22 export function spaceJson(ctx: Ctx): Response {
23 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
24 const repo = loadRepo(ctx);
25 if (!repo) return notFoundPage(ctx);
26 const space = timePhase(ctx.request, "space", () => spaceOf(ctx.app, repo, ctx.auth.user));
27 // The tag is the body's bytes: a run, issue or pull event refetches the plane,
28 // and when nothing it draws moved the answer is a header, not ~1.5 MB. The
29 // body is encoded by the server for every response (`http/compress.ts`).
30 const body = JSON.stringify(space);
31 const etag = weakEtag(body);
32 const headers = { "cache-control": "private, no-cache", etag };
33 if (holds(ctx.request, etag)) return new Response(null, { status: 304, headers });
34 return new Response(body, { headers: { "content-type": "application/json", ...headers } });
35 }
36
37 export function spacePage(ctx: Ctx): Response {
38 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
39 const repo = loadRepo(ctx);
40 if (!repo) return notFoundPage(ctx);
41 const base = repoBase(repo);
42 const space = timePhase(ctx.request, "space", () => spaceOf(ctx.app, repo, ctx.auth.user));
43 const regions = space.regions as Region[];
44 const rendering = performance.now();
45 const response = page({
46 ctx,
47 title: `Space · ${repo.name}`,
48 bare: true,
49 body: html`<div
50 class="space"
51 id="space"
52 data-base="${base}"
53 data-repo="${repo.ownerName}/${repo.name}"
54 data-src="${base}/space.json"
55 >
56 <canvas
57 id="space-cv"
58 tabindex="0"
59 role="img"
60 aria-label="${repo.name}: ${regions.length} pillars, drawn by open work. Zoom in for issues."
61 ></canvas>
62 <div class="space-bar">
63 ${btn("Map", { href: `${base}/map`, variant: "outline", size: "sm" })}
64 ${btn("Fit", { type: "button", id: "space-fit", variant: "outline", size: "sm" })}
65 ${btn("+", { type: "button", id: "space-in", label: "Zoom in", variant: "outline", size: "sm" })}
66 ${btn("−", { type: "button", id: "space-out", label: "Zoom out", variant: "outline", size: "sm" })}
67 <span id="space-crew"></span>
68 </div>
outdated

Principle 3/1 and JS-off: the control bars above are inert without script, and this caveat repeats the docs. Render controls only when the script runs; say the caveat once.

69 <aside class="space-panel" id="space-panel" hidden></aside>
70 <div class="space-time">
71 ${btn("Play", { type: "button", id: "space-play", variant: "outline", size: "sm" })}
72 <input type="range" id="space-t" min="0" max="10080" step="1" value="10080" aria-label="Time, the last 7 days" />
73 <output id="space-clock">Now</output>
74 ${btn("Now", { type: "button", id: "space-now", variant: "outline", size: "sm" })}
75 </div>
76 <div class="space-list">
77 <h1>${repo.name}</h1>
78 <p id="space-error" class="muted" hidden>The canvas could not load the space. The pillars are below.</p>
79 <ul>
80 ${regions.map(
81 (r) =>
82 html`<li><strong>${r.name}</strong> ${r.open} open, ${r.ready} ready, ${r.landed} landed this week</li>`,
83 )}
84 </ul>
85 </div>
86 </div>`,
87 styleCss: spaceCss,
88 scriptJs: spaceJs,
89 });
90 notePhase(ctx.request, "render", performance.now() - rendering);
91 return response;
92 }
src/web/space_client.ts added +461
@@ -0,0 +1,461 @@
1 /**
2 * The space's canvas (#10444): one full-screen 2D canvas, drawn only when
3 * something changed, from the compact arrays `space_model.ts` places. The
4 * script is a hashed file the page ships (`layout.ts`, `scriptJs`); the
5 * data is `space.json`, fetched once and again when the live channel says
6 * the repository moved.
7 *
8 * Detail appears as you zoom rather than shrinking: what is drawn is chosen
9 * by the size of a cluster's cell on the screen, and every word is set at a
10 * fixed pixel size so it stays readable at each level. Nothing here builds
11 * markup from data: text goes to the canvas or to `textContent`.
12 */
13
14 import { viewJs } from "./space_view.ts";
15
16 export const spaceCss = /* css */ `
17 html.space-on, html.space-on body { height: 100%; overflow: hidden; overscroll-behavior: none; }
18 .space { position: fixed; inset: 0; background: var(--bg); color: var(--fg); overflow: auto; }
19 .space canvas, .space-bar, .space-time { display: none; }
20 html.space-on .space { overflow: hidden; }
21 html.space-on .space canvas { display: block; position: absolute; inset: 0; width: 100%; height: 100%; touch-action: none; outline: none; cursor: grab; }
22 html.space-on .space canvas:active { cursor: grabbing; }
outdated

Principle 6 and 5: every button/link gets a bordered box with an accent hover. Use the named button component; a hairline only where a thing ends.

23 html.space-on .space-bar { display: flex; position: absolute; left: 0; right: 0; top: 0; gap: .5rem; align-items: center; flex-wrap: wrap; padding: .5rem .75rem; pointer-events: none; }
24 .space-bar > *, .space-bar span > * { pointer-events: auto; }
25 .space-bar span { display: contents; }
26 html.space-on .space-time { display: flex; position: absolute; left: 0; right: 0; bottom: 0; gap: .6rem; align-items: center; padding: .6rem .75rem calc(.6rem + env(safe-area-inset-bottom)); background: linear-gradient(transparent, var(--bg) 60%); }
27 .space-time input[type=range] { flex: 1; min-width: 0; accent-color: var(--accent); }
28 .space-time output { font-variant-numeric: tabular-nums; min-width: 9ch; text-align: right; }
29 .space-panel { position: absolute; right: .75rem; top: 3.5rem; width: min(22rem, calc(100% - 1.5rem)); background: var(--bg); border-left: 1px solid var(--border); padding: .75rem 1rem; }
outdated

Principle 7 and 6: the panel is a bordered card. Make it rows (a dl) on the page background with at most a hairline.

30 .space-panel[hidden] { display: none; }
31 .space-panel h2 { font-size: 1rem; margin: 0 0 .35rem; }
32 .space-panel .status-rows { margin-top: 0; border-top: 0; }
33 html.space-on .space-list { display: none; }
34 .space-list { padding: 4rem 1rem 2rem; max-width: 40rem; }
35 .space-list ul { list-style: none; padding: 0; margin: 0; }
36 `;
37
38 export const spaceJs = /* js */ `
39 (function () {
40 ${viewJs}
41 var root = document.getElementById('space');
42 var cv = document.getElementById('space-cv');
43 if (!root || !cv || !cv.getContext) return;
44 document.documentElement.classList.add('space-on');
45 var g = cv.getContext('2d');
46 var base = root.getAttribute('data-base');
47 var repo = root.getAttribute('data-repo');
48 var dataUrl = root.getAttribute('data-src');
49 var panel = document.getElementById('space-panel');
50 var slider = document.getElementById('space-t');
51 var clock = document.getElementById('space-clock');
52 var crewBox = document.getElementById('space-crew');
53 var playBtn = document.getElementById('space-play');
54 var S = null, rows = [], W = 0, H = 0, dpr = 1;
55 var view = { x: 0, y: 0, k: 1 }; // world point at the centre, pixels per world unit
56 var fitK = 1, T = 0, live = true, dirty = true, playing = false, flight = null;
57 var GLIDE = 4 * 3600000; // a landing slides from review to landed over this much time
58 var NAMES = { ready: 'Ready', building: 'Building', review: 'In review', waiting: 'Waiting', landed: 'Landed' };
59 var style = getComputedStyle(root);
60 function token(name, fallback) { return style.getPropertyValue(name).trim() || fallback; }
61 var fg = style.color || 'currentColor';
62 var accent = token('--accent', fg);
63 var accentFg = token('--accent-fg', fg);
64 var ground = token('--bg', 'Canvas');
65 // One accent, for interaction (the crew). A state is told by its cluster's label and position; its weight is muted as it ages.
66 var WEIGHT = { ready: 1, building: 1, review: 0.7, waiting: 0.5, landed: 0.35 };
67 var sans = style.fontFamily || 'system-ui, sans-serif';
68 var picked = null; // {kind:'issue'|'crew', ...}
69
70 function ago(ms) {
71 var m = Math.round(ms / 60000);
72 if (m < 1) return 'just now';
73 if (m < 90) return m + ' min ago';
74 var h = Math.round(m / 60);
75 return h < 48 ? h + ' h ago' : Math.round(h / 24) + ' d ago';
76 }
77
78 // ---- data ----
79 function load(first) {
80 return fetch(dataUrl, { credentials: 'same-origin', headers: { accept: 'application/json' } })
81 .then(function (r) { return r.ok ? r.json() : Promise.reject(r); })
82 .then(function (data) {
83 S = data; rows = data.issues;
84 slider.max = String(Math.max(1, Math.round((S.now - S.weekStart) / 60000)));
85 if (live) T = S.now;
86 slider.value = String(Math.round((T - S.weekStart) / 60000));
87 crewChips();
88 if (first) { resize(); fit(true); fromHash(); }
89 if (picked) refreshPanel();
90 dirty = true;
91 })
92 .catch(function () {
93 // The list the server drew is the page; say why the canvas is not.
94 if (first) { document.documentElement.classList.remove('space-on'); root.setAttribute('data-failed', ''); }
95 clock.textContent = 'Could not load';
96 var note = document.getElementById('space-error'); if (note) note.hidden = false;
97 });
98 }
99
100 // placement, crew, hash and picking are space_view.ts, shipped ahead of this script
101 function crewNow() { return S ? crewAt(S.crew, rows, T) : []; }
102 function at(r) { return placeAt(r, T); }
103
104 // ---- view ----
105 function resize() {
106 dpr = Math.min(window.devicePixelRatio || 1, 2);
107 W = root.clientWidth; H = root.clientHeight;
108 cv.width = Math.round(W * dpr); cv.height = Math.round(H * dpr);
109 if (S) { var old = fitK; computeFit(); if (old !== fitK && view.k === old) view.k = fitK; }
110 dirty = true;
111 }
112 function computeFit() {
113 fitK = Math.min(W / S.world.w, H / S.world.h) * 0.94;
114 }
115 function limits() { return [fitK * 0.5, Math.max(fitK * 60, 6)]; }
116 function fit(now) {
117 computeFit();
118 fly({ x: S.world.w / 2, y: S.world.h / 2, k: fitK }, now);
119 }
120 function fly(to, instant) {
121 if (instant) { view.x = to.x; view.y = to.y; view.k = to.k; flight = null; dirty = true; hashSoon(); return; }
122 flight = { from: { x: view.x, y: view.y, k: view.k }, to: to, t0: performance.now() };
123 dirty = true;
124 }
125 function step(now) {
126 if (flight) {
127 var p = clamp((now - flight.t0) / 420, 0, 1), e = ease(p), f = flight.from, t = flight.to;
128 // Zoom in log space so the motion feels even.
129 view.k = f.k * Math.pow(t.k / f.k, e);
130 view.x = f.x + (t.x - f.x) * e; view.y = f.y + (t.y - f.y) * e;
131 if (p >= 1) { flight = null; hashSoon(); }
132 dirty = true;
133 }
134 if (playing && S) {
135 T += (7 * 86400000 / 20000) * Math.min(now - (step.last || now), 100);
136 if (T >= S.now) { T = S.now; playing = false; live = true; playBtn.textContent = 'Play'; }
137 else live = false;
138 slider.value = String(Math.round((T - S.weekStart) / 60000));
139 dirty = true; hashSoon();
140 }
141 step.last = now;
142 if (dirty && S) { dirty = false; draw(); }
143 requestAnimationFrame(step);
144 }
145 function toScreen(x, y) { return [(x - view.x) * view.k + W / 2, (y - view.y) * view.k + H / 2]; }
146 function toWorld(sx, sy) { return [(sx - W / 2) / view.k + view.x, (sy - H / 2) / view.k + view.y]; }
147 function zoomAt(sx, sy, factor) {
148 var l = limits(), k2 = clamp(view.k * factor, l[0], l[1]);
149 var w = toWorld(sx, sy);
150 view.k = k2;
151 view.x = w[0] - (sx - W / 2) / k2; view.y = w[1] - (sy - H / 2) / k2;
152 flight = null; dirty = true; hashSoon();
153 }
154
155 // ---- drawing ----
156 function font(px, weight) { g.font = (weight || 400) + ' ' + px + 'px ' + sans; }
157 function wrap(text, width, maxLines) {
158 var words = text.split(' '), lines = [], line = '';
159 for (var i = 0; i < words.length; i++) {
160 var t = line ? line + ' ' + words[i] : words[i];
161 if (g.measureText(t).width <= width || !line) line = t;
162 else { lines.push(line); line = words[i]; if (lines.length === maxLines) break; }
163 }
164 if (lines.length < maxLines && line) lines.push(line);
165 else if (line && lines.length === maxLines) lines[maxLines - 1] += '\\u2026';
166 return lines.map(function (l) {
167 while (g.measureText(l).width > width && l.length > 1) l = l.slice(0, -2) + '\\u2026';
168 return l;
169 });
170 }
171 function fitText(text, width) {
172 if (g.measureText(text).width <= width) return text;
173 while (text.length > 1 && g.measureText(text + '\\u2026').width > width) text = text.slice(0, -1);
174 return text + '\\u2026';
175 }
176 function draw() {
177 g.setTransform(dpr, 0, 0, dpr, 0, 0);
178 g.clearRect(0, 0, W, H);
179 var k = view.k, tl = toWorld(0, 0), br = toWorld(W, H);
180 g.textBaseline = 'alphabetic';
181 // Regions: the pillars.
182 S.regions.forEach(function (r) {
183 if (r.x > br[0] || r.y > br[1] || r.x + r.w < tl[0] || r.y + r.h < tl[1]) return;
184 var p = toScreen(r.x, r.y), rw = r.w * k, rh = r.h * k;
185 g.globalAlpha = 0.07; g.fillStyle = fg; g.fillRect(p[0], p[1], rw, rh);
186 g.globalAlpha = 0.22; g.strokeStyle = fg; g.lineWidth = 1; g.strokeRect(p[0] + 0.5, p[1] + 0.5, rw - 1, rh - 1);
187 g.globalAlpha = 1;
188 // The label sticks to the top of the region's visible part, so it is readable at every depth.
189 var ly = Math.max(p[1], 0) + 4;
190 if (ly > p[1] + rh - 22) ly = p[1] + 4;
191 var lx = Math.max(p[0], 0) + 10;
192 var room = Math.min(p[0] + rw, W) - lx - 8;
193 if (room > 30) {
194 var size = clamp(rw / 14, 13, 24);
195 g.save(); g.beginPath(); g.rect(p[0], p[1], rw, rh); g.clip();
196 font(size, 700); g.fillStyle = fg;
197 g.fillText(fitText(r.name, room), lx, ly + size);
198 if (room > 150) {
199 font(12, 400); g.globalAlpha = 0.7;
200 var bits = r.open + ' open \\u00b7 ' + r.ready + ' ready';
201 if (r.landed) bits += ' \\u00b7 ' + r.landed + ' landed';
202 g.fillText(fitText(bits + (r.moving ? '' : ' \\u00b7 still'), room), lx, ly + size + 17);
203 g.globalAlpha = 1;
204 var bw = Math.min(room, 180), by = ly + size + 26;
205 g.globalAlpha = 0.2; g.fillRect(lx, by, bw, 4);
206 g.globalAlpha = 1; g.fillStyle = fg; g.fillRect(lx, by, bw * r.progress, 4);
207 }
208 g.restore();
209 }
210 });
211 // Clusters: the states inside a pillar.
212 S.clusters.forEach(function (c) {
213 var cw = c[4] * k;
214 if (cw < 80 || c[2] > br[0] || c[3] > br[1] || c[2] + c[4] < tl[0] || c[3] + c[5] < tl[1]) return;
215 var p = toScreen(c[2], c[3]), name = NAMES[S.states[c[1]]];
216 g.globalAlpha = 0.3; g.strokeStyle = fg; g.lineWidth = 1;
217 g.beginPath(); g.moveTo(p[0] + 2, p[1] + 2); g.lineTo(p[0] + cw - 2, p[1] + 2); g.stroke();
218 g.globalAlpha = 1; g.fillStyle = fg; font(12, 600);
219 var lx = Math.max(p[0], 0) + 6;
220 g.fillText(fitText(name + ' \\u00b7 ' + c[7], Math.min(p[0] + cw, W) - lx - 6), lx, Math.max(p[1], 0) + 20);
221 });
222 // Issues, at the level the cell size on the screen allows.
223 var buckets = {};
224 var cards = [];
225 for (var i = 0; i < rows.length; i++) {
226 var r = rows[i], pos = at(r);
227 if (!pos) continue;
228 var s = toScreen(pos[0], pos[1]), cl = S.clusters[pos[2]], cell = cl[6] * k;
229 if (s[0] < -cell || s[1] < -cell || s[0] > W + cell || s[1] > H + cell) continue;
230 var st = S.states[cl[1]];
231 if (cell >= 60) { cards.push([r, s, cell, st, pos]); continue; }
232 var d = Math.max(1.5, cell * 0.7);
233 (buckets[st] = buckets[st] || []).push(s[0] - d / 2, s[1] - d / 2, d);
234 }
235 Object.keys(buckets).forEach(function (st) {
236 g.globalAlpha = WEIGHT[st]; g.fillStyle = fg; var b = buckets[st];
237 for (var j = 0; j < b.length; j += 3) g.fillRect(b[j], b[j + 1], b[j + 2], b[j + 2]);
238 g.globalAlpha = 1;
239 });
240 cards.forEach(function (c) { card(c[0], c[1], c[2], c[3], c[4]); });
241 // Crew: where they are working.
242 var crew = crewNow();
243 crew.forEach(function (m) {
244 var s = toScreen(m.x, m.y), x = s[0] + 10 + m.slot * 26, y = s[1] - 10;
245 if (x < -20 || y < -20 || x > W + 20 || y > H + 20) return;
246 g.beginPath(); g.arc(x, y, 12, 0, 6.2832);
247 g.fillStyle = accent; g.fill();
248 g.lineWidth = 2; g.strokeStyle = picked && picked.kind === 'crew' && picked.name === m.name ? fg : ground; g.stroke();
249 g.fillStyle = accentFg; font(11, 700); g.textAlign = 'center';
250 g.fillText(m.name.slice(0, 1).toUpperCase(), x, y + 4); g.textAlign = 'start';
251 m.sx = x; m.sy = y;
252 });
253 lastCrew = crew;
254 }
255 var lastCrew = [];
256 function card(r, s, cell, st, pos) {
257 var size = cell * 0.92, x = s[0] - size / 2, y = s[1] - size / 2;
258 g.globalAlpha = 1; g.fillStyle = fg; g.globalAlpha = 0.06; g.fillRect(x, y, size, size);
259 g.globalAlpha = 0.25; g.strokeStyle = fg; g.lineWidth = 1; g.strokeRect(x + 0.5, y + 0.5, size - 1, size - 1);
260 g.globalAlpha = WEIGHT[st];
261 var open = !(r[5] && r[5] <= T);
262 if (!open) { g.globalAlpha = 0.75 * WEIGHT[st]; }
263 var pad = 10, tw = size - pad - 8;
264 g.fillStyle = fg; font(12, 700);
265 g.fillText('#' + r[0], x + pad, y + 18);
266 font(12, 400);
267 var lines = wrap(r[6], tw, size >= 170 ? 5 : 3);
268 for (var i = 0; i < lines.length; i++) g.fillText(lines[i], x + pad, y + 36 + i * 15);
269 if (size >= 170) {
270 var yy = y + 36 + lines.length * 15 + 8;
271 font(11, 400); g.globalAlpha = 0.75;
272 var bits = [NAMES[st], 'opened ' + ago(T - r[4])];
273 if (r[7]) bits.push('pull #' + r[7] + (r[12] ? ' \\u00b7 checks ' + r[12] : ''));
274 if (r[11]) bits.push(r[11]);
275 bits.forEach(function (b, n) { g.fillText(fitText(b, tw), x + pad, yy + n * 15); });
276 }
277 g.globalAlpha = 1;
278 }
279
280 // ---- input ----
281 var pointers = {}, gesture = null, moved = 0, downAt = 0, downPos = null;
282 function count() { return Object.keys(pointers).length; }
283 function pinchState() {
284 var ids = Object.keys(pointers), a = pointers[ids[0]], b = pointers[ids[1]];
285 return { cx: (a.x + b.x) / 2, cy: (a.y + b.y) / 2, d: Math.max(10, Math.hypot(a.x - b.x, a.y - b.y)) };
286 }
287 cv.addEventListener('pointerdown', function (e) {
288 cv.setPointerCapture(e.pointerId);
289 pointers[e.pointerId] = { x: e.offsetX, y: e.offsetY };
290 flight = null;
291 if (count() === 1) { moved = 0; downAt = e.timeStamp; downPos = { x: e.offsetX, y: e.offsetY }; }
292 if (count() === 2) gesture = pinchState();
293 });
294 cv.addEventListener('pointermove', function (e) {
295 var p = pointers[e.pointerId];
296 if (!p) return;
297 var dx = e.offsetX - p.x, dy = e.offsetY - p.y;
298 p.x = e.offsetX; p.y = e.offsetY;
299 moved += Math.abs(dx) + Math.abs(dy);
300 if (count() === 1) {
301 view.x -= dx / view.k; view.y -= dy / view.k;
302 } else if (count() === 2 && gesture) {
303 var now = pinchState();
304 view.x -= (now.cx - gesture.cx) / view.k; view.y -= (now.cy - gesture.cy) / view.k;
305 zoomAt(now.cx, now.cy, now.d / gesture.d);
306 gesture = pinchState();
307 }
308 dirty = true; hashSoon();
309 });
310 function up(e) {
311 var had = pointers[e.pointerId];
312 delete pointers[e.pointerId];
313 if (count() === 1) { gesture = null; moved = 99; }
314 if (count() === 0) {
315 gesture = null;
316 if (had && e.type === 'pointerup' && moved < 8 && e.timeStamp - downAt < 600) tap(downPos.x, downPos.y);
317 }
318 }
319 cv.addEventListener('pointerup', up);
320 cv.addEventListener('pointercancel', up);
321 cv.addEventListener('wheel', function (e) {
322 e.preventDefault();
323 var unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? 400 : 1;
324 var d = e.deltaY * unit;
325 zoomAt(e.offsetX, e.offsetY, Math.exp(-d * (e.ctrlKey ? 0.01 : 0.0016)));
326 }, { passive: false });
327 cv.addEventListener('keydown', function (e) {
328 var step = 80 / view.k, mod = e.metaKey || e.ctrlKey, key = e.key;
329 if (key === '+' || key === '=') zoomAt(W / 2, H / 2, 1.4);
330 else if (key === '-' || key === '_') zoomAt(W / 2, H / 2, 1 / 1.4);
331 else if (!mod && key === '0') fit();
332 else if (!mod && key === 'n') goNow();
333 else if (key === 'ArrowLeft') view.x -= step;
334 else if (key === 'ArrowRight') view.x += step;
335 else if (key === 'ArrowUp') view.y -= step;
336 else if (key === 'ArrowDown') view.y += step;
337 else return;
338 e.preventDefault(); dirty = true; hashSoon();
339 });
340 window.addEventListener('resize', resize);
341
342 // ---- picking ----
343 function tap(sx, sy) {
344 for (var i = 0; i < lastCrew.length; i++) {
345 var m = lastCrew[i];
346 if (Math.hypot(sx - m.sx, sy - m.sy) <= 16) { pick({ kind: 'crew', name: m.name }); return; }
347 }
348 var w = toWorld(sx, sy), best = pickIssue(rows, S.clusters, T, w[0], w[1]);
349 if (best) pick({ kind: 'issue', number: best[0] });
350 else { picked = null; panel.hidden = true; dirty = true; }
351 }
352 function pick(p) { picked = p; refreshPanel(); dirty = true; }
353 function row(term, text, href) {
354 var r = document.createElement('div'); r.className = 'status-row';
355 var dt = document.createElement('dt'); dt.textContent = term;
356 var dd = document.createElement('dd');
357 if (href) { var a = document.createElement('a'); a.href = href; a.textContent = text; dd.appendChild(a); }
358 else dd.textContent = text;
359 r.appendChild(dt); r.appendChild(dd); list.appendChild(r);
360 }
361 var list = null;
362 function refreshPanel() {
363 if (!picked) { panel.hidden = true; return; }
364 panel.textContent = '';
365 var h = document.createElement('h2');
366 list = document.createElement('dl'); list.className = 'status-rows';
367 if (picked.kind === 'issue') {
368 var r = rows.filter(function (x) { return x[0] === picked.number; })[0];
369 if (!r) { panel.hidden = true; return; }
370 var pos = at(r);
371 h.textContent = '#' + r[0] + ' ' + r[6];
372 var st = pos ? S.states[S.clusters[pos[2]][1]] : 'not yet opened';
373 row('State', NAMES[st] || st);
374 row('Opened', ago(T - r[4]));
375 if (r[5] && r[5] <= T) row('Landed', ago(T - r[5]));
376 if (r[7]) row('Pull', '#' + r[7] + (r[12] ? ' \\u00b7 checks ' + r[12] : ''), base + '/pulls/' + r[7]);
377 if (r[11]) row('Held', r[11]);
378 crewNow().filter(function (m) { return m.issue === r[0]; }).forEach(function (m) { row('On it', m.name); });
379 row('Issue', 'Open it', base + '/issues/' + r[0]);
380 } else {
381 h.textContent = picked.name;
382 var on = crewNow().filter(function (m) { return m.name === picked.name; })[0];
383 if (!on) row('On', 'Nothing ' + (live ? 'right now' : 'then'));
384 else {
385 var rr = rows.filter(function (x) { return x[0] === on.issue; })[0];
386 row('On', '#' + on.issue + (rr ? ' ' + rr[6] : ''), base + '/issues/' + on.issue);
387 row('Since', ago(T - on.since));
388 }
389 }
390 panel.appendChild(h); panel.appendChild(list);
391 panel.hidden = false;
392 }
393 function crewChips() {
394 crewBox.textContent = '';
395 S.crew.forEach(function (m) {
396 var b = document.createElement('button');
397 b.type = 'button'; b.className = 'btn'; b.setAttribute('data-variant', 'outline'); b.setAttribute('data-size', 'sm'); b.textContent = m.name;
398 b.addEventListener('click', function () {
399 var on = crewNow().filter(function (c) { return c.name === m.name; })[0];
400 pick({ kind: 'crew', name: m.name });
401 if (on) fly({ x: on.x, y: on.y, k: Math.max(view.k, fitK * 24) });
402 });
403 crewBox.appendChild(b);
404 });
405 }
406
407 // ---- time ----
408 function showClock() {
409 clock.textContent = live ? 'Now' : new Date(T).toLocaleString([], { weekday: 'short', hour: '2-digit', minute: '2-digit' });
410 }
411 function setT(ms) {
412 T = ms; live = S && T >= S.now; if (live) T = S.now;
413 slider.value = String(Math.round((T - S.weekStart) / 60000));
414 showClock(); if (picked) refreshPanel(); dirty = true; hashSoon();
415 }
416 function goNow() { playing = false; playBtn.textContent = 'Play'; setT(S.now); }
417 slider.addEventListener('input', function () { playing = false; playBtn.textContent = 'Play'; setT(S.weekStart + Number(slider.value) * 60000); });
418 playBtn.addEventListener('click', function () {
419 playing = !playing; playBtn.textContent = playing ? 'Pause' : 'Play';
420 if (playing && live) { T = S.weekStart; live = false; }
421 });
422 document.getElementById('space-now').addEventListener('click', function () { goNow(); });
423 document.getElementById('space-fit').addEventListener('click', function () { fit(); });
424 document.getElementById('space-in').addEventListener('click', function () { zoomAt(W / 2, H / 2, 1.5); });
425 document.getElementById('space-out').addEventListener('click', function () { zoomAt(W / 2, H / 2, 1 / 1.5); });
426 setInterval(showClock, 1000);
427
428 // ---- the view in the address ----
429 var hashTimer = 0;
430 function hashSoon() {
431 if (hashTimer) return;
432 hashTimer = setTimeout(function () {
433 hashTimer = 0;
434 if (!S) return;
435 var h = hashOf(view, fitK, live, T);
436 try { history.replaceState(null, '', '#' + h); } catch (err) {}
437 }, 250);
438 }
439 function fromHash() {
440 var h = parseHash(location.hash, S, fitK, limits());
441 if (h.k !== undefined) { view.x = h.x; view.y = h.y; view.k = h.k; }
442 if (h.t !== undefined) setT(h.t); else showClock();
443 }
444
445 // ---- the live channel: any event of this repository fetches the space again ----
446 var refetch = 0;
447 if (window.EventSource) {
448 var es = new EventSource('/events');
449 es.addEventListener('forge', function (ev) {
450 var d; try { d = JSON.parse(ev.data); } catch (err) { return; }
451 if (d.repo !== repo || !/^(run|issue|pull)\\./.test(d.kind || '')) return;
452 if (refetch) return;
453 refetch = setTimeout(function () { refetch = 0; if (!document.hidden) load(false); }, 1200);
454 });
455 }
456
457 resize();
458 requestAnimationFrame(step);
459 load(true);
460 })();
461 `;
src/web/space_model.ts added +313
@@ -0,0 +1,313 @@
1 /**
2 * The space (#10444): the map as a plane to move through. This module is
3 * the whole of its arithmetic and nothing else: plain data in (a
4 * repository's work, as `space_read.ts` gathers it), plain data out (every
5 * region, cluster and issue placed in one world, in compact arrays the
6 * canvas draws from). No database, no HTML.
7 *
8 * The world is one rectangle. Pillars are regions of it, cut by recursive
9 * bisection (a treemap) so that area follows open work. Inside a region the
10 * issues sit in clusters by state, each cluster a grid of square cells. An
11 * issue that landed this week also has a slot in the "in review" cluster of
12 * its region, where it was before it landed, so the scrubber can move it.
13 * Units are world units; the client scales them to pixels.
14 */
15
16 /** Where an issue sits in its pillar's life, left to right on the page. */
17 export const STATES = ["ready", "building", "review", "waiting", "landed"] as const;
18 export type State = (typeof STATES)[number];
19
20 /** The words the map page uses for a card, mapped to a cluster. */
21 export function stateOf(word: string): State {
22 switch (word) {
23 case "done":
24 return "landed";
25 case "working":
26 return "building";
27 case "held":
28 case "waiting":
29 return "review";
30 case "ready":
31 return "ready";
32 default:
33 return "waiting"; // blocked, deferred, parked
34 }
35 }
36
37 export const DAY_MS = 86_400_000;
38 /** How far back the scrubber goes. */
39 export const WEEK_MS = 7 * DAY_MS;
40
41 export interface SpaceIssue {
42 readonly number: number;
43 readonly title: string;
44 /** Index into `SpaceInput.pillars`; -1 for work that serves none. */
45 readonly pillar: number;
46 readonly state: State;
47 readonly createdAt: number;
48 /** Epoch ms; 0 while open. */
49 readonly closedAt: number;
50 /** The open pull that would close it, or 0. */
51 readonly pull: number;
52 /** Why a person holds that pull, or "". */
53 readonly hold: string;
54 /** Latest check verdict of that pull's head: "ok", "failed", "running" or "". */
55 readonly check: string;
56 }
57
58 export interface SpaceRun {
59 readonly actor: string;
60 readonly issue: number;
61 readonly startedAt: number;
62 /** Epoch ms; 0 while it runs. */
63 readonly endedAt: number;
64 }
65
66 export interface SpaceInput {
67 readonly repo: string;
68 readonly now: number;
69 /** Pillar names in order; "No pillar" is appended by the reader when needed. */
70 readonly pillars: readonly { name: string; merged: number }[];
71 /** Open issues, and those closed in the last week. Nothing older. */
72 readonly issues: readonly SpaceIssue[];
73 /** Agent runs of the last week, newest first. */
74 readonly runs: readonly SpaceRun[];
75 }
76
77 export interface Region {
78 readonly name: string;
79 readonly x: number;
80 readonly y: number;
81 readonly w: number;
82 readonly h: number;
83 readonly open: number;
84 readonly ready: number;
85 readonly blocked: number;
86 readonly landed: number;
87 readonly merged: number;
88 /** landed ÷ (open + landed) over the window, 0..1. */
89 readonly progress: number;
90 /** Whether anything moved in the window: a landing, or someone building. */
91 readonly moving: boolean;
92 }
93
94 /** [region, state index, x, y, w, h, cell, count] */
95 export type ClusterRow = [number, number, number, number, number, number, number, number];
96 /** [number, cluster, x, y, created, closed, title, pull, bx, by, bcluster, hold, check] */
97 export type IssueRow = [number, number, number, number, number, number, string, number, number, number, number, string, string];
98
99 export interface Space {
100 readonly repo: string;
101 readonly now: number;
102 readonly weekStart: number;
103 readonly world: { readonly w: number; readonly h: number };
104 readonly states: readonly State[];
105 readonly regions: Region[];
106 readonly clusters: ClusterRow[];
107 readonly issues: IssueRow[];
108 readonly crew: { name: string; runs: [number, number, number][] }[];
109 }
110
111 interface Rect {
112 x: number;
113 y: number;
114 w: number;
115 h: number;
116 }
117
118 /**
119 * Slice-and-dice by recursive bisection: `weights` (descending) cut `rect`
120 * into one rectangle each, areas in proportion. The list is split where the
121 * two halves weigh most nearly alike, along the longer side.
122 */
123 export function treemap(weights: readonly number[], rect: Rect): Rect[] {
124 if (weights.length === 0) return [];
125 if (weights.length === 1) return [{ ...rect }];
126 const total = weights.reduce((a, b) => a + b, 0);
127 let cut = 1;
128 let acc = weights[0]!;
129 let best = Math.abs(total - 2 * acc);
130 for (let i = 1; i < weights.length - 1; i++) {
131 acc += weights[i]!;
132 const gap = Math.abs(total - 2 * acc);
133 if (gap < best) {
134 best = gap;
135 cut = i + 1;
136 }
137 }
138 const left = weights.slice(0, cut);
139 const lw = left.reduce((a, b) => a + b, 0);
140 const share = total === 0 ? 0.5 : lw / total;
141 if (rect.w >= rect.h) {
142 const w = rect.w * share;
143 return [
144 ...treemap(left, { x: rect.x, y: rect.y, w, h: rect.h }),
145 ...treemap(weights.slice(cut), { x: rect.x + w, y: rect.y, w: rect.w - w, h: rect.h }),
146 ];
147 }
148 const h = rect.h * share;
149 return [
150 ...treemap(left, { x: rect.x, y: rect.y, w: rect.w, h }),
151 ...treemap(weights.slice(cut), { x: rect.x, y: rect.y + h, w: rect.w, h: rect.h - h }),
152 ];
153 }
154
155 /** The largest square cell with which `n` of them fit a `w`×`h` rectangle, and the columns that gives. */
156 export function gridFor(n: number, w: number, h: number): { cell: number; cols: number } {
157 if (n <= 0 || w <= 0 || h <= 0) return { cell: Math.max(0, Math.min(w, h)), cols: 1 };
158 let cell = Math.sqrt((w * h) / n);
159 while (cell > 0.01 && Math.floor(w / cell) * Math.floor(h / cell) < n) cell *= 0.97;
160 return { cell, cols: Math.max(1, Math.floor(w / cell)) };
161 }
162
163 /** Room above a region's or cluster's contents, for its words. */
164 const REGION_HEAD = 70;
165 const CLUSTER_HEAD = 30;
166 const GAP = 14;
167 /** The smallest cell a card is legible in, at the deepest zoom the page allows for it. */
168 const MIN_CELL = 40;
169
170 /** The world's size for `n` slots: a 1.6 : 1 plane with room for every cell at `MIN_CELL`. */
171 export function worldFor(n: number): { w: number; h: number } {
172 const area = Math.max(1_600_000, n * MIN_CELL * MIN_CELL * 2.4);
173 const h = Math.sqrt(area / 1.6);
174 return { w: Math.round(h * 1.6), h: Math.round(h) };
175 }
176
177 const inset = (r: Rect, top: number, pad: number): Rect => ({
178 x: r.x + pad,
179 y: r.y + top,
180 w: Math.max(1, r.w - 2 * pad),
181 h: Math.max(1, r.h - top - pad),
182 });
183
184 /** Place the work of one repository. Pure: the same input gives the same space. */
185 export function buildSpace(input: SpaceInput): Space {
186 const weekStart = input.now - WEEK_MS;
187 const names = input.pillars.map((p) => p.name);
188 const loose = input.issues.some((i) => i.pillar < 0);
189 if (loose) names.push("No pillar");
190 if (names.length === 0) names.push("All work");
191 const regionOf = (i: SpaceIssue): number => (i.pillar >= 0 ? i.pillar : loose ? names.length - 1 : 0);
192
193 // Which issue sits in which region and cluster. A landed one also holds a
194 // "before" slot in review.
195 const per = names.map(() => STATES.map(() => [] as { issue: SpaceIssue; before: boolean }[]));
196 const tally = names.map(() => ({ open: 0, ready: 0, blocked: 0, landed: 0 }));
197 for (const issue of input.issues) {
198 const r = regionOf(issue);
199 const s = STATES.indexOf(issue.state);
200 per[r]![s]!.push({ issue, before: false });
201 const t = tally[r]!;
202 if (issue.state === "landed") {
203 t.landed++;
204 per[r]![STATES.indexOf("review")]!.push({ issue, before: true });
205 } else {
206 t.open++;
207 if (issue.state === "ready") t.ready++;
208 if (issue.state === "waiting") t.blocked++;
209 }
210 }
211
212 const slots = per.reduce((a, r) => a + r.reduce((b, c) => b + c.length, 0), 0);
213 const world = worldFor(slots);
214 const weights = tally.map((t) => Math.max(1, t.open) + t.landed * 0.5);
215 const order = weights.map((w, i) => i).sort((a, b) => weights[b]! - weights[a]! || a - b);
216 const rects = treemap(
217 order.map((i) => weights[i]!),
218 { x: 0, y: 0, w: world.w, h: world.h },
219 );
220
221 const runsByActor = new Map<string, [number, number, number][]>();
222 for (const run of input.runs) {
223 const list = runsByActor.get(run.actor) ?? [];
224 list.push([run.issue, run.startedAt, run.endedAt]);
225 runsByActor.set(run.actor, list);
226 }
227
228 const regions: Region[] = names.map((name, i) => {
229 const t = tally[i]!;
230 const building = per[i]![STATES.indexOf("building")]!.length;
231 return {
232 name,
233 x: 0,
234 y: 0,
235 w: 0,
236 h: 0,
237 open: t.open,
238 ready: t.ready,
239 blocked: t.blocked,
240 landed: t.landed,
241 merged: input.pillars[i]?.merged ?? 0,
242 progress: t.open + t.landed === 0 ? 0 : t.landed / (t.open + t.landed),
243 moving: t.landed > 0 || building > 0,
244 };
245 });
246 const clusters: ClusterRow[] = [];
247 const issues: IssueRow[] = [];
248 const byNumber = new Map<number, IssueRow>();
249 const befores: [number, number, number, number][] = [];
250
251 order.forEach((ri, k) => {
252 const rect = rects[k]!;
253 const placed = regions[ri]!;
254 regions[ri] = { ...placed, x: rect.x, y: rect.y, w: rect.w, h: rect.h };
255 const inner = inset(rect, REGION_HEAD, GAP);
256 const used = STATES.map((_, s) => s).filter((s) => per[ri]![s]!.length > 0);
257 const cutRects = treemap(
258 used.map((s) => per[ri]![s]!.length).sort((a, b) => b - a),
259 inner,
260 );
261 // The treemap wants descending weights; give each rectangle to the
262 // cluster of that size, ties in state order.
263 const bySize = [...used].sort((a, b) => per[ri]![b]!.length - per[ri]![a]!.length || a - b);
264 const home = new Map<number, number>(); // state -> cluster index
265 bySize.forEach((s, j) => {
266 const box = cutRects[j]!;
267 const body = inset(box, CLUSTER_HEAD, 4);
268 const list = per[ri]![s]!;
269 const { cell, cols } = gridFor(list.length, body.w, body.h);
270 const ci = clusters.length;
271 home.set(s, ci);
272 clusters.push([ri, s, box.x, box.y, box.w, box.h, cell, list.length]);
273 // Sort so the newest sit first, and "before" slots come last.
274 const ordered = [...list].sort(
275 (a, b) => Number(a.before) - Number(b.before) || b.issue.number - a.issue.number,
276 );
277 ordered.forEach((entry, n) => {
278 const x = body.x + (n % cols) * cell + cell / 2;
279 const y = body.y + Math.floor(n / cols) * cell + cell / 2;
280 if (entry.before) {
281 befores.push([entry.issue.number, x, y, ci]);
282 return;
283 }
284 const i = entry.issue;
285 const row: IssueRow = [i.number, ci, x, y, i.createdAt, i.closedAt, i.title, i.pull, 0, 0, -1, i.hold, i.check];
286 byNumber.set(i.number, row);
287 issues.push(row);
288 });
289 });
290 });
291 for (const [n, x, y, ci] of befores) {
292 const row = byNumber.get(n);
293 if (row) [row[8], row[9], row[10]] = [x, y, ci];
294 }
295 // An issue with no before slot starts where it ended.
296 for (const row of issues) if (row[10] < 0) [row[8], row[9], row[10]] = [row[2], row[3], row[1]];
297
298 const known = new Set(issues.map((r) => r[0]));
299 return {
300 repo: input.repo,
301 now: input.now,
302 weekStart,
303 world,
304 states: STATES,
305 regions,
306 clusters,
307 issues,
308 crew: [...runsByActor].map(([name, runs]) => ({
309 name,
310 runs: runs.filter((r) => known.has(r[0])),
311 })),
312 };
313 }
src/web/space_read.ts added +107
@@ -0,0 +1,107 @@
1 /**
2 * What the space draws, read from the forge: one repository's work as
3 * `SpaceInput` (`space_model.ts` places it). Reads exactly what the map page
4 * reads — the graph, the words, the pillars, the crew's runs — so the two
5 * never disagree about what an issue is doing. Respects the viewer's sealed
6 * scope; the answer is kept by the forge's write clock like the map's.
7 *
8 * Assumption shown on the page: the week's landed work is the closed issues
9 * the graph still carries (those on an edge), not every close.
10 */
11
12 import type { App } from "../app.ts";
13 import { proposedWork } from "../crew/direct.ts";
14 import { holdsFor } from "../core/holds.ts";
15 import { readyNow, repoGraph } from "../core/dependencies.ts";
16 import { isStructural, pillarView } from "../core/pillars.ts";
17 import type { Repo } from "../core/repos.ts";
18 import { listActiveRuns, listRuns, verdictForHead, type Run } from "../core/runs.ts";
19 import { sealedScope } from "../core/sealed.ts";
20 import { getUsersByIds, type User } from "../core/users.ts";
21 import { cachedMap } from "./map_snapshot.ts";
22 import { marksFor, wordFor } from "./pages/map.ts";
23 import { buildSpace, WEEK_MS, stateOf, type SpaceInput, type SpaceIssue, type SpaceRun } from "./space_model.ts";
24
25 const ms = (iso: string | null): number => (iso === null ? 0 : Date.parse(iso) || 0);
26
27 /** A pull head's checks in one word: failed beats running beats ok. */
28 function checkWord(runs: readonly Run[]): string {
29 if (runs.length === 0) return "";
30 if (runs.some((r) => r.state === "failed")) return "failed";
31 if (runs.some((r) => r.state === "running" || r.state === "queued")) return "running";
32 return runs.every((r) => r.state === "ok") ? "ok" : "";
33 }
34
35 export function spaceInput(app: App, repo: Repo, viewer: User | undefined, now: Date): SpaceInput {
36 const db = app.db;
37 const scope = sealedScope(db, repo, viewer);
38 const graph = repoGraph(db, repo.id, { ...scope, lean: true });
39 const flying = listActiveRuns(db, [repo.id]);
40 const marks = marksFor(db, repo.id, graph, flying);
41 const ready = new Set(readyNow(graph).map((r) => r.issue.id));
42 const view = pillarView(db, repo, now);
43 const index = new Map((view?.names ?? []).map((n, i) => [n, i]));
44 const since = now.getTime() - WEEK_MS;
45
46 const pulls = proposedWork(db, repo);
47 const holds = holdsFor(db, [...pulls.values()].map((p) => p.id));
48 const issues: SpaceIssue[] = [];
49 for (const issue of graph.issues) {
50 if (view !== undefined && isStructural(view.set, issue.id)) continue;
51 if (issue.id === repo.directionIssueId) continue;
52 const closedAt = ms(issue.closedAt);
53 if (issue.state === "closed" && closedAt < since) continue;
54 const name = view?.of.get(issue.id);
55 const pull = issue.state === "open" ? pulls.get(issue.number) : undefined;
56 issues.push({
57 number: issue.number,
58 title: issue.title.slice(0, 140),
59 pillar: name === undefined ? -1 : (index.get(name) ?? -1),
60 state: stateOf(wordFor(issue, ready, marks)),
61 createdAt: ms(issue.createdAt),
62 closedAt: issue.state === "closed" ? closedAt : 0,
63 pull: pull?.number ?? 0,
64 hold: pull === undefined ? "" : (holds.get(pull.id)?.why ?? ""),
65 check: pull === undefined ? "" : checkWord(verdictForHead(db, pull.id, pull.headOid)),
66 });
67 }
68
69 const numbers = new Map(graph.issues.map((i) => [i.id, i.number]));
70 const runs = listRuns(db, repo.id, { kind: "agent", limit: 200 }).filter(
71 (r) => r.actorId !== null && r.issueId !== null && (r.finishedAt === null || ms(r.finishedAt) >= since),
72 );
73 const who = getUsersByIds(db, runs.map((r) => r.actorId!));
74 const crew: SpaceRun[] = [];
75 for (const run of runs) {
76 const number = numbers.get(run.issueId!);
77 const name = who.get(run.actorId!)?.username;
78 if (number === undefined || name === undefined) continue;
79 const started = ms(run.startedAt) || ms(run.createdAt);
80 crew.push({
81 actor: name,
82 issue: number,
83 startedAt: Math.max(started, since),
84 endedAt: run.state === "running" || run.state === "queued" ? 0 : ms(run.finishedAt),
85 });
86 }
87
88 return {
89 repo: `${repo.ownerName}/${repo.name}`,
90 now: now.getTime(),
91 pillars: (view?.names ?? []).map((name) => ({
92 name,
93 merged: view?.merged.find((m) => m.pillar === name)?.merged ?? 0,
94 })),
95 issues,
96 runs: crew,
97 };
98 }
99
100 /** The placed space of `repo` for `viewer`, once per minute while nothing is written. */
101 export function spaceOf(app: App, repo: Repo, viewer: User | undefined, now = new Date()): Record<string, unknown> {
102 return cachedMap(
103 app.db,
104 { repoId: repo.id, scope: sealedScope(app.db, repo, viewer), now, kind: "space" },
105 () => ({ ...buildSpace(spaceInput(app, repo, viewer, now)) }),
106 ).value;
107 }
src/web/space_view.ts added +119
@@ -0,0 +1,119 @@
1 /**
2 * The space's pure arithmetic on the client (#10444): where an issue is at a
3 * time, who of the crew is on what, how the view reads and writes the address
4 * hash, and which issue a tap lands on. Plain data in, plain data out, no DOM.
5 *
6 * These are written as ordinary functions so a test can call them; the page
7 * ships their source (`fn.toString()`, as `palette.ts` does), so what is
8 * tested is what runs. Each is self-contained: it uses only its arguments
9 * and the other functions here, by name.
10 */
11
12 /** A landing slides from "in review" to "landed" over this much time (4 h). */
13 export const GLIDE_MS = 4 * 3600000;
14
15 export function clamp(v: number, lo: number, hi: number): number {
16 return v < lo ? lo : v > hi ? hi : v;
17 }
18
19 /** Ease in and out, 0..1 to 0..1. */
20 export function ease(p: number): number {
21 return p < 0.5 ? 2 * p * p : 1 - Math.pow(-2 * p + 2, 2) / 2;
22 }
23
24 /**
25 * Where issue row `r` is at time `T`: [x, y, cluster, landed 0..1], or null
26 * before it was opened. Row: [number, cluster, x, y, created, closed, title,
27 * pull, bx, by, bcluster, hold, check]; (bx, by, bcluster) is where it sat
28 * in review before it landed.
29 */
30 export function placeAt(r: any[], T: number): number[] | null {
31 if (r[4] > T) return null;
32 const c = r[5];
33 if (!c) return [r[2], r[3], r[1], 0];
34 if (c > T) return [r[8], r[9], r[10], 0];
35 const p = ease(clamp((T - c) / (4 * 3600000), 0, 1));
36 return [r[8] + (r[2] - r[8]) * p, r[9] + (r[3] - r[9]) * p, p > 0.5 ? r[1] : r[10], p];
37 }
38
39 /**
40 * The crew members on an issue at `T`: each one's latest run that started by
41 * `T` and had not ended. `crew` is [{name, runs: [[issue, start, end]]}]
42 * (end 0 while running); `slot` numbers those sharing an issue.
43 */
44 export function crewAt(crew: any[], rows: any[][], T: number): any[] {
45 const out: any[] = [];
46 const byNum: Record<number, number> = {};
47 crew.forEach(function (m: any, mi: number) {
48 let cur: any = null;
49 m.runs.forEach(function (run: any) {
50 if (run[1] <= T && (run[2] === 0 || run[2] > T) && (!cur || run[1] > cur[1])) cur = run;
51 });
52 if (!cur) return;
53 let row: any[] | null = null;
54 for (let i = 0; i < rows.length; i++)
55 if (rows[i]![0] === cur[0]) {
56 row = rows[i]!;
57 break;
58 }
59 if (!row) return;
60 const pos = placeAt(row, T);
61 if (!pos) return;
62 const n = (byNum[cur[0]] = (byNum[cur[0]] || 0) + 1);
63 out.push({ name: m.name, issue: cur[0], x: pos[0], y: pos[1], slot: n - 1, since: cur[1], mi: mi });
64 });
65 return out;
66 }
67
68 /**
69 * The view an address hash names: `#x=…&y=…&z=…` (centre in world units, zoom
70 * relative to Fit) and `&t=` (minutes since the epoch). `S` is
71 * {world:{w,h}, weekStart, now}; `lim` is [min k, max k]. Missing or
72 * non-numeric parts are left out; numbers are clamped into the plane.
73 */
74 export function parseHash(hash: string, S: any, fitK: number, lim: number[]): any {
75 const m: Record<string, number> = {};
76 hash
77 .replace(/^#/, "")
78 .split("&")
79 .forEach(function (kv: string) {
80 const p = kv.split("=");
81 m[p[0]!] = p[1] === undefined || p[1] === "" ? NaN : Number(p[1]);
82 });
83 const out: any = {};
84 if (isFinite(m.x!) && isFinite(m.y!) && isFinite(m.z!) && m.z! > 0) {
85 out.x = clamp(m.x!, 0, S.world.w);
86 out.y = clamp(m.y!, 0, S.world.h);
87 out.k = clamp(m.z! * fitK, lim[0]!, lim[1]!);
88 }
89 if (isFinite(m.t!) && m.t! * 60000 >= S.weekStart && m.t! * 60000 < S.now) out.t = m.t! * 60000;
90 return out;
91 }
92
93 /** The hash for a view: the inverse of `parseHash` (centre rounded, zoom to 3 places). */
94 export function hashOf(view: any, fitK: number, live: boolean, T: number): string {
95 let h = "x=" + Math.round(view.x) + "&y=" + Math.round(view.y) + "&z=" + (view.k / fitK).toFixed(3);
96 if (!live) h += "&t=" + Math.round(T / 60000);
97 return h;
98 }
99
100 /** The issue row whose cell holds world point (wx, wy) at `T`, nearest the centre; or null. */
101 export function pickIssue(rows: any[][], clusters: any[][], T: number, wx: number, wy: number): any[] | null {
102 let best: any[] | null = null;
103 let bd = 1e18;
104 for (let j = 0; j < rows.length; j++) {
105 const pos = placeAt(rows[j]!, T);
106 if (!pos) continue;
107 const cell = clusters[pos[2]!]![6];
108 const dx = Math.abs(wx - pos[0]!);
109 const dy = Math.abs(wy - pos[1]!);
110 if (dx <= cell / 2 && dy <= cell / 2 && dx + dy < bd) {
111 bd = dx + dy;
112 best = rows[j]!;
113 }
114 }
115 return best;
116 }
117
118 /** The source the page ships for these functions. */
119 export const viewJs = [clamp, ease, placeAt, crewAt, parseHash, hashOf, pickIssue].map((f) => f.toString()).join("\n");
test/no-polling.test.ts +1
@@ -12,6 +12,7 @@ const ALLOWED: Record<string, { allow: string[]; why: string }> = {
1212 "src/web/sw.ts": { allow: ['http-equiv="refresh"'], why: "the restart page: the forge is down, so there is no /events to hear" },
1313 "src/web/pages/talk.ts": { allow: ["setInterval"], why: "server side: the stream of one turn watches the run's row, which has no event to hear" },
1414 "src/web/client.ts": { allow: ["data-live-every", "setInterval"], why: "the data-live-every mechanism itself, and clocks that only redraw text" },
15 "src/web/space_client.ts": { allow: ["setInterval"], why: "the map clock label only redraws text; it fetches and reloads nothing" },
1516 };
1617
1718 const PATTERNS: [string, RegExp][] = [
test/palette-roster.test.ts +4
@@ -61,6 +61,7 @@ test("a site-admin person is offered the whole roster", () => {
6161 ["held", "Held by the Later gate", "owner/name"],
6262 ["fork-tree", "Fork tree of a repository", "owner/name"],
6363 ["roadmap", "Roadmap of a repository", "owner/name"],
64 ["space", "Space of a repository", "owner/name"],
6465 ["pulls", "Pull requests in a repository", "owner/name"],
6566 ["milestones", "Milestones in a repository", "owner/name"],
6667 ["next-up", "Next up in a repository", "owner/name"],
@@ -184,6 +185,7 @@ test("a site-admin agent is offered the roster without the person-only entries",
184185 ["held", "Held by the Later gate", "owner/name"],
185186 ["fork-tree", "Fork tree of a repository", "owner/name"],
186187 ["roadmap", "Roadmap of a repository", "owner/name"],
188 ["space", "Space of a repository", "owner/name"],
187189 ["pulls", "Pull requests in a repository", "owner/name"],
188190 ["milestones", "Milestones in a repository", "owner/name"],
189191 ["next-up", "Next up in a repository", "owner/name"],
@@ -259,6 +261,7 @@ test("a member person is offered the signed-in roster without the admin entries"
259261 ["held", "Held by the Later gate", "owner/name"],
260262 ["fork-tree", "Fork tree of a repository", "owner/name"],
261263 ["roadmap", "Roadmap of a repository", "owner/name"],
264 ["space", "Space of a repository", "owner/name"],
262265 ["pulls", "Pull requests in a repository", "owner/name"],
263266 ["milestones", "Milestones in a repository", "owner/name"],
264267 ["next-up", "Next up in a repository", "owner/name"],
@@ -328,6 +331,7 @@ test("signed out, the palette offers sign-in and the public entries", () => {
328331 ["held", "Held by the Later gate", "owner/name"],
329332 ["fork-tree", "Fork tree of a repository", "owner/name"],
330333 ["roadmap", "Roadmap of a repository", "owner/name"],
334 ["space", "Space of a repository", "owner/name"],
331335 ["pulls", "Pull requests in a repository", "owner/name"],
332336 ["milestones", "Milestones in a repository", "owner/name"],
333337 ["next-up", "Next up in a repository", "owner/name"],
test/space-model.test.ts added +139
@@ -0,0 +1,139 @@
1 /**
2 * The space's arithmetic (#10444): areas follow open work, cells fit their
3 * rectangle, a landing has a place to come from, and the whole forge's
4 * twelve thousand issues are placed and serialized inside the first-draw
5 * budget. Expected values are worked by hand in the comments.
6 */
7 import { expect, test } from "bun:test";
8 import {
9 buildSpace,
10 gridFor,
11 stateOf,
12 treemap,
13 WEEK_MS,
14 type SpaceInput,
15 type SpaceIssue,
16 type State,
17 } from "../src/web/space_model.ts";
18
19 const NOW = Date.parse("2026-11-10T12:00:00Z");
20
21 function issue(number: number, pillar: number, state: State, over: Partial<SpaceIssue> = {}): SpaceIssue {
22 return {
23 number,
24 title: `Issue ${number}`,
25 pillar,
26 state,
27 createdAt: NOW - 3 * 86_400_000,
28 closedAt: state === "landed" ? NOW - 3_600_000 : 0,
29 pull: 0,
30 hold: "",
31 check: "",
32 ...over,
33 };
34 }
35
36 test("treemap: areas are in proportion to weights and tile the rectangle", () => {
37 // 3:1 over 400×100 = 40000 → 30000 and 10000.
38 const [a, b] = treemap([3, 1], { x: 0, y: 0, w: 400, h: 100 });
39 expect(a!.w * a!.h).toBeCloseTo(30000, 6);
40 expect(b!.w * b!.h).toBeCloseTo(10000, 6);
41 const many = treemap([8, 5, 3, 2, 1, 1], { x: 0, y: 0, w: 1600, h: 1000 });
42 expect(many.reduce((s, r) => s + r.w * r.h, 0)).toBeCloseTo(1_600_000, 3);
43 // 8/20 of 1.6M = 640000.
44 expect(many[0]!.w * many[0]!.h).toBeCloseTo(640_000, 3);
45 });
46
47 test("gridFor: n square cells fit the rectangle", () => {
48 for (const [n, w, h] of [[1, 100, 100], [7, 300, 90], [1000, 800, 500], [12_000, 5000, 3000]] as const) {
49 const { cell, cols } = gridFor(n, w, h);
50 const rows = Math.ceil(n / cols);
51 expect(cols * cell).toBeLessThanOrEqual(w + 1e-6);
52 expect(rows * cell).toBeLessThanOrEqual(h + 1e-6);
53 }
54 });
55
56 test("stateOf maps the map's words to the five clusters", () => {
57 expect(["done", "working", "held", "waiting", "ready", "blocked", "deferred", "parked"].map(stateOf)).toEqual([
58 "landed", "building", "review", "review", "ready", "waiting", "waiting", "waiting",
59 ]);
60 });
61
62 test("a pillar's progress and region size follow its work; a landing has a before slot in review", () => {
63 const input: SpaceInput = {
64 repo: "o/r",
65 now: NOW,
66 pillars: [{ name: "P0 Trust", merged: 4 }, { name: "P1 Feed", merged: 0 }],
67 issues: [
68 issue(1, 0, "ready"), issue(2, 0, "building"), issue(3, 0, "waiting"), issue(4, 0, "landed"),
69 issue(5, 1, "ready"),
70 ],
71 runs: [{ actor: "torque", issue: 2, startedAt: NOW - 3_600_000, endedAt: 0 }],
72 };
73 const s = buildSpace(input);
74 const p0 = s.regions[0]!;
75 // 1 landed of (3 open + 1 landed) = 0.25.
76 expect(p0.open).toBe(3);
77 expect(p0.landed).toBe(1);
78 expect(p0.progress).toBeCloseTo(0.25, 10);
79 expect(p0.moving).toBe(true);
80 expect(s.regions[1]!.moving).toBe(false);
81 expect(p0.w * p0.h).toBeGreaterThan(s.regions[1]!.w * s.regions[1]!.h);
82 expect(s.regions[0]!.merged).toBe(4);
83 // #4 landed: its row is in the landed cluster, its before slot in a review cluster of its region.
84 const row = s.issues.find((r) => r[0] === 4)!;
85 expect(s.states[s.clusters[row[1]]![1]!]).toBe("landed");
86 expect(s.states[s.clusters[row[10]]![1]!]).toBe("review");
87 expect(s.clusters[row[10]]![0]).toBe(s.clusters[row[1]]![0]);
88 // An open issue starts where it ends.
89 const open = s.issues.find((r) => r[0] === 1)!;
90 expect([open[8], open[9], open[10]]).toEqual([open[2], open[3], open[1]]);
91 expect(s.crew).toEqual([{ name: "torque", runs: [[2, NOW - 3_600_000, 0]] }]);
92 expect(s.weekStart).toBe(NOW - WEEK_MS);
93 });
94
95 test("every issue lies inside its cluster, and clusters inside the world", () => {
96 const issues: SpaceIssue[] = [];
97 const kinds = ["ready", "building", "review", "waiting", "landed"] as const;
98 for (let n = 1; n <= 500; n++) issues.push(issue(n, n % 3 === 2 ? -1 : n % 3, kinds[n % 5]!));
99 const s = buildSpace({ repo: "o/r", now: NOW, pillars: [{ name: "A", merged: 0 }, { name: "B", merged: 0 }], issues, runs: [] });
100 expect(s.regions.map((r) => r.name)).toEqual(["A", "B", "No pillar"]);
101 for (const c of s.clusters) {
102 expect(c[2]).toBeGreaterThanOrEqual(-1e-6);
103 expect(c[2] + c[4]).toBeLessThanOrEqual(s.world.w + 1e-6);
104 expect(c[3] + c[5]).toBeLessThanOrEqual(s.world.h + 1e-6);
105 }
106 for (const r of s.issues) {
107 const c = s.clusters[r[1]]!;
108 expect(r[2]).toBeGreaterThan(c[2]);
109 expect(r[2]).toBeLessThan(c[2] + c[4]);
110 expect(r[3]).toBeGreaterThan(c[3]);
111 expect(r[3]).toBeLessThan(c[3] + c[5]);
112 }
113 });
114
115 test("the whole forge (12,000 issues) is placed and serialized in a size a phone can take", () => {
116 const states = ["ready", "building", "review", "waiting", "landed"] as const;
117 const issues: SpaceIssue[] = [];
118 for (let n = 1; n <= 12_000; n++) {
119 // trevato/forge's shape: most history is closed, ~300 open; only the week's closes are kept.
120 const state = n % 40 === 0 ? states[n % 4]! : "landed";
121 issues.push(issue(n, n % 10, state, state === "landed" ? { closedAt: NOW - (n % 7) * 86_400_000 - 1000 } : {}));
122 }
123 const input: SpaceInput = {
124 repo: "trevato/forge",
125 now: NOW,
126 pillars: Array.from({ length: 10 }, (_, i) => ({ name: `P${i} Pillar`, merged: i })),
127 issues,
128 runs: [],
129 };
130 buildSpace(input); // warm the JIT, as a running server is
131 const started = performance.now();
132 const space = buildSpace(input);
133 const body = JSON.stringify(space);
134 const ms = performance.now() - started;
135 console.log(`space: ${space.issues.length} issues, ${(body.length / 1024).toFixed(0)} KB, ${ms.toFixed(1)} ms`);
136 expect(space.issues.length).toBe(12_000);
137 // Shape and size, not a wall-clock: the time is logged above for a person to read.
138 expect(body.length).toBeLessThan(3 * 1024 * 1024);
139 });
test/space-page.test.ts added +113
@@ -0,0 +1,113 @@
1 /**
2 * The space is reachable and safe (#10444): the page and its JSON answer for
3 * who may read the repository and no one else, carry the security floor,
4 * the map links to it, and the canvas script is well-formed.
5 */
6 import { afterAll, beforeAll, expect, test } from "bun:test";
7 import { mkdtempSync, rmSync } from "node:fs";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { seedFixture, type Fixture, type Shape } from "../bench/map-home.ts";
11 import { serve, type Servers } from "../src/http/server.ts";
12 import { clearMapCache } from "../src/web/map_cache.ts";
13 import { spaceCss, spaceJs } from "../src/web/space_client.ts";
14 import { quietLogs } from "./helpers/globals.ts";
15
16 quietLogs();
17
18 const SMALL: Shape = {
19 repos: 3, bigIssues: 40, bigOpen: 12, bigGraph: 20, bigEdges: 24, bigPulls: 2,
20 lanes: [5, 3], privateLanes: 1, sealed: 1, runs: 2, comments: 1, events: 50,
21 };
22
23 let dir = "";
24 let f: Fixture;
25 let servers: Servers;
26
27 beforeAll(async () => {
28 dir = mkdtempSync(join(tmpdir(), "forge-space-"));
29 clearMapCache();
30 f = await seedFixture(dir, SMALL);
31 servers = await serve(f.app);
32 }, 120_000);
33 afterAll(() => {
34 servers?.stop();
35 f?.app.close();
36 rmSync(dir, { recursive: true, force: true });
37 });
38
39 const get = (path: string, cookie = f.ownerCookie) => fetch(`${servers.url}${path}`, { headers: { cookie } });
40
41 test("the page answers with a canvas, the controls and the security floor", async () => {
42 const res = await get("/keeper/forge/space");
43 expect(res.status).toBe(200);
44 expect(res.headers.get("x-content-type-options")).toBe("nosniff");
45 expect(res.headers.get("content-security-policy")).toContain("default-src");
46 const body = await res.text();
47 for (const id of ["space-cv", "space-fit", "space-now", "space-t", "space-crew", "space-panel"]) expect(body).toContain(`id="${id}"`);
48 expect(body).toContain('data-src="/keeper/forge/space.json"');
49 });
50
51 test("space.json places the repository's work in one world", async () => {
52 const res = await get("/keeper/forge/space.json");
53 expect(res.status).toBe(200);
54 const s = (await res.json()) as { repo: string; world: { w: number; h: number }; issues: unknown[][]; clusters: number[][]; regions: { name: string }[] };
55 expect(s.repo).toBe("keeper/forge");
56 expect(s.world.w).toBeGreaterThan(s.world.h);
57 expect(s.issues.length).toBeGreaterThan(0);
58 expect(s.regions.length).toBeGreaterThan(0);
59 for (const row of s.issues) expect(row[1]).toBeLessThan(s.clusters.length);
60 });
61
62 test("a private repository is not found by one who may not read it", async () => {
63 const priv = f.privateRepos[0]!;
64 expect((await get(`/${priv}/space`, f.strangerCookie)).status).toBe(404);
65 expect((await get(`/${priv}/space.json`, f.strangerCookie)).status).toBe(404);
66 expect((await get(`/${priv}/space.json`, "")).status).toBe(404);
67 });
68
69 test("the map offers the way in", async () => {
70 const body = await (await get("/keeper/forge/map?view=graph")).text();
71 expect(body).toContain('href="/keeper/forge/space"');
72 });
73
74 test("the canvas script parses and builds no markup from data", () => {
75 new Bun.Transpiler({ loader: "js" }).transformSync(spaceJs);
76 expect(spaceJs).not.toContain("innerHTML");
77 expect(spaceJs).not.toContain("eval(");
78 expect(spaceCss).toContain("touch-action: none");
79 });
80
81 test("without script the page is the pillar list: controls are hidden unless the script runs", async () => {
82 const body = await (await get("/keeper/forge/space")).text();
83 expect(body).toContain('class="space-list"');
84 // The bars show only under html.space-on, which the script sets.
85 expect(spaceCss).toMatch(/\.space canvas, \.space-bar, \.space-time \{ display: none; \}/);
86 expect(spaceCss).toContain("html.space-on .space-bar { display: flex");
87 expect(spaceCss).toContain("html.space-on .space-time { display: flex");
88 // Buttons are the forge's own, and the caveat is said once (in the docs), not on the page.
89 expect(body).toContain('id="space-fit"');
90 expect(body).toMatch(/class="btn"[^>]*data-variant="outline"/);
91 expect(body).not.toContain("no longer carries");
92 });
93
94 test("space.json carries an ETag; the same tag is answered 304 without a body", async () => {
95 const first = await get("/keeper/forge/space.json");
96 const tag = first.headers.get("etag")!;
97 expect(tag).toMatch(/^W\//);
98 await first.arrayBuffer();
99 const again = await fetch(`${servers.url}/keeper/forge/space.json`, { headers: { cookie: f.ownerCookie, "if-none-match": tag } });
100 expect(again.status).toBe(304);
101 expect(await again.text()).toBe("");
102 const other = await fetch(`${servers.url}/keeper/forge/space.json`, { headers: { cookie: f.ownerCookie, "if-none-match": 'W/"nope"' } });
103 expect(other.status).toBe(200);
104 });
105
106 test("the script starts its frame loop before the data arrives, and says when it failed", () => {
107 expect(spaceJs).toMatch(/requestAnimationFrame\(step\);\s*load\(true\);/);
108 expect(spaceJs).toContain("space-error");
109 });
110
111 test("the one accent is for interaction: the canvas paints no state hues", () => {
112 for (const token of ["--series-1", "--attn", "--series-4", "--success"]) expect(spaceJs).not.toContain(token);
113 });
test/space-screen.test.ts added +32
@@ -0,0 +1,32 @@
1 /** The space in the terminal (#10444): the same plane as text. */
2 import { expect, test } from "bun:test";
3 import { buildSpace, type SpaceInput } from "../src/web/space_model.ts";
4 import { spaceLines } from "../src/tui/screens/space.ts";
5
6 const NOW = Date.UTC(2026, 10, 10, 12);
7 const input: SpaceInput = {
8 repo: "a/b",
9 now: NOW,
10 pillars: [{ name: "Speed", merged: 1 }, { name: "Trust", merged: 0 }],
11 issues: [
12 { number: 1, title: "Cache it", pillar: 0, state: "ready", createdAt: NOW - 1e8, closedAt: 0, pull: 0, hold: "", check: "" },
13 { number: 2, title: "Build it", pillar: 0, state: "building", createdAt: NOW - 1e8, closedAt: 0, pull: 7, hold: "", check: "running" },
14 { number: 3, title: "Shipped", pillar: 1, state: "landed", createdAt: NOW - 2e8, closedAt: NOW - 1e7, pull: 0, hold: "", check: "" },
15 ],
16 runs: [{ actor: "torque", issue: 2, startedAt: NOW - 1e6, endedAt: 0 }],
17 };
18
19 test("spaceLines: pillars with counts, issues by state, the crew, the week's landings", () => {
20 const text = spaceLines({ ...buildSpace(input) }, 80).join("\n");
21 expect(text).toContain("Speed 2 open, 1 ready");
22 expect(text).toContain(" Ready (1)\n #1 Cache it");
23 expect(text).toContain("#2 Build it (pull #7, checks running)");
24 expect(text).toContain("torque is on #2");
25 expect(text).toContain("Landed this week (1)\n #3 Shipped");
26 });
27
28 test("spaceLines: a quiet repository says so", () => {
29 const text = spaceLines({ ...buildSpace({ ...input, issues: [], runs: [] }) }, 80).join("\n");
30 expect(text).toContain("Nobody is building right now.");
31 expect(text).toContain("Landed this week (0)");
32 });
test/space-view.test.ts added +84
@@ -0,0 +1,84 @@
1 /**
2 * The space's client arithmetic (#10444), the same functions the page ships
3 * (`space_view.ts`): placement over time, the crew at a time, the address
4 * hash both ways, and which issue a tap lands on.
5 */
6 import { expect, test } from "bun:test";
7 import { crewAt, hashOf, parseHash, pickIssue, placeAt } from "../src/web/space_view.ts";
8 import { spaceJs } from "../src/web/space_client.ts";
9
10 // [number, cluster, x, y, created, closed, title, pull, bx, by, bcluster, hold, check]
11 const open = [1, 0, 100, 100, 1000, 0, "open", 0, 0, 0, 0, "", ""];
12 const closed = [2, 1, 300, 100, 1000, 5000, "closed", 0, 100, 100, 2, "", ""];
13 const GLIDE = 4 * 3600000;
14
15 test("placeAt: not there before it is opened; open issues sit in place", () => {
16 expect(placeAt(open, 999)).toBeNull();
17 expect(placeAt(open, 1000)).toEqual([100, 100, 0, 0]);
18 });
19
20 test("placeAt: an issue waits in review until it closes, then glides to landed", () => {
21 expect(placeAt(closed, 4999)).toEqual([100, 100, 2, 0]);
22 expect(placeAt(closed, 5000)).toEqual([100, 100, 2, 0]);
23 const mid = placeAt(closed, 5000 + GLIDE / 2)!;
24 expect(mid[0]).toBeCloseTo(200, 6); // ease(.5) = .5, halfway from 100 to 300
25 expect(mid[3]).toBeCloseTo(0.5, 6);
26 expect(placeAt(closed, 5000 + GLIDE / 4)![2]).toBe(2); // before half-way: still counted in review
27 expect(placeAt(closed, 5000 + GLIDE)).toEqual([300, 100, 1, 1]);
28 });
29
30 test("crewAt: a member is on the latest run that had started and not ended", () => {
31 const crew = [
32 { name: "torque", runs: [[1, 1000, 3000], [2, 6000, 0]] },
33 { name: "mason", runs: [[1, 1500, 0]] },
34 ];
35 const rows = [open, closed];
36 expect(crewAt(crew, rows, 500)).toEqual([]);
37 const at2000 = crewAt(crew, rows, 2000);
38 expect(at2000.map((m) => [m.name, m.issue, m.slot])).toEqual([["torque", 1, 0], ["mason", 1, 1]]); // two on one issue take slots
39 expect(crewAt(crew, rows, 4000).map((m) => m.name)).toEqual(["mason"]); // torque's run ended
40 const late = crewAt(crew, rows, 7000);
41 expect(late.map((m) => [m.name, m.issue])).toEqual([["torque", 2], ["mason", 1]]);
42 expect(late[0]!.x).toBeCloseTo(100, 3); // issue 2 has only begun to glide from review
43 });
44
45 const S = { world: { w: 1000, h: 500 }, weekStart: 60_000_000, now: 66_000_000 };
46
47 test("parseHash: reads the view, clamps it into the plane, drops what is not a number", () => {
48 const v = parseHash("#x=250&y=125&z=2", S, 0.5, [0.25, 30]);
49 expect(v).toEqual({ x: 250, y: 125, k: 1 });
50 expect(parseHash("#x=-50&y=9999&z=1000", S, 0.5, [0.25, 30])).toEqual({ x: 0, y: 500, k: 30 });
51 expect(parseHash("#x=abc&y=1&z=1", S, 1, [0.5, 9])).toEqual({});
52 expect(parseHash("#x=1&y=1&z=0", S, 1, [0.5, 9])).toEqual({});
53 expect(parseHash("", S, 1, [0.5, 9])).toEqual({});
54 });
55
56 test("parseHash: a time is minutes since the epoch, kept only inside the week", () => {
57 expect(parseHash("#t=1050", S, 1, [1, 2]).t).toBe(63_000_000);
58 expect(parseHash("#t=1", S, 1, [1, 2])).toEqual({});
59 expect(parseHash(`#t=${S.now / 60000}`, S, 1, [1, 2])).toEqual({}); // now is live, not a time
60 });
61
62 test("hashOf and parseHash are inverses", () => {
63 const view = { x: 412, y: 77, k: 3 };
64 const h = hashOf(view, 1.5, false, 63_000_000);
65 expect(h).toBe("x=412&y=77&z=2.000&t=1050");
66 expect(parseHash(h, S, 1.5, [0.1, 100])).toEqual({ x: 412, y: 77, k: 3, t: 63_000_000 });
67 expect(hashOf(view, 1.5, true, 0)).not.toContain("t=");
68 });
69
70 test("pickIssue: the cell under the tap, the nearest centre, none where the plane is empty", () => {
71 const clusters = [[0, 0, 0, 0, 0, 0, 40, 0], [0, 1, 0, 0, 0, 0, 40, 0], [0, 2, 0, 0, 0, 0, 40, 0]];
72 const a = [1, 0, 100, 100, 0, 0, "a", 0, 0, 0, 0, "", ""];
73 const b = [2, 0, 130, 100, 0, 0, "b", 0, 0, 0, 0, "", ""];
74 const later = [3, 0, 100, 100, 9000, 0, "later", 0, 0, 0, 0, "", ""];
75 expect(pickIssue([a, b], clusters, 1, 120, 100)![0]).toBe(2); // both cells hold x=120; b's centre is nearer
76 expect(pickIssue([a, b], clusters, 1, 105, 100)![0]).toBe(1);
77 expect(pickIssue([a, b], clusters, 1, 500, 500)).toBeNull();
78 expect(pickIssue([later], clusters, 1, 100, 100)).toBeNull(); // not opened yet at T=1
79 });
80
81 test("the page ships these functions, not a second copy", () => {
82 for (const name of ["placeAt", "crewAt", "parseHash", "hashOf", "pickIssue"]) expect(spaceJs).toContain(`function ${name}(`);
83 new Bun.Transpiler({ loader: "js" }).transformSync(spaceJs);
84 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs