show_diagram: animated box-and-arrow explainers in the terminal

Six mods for Claude Code: a one-line status bar, image previews for pasted screenshots, boxed cards for tool calls, a keepalive for the prompt cache, a weekly calendar of your sessions, and animated diagrams Claude can draw in the transcript.
Mods are Claude Code plugins built from function hooks. They need Claude Code 2.1.287 or newer. Mods are an early-access feature: the hooks API can change between releases.
| Mod | What it does |
|---|---|
slick-bar | Replaces the hint line under the prompt with a one-line bar: model, effort, folder, branch, context gauge, prompt-cache health, rate-limit gauges |
image-peek | Shows a preview of a pasted image above the prompt and under the message that sent it |
tool-cards | Draws each tool call as a boxed card: highlighted command, output preview, timing footer |
cache-warm | Keeps the prompt cache of an idle chat warm with a small capped ping, and counts cache hits and misses |
week-calendar | /week draws a calendar of the week's sessions with commits, hours per project, and a 3-line report |
diagram-mod | Gives Claude a show_diagram tool: animated diagrams that play a story step by step, with packets, gauges and a live log |
git clone https://github.com/mustafa89/my-claude-code-mods.git ~/.claude/mods
env block of ~/.claude/settings.json (paths separated by :; ~ is allowed): {
"env": {
"CLAUDE_CODE_PLUGIN_DIRS": "~/.claude/mods/slick-bar:~/.claude/mods/image-peek:~/.claude/mods/tool-cards:~/.claude/mods/cache-warm:~/.claude/mods/week-calendar:~/.claude/mods/diagram-mod"
}
}
Leave out the mods you do not want. slick-bar lists cache-warm as a dependency for its cache segment, so load the two together.
<name>: loaded in the transcript.To try one mod for a single session only:
claude --plugin-dir ~/.claude/mods/slick-bar
If you use a statusLine command, the bar does not replace it. The bar sits on the hint line under the prompt; your status line stays below it. Remove statusLine from your settings if you want only the bar.

One line under the prompt, after Claude Code's own permission-mode label:
✻ Opus 5.5 1M · medium. The ✻ turns into ◉ while a turn runs.± when the tree has changes, ↑/↓ for commits ahead of or behind the upstream.↻ 3h12m), and a ┃ tick that marks how much of the window has passed. Usage to the right of the tick means you are using it faster than the window allows; the percentage turns amber when the current pace projects past 100%.cache ● 97% 52m ✗1 ⟳2. The dot is green while the cache is warm, amber in the last minutes before it expires, and ○ cold after. Then the share of the last request served from cache, the time until the cache expires, misses (✗) and keepalive pings (⟳).When the terminal is narrow, segments drop in this order: hint text, 7-day gauge, token count, cache, branch, folder. The model and the context gauge always stay.
The bar needs a Nerd Font for the pill shapes and icons.

Image #3 · 1246×846).image-peek draws real images through the kitty graphics protocol, so it needs a terminal that supports it, such as kitty or Ghostty. Elsewhere it shows the image's label instead.
It also needs macOS: it uses sips to make a smaller copy of each image.
Terminal multiplexers: inside a multiplexer that passes kitty graphics through but identifies itself differently (for example herdr, which reports libghostty), Claude Code turns graphics off. To turn them on, add this to the same env block:
"CLAUDE_CODE_FORCE_TERMINAL_IMAGES": "1"
Only do this if your terminal really supports kitty graphics. Otherwise images draw as empty boxes.

Every tool call becomes a card:
✓ done, ◌ running, ✗ error, ⊘ interrupted. The command's description follows.+N -M header with a bar shows the size of the change. Narrow terminals stack old over new. A new file from Write keeps Claude Code's own view.▾ output · N lines. JSON is indented; errors show in red.src/cart/total.js:10-30 for a Read.◇ diagram, as wide as the diagram, with the animation inside and ▴ collapse / ▾ expand.Claude Code normally folds runs of reads and searches into one line (Searched for 2 patterns). tool-cards unfolds them, so each call gets its own card.
▾ expand on a card to see all of its output or diff (up to 400 lines). Clicks reach the card in fullscreen mode./cards full expands every card; /cards compact goes back to the preview (5 output lines, 16 diff rows, MCP output folded); /cards switches between the two.Ctrl+o does not expand a card. Claude Code does not tell mods when the Ctrl+o view is open.

Claude Code caches the conversation on the API side. A request that reads the cache pays a small part of the input price; a request after the cache expired writes the whole conversation again. cache-warm sends one small request just before the cache expires, so an idle chat stays warm.
$.model.fork. The fork sends the session's last request again with a one-line prompt. The API reads the conversation from the cache, and that read starts the cache lifetime again. The fork runs no tools and does not show in the transcript.Prompt caching prices, as a multiple of the normal input price:
| Request | Price |
|---|---|
| Cache write, 1-hour cache | 2× |
| Cache write, 5-minute cache | 1.25× |
| Cache read | 0.1× (Opus 5.5: 0.05×, Fable 5.1: 0.025×) |
Example: a 400k-token chat on Opus 5.5 with the 1-hour cache.
A ping costs 1/40 of a cold restart. Four pings cost 80k, one tenth of a restart, even if you never come back to the chat. The keepalive pays for itself when there is a better than 1-in-40 chance that you come back within the next hour. At the general read price of 0.1×, the break-even is 20 pings.
maxPings pings (default 4) per idle stretch. A prompt you type resets the count.| Setting | Values | Default |
|---|---|---|
ttl | 1h, 5m | 1h |
maxPings | a number | 4 |
Change them in /config. Claude Code sessions use the 1-hour cache on most plans. Use 5m for the API default, or when your account runs in usage overage.
/cache-warm status shows the cache state, /cache-warm off stops the pings, /cache-warm on starts them again.

The screenshot shows a real week with every title, project and commit replaced.
/week builds a calendar of this week's Claude Code and Codex sessions, opens it in your browser, and prints a 3-line report:
Shipped: rate limiting, CI runner migration, deploy runbook
Most time: api-service, 22h 27m
Next: Prototype caching layer, Upgrade database driver
/week last shows last week; /week -2 goes two weeks back.
git config user.email.bin/build.mjs reads ~/.claude/projects/**/*.jsonl and ~/.codex/sessions/**/*.jsonl, runs git log in each project, and writes one HTML file to ~/.calendar/week-<monday>.html. Node only, no network.The calendar file holds the first message of each session as plain text. It stays on your machine.
~/.calendar/config.json is created on the first run:
| Setting | Default | What it does |
|---|---|---|
dayStartHour | 6 | Work before this hour counts toward the previous day |
gapMinutes | 30 | A gap longer than this starts a new block |
minNoCommitMinutes | 15 | Shorter blocks are not flagged as ending without a commit |
weekStart | mon | mon or sun |
theme | dark | dark or light |
accent, palette | Colours for today, the busiest day, and the projects |
It needs Node and git. It opens the file with open on macOS or xdg-open on Linux.

