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
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/10444-the-map-is-a-space-full-screen-pan-and-p → main 3 commits fork 3 threads · 3 unresolved
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"; | ||
| 231 | 231 | import { tellHerePage } from "../web/studio/tell_here.ts"; |
| 232 | 232 | import { globalMapPage } from "../web/pages/global-map.ts"; |
| 233 | 233 | import { mapPage } from "../web/pages/map.ts"; |
| 234 | import { spaceJson, spacePage } from "../web/pages/space.ts"; | |
| 234 | 235 | import { firstSubmit } from "../web/pages/first.ts"; |
| 235 | 236 | import { nextPage, nextSubmit } from "../web/pages/next.ts"; |
| 236 | 237 | import { weekPage, weekSubmit } from "../web/pages/week.ts"; |
| @@ -1194,6 +1195,8 @@ export function buildRouter(): Router<Handler> { | ||
| 1194 | 1195 | router.add("GET", "/:owner/:name/held", heldPage); |
| 1195 | 1196 | router.add("POST", "/:owner/:name/held/apply", heldApply); |
| 1196 | 1197 | router.add("GET", "/:owner/:name/map", mapPage); |
| 1198 | router.add("GET", "/:owner/:name/space", spacePage); | |
| 1199 | router.add("GET", "/:owner/:name/space.json", spaceJson); | |
| 1197 | 1200 | router.add("GET", "/:owner/:name/next", nextPage); |
| 1198 | 1201 | router.add("POST", "/:owner/:name/next", nextSubmit); |
| 1199 | 1202 | router.add("GET", "/:owner/:name/week", weekPage); |
src/tui/forge.ts +6
| @@ -20,6 +20,7 @@ import { packsCommand } from "./screens/packs.ts"; | ||
| 20 | 20 | import { shellsScreen } from "./screens/shells.ts"; |
| 21 | 21 | import { forkTreeScreen } from "./screens/fork_tree.ts"; |
| 22 | 22 | import { roadmapScreen } from "./screens/roadmap.ts"; |
| 23 | import { spaceScreen } from "./screens/space.ts"; | |
| 23 | 24 | import { App, type Ctx, type Screen } from "./app/app.ts"; |
| 24 | 25 | import type { Command, PaletteItem, PaletteSearch } from "./app/palette.ts"; |
| 25 | 26 | import { G } from "./term/screen.ts"; |
| @@ -141,6 +142,11 @@ const REPO_VERBS: readonly { | ||
| 141 | 142 | title: "Roadmap of a repository", |
| 142 | 143 | open: (f, ctx, repo) => roadmapScreen(f, ctx, repo), |
| 143 | 144 | }, |
| 145 | { | |
| 146 | id: "space", | |
| 147 | title: "Space of a repository", | |
| 148 | open: (f, ctx, repo) => spaceScreen(f, ctx, repo), | |
| 149 | }, | |
| 144 | 150 | { |
| 145 | 151 | id: "pulls", |
| 146 | 152 | 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>> { | ||
| 28 | 28 | /** `build()` once per repository, scope and minute while nothing has been written. */ |
| 29 | 29 | export function cachedMap( |
| 30 | 30 | 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 }, | |
| 32 | 32 | build: () => Record<string, unknown>, |
| 33 | 33 | ): { value: Record<string, unknown>; hit: boolean } { |
| 34 | 34 | const stamp = writeStamp(db); |
| 35 | 35 | if (stamp === undefined) return { value: build(), hit: false }; |
| 36 | 36 | const cache = mapSnapshots(db); |
| 37 | 37 | 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)}`; | |
| 39 | 39 | const value = cache.get(k, stamp, build); |
| 40 | 40 | return { value, hit: cache.hits > before }; |
| 41 | 41 | } |
src/web/pages/map.ts +2 −1
| @@ -1439,7 +1439,7 @@ export async function mapPage(ctx: Ctx): Promise<Response> { | ||
| 1439 | 1439 | body: html`${repoHeader(ctx, repo, "map")} |
| 1440 | 1440 | <div class="page-head mt"> |
| 1441 | 1441 | <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> | |
| 1443 | 1443 | </div> |
| 1444 | 1444 | <div |
| 1445 | 1445 | class="rm" |
| @@ -1500,6 +1500,7 @@ export async function mapPage(ctx: Ctx): Promise<Response> { | ||
| 1500 | 1500 | <p class="muted"> |
| 1501 | 1501 | What waits for what. The top edge is ready now; the chain in the accent |
| 1502 | 1502 | 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>. | |
| 1503 | 1504 | <a href="${repoBase(repo)}/milestones">How far each milestone is</a>. |
| 1504 | 1505 | <a href="${repoBase(repo)}/next">Next up</a> is the one ranked list the crew builds from. |
| 1505 | 1506 | </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> | |
| 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 |
||
| 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 }> = { | ||
| 12 | 12 | "src/web/sw.ts": { allow: ['http-equiv="refresh"'], why: "the restart page: the forge is down, so there is no /events to hear" }, |
| 13 | 13 | "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" }, |
| 14 | 14 | "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" }, | |
| 15 | 16 | }; |
| 16 | 17 | |
| 17 | 18 | const PATTERNS: [string, RegExp][] = [ |
test/palette-roster.test.ts +4
| @@ -61,6 +61,7 @@ test("a site-admin person is offered the whole roster", () => { | ||
| 61 | 61 | ["held", "Held by the Later gate", "owner/name"], |
| 62 | 62 | ["fork-tree", "Fork tree of a repository", "owner/name"], |
| 63 | 63 | ["roadmap", "Roadmap of a repository", "owner/name"], |
| 64 | ["space", "Space of a repository", "owner/name"], | |
| 64 | 65 | ["pulls", "Pull requests in a repository", "owner/name"], |
| 65 | 66 | ["milestones", "Milestones in a repository", "owner/name"], |
| 66 | 67 | ["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", | ||
| 184 | 185 | ["held", "Held by the Later gate", "owner/name"], |
| 185 | 186 | ["fork-tree", "Fork tree of a repository", "owner/name"], |
| 186 | 187 | ["roadmap", "Roadmap of a repository", "owner/name"], |
| 188 | ["space", "Space of a repository", "owner/name"], | |
| 187 | 189 | ["pulls", "Pull requests in a repository", "owner/name"], |
| 188 | 190 | ["milestones", "Milestones in a repository", "owner/name"], |
| 189 | 191 | ["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" | ||
| 259 | 261 | ["held", "Held by the Later gate", "owner/name"], |
| 260 | 262 | ["fork-tree", "Fork tree of a repository", "owner/name"], |
| 261 | 263 | ["roadmap", "Roadmap of a repository", "owner/name"], |
| 264 | ["space", "Space of a repository", "owner/name"], | |
| 262 | 265 | ["pulls", "Pull requests in a repository", "owner/name"], |
| 263 | 266 | ["milestones", "Milestones in a repository", "owner/name"], |
| 264 | 267 | ["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", () => { | ||
| 328 | 331 | ["held", "Held by the Later gate", "owner/name"], |
| 329 | 332 | ["fork-tree", "Fork tree of a repository", "owner/name"], |
| 330 | 333 | ["roadmap", "Roadmap of a repository", "owner/name"], |
| 334 | ["space", "Space of a repository", "owner/name"], | |
| 331 | 335 | ["pulls", "Pull requests in a repository", "owner/name"], |
| 332 | 336 | ["milestones", "Milestones in a repository", "owner/name"], |
| 333 | 337 | ["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 | }); |
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.