SLOPSHOPPER

diagram-mod

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

newpanerowsguardtooltimer
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · diagram-mod
│ ┃ diagram ✕ › fix the failing auth test and add an audit log call │ ┃ No diagram to show. Ask Claude for one. │ ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · diagram
No diagram to show. Ask Claude for one.
README

my-claude-code-mods

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.

ModWhat it does
slick-barReplaces the hint line under the prompt with a one-line bar: model, effort, folder, branch, context gauge, prompt-cache health, rate-limit gauges
image-peekShows a preview of a pasted image above the prompt and under the message that sent it
tool-cardsDraws each tool call as a boxed card: highlighted command, output preview, timing footer
cache-warmKeeps 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-modGives Claude a show_diagram tool: animated diagrams that play a story step by step, with packets, gauges and a live log

Install

  1. Clone the repo:
   git clone https://github.com/mustafa89/my-claude-code-mods.git ~/.claude/mods
  1. Tell Claude Code to load the mods. Add this to the 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.

  1. Start a new Claude Code session. Each mod prints <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.

slick-bar

slick-bar

One line under the prompt, after Claude Code's own permission-mode label:

  • Model and effort: ✻ Opus 5.5 1M · medium. The ✻ turns into ◉ while a turn runs.
  • Folder and branch: repository name (plus subfolder), branch, ± when the tree has changes, ↑/↓ for commits ahead of or behind the upstream.
  • Context gauge: how full the context window is, green → amber → red.
  • Rate-limit gauges (subscription plans): 5-hour and 7-day usage, the time until each window resets (↻ 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%.
  • Prompt cache (with cache-warm): 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-peek

image-peek

  • While you write a prompt, every pasted image shows above the prompt with its size (Image #3 · 1246×846).
  • After you send it, the image shows under your message in the transcript.

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.

tool-cards

tool-cards

Every tool call becomes a card:

  • Header: tool name and status: ✓ done, ◌ running, ✗ error, ⊘ interrupted. The command's description follows.
  • Bash: the command with syntax colours, then the first 5 lines of output (errors in red).
  • Edit and Write: a split diff, old on the left and new on the right, with line numbers, red and green rows, and the changed words highlighted. A +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.
  • MCP tools: the arguments, then the output folded behind ▾ output · N lines. JSON is indented; errors show in red.
  • Other tools: one line saying what the call acted on, for example src/cart/total.js:10-30 for a Read.
  • Footer: how long the call took, how many words it printed, and the Bash timeout if one was set.
  • Diagrams from diagram-mod: a rounded card headed ◇ 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.

Expanding output

  • Click ▾ 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.

cache-warm

cache-warm

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.

How it works

  • Timer: a 15-second timer runs inside the Claude Code session. There is no cron job; the timer stops when the session closes or the computer sleeps.
  • Ping: when the chat is idle and the cache is 2 minutes from expiry, the mod calls $.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.
  • Hits and misses: after each turn, the mod reads the token counts of the turn's first request. If less than half of the input came from the cache, the turn counts as a miss.

Why it is worth it

Prompt caching prices, as a multiple of the normal input price:

RequestPrice
Cache write, 1-hour cache2×
Cache write, 5-minute cache1.25×
Cache read0.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.

  • The cache expires: your next prompt writes 400k tokens at 2×, the cost of 800k input tokens.
  • The cache is warm: your next prompt reads 400k tokens at 0.05×, the cost of 20k input tokens.
  • One ping: the same read, 20k.

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.

Limits on the pings

  • Cap: at most maxPings pings (default 4) per idle stretch. A prompt you type resets the count.
  • Rate limit: no pings while the 5-hour usage window is 80% full or more.
  • Expired cache: no ping after the cache expired, for example after the computer slept. A ping then would pay a full cache write.
  • Missed ping: when a ping does not read from the cache, the mod stops pinging until your next turn.
  • Failed ping: an API error does not count as a refresh. The next tick tries again.

Settings and command

SettingValuesDefault
ttl1h, 5m1h
maxPingsa number4

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.

week-calendar

week-calendar

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.

What the calendar shows

  • Sessions as blocks, one colour per project. A session splits into blocks at gaps longer than 30 minutes.
  • Commits you made during each block, matched by your git config user.email.
  • Sessions that ended without a commit: a dashed outline and an amber dot. The sidebar lists them, longest first.
  • Active time: hours per project and per day. Sessions that run in parallel count once.
  • Details: click a block to see its time, model, commits, and the first message you typed.

How it works

  • 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 hook sends the week's commit subjects to Sonnet once to write the "Shipped" line.
  • The HTML file holds all its data inline and loads nothing from the network.

The calendar file holds the first message of each session as plain text. It stays on your machine.

Settings

~/.calendar/config.json is created on the first run:

SettingDefaultWhat it does
dayStartHour6Work before this hour counts toward the previous day
gapMinutes30A gap longer than this starts a new block
minNoCommitMinutes15Shorter blocks are not flagged as ending without a commit
weekStartmonmon or sun
themedarkdark or light
accent, paletteColours 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.

diagram-mod

diagram-mod

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:

  • Boxes: a label, detail lines and a status, with dashed borders in the colour of their role. A status has a tone: a spinner while running, ✓ ok, ✗ error, ! warning.
  • Edges: dashed arrows with optional labels. The layout runs top to bottom and is computed from the edges; Claude never gives coordinates. Edges that point back up, or skip a row, run along the right margin.
  • Packets: braille "comets" with a fading tail, moving a quarter cell at a time.
  • Gauges: bars (meters) that fill in eighths of a cell, and a moving activity wave (spark).
  • Side panels: a node with side: "left" or "right" becomes a tall panel beside the tree, with rows a step can highlight.
  • Frame: a legend under the title, a caption under the diagram, a log table (time, actor, message, status) and a footer of counters.

Stories

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.

How it animates

  • About 60 frames a second, repainting one cell grid in place; frames that would not change are skipped.
  • Only the newest diagram moves; older ones keep a still frame.
  • The animation waits while Claude is still writing its reply, so the streaming text stays fast, and pauses while the row is scrolled out of view or folded.
  • A terminal narrower than the diagram gets a still text version instead.

Settings

SettingDefaultWhat it does
displayinlineinline: 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)

Development

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.

Limits

These come from the mods API, not from the mods:

  • Permission mode: mods cannot read it, so the bar sits after Claude Code's own mode label.
  • Space above the prompt: the empty row between the transcript and the prompt belongs to Claude Code.
  • Ctrl+o: tool rows do not report the expanded view (see Expanding output).
  • Cache lifetime: mods cannot read which cache lifetime the session uses, so cache-warm takes it from the ttl setting.
  • Tool names: Claude Code lists a tool a mod registers as mcp__<mod>__<tool>, so diagram-mod's tool is mcp__diagram-mod__show_diagram though no MCP server is involved.

License

MIT

Source 5 files
hooks/register.tsx 239 lines
1import 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}
239
hooks/layout.ts 349 lines
1import { 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}
349
hooks/render.ts 400 lines
1import { 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}
400
hooks/spec.ts 355 lines
1/** 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}
355
hooks/timeline.ts 151 lines
1import 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