Ask Claude for a diagram or a visual explanation and it calls the mod's show_diagram tool. The diagram draws in the tool's row in the transcript:
✓ ok, ✗ error, ! warning.meters) that fill in eighths of a cell, and a moving activity wave (spark).side: "left" or "right" becomes a tall panel beside the tree, with rows a step can highlight.Give the spec steps and the diagram plays them in a loop. Each step names the edges carrying traffic (only those light up), changes node statuses, meters and panel highlights, adds log rows, and moves the counters. Meters ease to their new values and counters count up across the step. Without steps, packets loop on the edges you name.
examples/agent-tree.json is the story in the GIF; examples/dispatcher.json is a simple diagram without steps. The bundled skill tells Claude when to use the tool and how to keep a diagram readable: one idea, about 12 nodes at most, labels of 1-3 words, 3-6 steps.
| Setting | Default | What it does |
|---|---|---|
display | inline | inline: the diagram animates in the transcript row. pane: it opens in a side pane, closed with q or Esc (Claude Code only seats a pane it did not ask for from 144 columns) |
Each mod is a folder:
<mod>/
.claude-plugin/plugin.json name, version, description, types
hooks/hooks.json { "modules": ["./register.tsx"] }
hooks/register.tsx the hooks
types/index.d.ts the state the mod keeps
tests/*.test.tsx tests
Check and test a mod:
claude plugin validate ./slick-bar
claude plugin test ./slick-bar
Claude Code writes the API types into <mod>/.claude-plugin/types/ the first time it loads a mod; those files are not committed. A session started with --plugin-dir reloads a mod when you save its files.
These come from the mods API, not from the mods:
Ctrl+o: tool rows do not report the expanded view (see Expanding output).ttl setting.mcp__<mod>__<tool>, so diagram-mod's tool is mcp__diagram-mod__show_diagram though no MCP server is involved.hooks/register.tsx 239 lines1import type { Args, EngineInterface, Register, Timer, ToolCallResult } from 'claude-code'
2
3import { layout, type Layout } from './layout'
4import { encode, paint, toText } from './render'
5import { INPUT_SCHEMA, parseSpec, type DiagramSpec } from './spec'
6
7const PANE = 'diagram'
8const RASTER = 'diagram'
9const TOOL = 'mcp__diagram-mod__show_diagram'
10const FRAME_MS = 16 // about 60 fps
11const CARD_MARGIN = 8 // a tool card's border and padding around the row (tool-cards)
12const MOUNT_WAIT_FRAMES = 600 // stop an inline timer whose row never mounts (10 s)
13
14const DESCRIPTION = [
15 'Show an animated box-and-arrow diagram in the terminal.',
16 'Use it when the user asks for a diagram, a flow or a visual explanation.',
17 'Give nodes and edges only; the layout is computed top to bottom.',
18 'Keep it to one idea, about 12 nodes at most, labels of 1-3 words.',
19 'Packets loop along named edges (edge id is "from->to"); log lines type out under the diagram.',
20].join(' ')
21
22type Shown = { spec: DiagramSpec; layout: Layout }
23
24// inline: the tool's transcript row draws the diagram; only the newest row animates.
25const drawn = new Map<string, Shown>()
26type Live = {
27 id: string
28 tick: number
29 timer?: Timer
30 isMounted: boolean
31 misses: number
32 /** Unmounted (folded, scrolled away): only a new drawing of the row starts it again. */
33 isGone: boolean
34 isSending: boolean
35 last?: Uint32Array
36}
37let live: Live | undefined
38const KEEP_DRAWN = 20
39let isBusy = false
40
41// pane: one pane the tool opens; q or Esc closes it.
42let shown: Shown | undefined
43let tick = 0
44let timer: Timer | undefined
45
46export const register: Register = (on, options) => {
47 const isPane = options.display === 'pane'
48
49 on('session.start', async ($, e, next) => {
50 await $.tool.register({ name: 'show_diagram', description: DESCRIPTION, inputSchema: INPUT_SCHEMA, isDeferred: false })
51 return next(e)
52 })
53
54 on('tool.call', { tool: TOOL }, ($, e) => (isPane ? callPane($, e) : callInline($, e))).catch(() => ({
55 deny: 'show_diagram failed to draw the diagram',
56 }))
57
58 // While a turn runs the frames would compete with the streaming reply: hold the still picture.
59 on('turn.start', ($, e, next) => {
60 isBusy = true
61 stopInline()
62 return next(e)
63 })
64
65 on('turn.complete', ($, e, next) => {
66 if (e.agentId !== undefined) return next(e) // a subagent's run, not the main turn
67 isBusy = false
68 if (!isPane) startInline($)
69 return next(e)
70 })
71
72 // The diagram is the row itself, so a card around the row (tool-cards) can fold it.
73 on('ui.render', { component: 'ToolUse' }, ($, e, next) => {
74 const row = drawn.get(e.requestId)
75 if (isPane || e.props.tool !== TOOL || !row || e.surface !== 'terminal' || e.props.isErrored) return next(e)
76 if ((e.viewport?.columns ?? Infinity) - CARD_MARGIN < row.layout.width) return next(e)
77
78 const isLive = live?.id === e.requestId
79 if (isLive && live) {
80 // Pause off screen; `undefined` means the surface does not say, so keep going.
81 if (e.props.onScreen === null) stopInline()
82 else {
83 live.isGone = false
84 live.last = undefined
85 startInline($)
86 }
87 }
88 const { Raster } = $.ui.resolve(e)
89 const cells = encode(paint(row.spec, row.layout, (live?.tick ?? 0) * FRAME_MS, isLive && !isBusy))
90 return <Raster key={RASTER} columns={row.layout.width} rows={row.layout.height} cells={cells} />
91 })
92
93 // The row already draws the diagram; the result block under it stays empty.
94 on('ui.render', { component: 'ToolResult' }, ($, e, next) => {
95 const row = drawn.get(e.requestId)
96 if (isPane || e.props.tool !== TOOL || !row || e.surface !== 'terminal' || e.props.isErrored) return next(e)
97 if ((e.viewport?.columns ?? Infinity) - CARD_MARGIN < row.layout.width) return next(e)
98 const { Box } = $.ui.resolve(e)
99 return <Box />
100 })
101
102 on('ui.close', ($, e, next) => {
103 if (e.id === PANE) forgetPane()
104 return next(e)
105 }).catch(($, e, next) => next(e))
106
107 on('ui.render', { component: 'Pane', requestId: PANE }, ($, e) => {
108 const { Box, Text } = $.ui.resolve(e)
109 if (!shown) return <Text dimColor>No diagram to show. Ask Claude for one.</Text>
110 // No Raster off the terminal; a pane narrower than the diagram gets the still text, which scrolls.
111 if (e.surface !== 'terminal' || e.props.bodyColumns < shown.layout.width) {
112 return <Text>{toText(shown.spec, shown.layout)}</Text>
113 }
114
115 const { Raster, Button } = $.ui.resolve(e)
116 const { width, height } = shown.layout
117 return (
118 <Box flexDirection="column">
119 <Raster key={RASTER} columns={width} rows={height} cells={encode(paint(shown.spec, shown.layout, tick * FRAME_MS))} />
120 <Button key="close" hotkey="q" plain onPress={() => {
121 forgetPane()
122 return $.ui.close({ id: PANE })
123 }}>
124 close
125 </Button>
126 </Box>
127 )
128 })
129}
130
131function parsedOf(e: Args<'tool.call'>) {
132 const { tool: _tool, tool_use_id: _id, agentId: _agent, consent: _consent, ...input } = e as Record<string, unknown>
133 return parseSpec(input)
134}
135
136async function callInline($: EngineInterface, e: Args<'tool.call'>): Promise<ToolCallResult> {
137 const parsed = parsedOf(e)
138 if ('error' in parsed) return { deny: `show_diagram: ${parsed.error}` }
139
140 const row = { spec: parsed.spec, layout: layout(parsed.spec) }
141 stopInline()
142 drawn.set(e.tool_use_id, row)
143 for (const id of [...drawn.keys()].slice(0, -KEEP_DRAWN)) drawn.delete(id)
144 live = { id: e.tool_use_id, tick: 0, isMounted: false, misses: 0, isGone: false, isSending: false }
145 startInline($)
146 return {
147 result: `Drawn in the transcript row above; it animates once this turn ends. Do not repeat it in the reply. Static version for reference:\n\n${toText(row.spec, row.layout)}`,
148 }
149}
150
151function startInline($: EngineInterface) {
152 const current = live
153 const row = current && drawn.get(current.id)
154 if (!current || !row || current.timer || current.isGone || isBusy) return
155 current.misses = 0
156 current.timer = $.clock.every(FRAME_MS, () => {
157 current.tick++
158 // One frame in flight at a time, and none when nothing on screen changed.
159 if (current.isSending) return
160 const grid = paint(row.spec, row.layout, current.tick * FRAME_MS)
161 if (current.last && sameCells(current.last, grid.words)) return
162 current.last = grid.words
163 current.isSending = true
164 void $.ui
165 .blit({ requestId: current.id, key: RASTER, cells: encode(grid) })
166 .then(r => {
167 if (!r.deny) current.isMounted = true
168 else if (current.isMounted || ++current.misses > MOUNT_WAIT_FRAMES) stopTimer(current, true)
169 })
170 .catch(() => stopTimer(current, true))
171 .finally(() => {
172 current.isSending = false
173 })
174 })
175}
176
177/** Stops this diagram's own timer; a late answer for an older diagram never touches the newer one. */
178function stopTimer(target: Live, isGone = false) {
179 target.timer?.cancel()
180 target.timer = undefined
181 if (isGone) target.isGone = true
182 target.last = undefined
183}
184
185function stopInline() {
186 if (live) stopTimer(live)
187}
188
189function sameCells(a: Uint32Array, b: Uint32Array): boolean {
190 if (a.length !== b.length) return false
191 for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false
192 return true
193}
194
195async function callPane($: EngineInterface, e: Args<'tool.call'>): Promise<ToolCallResult> {
196 const parsed = parsedOf(e)
197 if ('error' in parsed) return { deny: `show_diagram: ${parsed.error}` }
198
199 stopPane()
200 const next = { spec: parsed.spec, layout: layout(parsed.spec) }
201 const opened = await $.ui.open({
202 id: PANE,
203 title: parsed.spec.title,
204 focus: true,
205 closeOnEscape: true,
206 rows: next.layout.height + 1,
207 columns: next.layout.width,
208 })
209 if (!opened.isPlaced) {
210 forgetPane()
211 await $.ui.close({ id: PANE })
212 return {
213 result: `The terminal is too narrow for the diagram pane (${opened.reason}). Static version:\n\n${toText(next.spec, next.layout)}`,
214 }
215 }
216
217 shown = next
218 tick = 0
219 $.ui.invalidate('ui.render')
220 timer = $.clock.every(FRAME_MS, () => {
221 if (!shown) return stopPane()
222 tick++
223 void $.ui.blit({ requestId: PANE, key: RASTER, cells: encode(paint(shown.spec, shown.layout, tick * FRAME_MS)) })
224 })
225 return { result: `Showing "${parsed.spec.title}" in the diagram pane (q or Esc closes it).` }
226}
227
228function stopPane() {
229 timer?.cancel()
230 timer = undefined
231}
232
233// The person's close (Esc, the close mark) arrives at the ui.close hook;
234// our own $.ui.close does not pass through it, so q stops the timer here.
235function forgetPane() {
236 stopPane()
237 shown = undefined
238}
239hooks/layout.ts 349 lines1import { edgeIdOf, type DiagramNode, type DiagramSpec } from './spec'
2
3export type Placed = { id: string; layer: number; x: number; y: number; w: number; h: number }
4
5/** An edge's cells from source to arrowhead, in travel order. */
6export type Route = {
7 id: string
8 from: string
9 to: string
10 cells: [number, number][]
11 dashed: boolean
12 arrow: '▼' | '◀' | '▶'
13 label?: { x: number; y: number; text: string }
14}
15
16export type Layout = {
17 width: number
18 height: number
19 boxes: Map<string, Placed>
20 /** Side panels, keyed by node id; drawn as tall boxes beside the tree. */
21 panels: Map<string, Placed>
22 routes: Map<string, Route>
23 legendRow?: number
24 captionRow?: number
25 /** First log row and how many rows the log shows (the newest ones). */
26 logTop: number
27 logRows: number
28 footerRow?: number
29}
30
31const ROW_GAP = 4
32const COL_GAP = 3
33const SIDE_GAP = 6
34const MIN_BOX = 10
35const MAX_BOX = 44
36const MAX_PANEL = 34
37export const METER_BAR = 12
38export const LOG_ROWS = 6
39/** Columns before a log row's text: `00:04 actor `. */
40export const LOG_TEXT_AT = 21
41
42/** True when no row of the log names an actor or a status: rows print as plain lines. */
43export const isPlainLog = (spec: DiagramSpec) =>
44 !spec.steps?.length && (spec.log ?? []).every(l => l.actor === undefined && l.status === undefined)
45
46/** Layer per node by longest path from the roots; back edges (cycles) are skipped. */
47export function layersOf(spec: DiagramSpec): { layer: Map<string, number>; back: Set<string> } {
48 const tree = spec.nodes.filter(n => !n.side)
49 const inTree = new Set(tree.map(n => n.id))
50 const treeEdges = (spec.edges ?? []).filter(e => inTree.has(e.from) && inTree.has(e.to))
51 const out = new Map(tree.map(n => [n.id, [] as { to: string; id: string }[]]))
52 for (const e of treeEdges) out.get(e.from)?.push({ to: e.to, id: edgeIdOf(e) })
53
54 const state = new Map<string, 'open' | 'done'>()
55 const back = new Set<string>()
56 const post: string[] = []
57 const visit = (id: string) => {
58 state.set(id, 'open')
59 for (const { to, id: edgeId } of out.get(id) ?? []) {
60 if (state.get(to) === 'open') back.add(edgeId)
61 else if (!state.has(to)) visit(to)
62 }
63 state.set(id, 'done')
64 post.push(id)
65 }
66 for (const n of tree) if (!state.has(n.id)) visit(n.id)
67
68 const layer = new Map(tree.map(n => [n.id, 0]))
69 for (const id of post.reverse()) {
70 for (const { to, id: edgeId } of out.get(id) ?? []) {
71 if (!back.has(edgeId)) layer.set(to, Math.max(layer.get(to) ?? 0, (layer.get(id) ?? 0) + 1))
72 }
73 }
74 // A source sits just above its nearest child, not in the top row with long edges down.
75 const hasParent = new Set(treeEdges.filter(e => !back.has(edgeIdOf(e))).map(e => e.to))
76 for (const [id, children] of out) {
77 const forward = children.filter(c => !back.has(c.id))
78 if (hasParent.has(id) || forward.length === 0) continue
79 layer.set(id, Math.min(...forward.map(c => layer.get(c.to) ?? 1)) - 1)
80 }
81 return { layer, back }
82}
83
84/** Every status a node shows over the story, so its box is wide enough for all of them. */
85function statusesOf(spec: DiagramSpec, id: string): string[] {
86 const base = spec.nodes.find(n => n.id === id)?.status
87 const later = (spec.steps ?? []).map(s => s.nodes[id]?.status).filter((s): s is string => s !== undefined)
88 return [...(base ? [base] : []), ...later]
89}
90
91export function meterWidth(n: DiagramNode): number {
92 const label = Math.max(0, ...(n.meters ?? []).map(m => m.label.length))
93 const text = Math.max(0, ...(n.meters ?? []).map(m => (m.text ? m.text.length + 1 : 0)))
94 return n.meters?.length ? label + 1 + METER_BAR + 5 + text : 0
95}
96
97/** Rows inside a box after its label: details, meters, the wave, the status. */
98export function rowsOf(spec: DiagramSpec, n: DiagramNode) {
99 const hasStatus = statusesOf(spec, n.id).length > 0
100 return { detail: n.detail?.length ?? 0, meters: n.meters?.length ?? 0, spark: n.spark ? 1 : 0, status: hasStatus ? 1 : 0 }
101}
102
103/** Places every box top to bottom, side panels beside them, and routes every edge. */
104export function layout(spec: DiagramSpec): Layout {
105 const { layer, back } = layersOf(spec)
106 const tree = spec.nodes.filter(n => !n.side)
107 const order = new Map(spec.nodes.map((n, i) => [n.id, i]))
108 const depth = tree.length ? Math.max(...layer.values()) + 1 : 0
109
110 const sizeOf = (n: DiagramNode) => {
111 const statusW = Math.max(-2, ...statusesOf(spec, n.id).map(s => s.length)) + 2
112 const text = Math.max(n.label.length, ...(n.detail ?? []).map(d => d.length), statusW, meterWidth(n), n.spark ? 16 : 0)
113 const r = rowsOf(spec, n)
114 return { w: Math.min(MAX_BOX, Math.max(MIN_BOX, text + 4)), h: 3 + r.detail + r.meters + r.spark + r.status }
115 }
116
117 // Rows of ids per layer, each layer after the first sorted under its parents.
118 const rows: string[][] = Array.from({ length: depth }, () => [])
119 for (const n of tree) rows[layer.get(n.id) ?? 0]!.push(n.id)
120 const parents = new Map<string, string[]>()
121 for (const e of spec.edges ?? []) {
122 if (layer.has(e.from) && layer.has(e.to) && !back.has(edgeIdOf(e))) parents.set(e.to, [...(parents.get(e.to) ?? []), e.from])
123 }
124 const slot = new Map<string, number>()
125 rows.forEach((row, i) => {
126 if (i > 0) {
127 const center = (id: string) => {
128 const ps = (parents.get(id) ?? []).map(p => slot.get(p) ?? 0)
129 return ps.length ? ps.reduce((a, b) => a + b, 0) / ps.length : Infinity
130 }
131 row.sort((a, b) => center(a) - center(b) || (order.get(a) ?? 0) - (order.get(b) ?? 0))
132 }
133 row.forEach((id, j) => slot.set(id, j))
134 })
135
136 const sizes = new Map(spec.nodes.map(n => [n.id, sizeOf(n)]))
137 const rowWidth = (row: string[]) => row.reduce((sum, id) => sum + sizes.get(id)!.w, 0) + COL_GAP * (row.length - 1)
138 const legendRow = spec.legend ? (spec.subtitle ? 2 : 1) : undefined
139 const header = 1 + (spec.subtitle ? 1 : 0) + (spec.legend ? 1 : 0) + 1
140 // Boxes are laid out over their own widest row; the whole block is centered in the canvas at the end.
141 const content = Math.max(0, ...rows.map(rowWidth))
142
143 const boxes = new Map<string, Placed>()
144 let y = header
145 rows.forEach((row, i) => {
146 let x = Math.floor((content - rowWidth(row)) / 2)
147 for (const id of row) {
148 const { w, h } = sizes.get(id)!
149 boxes.set(id, { id, layer: i, x, y, w, h })
150 x += w + COL_GAP
151 }
152 y += Math.max(...row.map(id => sizes.get(id)!.h)) + ROW_GAP
153 })
154 let bottom = rows.length ? y - ROW_GAP : header
155
156 const routes = new Map<string, Route>()
157 const wanted = new Map<string, { text: string; spots: { x: number; y: number }[] }>()
158 let backCount = 0
159 const laneLabels: { id: string; text: string; y: number }[] = []
160 const lineOf = (cells: [number, number][]) => {
161 const push = (x: number, y: number) => {
162 const last = cells[cells.length - 1]
163 if (!last || last[0] !== x || last[1] !== y) cells.push([x, y])
164 }
165 return (x0: number, y0: number, x1: number, y1: number) => {
166 const dx = Math.sign(x1 - x0)
167 const dy = Math.sign(y1 - y0)
168 for (let x = x0, y = y0; ; x += dx, y += dy) {
169 push(x, y)
170 if (x === x1 && y === y1) break
171 }
172 }
173 }
174
175 for (const e of spec.edges ?? []) {
176 const id = edgeIdOf(e)
177 const s = boxes.get(e.from)
178 const t = boxes.get(e.to)
179 if (!s || !t) continue // a side panel's edge, routed once the panels are placed
180 const cells: [number, number][] = []
181 const line = lineOf(cells)
182 const base = { id, from: e.from, to: e.to, cells, dashed: e.dashed !== false }
183 if (!back.has(id) && t.layer === s.layer + 1) {
184 const sx = s.x + Math.floor(s.w / 2)
185 const tx = t.x + Math.floor(t.w / 2)
186 const sy = s.y + s.h
187 const mid = sy + 1
188 const ty = t.y - 1
189 line(sx, sy, sx, mid)
190 line(sx, mid, tx, mid)
191 line(tx, mid, tx, ty)
192 if (e.label) {
193 const left = tx - 1 - e.label.length
194 wanted.set(id, {
195 text: e.label,
196 spots: [{ x: tx + 2, y: mid + 1 }, { x: left, y: mid + 1 }, { x: sx + 2, y: sy }, { x: sx - 1 - e.label.length, y: sy }],
197 })
198 }
199 routes.set(id, { ...base, arrow: '▼' })
200 } else {
201 // Back edges and edges skipping a layer run along the right margin, one lane each,
202 // so they never cut through the boxes between.
203 const lane = content + 2 + 2 * backCount++
204 const sy = s.y + 1
205 const ty = t.y + 1
206 line(s.x + s.w, sy, lane, sy)
207 line(lane, sy, lane, ty)
208 line(lane, ty, t.x + t.w, ty)
209 if (e.label) laneLabels.push({ id, text: e.label, y: Math.floor((sy + ty) / 2) })
210 routes.set(id, { ...base, arrow: '◀' })
211 }
212 }
213
214 for (const l of laneLabels) wanted.set(l.id, { text: l.text, spots: [{ x: content + 2 + 2 * backCount, y: l.y }] })
215
216 // Labels go where no edge, box or earlier label is; the first spot when all are taken.
217 const taken = new Set<string>()
218 for (const r of routes.values()) for (const [x, y] of r.cells) taken.add(`${x},${y}`)
219 for (const b of boxes.values()) {
220 for (let x = b.x; x < b.x + b.w; x++) for (let y = b.y; y < b.y + b.h; y++) taken.add(`${x},${y}`)
221 }
222 const isFree = ({ x, y }: { x: number; y: number }, text: string) =>
223 x >= 0 && [...Array(text.length + 1).keys()].every(i => !taken.has(`${x + i},${y}`))
224 for (const [id, { text, spots }] of wanted) {
225 const at = spots.find(spot => isFree(spot, text)) ?? spots[0]!
226 for (let i = 0; i < text.length; i++) taken.add(`${at.x + i},${at.y}`)
227 routes.get(id)!.label = { ...at, text }
228 }
229
230 // Side panels: as tall as the tree, or their own content if taller.
231 const panelOf = (n: DiagramNode) => {
232 const items = n.items ?? []
233 const w = Math.min(MAX_PANEL, Math.max(MIN_BOX, n.label.length + 4, ...(n.detail ?? []).map(d => d.length + 4), ...items.map(i => i.length + 6)))
234 const h = 3 + (n.detail?.length ?? 0) + (items.length ? items.length + 1 : 0) + (statusesOf(spec, n.id).length ? 2 : 0)
235 return { w, h }
236 }
237 const leftPanels = spec.nodes.filter(n => n.side === 'left')
238 const rightPanels = spec.nodes.filter(n => n.side === 'right')
239 const panelColumn = (list: DiagramNode[]) => ({
240 w: Math.max(0, ...list.map(n => panelOf(n).w)),
241 h: list.reduce((sum, n) => sum + panelOf(n).h + 1, -1),
242 })
243 const left = panelColumn(leftPanels)
244 const right = panelColumn(rightPanels)
245 bottom = Math.max(bottom, header + left.h, header + right.h)
246
247 const labelRight = Math.max(0, ...[...routes.values()].map(r => (r.label ? r.label.x + r.label.text.length : 0)))
248 const treeRight = Math.max(content + (backCount ? 2 + 2 * backCount : 0), labelRight)
249 const treeAt = leftPanels.length ? left.w + SIDE_GAP : 0
250 const rightAt = treeAt + treeRight + SIDE_GAP
251 const blockRight = rightPanels.length ? rightAt + right.w : treeAt + treeRight
252
253 // Rows under the diagram: caption, the log table, the footer.
254 let below = bottom + 1
255 const captionRow = spec.caption ? below : undefined
256 if (spec.caption) below += 2
257 const logCount = (spec.log?.length ?? 0) + (spec.steps ?? []).reduce((sum, s) => sum + s.log.length, 0)
258 const logRows = Math.min(LOG_ROWS, logCount)
259 const logTop = below
260 below += logRows
261 const footerRow = spec.counters || spec.steps?.some(s => Object.keys(s.counters).length) ? below + (logRows ? 1 : 0) : undefined
262 const height = Math.min(256, footerRow !== undefined ? footerRow + 1 : below)
263
264 const allLog = [...(spec.log ?? []), ...(spec.steps ?? []).flatMap(s => s.log)]
265 const textAt = isPlainLog(spec) ? 2 : LOG_TEXT_AT
266 const logRight = Math.max(0, ...allLog.map(l => textAt + l.text.length + (l.status ? l.status.length + 3 : 0)))
267 const counterNames = [...new Set([...Object.keys(spec.counters ?? {}), ...(spec.steps ?? []).flatMap(st => Object.keys(st.counters))])]
268 const counterMax = (name: string) =>
269 Math.max(spec.counters?.[name] ?? 0, ...(spec.steps ?? []).map(st => st.counters[name] ?? 0))
270 const footerRight =
271 counterNames.reduce((sum, name) => sum + name.length + 2 + Math.round(counterMax(name)).toLocaleString('en-US').length + 5, 0) +
272 (spec.steps?.length ? 16 : 0)
273 const textRight = Math.max(
274 footerRight,
275 spec.title.length,
276 spec.subtitle?.length ?? 0,
277 spec.caption?.length ?? 0,
278 (spec.legend ?? []).reduce((sum, l) => sum + l.label.length + 5, 0),
279 logRight,
280 )
281 const width = Math.min(512, Math.max(blockRight, textRight) + 1)
282
283 // Center the block (panels, boxes, lanes, labels) under the text rows, which stay left.
284 const shift = Math.max(0, Math.floor((width - 1 - blockRight) / 2))
285 const dx = shift + treeAt
286 for (const b of boxes.values()) b.x += dx
287 for (const r of routes.values()) {
288 r.cells = r.cells.map(([x, y]) => [x + dx, y])
289 if (r.label) r.label.x += dx
290 }
291
292 const panels = new Map<string, Placed>()
293 const stack = (list: DiagramNode[], x: number, w: number) => {
294 let py = header
295 list.forEach((n, i) => {
296 const own = panelOf(n).h
297 // The last panel in a column stretches to the bottom of the diagram.
298 const h = i === list.length - 1 ? Math.max(own, bottom - py) : own
299 panels.set(n.id, { id: n.id, layer: -1, x, y: py, w, h })
300 py += h + 1
301 })
302 }
303 stack(leftPanels, shift, left.w)
304 stack(rightPanels, shift + rightAt, right.w)
305
306 // Edges between a panel and a box run straight across at the box's middle row.
307 const placed = new Set<string>()
308 for (const b of [...boxes.values(), ...panels.values()]) {
309 for (let x = b.x; x < b.x + b.w; x++) for (let y = b.y; y < b.y + b.h; y++) placed.add(`${x},${y}`)
310 }
311 for (const r of routes.values()) {
312 for (const [x, y] of r.cells) placed.add(`${x},${y}`)
313 if (r.label) for (let i = 0; i < r.label.text.length; i++) placed.add(`${r.label.x + i},${r.label.y}`)
314 }
315 const isOpen = ({ x, y }: { x: number; y: number }, text: string) =>
316 [...Array(text.length + 1).keys()].every(i => !placed.has(`${x + i},${y}`))
317 for (const e of spec.edges ?? []) {
318 const id = edgeIdOf(e)
319 if (routes.has(id)) continue
320 const p = panels.get(e.from) ?? panels.get(e.to)
321 const b = boxes.get(e.from) ?? boxes.get(e.to)
322 if (!p || !b) continue
323 const boxRow = b.y + Math.floor(b.h / 2)
324 const row = Math.min(Math.max(boxRow, p.y + 1), p.y + p.h - 2)
325 const isLeft = p.x < b.x
326 const panelSide = isLeft ? p.x + p.w : p.x - 1
327 const boxSide = isLeft ? b.x - 1 : b.x + b.w
328 const cells: [number, number][] = []
329 const line = lineOf(cells)
330 const fromPanel = panels.has(e.from)
331 const bend = isLeft ? panelSide + 2 : panelSide - 2
332 const path: [number, number][] =
333 row === boxRow ? [[panelSide, row], [boxSide, row]] : [[panelSide, row], [bend, row], [bend, boxRow], [boxSide, boxRow]]
334 const ordered = fromPanel ? path : [...path].reverse()
335 ordered.slice(1).forEach(([x, y], i) => line(ordered[i]![0], ordered[i]![1], x, y))
336 const goesRight = cells.length > 1 && cells[cells.length - 1]![0] > cells[0]![0]
337 const startX = Math.min(...cells.map(c => c[0]))
338 const label = e.label ? [{ x: startX + 2, y: row - 1 }, { x: startX + 2, y: row + 1 }].find(spot => isOpen(spot, e.label!)) : undefined
339 for (const [x, y] of cells) placed.add(`${x},${y}`)
340 if (label && e.label) for (let i = 0; i < e.label.length; i++) placed.add(`${label.x + i},${label.y}`)
341 routes.set(id, {
342 id, from: e.from, to: e.to, cells, dashed: e.dashed !== false, arrow: goesRight ? '▶' : '◀',
343 label: label && e.label ? { ...label, text: e.label } : undefined,
344 })
345 }
346
347 return { width, height, boxes, panels, routes, legendRow, captionRow, logTop, logRows, footerRow }
348}
349hooks/render.ts 400 lines1import { isPlainLog, LOG_TEXT_AT, METER_BAR, meterWidth, rowsOf, type Layout, type Placed, type Route } from './layout'
2import type { DiagramNode, DiagramSpec, Tone } from './spec'
3import { frameAt, type Frame, type NodeState } from './timeline'
4
5const DEFAULT_BG = 0x01000000
6const PALETTE: Record<string, number> = {
7 blue: 0x89b4fa, green: 0xa6e3a1, yellow: 0xf9e2af, red: 0xf38ba8, magenta: 0xcba6f7,
8 cyan: 0x94e2d5, orange: 0xfab387, gray: 0x9399b2, white: 0xcdd6f4,
9}
10const INK = {
11 title: 0xcdd6f4, subtitle: 0x7f849c, detail: 0xa6adc8, dim: 0x45475a, edge: 0x585b70, edgeLabel: 0x9399b2,
12 log: 0xbac2de, muted: 0x6c7086, shade: 0x1e1e2e,
13}
14const TONE_INK: Record<Tone, number | undefined> = { ok: 0xa6e3a1, warn: 0xf9e2af, err: 0xf38ba8, dim: 0x7f849c, run: undefined, info: undefined }
15const TONE_MARK: Record<Tone, string> = { ok: '✓', warn: '!', err: '✗', dim: '·', run: '', info: '•' }
16const SPINNER = '⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏'
17const WAVE = '▁▂▃▄▅▆▇█'
18const EIGHTHS = ' ▏▎▍▌▋▊▉'
19
20/** Packet speed at speed 1, in cells a second. */
21export const CELLS_PER_SECOND = 15
22/** A packet is a comet of braille dots: this many samples, a quarter cell apart. */
23const COMET = 7
24export const BRAILLE = 0x2800
25
26export function colorOf(name: string | undefined, fallback: number): number {
27 if (!name) return fallback
28 const hex = /^#?([0-9a-f]{6})$/i.exec(name)
29 return hex ? parseInt(hex[1]!, 16) : (PALETTE[name.toLowerCase()] ?? fallback)
30}
31
32export function blend(fg: number, bg: number, share: number): number {
33 const ch = (shift: number) => Math.round(((fg >> shift) & 0xff) * share + ((bg >> shift) & 0xff) * (1 - share))
34 return (ch(16) << 16) | (ch(8) << 8) | ch(0)
35}
36
37/** A grid of [codePoint, fg, bg] triplets, row-major, as a Raster packs it. */
38export type Grid = { columns: number; rows: number; words: Uint32Array }
39
40type Put = (x: number, y: number, ch: string | number, fg: number, bg?: number) => void
41type Write = (x: number, y: number, s: string, fg: number, bg?: number) => void
42
43function writer(words: Uint32Array, columns: number, rows: number): { put: Put; text: Write } {
44 const put: Put = (x, y, ch, fg, bg = DEFAULT_BG) => {
45 if (x < 0 || y < 0 || x >= columns || y >= rows) return
46 const i = (y * columns + x) * 3
47 words[i] = typeof ch === 'number' ? ch : (ch.codePointAt(0) ?? 32)
48 words[i + 1] = fg
49 words[i + 2] = bg
50 }
51 const text: Write = (x, y, s, fg, bg = DEFAULT_BG) => [...s].forEach((ch, i) => put(x + i, y, ch, fg, bg))
52 return { put, text }
53}
54
55const DIRS = { n: 1, s: 2, e: 4, w: 8 }
56const JOINS: Record<number, [string, string]> = {
57 [DIRS.n | DIRS.s]: ['┆', '│'], [DIRS.e | DIRS.w]: ['┄', '─'],
58 [DIRS.n]: ['┆', '│'], [DIRS.s]: ['┆', '│'], [DIRS.e]: ['┄', '─'], [DIRS.w]: ['┄', '─'],
59 [DIRS.s | DIRS.e]: ['╭', '╭'], [DIRS.s | DIRS.w]: ['╮', '╮'], [DIRS.n | DIRS.e]: ['╰', '╰'], [DIRS.n | DIRS.w]: ['╯', '╯'],
60 [DIRS.n | DIRS.s | DIRS.e]: ['├', '├'], [DIRS.n | DIRS.s | DIRS.w]: ['┤', '┤'],
61 [DIRS.e | DIRS.w | DIRS.s]: ['┬', '┬'], [DIRS.e | DIRS.w | DIRS.n]: ['┴', '┴'],
62 [DIRS.n | DIRS.s | DIRS.e | DIRS.w]: ['┼', '┼'],
63}
64
65type EdgeCell = { x: number; y: number; glyph: string; routes: string[] }
66type Still = { words: Uint32Array; edgeCells: EdgeCell[]; inBox: Set<number>; nodeColor: Map<string, number> }
67
68// What never changes (text, edge glyphs) is worked out once per layout; frames copy it.
69const stills = new WeakMap<Layout, Still>()
70
71function stillOf(spec: DiagramSpec, layout: Layout): Still {
72 const cached = stills.get(layout)
73 if (cached) return cached
74 const { width: columns, height: rows } = layout
75 const words = new Uint32Array(columns * rows * 3)
76 for (let i = 0; i < columns * rows; i++) words.set([32, DEFAULT_BG, DEFAULT_BG], i * 3)
77 const { put, text } = writer(words, columns, rows)
78
79 text(0, 0, spec.title, INK.title)
80 if (spec.subtitle) text(0, 1, spec.subtitle, INK.subtitle)
81 if (layout.legendRow !== undefined) {
82 let x = 0
83 for (const l of spec.legend ?? []) {
84 put(x, layout.legendRow, '■', colorOf(l.color, INK.detail))
85 text(x + 2, layout.legendRow, l.label, INK.muted)
86 x += l.label.length + 5
87 }
88 }
89 if (layout.captionRow !== undefined && spec.caption) {
90 text(Math.max(0, Math.floor((columns - spec.caption.length) / 2)), layout.captionRow, spec.caption, INK.title)
91 }
92
93 // Edge glyphs: join directions per cell so merges and splits draw as junctions.
94 const joins = new Map<string, { bits: number; dashed: boolean; routes: string[] }>()
95 const mark = ([x, y]: [number, number], bit: number, r: Route) => {
96 const k = `${x},${y}`
97 const j = joins.get(k)
98 joins.set(k, { bits: (j?.bits ?? 0) | bit, dashed: (j?.dashed ?? false) || r.dashed, routes: [...(j?.routes ?? []), r.id] })
99 }
100 for (const r of layout.routes.values()) {
101 r.cells.forEach((c, i) => {
102 const next = r.cells[i + 1]
103 if (!next) return
104 const bit = next[0] > c[0] ? DIRS.e : next[0] < c[0] ? DIRS.w : next[1] > c[1] ? DIRS.s : DIRS.n
105 const back = bit === DIRS.e ? DIRS.w : bit === DIRS.w ? DIRS.e : bit === DIRS.s ? DIRS.n : DIRS.s
106 mark(c, bit, r)
107 mark(next, back, r)
108 })
109 }
110 // Cells inside a box or panel: an edge crossing one must not paint over its text.
111 const inBox = new Set<number>()
112 for (const b of [...layout.boxes.values(), ...layout.panels.values()]) {
113 for (let x = b.x; x < b.x + b.w; x++) for (let y = b.y; y < b.y + b.h; y++) inBox.add(y * columns + x)
114 }
115 const edgeCells: EdgeCell[] = [...joins]
116 .map(([k, { bits, dashed, routes }]) => {
117 const [x, y] = k.split(',').map(Number) as [number, number]
118 return { x, y, glyph: JOINS[bits]?.[dashed ? 0 : 1] ?? '┼', routes: [...new Set(routes)] }
119 })
120 .filter(c => !inBox.has(c.y * columns + c.x))
121 for (const r of layout.routes.values()) if (r.label) text(r.label.x, r.label.y, r.label.text, INK.edgeLabel)
122
123 // Box text that never changes: label and details.
124 for (const n of spec.nodes) {
125 const b = layout.boxes.get(n.id) ?? layout.panels.get(n.id)
126 if (!b) continue
127 const color = colorOf(n.color, PALETTE.cyan!)
128 const inner = b.w - 4
129 const at = n.side ? 2 : 2 + Math.floor((inner - Math.min(n.label.length, inner)) / 2)
130 text(b.x + at, b.y + 1, n.label.slice(0, inner), color)
131 n.detail?.forEach((d, i) => text(b.x + 2, b.y + 2 + i, d.slice(0, inner), n.side ? INK.muted : INK.detail))
132 }
133
134 const nodeColor = new Map(spec.nodes.map(n => [n.id, colorOf(n.color, PALETTE.cyan!)]))
135 const still = { words, edgeCells, inBox, nodeColor }
136 stills.set(layout, still)
137 return still
138}
139
140function border(put: Put, b: Placed, ink: number) {
141 const right = b.x + b.w - 1
142 const bottom = b.y + b.h - 1
143 for (let x = b.x + 1; x < right; x++) {
144 put(x, b.y, '┄', ink)
145 put(x, bottom, '┄', ink)
146 }
147 for (let y = b.y + 1; y < bottom; y++) {
148 put(b.x, y, '┆', ink)
149 put(right, y, '┆', ink)
150 }
151 put(b.x, b.y, '╭', ink)
152 put(right, b.y, '╮', ink)
153 put(b.x, bottom, '╰', ink)
154 put(right, bottom, '╯', ink)
155}
156
157const toneInk = (tone: Tone | undefined, node: number): number => (tone && TONE_INK[tone]) ?? node
158
159/** `ms` undefined for a still picture: a running status shows a fixed mark, not a frozen spinner. */
160function statusText(status: string, tone: Tone | undefined, ms: number | undefined): string {
161 const spin = ms === undefined ? '◌' : SPINNER[Math.floor(ms / 80) % SPINNER.length]!
162 const mark = tone === 'run' ? spin : TONE_MARK[tone ?? 'info']
163 return mark ? `${mark} ${status}` : status
164}
165
166/** A bar `width` cells long filled to `value`, in eighths of a cell. */
167function bar(value: number, width: number): [string, string] {
168 const eighths = Math.round(Math.min(1, Math.max(0, value)) * width * 8)
169 const filled = '█'.repeat(Math.floor(eighths / 8)) + (eighths % 8 ? EIGHTHS[eighths % 8] : '')
170 return [filled, '░'.repeat(Math.max(0, width - [...filled].length))]
171}
172
173const BRAILLE_ROWS = [
174 [0x01, 0x08],
175 [0x02, 0x10],
176 [0x04, 0x20],
177 [0x40, 0x80],
178]
179
180/** Braille dots for a comet sample `frac` of the way through a cell, moving (dx, dy). */
181function dotsAt(frac: number, dx: number, dy: number): number {
182 if (dy !== 0) {
183 const row = Math.min(3, Math.floor(frac * 4))
184 const r = BRAILLE_ROWS[dy > 0 ? row : 3 - row]!
185 return r[0]! | r[1]!
186 }
187 const col = frac < 0.5 ? 0 : 1
188 const c = dx > 0 ? col : 1 - col
189 return BRAILLE_ROWS[1]![c]! | BRAILLE_ROWS[2]![c]!
190}
191
192/** Where a packet's head is along its route (in cells, before the arrowhead) at `ms`. */
193export function packetAt(route: Route, ms: number, speed = 1, offset = 0): number {
194 const length = Math.max(1, route.cells.length - 1)
195 return ((ms / 1000) * CELLS_PER_SECOND * speed + offset) % length
196}
197
198function comet(put: Put, route: Route, head: number, color: number, inBox: Set<number>, columns: number) {
199 const cells = new Map<number, { bits: number; share: number }>()
200 for (let k = 0; k < COMET; k++) {
201 const d = head - k * 0.25
202 if (d < 0) break
203 const i = Math.floor(d)
204 const here = route.cells[i]
205 const next = route.cells[i + 1]
206 if (!here || !next) continue
207 const prev = cells.get(i)
208 cells.set(i, {
209 bits: (prev?.bits ?? 0) | dotsAt(d - i, next[0] - here[0], next[1] - here[1]),
210 share: Math.max(prev?.share ?? 0, 1 - (k / COMET) * 0.85),
211 })
212 }
213 for (const [i, { bits, share }] of cells) {
214 const [x, y] = route.cells[i]!
215 if (!inBox.has(y * columns + x)) put(x, y, BRAILLE | bits, blend(color, INK.shade, share))
216 }
217}
218
219const clock = (ms: number) => {
220 const s = Math.floor(ms / 1000)
221 return `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`
222}
223
224/**
225 * Paints the story at `ms`: still text, edges lit by activity, boxes and panels
226 * with their current state, comets on active edges, the log table and the
227 * footer. `animate: false` paints the finished picture with nothing moving.
228 */
229export function paint(spec: DiagramSpec, layout: Layout, ms: number, animate = true): Grid {
230 const { width: columns, height: rows } = layout
231 const still = stillOf(spec, layout)
232 const words = still.words.slice()
233 const { put, text } = writer(words, columns, rows)
234 const frame = frameAt(spec, ms, !animate)
235 const { nodeColor } = still
236 const packetOf = new Map((spec.packets ?? []).map(p => [p.edge, p]))
237 const edgeInk = (r: Route) => colorOf(packetOf.get(r.id)?.color, nodeColor.get(r.from) ?? INK.edge)
238
239 // Edges: the ones carrying traffic in their packet's color, the rest dim.
240 const busy = new Set<string>()
241 for (const id of frame.active) {
242 const r = layout.routes.get(id)
243 if (r) busy.add(r.from).add(r.to)
244 }
245 const restInk = frame.isStill || frame.active.size === 0 ? INK.edge : INK.dim
246 for (const c of still.edgeCells) {
247 const lit = c.routes.find(id => frame.active.has(id))
248 put(c.x, c.y, c.glyph, lit ? blend(edgeInk(layout.routes.get(lit)!), INK.shade, 0.75) : restInk)
249 }
250 for (const r of layout.routes.values()) {
251 const [x, y] = r.cells[r.cells.length - 1]!
252 if (!still.inBox.has(y * columns + x)) put(x, y, r.arrow, frame.active.has(r.id) ? edgeInk(r) : restInk)
253 }
254
255 for (const n of spec.nodes) {
256 const b = layout.boxes.get(n.id) ?? layout.panels.get(n.id)
257 if (!b) continue
258 const color = nodeColor.get(n.id)!
259 const state = frame.nodes.get(n.id)
260 const isLit = busy.has(n.id) || state?.isFresh
261 border(put, b, blend(color, INK.shade, isLit ? 0.95 : frame.isStill ? 0.6 : 0.4))
262 if (n.side) paintPanel(text, put, n, b, color, state, frame.isStill ? undefined : frame.ms)
263 else paintBox(spec, text, put, n, b, color, state, frame)
264 }
265
266 // Comets on the edges that carry traffic.
267 if (animate) {
268 const since = spec.steps?.length ? frame.ms - frame.stepStart : frame.ms
269 const launch = (edge: string, speed: number, nth: number, of: number) => {
270 const r = layout.routes.get(edge)
271 if (!r || r.cells.length < 2) return
272 comet(put, r, packetAt(r, since, speed, (nth * (r.cells.length - 1)) / of), edgeInk(r), still.inBox, columns)
273 }
274 if (spec.steps?.length) {
275 for (const edge of frame.active) launch(edge, packetOf.get(edge)?.speed ?? 1, 0, 1)
276 } else {
277 const onEdge = new Map<string, number>()
278 for (const p of spec.packets ?? []) onEdge.set(p.edge, (onEdge.get(p.edge) ?? 0) + 1)
279 const sent = new Map<string, number>()
280 for (const p of spec.packets ?? []) {
281 const nth = sent.get(p.edge) ?? 0
282 sent.set(p.edge, nth + 1)
283 launch(p.edge, p.speed ?? 1, nth, onEdge.get(p.edge) ?? 1)
284 }
285 }
286 }
287
288 // Log: the newest rows that fit; the row still typing carries a cursor.
289 const shown = frame.log.slice(-layout.logRows)
290 const plain = isPlainLog(spec)
291 shown.forEach((row, i) => {
292 const y = layout.logTop + i
293 const isNewest = i === shown.length - 1
294 const { entry } = row
295 const typed = entry.text.slice(0, row.typed)
296 const isTyping = row.typed < entry.text.length
297 put(0, y, '›', isNewest ? INK.title : INK.muted)
298 if (plain) {
299 text(2, y, typed, PALETTE.green!)
300 } else {
301 text(2, y, clock(row.at), INK.muted)
302 if (entry.actor) text(9, y, entry.actor.slice(0, 11), nodeColor.get(entry.actor) ?? INK.edgeLabel)
303 text(LOG_TEXT_AT, y, typed, isNewest ? INK.title : INK.log)
304 if (entry.status && !isTyping) text(columns - 1 - entry.status.length, y, entry.status, toneInk(entry.tone, INK.detail))
305 }
306 if (isTyping && !frame.isStill) put((plain ? 2 : LOG_TEXT_AT) + typed.length, y, '▌', INK.title)
307 })
308
309 // Footer: counters, and where the story is.
310 if (layout.footerRow !== undefined) {
311 let x = 0
312 for (const [name, value] of frame.counters) {
313 text(x, layout.footerRow, `${name}:`, INK.muted)
314 x += name.length + 2
315 const shownValue = `[${Math.round(value).toLocaleString('en-US')}]`
316 text(x, layout.footerRow, shownValue, INK.title)
317 x += shownValue.length + 3
318 }
319 if (spec.steps?.length && !frame.isStill) {
320 const where = `[step ${frame.step + 1}/${spec.steps.length}]`
321 text(columns - 1 - where.length, layout.footerRow, where, INK.muted)
322 }
323 }
324
325 return { columns, rows, words }
326}
327
328function paintBox(spec: DiagramSpec, text: Write, put: Put, n: DiagramNode, b: Placed, color: number, state: NodeState | undefined, frame: Frame) {
329 const r = rowsOf(spec, n)
330 const inner = b.w - 4
331 let y = b.y + 2 + r.detail
332
333 // Meters: label, a bar filled in eighths of a cell, the value, an optional note.
334 const labelW = Math.max(0, ...(n.meters ?? []).map(m => m.label.length))
335 const barAt = b.x + 2 + Math.max(0, Math.floor((inner - meterWidth(n)) / 2)) + labelW + 1
336 n.meters?.forEach((m, i) => {
337 const value = state?.meters[i] ?? m.value
338 const ink = toneInk(m.tone, color)
339 text(barAt - labelW - 1, y, m.label, INK.detail)
340 const [filled, rest] = bar(value, METER_BAR)
341 text(barAt, y, filled, ink)
342 text(barAt + [...filled].length, y, rest, blend(ink, INK.shade, 0.35))
343 text(barAt + METER_BAR + 1, y, value.toFixed(2), ink)
344 if (m.text) text(barAt + METER_BAR + 6, y, m.text, ink)
345 y++
346 })
347
348 // Spark: a wave that drifts left, brighter at its peaks.
349 if (n.spark) {
350 const t = frame.isStill ? 0 : frame.ms / 1000
351 for (let i = 0; i < inner; i++) {
352 const v = 0.5 + 0.3 * Math.sin(i * 0.55 - t * 5) + 0.2 * Math.sin(i * 0.23 + t * 2.3)
353 const level = Math.max(0, Math.min(7, Math.round(v * 7)))
354 put(b.x + 2 + i, y, WAVE[level]!, blend(color, INK.shade, 0.35 + 0.65 * (level / 7)))
355 }
356 y++
357 }
358
359 if (r.status && state?.status) {
360 const ink = toneInk(state.tone, color)
361 text(b.x + 2, y, statusText(state.status, state.tone, frame.isStill ? undefined : frame.ms).slice(0, inner), state.isFresh ? blend(ink, 0xffffff, 0.75) : ink)
362 }
363}
364
365function paintPanel(text: Write, put: Put, n: DiagramNode, b: Placed, color: number, state: NodeState | undefined, ms: number | undefined) {
366 const inner = b.w - 4
367 let y = b.y + 2 + (n.detail?.length ?? 0) + (n.items?.length ? 1 : 0)
368 n.items?.forEach((item, i) => {
369 if (i === state?.highlight) {
370 const shade = blend(color, INK.shade, 0.3)
371 for (let x = b.x + 1; x < b.x + b.w - 1; x++) put(x, y, ' ', INK.title, shade)
372 text(b.x + 2, y, `◆ ${item}`.slice(0, inner), INK.title, shade)
373 } else {
374 text(b.x + 2, y, `○ ${item}`.slice(0, inner), INK.muted)
375 }
376 y++
377 })
378 if (state?.status) text(b.x + 2, b.y + b.h - 2, statusText(state.status, state.tone, ms).slice(0, inner), toneInk(state.tone, color))
379}
380
381/** Standard padded base64 of the grid's little-endian u32s, as Raster `cells` wants. */
382export function encode(grid: Grid): string {
383 const bytes = new Uint8Array(grid.words.buffer, grid.words.byteOffset, grid.words.byteLength)
384 let bin = ''
385 for (let i = 0; i < bytes.length; i += 0x8000) bin += String.fromCharCode(...bytes.subarray(i, i + 0x8000))
386 return btoa(bin)
387}
388
389/** The finished picture as plain text, for a narrow terminal or a surface with no Raster. */
390export function toText(spec: DiagramSpec, layout: Layout): string {
391 const { columns, rows, words } = paint(spec, layout, 0, false)
392 const lines: string[] = []
393 for (let y = 0; y < rows; y++) {
394 let line = ''
395 for (let x = 0; x < columns; x++) line += String.fromCodePoint(words[(y * columns + x) * 3]!)
396 lines.push(line.trimEnd())
397 }
398 return lines.join('\n')
399}
400hooks/spec.ts 355 lines1/** How a status, meter or log row reads: ok green, warn amber, err red, run spins, dim grey, info the node's color. */
2export type Tone = 'ok' | 'warn' | 'err' | 'run' | 'dim' | 'info'
3const TONES: readonly Tone[] = ['ok', 'warn', 'err', 'run', 'dim', 'info']
4
5/** A labelled bar inside a box; `value` 0 to 1. */
6export type DiagramMeter = { label: string; value: number; text?: string; tone?: Tone }
7
8/**
9 * One box of the diagram. `side` makes it a tall panel beside the tree, whose
10 * `items` a step can highlight.
11 */
12export type DiagramNode = {
13 id: string
14 label: string
15 detail?: string[]
16 color?: string
17 status?: string
18 tone?: Tone
19 meters?: DiagramMeter[]
20 spark?: boolean
21 side?: 'left' | 'right'
22 items?: string[]
23}
24
25/** One arrow; dashed unless `dashed` is false. Its id is `id` or `from->to`. */
26export type DiagramEdge = {
27 id?: string
28 from: string
29 to: string
30 label?: string
31 dashed?: boolean
32}
33
34/** A dot looping along an edge; `speed` 0 to 1, where 1 is 15 cells a second. */
35export type DiagramPacket = {
36 edge: string
37 color?: string
38 speed?: number
39}
40
41/** A log row: plain text, or an actor (a node id colors it), the message and a toned status. */
42export type DiagramLogEntry = { actor?: string; text: string; status?: string; tone?: Tone }
43
44/** What changes on a node when a step starts; `highlight` picks a side panel item. */
45export type DiagramNodeUpdate = { status?: string; tone?: Tone; meters?: number[]; highlight?: number }
46
47/** One beat of the story: which edges carry traffic, what changes, what is logged. */
48export type DiagramStep = {
49 duration: number
50 active: string[]
51 nodes: Record<string, DiagramNodeUpdate>
52 log: DiagramLogEntry[]
53 counters: Record<string, number>
54}
55
56export type DiagramSpec = {
57 title: string
58 subtitle?: string
59 caption?: string
60 legend?: { label: string; color: string }[]
61 nodes: DiagramNode[]
62 edges?: DiagramEdge[]
63 packets?: DiagramPacket[]
64 log?: DiagramLogEntry[]
65 steps?: DiagramStep[]
66 counters?: Record<string, number>
67}
68
69export const LIMITS = { nodes: 24, label: 28, detail: 6, meters: 4, items: 8, log: 40, steps: 16, line: 120 }
70export const STEP_MS = { min: 600, default: 2500, max: 10000 }
71
72const TONE_SCHEMA = { type: 'string', enum: TONES, description: 'ok, warn, err, run (spinner), dim or info' }
73const LOG_SCHEMA = {
74 type: 'array',
75 items: {
76 anyOf: [
77 { type: 'string' },
78 {
79 type: 'object',
80 required: ['text'],
81 properties: {
82 actor: { type: 'string', description: 'A node id colors it' },
83 text: { type: 'string' },
84 status: { type: 'string', description: 'Right-aligned, e.g. "[ok]" or "exit 1"' },
85 tone: TONE_SCHEMA,
86 },
87 },
88 ],
89 },
90}
91
92export const INPUT_SCHEMA = {
93 type: 'object',
94 required: ['title', 'nodes'],
95 properties: {
96 title: { type: 'string' },
97 subtitle: { type: 'string' },
98 caption: { type: 'string', description: 'One line under the diagram: the takeaway' },
99 legend: {
100 type: 'array',
101 items: { type: 'object', required: ['label', 'color'], properties: { label: { type: 'string' }, color: { type: 'string' } } },
102 },
103 nodes: {
104 type: 'array',
105 items: {
106 type: 'object',
107 required: ['id', 'label'],
108 properties: {
109 id: { type: 'string' },
110 label: { type: 'string', description: 'Short, 1-3 words' },
111 detail: { type: 'array', items: { type: 'string' }, description: 'Up to 6 short lines' },
112 color: { type: 'string', description: 'blue, green, yellow, red, magenta, cyan, orange, gray or #rrggbb' },
113 status: { type: 'string' },
114 tone: TONE_SCHEMA,
115 meters: {
116 type: 'array',
117 description: 'Up to 4 bars: label, value 0-1, optional text after the value',
118 items: {
119 type: 'object',
120 required: ['label', 'value'],
121 properties: { label: { type: 'string' }, value: { type: 'number' }, text: { type: 'string' }, tone: TONE_SCHEMA },
122 },
123 },
124 spark: { type: 'boolean', description: 'Adds a moving activity wave row' },
125 side: { type: 'string', enum: ['left', 'right'], description: 'A tall panel beside the tree' },
126 items: { type: 'array', items: { type: 'string' }, description: 'Panel rows a step can highlight' },
127 },
128 },
129 },
130 edges: {
131 type: 'array',
132 items: {
133 type: 'object',
134 required: ['from', 'to'],
135 properties: {
136 id: { type: 'string', description: 'Defaults to "from->to"' },
137 from: { type: 'string' },
138 to: { type: 'string' },
139 label: { type: 'string' },
140 dashed: { type: 'boolean', description: 'Default true' },
141 },
142 },
143 },
144 packets: {
145 type: 'array',
146 description: 'Without steps: packets loop on these edges. With steps: sets the color and speed per edge',
147 items: {
148 type: 'object',
149 required: ['edge'],
150 properties: {
151 edge: { type: 'string', description: 'An edge id ("from->to" unless the edge set one)' },
152 color: { type: 'string' },
153 speed: { type: 'number', description: 'Relative speed, 0-1 (default 1 = 15 cells a second)' },
154 },
155 },
156 },
157 log: { ...LOG_SCHEMA, description: 'Without steps: rows typed out one by one under the diagram' },
158 steps: {
159 type: 'array',
160 description: 'A story played in a loop. Each step lights its active edges, applies node updates, adds log rows and moves counters',
161 items: {
162 type: 'object',
163 properties: {
164 duration: { type: 'number', description: 'Milliseconds, default 2500' },
165 active: { type: 'array', items: { type: 'string' }, description: 'Edge ids carrying traffic in this step' },
166 nodes: {
167 type: 'object',
168 description: 'Node id -> { status, tone, meters (values 0-1, in meter order), highlight (panel item index) }',
169 additionalProperties: {
170 type: 'object',
171 properties: {
172 status: { type: 'string' },
173 tone: TONE_SCHEMA,
174 meters: { type: 'array', items: { type: 'number' } },
175 highlight: { type: 'number' },
176 },
177 },
178 },
179 log: LOG_SCHEMA,
180 counters: { type: 'object', additionalProperties: { type: 'number' }, description: 'Counter name -> value to count up to' },
181 },
182 },
183 },
184 counters: { type: 'object', additionalProperties: { type: 'number' }, description: 'Footer counters and their start values' },
185 },
186} as const
187
188export const edgeIdOf = (edge: DiagramEdge): string => edge.id ?? `${edge.from}->${edge.to}`
189
190/** Keeps characters a Raster cell can hold (printable, one column wide). */
191export function clean(text: unknown, max = LIMITS.line): string {
192 const out = [...String(text ?? '')].map(ch => (isNarrow(ch.codePointAt(0) ?? 0) ? ch : '?'))
193 return out.slice(0, max).join('')
194}
195
196function isNarrow(cp: number): boolean {
197 if (cp < 0x20 || (cp >= 0x7f && cp < 0xa0) || cp > 0xffff) return false
198 if (cp >= 0x300 && cp < 0x370) return false
199 if (cp >= 0xd800 && cp < 0xe000) return false
200 const wide: [number, number][] = [
201 [0x1100, 0x115f], [0x231a, 0x231b], [0x2614, 0x2615], [0x26a1, 0x26a1],
202 [0x2e80, 0xa4cf], [0xac00, 0xd7a3], [0xf900, 0xfaff], [0xfe30, 0xfe4f],
203 [0xff00, 0xff60], [0xffe0, 0xffe6],
204 ]
205 return !wide.some(([lo, hi]) => cp >= lo && cp <= hi)
206}
207
208const toneOf = (v: unknown): Tone | undefined => (TONES.includes(v as Tone) ? (v as Tone) : undefined)
209const unit = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? Math.min(1, Math.max(0, v)) : 0)
210const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
211
212function logOf(raw: unknown): DiagramLogEntry[] {
213 return (Array.isArray(raw) ? raw : []).slice(0, LIMITS.log).map(entry =>
214 isRecord(entry)
215 ? {
216 actor: typeof entry.actor === 'string' ? clean(entry.actor, 14) : undefined,
217 text: clean(entry.text),
218 status: typeof entry.status === 'string' ? clean(entry.status, 24) : undefined,
219 tone: toneOf(entry.tone),
220 }
221 : { text: clean(entry) },
222 )
223}
224
225function countersOf(raw: unknown): Record<string, number> {
226 if (!isRecord(raw)) return {}
227 return Object.fromEntries(
228 Object.entries(raw)
229 .filter(([, v]) => typeof v === 'number' && Number.isFinite(v))
230 .slice(0, 6)
231 .map(([k, v]) => [clean(k, 16), v as number]),
232 )
233}
234
235/** Checks and trims the model's input; returns the spec or what is wrong. */
236export function parseSpec(input: unknown): { spec: DiagramSpec } | { error: string } {
237 const raw = { ...((input ?? {}) as Record<string, unknown>) }
238 // A caller with an older schema may send lists and objects as JSON text.
239 for (const key of ['nodes', 'edges', 'packets', 'log', 'steps', 'legend', 'counters']) {
240 if (typeof raw[key] !== 'string') continue
241 try {
242 raw[key] = JSON.parse(raw[key] as string)
243 } catch {
244 return { error: `${key} is not valid JSON` }
245 }
246 }
247 if (typeof raw.title !== 'string' || raw.title.trim() === '') return { error: 'title is required' }
248 if (!Array.isArray(raw.nodes) || raw.nodes.length === 0) return { error: 'nodes must be a non-empty array' }
249 if (raw.nodes.length > LIMITS.nodes) return { error: `at most ${LIMITS.nodes} nodes; split the idea into several diagrams` }
250
251 const nodes: DiagramNode[] = []
252 const ids = new Set<string>()
253 for (const n of raw.nodes as Record<string, unknown>[]) {
254 const id = String(n?.id ?? '')
255 if (id === '' || ids.has(id)) return { error: `node id "${id}" is empty or repeated` }
256 ids.add(id)
257 const meters = Array.isArray(n.meters)
258 ? (n.meters as Record<string, unknown>[]).slice(0, LIMITS.meters).map(m => ({
259 label: clean(m?.label, 10),
260 value: unit(m?.value),
261 text: typeof m?.text === 'string' ? clean(m.text, 8) : undefined,
262 tone: toneOf(m?.tone),
263 }))
264 : undefined
265 nodes.push({
266 id,
267 label: clean(n.label ?? id, LIMITS.label),
268 detail: Array.isArray(n.detail) ? n.detail.slice(0, LIMITS.detail).map(d => clean(d, LIMITS.label + 8)) : undefined,
269 color: typeof n.color === 'string' ? n.color : undefined,
270 status: typeof n.status === 'string' ? clean(n.status, LIMITS.label) : undefined,
271 tone: toneOf(n.tone),
272 meters,
273 spark: n.spark === true || undefined,
274 side: n.side === 'left' || n.side === 'right' ? n.side : undefined,
275 items: Array.isArray(n.items) ? n.items.slice(0, LIMITS.items).map(i => clean(i, LIMITS.label)) : undefined,
276 })
277 }
278
279 const edges: DiagramEdge[] = []
280 const edgeIds = new Set<string>()
281 for (const e of (Array.isArray(raw.edges) ? raw.edges : []) as Record<string, unknown>[]) {
282 const edge: DiagramEdge = {
283 id: typeof e?.id === 'string' ? e.id : undefined,
284 from: String(e?.from ?? ''),
285 to: String(e?.to ?? ''),
286 label: typeof e?.label === 'string' ? clean(e.label, 24) : undefined,
287 dashed: e?.dashed !== false,
288 }
289 if (!ids.has(edge.from) || !ids.has(edge.to)) return { error: `edge ${edge.from} -> ${edge.to} names an unknown node` }
290 if (edge.from === edge.to) return { error: `edge on ${edge.from} loops to itself` }
291 const sideOf = (id: string) => nodes.find(n => n.id === id)?.side
292 if (sideOf(edge.from) && sideOf(edge.to)) return { error: `edge ${edge.from} -> ${edge.to} joins two side panels; connect a panel to a box` }
293 if (edgeIds.has(edgeIdOf(edge))) return { error: `edge id "${edgeIdOf(edge)}" is repeated; give one an id` }
294 edgeIds.add(edgeIdOf(edge))
295 edges.push(edge)
296 }
297 const knownEdge = (id: string) => edgeIds.has(id) || `packet or step names unknown edge "${id}"; edge ids are ${[...edgeIds].join(', ')}`
298
299 const packets: DiagramPacket[] = []
300 for (const p of (Array.isArray(raw.packets) ? raw.packets : []) as Record<string, unknown>[]) {
301 const edge = String(p?.edge ?? '')
302 const known = knownEdge(edge)
303 if (known !== true) return { error: known }
304 const speed = typeof p.speed === 'number' && p.speed > 0 ? Math.min(p.speed, 1) : 1
305 packets.push({ edge, color: typeof p.color === 'string' ? p.color : undefined, speed })
306 }
307
308 let steps: DiagramStep[] | undefined
309 if (Array.isArray(raw.steps) && raw.steps.length > 0) {
310 steps = []
311 for (const s of (raw.steps as Record<string, unknown>[]).slice(0, LIMITS.steps)) {
312 const active = (Array.isArray(s?.active) ? s.active : []).map(String)
313 for (const id of active) {
314 const known = knownEdge(id)
315 if (known !== true) return { error: known }
316 }
317 const updates: Record<string, DiagramNodeUpdate> = {}
318 for (const [id, u] of Object.entries(isRecord(s?.nodes) ? s.nodes : {})) {
319 if (!ids.has(id)) return { error: `a step updates unknown node "${id}"` }
320 if (!isRecord(u)) continue
321 updates[id] = {
322 status: typeof u.status === 'string' ? clean(u.status, LIMITS.label) : undefined,
323 tone: toneOf(u.tone),
324 meters: Array.isArray(u.meters) ? u.meters.map(unit) : undefined,
325 highlight: typeof u.highlight === 'number' ? Math.floor(u.highlight) : undefined,
326 }
327 }
328 const duration = typeof s?.duration === 'number' ? Math.min(STEP_MS.max, Math.max(STEP_MS.min, s.duration)) : STEP_MS.default
329 steps.push({ duration, active, nodes: updates, log: logOf(s?.log), counters: countersOf(s?.counters) })
330 }
331 }
332
333 const legend = Array.isArray(raw.legend)
334 ? (raw.legend as Record<string, unknown>[])
335 .filter(l => typeof l?.label === 'string' && typeof l?.color === 'string')
336 .slice(0, 6)
337 .map(l => ({ label: clean(l.label, 24), color: String(l.color) }))
338 : undefined
339
340 return {
341 spec: {
342 title: clean(raw.title),
343 subtitle: typeof raw.subtitle === 'string' ? clean(raw.subtitle) : undefined,
344 caption: typeof raw.caption === 'string' ? clean(raw.caption) : undefined,
345 legend: legend?.length ? legend : undefined,
346 nodes,
347 edges,
348 packets,
349 log: raw.log === undefined ? undefined : logOf(raw.log),
350 steps,
351 counters: raw.counters === undefined ? undefined : countersOf(raw.counters),
352 },
353 }
354}
355hooks/timeline.ts 151 lines1import type { DiagramLogEntry, DiagramSpec, DiagramStep, Tone } from './spec'
2
3/** What a node shows at one moment; `isFresh` while a step's change to it is new. */
4export type NodeState = { status?: string; tone?: Tone; meters: number[]; highlight?: number; isFresh: boolean }
5
6/** A log row that has appeared, and how many characters of its text are typed so far. */
7export type LogRow = { at: number; entry: DiagramLogEntry; typed: number }
8
9export type Frame = {
10 /** Time into the current loop, and when the current step began. */
11 ms: number
12 stepStart: number
13 step: number
14 active: Set<string>
15 nodes: Map<string, NodeState>
16 log: LogRow[]
17 counters: [string, number][]
18 isStill: boolean
19}
20
21const LOG_GAP_MS = 450
22const LOG_PAUSE_MS = 250
23const TYPE_PER_MS = 0.06 // 60 characters a second
24const EASE_MS = 700
25const FRESH_MS = 900
26const END_HOLD_MS = 1500
27
28const smooth = (x: number) => {
29 const t = Math.min(1, Math.max(0, x))
30 return t * t * (3 - 2 * t)
31}
32const lerp = (a: number, b: number, t: number) => a + (b - a) * t
33
34/** When each of a step's log rows starts: after the row before it has typed, squeezed into the step. */
35function logOffsets(step: DiagramStep): number[] {
36 const offsets: number[] = []
37 let at = 0
38 for (const entry of step.log) {
39 offsets.push(at)
40 at += entry.text.length / TYPE_PER_MS + LOG_PAUSE_MS
41 }
42 const room = step.duration * 0.85
43 const last = offsets[offsets.length - 1] ?? 0
44 return last > room ? offsets.map(o => (o * room) / last) : offsets
45}
46
47/** How long one loop of the story lasts, the hold on its last frame included. */
48export function loopMs(spec: DiagramSpec): number {
49 return (spec.steps ?? []).reduce((sum, s) => sum + s.duration, 0) + END_HOLD_MS
50}
51
52function typedRow(entry: DiagramLogEntry, at: number, now: number, isStill: boolean): LogRow {
53 const typed = isStill ? entry.text.length : Math.min(entry.text.length, Math.floor((now - at) * TYPE_PER_MS))
54 return { at, entry, typed }
55}
56
57/**
58 * The story at `ms`. Without steps the picture is fixed and the log types out
59 * once; with steps it loops. `isStill` gives the finished picture, nothing moving.
60 */
61export function frameAt(spec: DiagramSpec, ms: number, isStill = false): Frame {
62 const base = new Map(
63 spec.nodes.map(n => [n.id, { status: n.status, tone: n.tone, meters: (n.meters ?? []).map(m => m.value), isFresh: false } as NodeState]),
64 )
65 const startCounters = Object.entries(spec.counters ?? {})
66
67 if (!spec.steps?.length) {
68 // The log types out row after row, then stays.
69 let at = 0
70 const log: LogRow[] = []
71 for (const entry of spec.log ?? []) {
72 if (!isStill && at > ms) break
73 log.push(typedRow(entry, at, ms, isStill))
74 at += entry.text.length / TYPE_PER_MS + LOG_GAP_MS
75 }
76 const active = new Set(isStill ? [] : (spec.packets ?? []).map(p => p.edge))
77 return { ms, stepStart: 0, step: -1, active, nodes: base, log, counters: startCounters, isStill }
78 }
79
80 const steps = spec.steps
81 const total = loopMs(spec)
82 const t = isStill ? total - 1 : ((ms % total) + total) % total
83
84 // Which step is running, and when each began.
85 const starts: number[] = []
86 let sum = 0
87 for (const s of steps) {
88 starts.push(sum)
89 sum += s.duration
90 }
91 let step = steps.length - 1
92 for (let i = 0; i < steps.length; i++) if (t < starts[i]! + steps[i]!.duration) {
93 step = i
94 break
95 }
96 const stepStart = starts[step]!
97 const into = t - stepStart
98
99 // Node state: every earlier step's updates applied, then this step's eased in.
100 const nodes = new Map([...base].map(([id, s]) => [id, { ...s, meters: [...s.meters] }]))
101 const before = new Map<string, number[]>()
102 for (let i = 0; i <= step; i++) {
103 for (const [id, u] of Object.entries(steps[i]!.nodes)) {
104 const n = nodes.get(id)
105 if (!n) continue
106 if (i === step) before.set(id, [...n.meters])
107 if (u.status !== undefined) n.status = u.status
108 if (u.tone !== undefined) n.tone = u.tone
109 u.meters?.forEach((v, k) => {
110 if (k < n.meters.length) n.meters[k] = v
111 })
112 }
113 }
114 const ease = isStill ? 1 : smooth(into / EASE_MS)
115 for (const [id, update] of Object.entries(steps[step]!.nodes)) {
116 const n = nodes.get(id)
117 if (!n) continue
118 const from = before.get(id) ?? n.meters
119 n.meters = n.meters.map((v, k) => lerp(from[k] ?? v, v, ease))
120 n.highlight = update.highlight
121 n.isFresh = !isStill && into < FRESH_MS
122 }
123
124 // Log: the preamble at the start, then each step's rows a beat apart.
125 const log: LogRow[] = []
126 ;(spec.log ?? []).forEach((entry, j) => {
127 const at = j * LOG_GAP_MS
128 if (isStill || at <= t) log.push(typedRow(entry, at, t, isStill))
129 })
130 for (let i = 0; i <= step; i++) {
131 logOffsets(steps[i]!).forEach((offset, j) => {
132 const at = starts[i]! + offset
133 if (isStill || at <= t) log.push(typedRow(steps[i]!.log[j]!, at, t, isStill))
134 })
135 }
136
137 // Counters count up across each step towards the value the step names.
138 const names = [...new Set([...startCounters.map(([k]) => k), ...steps.flatMap(s => Object.keys(s.counters))])]
139 const valueAfter = (name: string, last: number) => {
140 let v = spec.counters?.[name] ?? 0
141 for (let i = 0; i <= last; i++) v = steps[i]!.counters[name] ?? v
142 return v
143 }
144 const progress = isStill ? 1 : Math.min(1, into / steps[step]!.duration)
145 const counters = names.map(name => [name, lerp(valueAfter(name, step - 1), valueAfter(name, step), progress)] as [string, number])
146
147 const isHold = t >= sum
148 const active = new Set(isStill || isHold ? [] : steps[step]!.active)
149 return { ms: t, stepStart, step, active, nodes, log, counters, isStill }
150}
151