Skip to content
449 linesCodeBlameRaw
1/**
2 * The docs' diagrams, drawn from data. A diagram is a spec in
3 * src/diagrams/*.ts; <Diagram name="..."> (components/Diagram.astro) draws
4 * it as an inline SVG coloured by the g1t tokens, so it follows the light
5 * and dark themes, and each page's Markdown twin ([...slug].md.ts) writes
6 * the same spec out as text. Change the spec, and the picture and the text
7 * change together.
8 *
9 * Two kinds:
10 *
11 * - a graph: boxes at fixed places, grouped in frames, joined by arrows
12 * routed at right angles between their sides;
13 * - a sequence: actors across the top, numbered messages down the page,
14 * which the prose refers to by number.
15 *
16 * Text in labels may hold `code` in backticks, drawn in the mono face.
17 */
18
19/** How a box or an arrow is coloured. */
20export type Tone = 'default' | 'accent' | 'store' | 'external' | 'success' | 'warn';
21
22export type Side = 'top' | 'bottom' | 'left' | 'right';
23
24export type GraphNode = {
25 id: string;
26 x: number;
27 y: number;
28 w?: number;
29 h?: number;
30 title: string;
31 /** A second line, smaller: usually a path in the repository. */
32 sub?: string;
33 tone?: Tone;
34 /** A database or a bucket, drawn as a cylinder. */
35 shape?: 'box' | 'store';
36};
37
38export type GraphGroup = {
39 /** Set to let arrows start or end on the frame itself. */
40 id?: string;
41 x: number;
42 y: number;
43 w: number;
44 h: number;
45 label: string;
46 tone?: Tone;
47};
48
49export type GraphEdge = {
50 from: string;
51 to: string;
52 label?: string;
53 /** Asynchronous: an event, a queue, a stream. Drawn dashed. */
54 async?: boolean;
55 tone?: Tone;
56 fromSide?: Side;
57 toSide?: Side;
58 /** Where along the side the arrow leaves or arrives, -0.5 to 0.5 of its length. */
59 fromAt?: number;
60 toAt?: number;
61 /** Where the bend sits, for an arrow that turns twice: an x or a y. */
62 bend?: number;
63 /** Arrowheads at both ends. */
64 both?: boolean;
65 /** Where the label's centre goes, when the middle of the longest leg is a bad place. */
66 labelAt?: [number, number];
67};
68
69export type Graph = {
70 kind: 'graph';
71 caption: string;
72 width: number;
73 height: number;
74 groups?: GraphGroup[];
75 nodes: GraphNode[];
76 edges: GraphEdge[];
77};
78
79export type SequenceActor = { id: string; title: string; sub?: string; tone?: Tone };
80
81export type SequenceStep =
82 /** A message from one actor to another, or to itself. */
83 | { from: string; to: string; label: string; reply?: boolean; async?: boolean }
84 /** A note across one or more actors. */
85 | { note: string; over: string[] }
86 /** A line across the whole diagram, naming a phase. */
87 | { phase: string };
88
89export type Sequence = {
90 kind: 'sequence';
91 caption: string;
92 actors: SequenceActor[];
93 steps: SequenceStep[];
94 /** Wider than the default 760 when there are many actors. */
95 width?: number;
96};
97
98export type Spec = Graph | Sequence;
99
100// ── Text ───────────────────────────────────────────────────────────────────
101
102const esc = (text: string) => text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
103
104/** Text with `code` spans as tspans in the mono face. */
105function rich(text: string): string {
106 return text
107 .split('`')
108 .map((part, i) => (i % 2 ? `<tspan class="d-mono">${esc(part)}</tspan>` : esc(part)))
109 .join('');
110}
111
112/** About how wide a label is, in user units, at the given size: wide enough for a backing pill. */
113export function textWidth(text: string, size: number): number {
114 let width = 0;
115 text.split('`').forEach((part, i) => {
116 width += part.length * size * (i % 2 ? 0.6 : 0.54);
117 });
118 return width;
119}
120
121/** Text without its backticks, for the Markdown twin and the accessible description. */
122const plain = (text: string) => text.replace(/`/g, '');
123
124// ── Graph ──────────────────────────────────────────────────────────────────
125
126const NODE_W = 150;
127const NODE_H = 52;
128
129type Box = { x: number; y: number; w: number; h: number };
130
131function box(node: GraphNode): Box {
132 return { x: node.x, y: node.y, w: node.w ?? NODE_W, h: node.h ?? NODE_H };
133}
134
135function anchor(b: Box, side: Side, at = 0): [number, number] {
136 switch (side) {
137 case 'top':
138 return [b.x + b.w / 2 + at * b.w, b.y];
139 case 'bottom':
140 return [b.x + b.w / 2 + at * b.w, b.y + b.h];
141 case 'left':
142 return [b.x, b.y + b.h / 2 + at * b.h];
143 case 'right':
144 return [b.x + b.w, b.y + b.h / 2 + at * b.h];
145 }
146}
147
148/** The sides an arrow leaves and enters by, when the spec does not say. */
149function sides(a: Box, b: Box): [Side, Side] {
150 if (b.y >= a.y + a.h) return ['bottom', 'top'];
151 if (b.y + b.h <= a.y) return ['top', 'bottom'];
152 return b.x >= a.x ? ['right', 'left'] : ['left', 'right'];
153}
154
155const vertical = (side: Side) => side === 'top' || side === 'bottom';
156
157/** The corners an arrow passes through, at right angles. */
158export function route(edge: GraphEdge, a: Box, b: Box): [number, number][] {
159 const [auto1, auto2] = sides(a, b);
160 const s1 = edge.fromSide ?? auto1;
161 const s2 = edge.toSide ?? auto2;
162 const [x1, y1] = anchor(a, s1, edge.fromAt);
163 const [x2, y2] = anchor(b, s2, edge.toAt);
164 if (vertical(s1) && vertical(s2)) {
165 if (Math.abs(x1 - x2) < 0.5) return [[x1, y1], [x2, y2]];
166 const ym = edge.bend ?? (y1 + y2) / 2;
167 return [[x1, y1], [x1, ym], [x2, ym], [x2, y2]];
168 }
169 if (!vertical(s1) && !vertical(s2)) {
170 if (Math.abs(y1 - y2) < 0.5) return [[x1, y1], [x2, y2]];
171 const xm = edge.bend ?? (x1 + x2) / 2;
172 return [[x1, y1], [xm, y1], [xm, y2], [x2, y2]];
173 }
174 // One of each: a single turn.
175 return vertical(s1) ? [[x1, y1], [x1, y2], [x2, y2]] : [[x1, y1], [x2, y1], [x2, y2]];
176}
177
178/** A path through the points with its corners rounded. */
179function rounded(points: [number, number][], radius = 7): string {
180 let d = `M${points[0][0]},${points[0][1]}`;
181 for (let i = 1; i < points.length - 1; i++) {
182 const [px, py] = points[i - 1];
183 const [cx, cy] = points[i];
184 const [nx, ny] = points[i + 1];
185 const inLen = Math.hypot(cx - px, cy - py);
186 const outLen = Math.hypot(nx - cx, ny - cy);
187 const r = Math.min(radius, inLen / 2, outLen / 2);
188 const ax = cx - ((cx - px) / inLen) * r;
189 const ay = cy - ((cy - py) / inLen) * r;
190 const bx = cx + ((nx - cx) / outLen) * r;
191 const by = cy + ((ny - cy) / outLen) * r;
192 d += ` L${ax},${ay} Q${cx},${cy} ${bx},${by}`;
193 }
194 const [lx, ly] = points[points.length - 1];
195 return `${d} L${lx},${ly}`;
196}
197
198/** The middle of the arrow's longest leg. */
199function labelPoint(points: [number, number][]): [number, number] {
200 let best = 0;
201 let at: [number, number] = points[0];
202 for (let i = 1; i < points.length; i++) {
203 const [ax, ay] = points[i - 1];
204 const [bx, by] = points[i];
205 const len = Math.hypot(bx - ax, by - ay);
206 if (len > best) {
207 best = len;
208 at = [(ax + bx) / 2, (ay + by) / 2];
209 }
210 }
211 return at;
212}
213
214function pill(x: number, y: number, text: string, size: number, cls: string): string {
215 const w = textWidth(text, size) + 12;
216 const h = size + 8;
217 return (
218 `<rect class="${cls}-bg" x="${x - w / 2}" y="${y - h / 2}" width="${w}" height="${h}" rx="${h / 2}"/>` +
219 `<text class="${cls}" x="${x}" y="${y}" font-size="${size}" text-anchor="middle" dominant-baseline="central">${rich(text)}</text>`
220 );
221}
222
223function markers(id: string): string {
224 const tones: Tone[] = ['default', 'accent', 'success', 'warn'];
225 return (
226 '<defs>' +
227 tones
228 .map(
229 (tone) =>
230 `<marker id="${id}-arrow-${tone}" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">` +
231 `<path d="M1,1 L9,5 L1,9 z" class="d-head d-tone-${tone}"/></marker>`,
232 )
233 .join('') +
234 '</defs>'
235 );
236}
237
238function renderNode(node: GraphNode): string {
239 const b = box(node);
240 const tone = node.tone ?? (node.shape === 'store' ? 'store' : 'default');
241 let shape: string;
242 if (node.shape === 'store') {
243 // A cylinder: the body, then the rim of its top.
244 const ry = 6;
245 shape =
246 `<path class="d-node d-tone-${tone}" d="M${b.x},${b.y + ry} A${b.w / 2},${ry} 0 0 1 ${b.x + b.w},${b.y + ry} V${b.y + b.h - ry} A${b.w / 2},${ry} 0 0 1 ${b.x},${b.y + b.h - ry} Z"/>` +
247 `<path class="d-rim d-tone-${tone}" d="M${b.x},${b.y + ry} A${b.w / 2},${ry} 0 0 0 ${b.x + b.w},${b.y + ry}"/>`;
248 } else {
249 shape = `<rect class="d-node d-tone-${tone}" x="${b.x}" y="${b.y}" width="${b.w}" height="${b.h}" rx="9"/>`;
250 }
251 const cx = b.x + b.w / 2;
252 const top = node.shape === 'store' ? 6 : 0;
253 const cy = b.y + top / 2 + b.h / 2;
254 const text = node.sub
255 ? `<text class="d-title" x="${cx}" y="${cy - 8}" text-anchor="middle" dominant-baseline="central">${rich(node.title)}</text>` +
256 `<text class="d-sub" x="${cx}" y="${cy + 9}" text-anchor="middle" dominant-baseline="central">${rich(node.sub)}</text>`
257 : `<text class="d-title" x="${cx}" y="${cy}" text-anchor="middle" dominant-baseline="central">${rich(node.title)}</text>`;
258 return `<g class="d-box">${shape}${text}</g>`;
259}
260
261export function renderGraph(id: string, graph: Graph): string {
262 const boxes = new Map<string, Box>(graph.nodes.map((node) => [node.id, box(node)]));
263 for (const g of graph.groups ?? []) if (g.id) boxes.set(g.id, g);
264 const groups = (graph.groups ?? [])
265 .map(
266 (g) =>
267 `<rect class="d-group d-tone-${g.tone ?? 'default'}" x="${g.x}" y="${g.y}" width="${g.w}" height="${g.h}" rx="14"/>` +
268 `<text class="d-group-label d-tone-${g.tone ?? 'default'}" x="${g.x + 14}" y="${g.y + 18}" dominant-baseline="central">${rich(g.label)}</text>`,
269 )
270 .join('');
271 const edges: string[] = [];
272 const labels: string[] = [];
273 for (const edge of graph.edges) {
274 const from = boxes.get(edge.from);
275 const to = boxes.get(edge.to);
276 if (!from || !to) throw new Error(`Diagram ${id}: an arrow joins ${edge.from} to ${edge.to}, and one of them is not a box.`);
277 const points = route(edge, from, to);
278 const tone = edge.tone ?? 'default';
279 const marker = `url(#${id}-arrow-${tone})`;
280 edges.push(
281 `<path class="d-edge d-tone-${tone}${edge.async ? ' d-async' : ''}" d="${rounded(points)}" marker-end="${marker}"${edge.both ? ` marker-start="${marker}"` : ''}/>`,
282 );
283 if (edge.label) {
284 const [lx, ly] = edge.labelAt ?? labelPoint(points);
285 labels.push(pill(lx, ly, edge.label, 10.5, 'd-label'));
286 }
287 }
288 return (
289 `<svg viewBox="0 0 ${graph.width} ${graph.height}" width="${graph.width}" height="${graph.height}" role="img" aria-labelledby="${id}-title ${id}-desc" class="d-svg">` +
290 `<title id="${id}-title">${esc(graph.caption)}</title>` +
291 `<desc id="${id}-desc">${esc(describe(graph))}</desc>` +
292 markers(id) +
293 groups +
294 edges.join('') +
295 graph.nodes.map(renderNode).join('') +
296 labels.join('') +
297 '</svg>'
298 );
299}
300
301// ── Sequence ───────────────────────────────────────────────────────────────
302
303const SEQ_HEAD = 50;
304const SEQ_ROW = 42;
305
306export function renderSequence(id: string, seq: Sequence): string {
307 const width = seq.width ?? 760;
308 const margin = 16;
309 const col = (width - margin * 2) / seq.actors.length;
310 const xs = new Map(seq.actors.map((actor, i) => [actor.id, margin + col * (i + 0.5)]));
311 const at = (actor: string) => {
312 const x = xs.get(actor);
313 if (x === undefined) throw new Error(`Diagram ${id}: no actor named ${actor}.`);
314 return x;
315 };
316 const headW = Math.min(col - 14, 168);
317
318 const body: string[] = [];
319 let y = SEQ_HEAD + 26;
320 let n = 0;
321 for (const step of seq.steps) {
322 if ('phase' in step) {
323 y += 4;
324 body.push(`<line class="d-phase" x1="${margin}" y1="${y}" x2="${width - margin}" y2="${y}"/>`);
325 body.push(pill(width / 2, y, step.phase, 10.5, 'd-phase-label'));
326 y += 30;
327 continue;
328 }
329 if ('note' in step) {
330 const sx = step.over.map(at);
331 const left = Math.max(margin, Math.min(...sx) - Math.min(col / 2 - 8, 92));
332 const right = Math.min(width - margin, Math.max(...sx) + Math.min(col / 2 - 8, 92));
333 body.push(`<rect class="d-note" x="${left}" y="${y - 12}" width="${right - left}" height="26" rx="6"/>`);
334 body.push(`<text class="d-note-text" x="${(left + right) / 2}" y="${y + 1}" text-anchor="middle" dominant-baseline="central">${rich(step.note)}</text>`);
335 y += 38;
336 continue;
337 }
338 n++;
339 const x1 = at(step.from);
340 const x2 = at(step.to);
341 const cls = `d-msg${step.reply || step.async ? ' d-async' : ''}${step.reply ? ' d-reply' : ''}`;
342 const marker = `url(#${id}-arrow-${step.reply ? 'default' : 'accent'})`;
343 if (x1 === x2) {
344 // To itself: a loop out to the right and back.
345 const loop = 30;
346 body.push(`<path class="${cls}" d="M${x1},${y} H${x1 + loop} V${y + 16} H${x1 + 3}" marker-end="${marker}"/>`);
347 body.push(badge(x1 - 14, y + 8, n));
348 body.push(`<text class="d-msg-label" x="${x1 + loop + 8}" y="${y + 8}" dominant-baseline="central">${rich(step.label)}</text>`);
349 y += SEQ_ROW + 10;
350 continue;
351 }
352 const dir = x2 > x1 ? 1 : -1;
353 body.push(`<line class="${cls}" x1="${x1}" y1="${y}" x2="${x2 - dir * 3}" y2="${y}" marker-end="${marker}"/>`);
354 body.push(`<text class="d-msg-label" x="${(x1 + x2) / 2}" y="${y - 9}" text-anchor="middle">${rich(step.label)}</text>`);
355 body.push(badge(x1 - dir * 13, y, n));
356 y += SEQ_ROW;
357 }
358 const height = y + 4;
359
360 const heads = seq.actors
361 .map((actor) => {
362 const x = at(actor.id);
363 const tone = actor.tone ?? 'default';
364 const title = actor.sub
365 ? `<text class="d-title" x="${x}" y="${SEQ_HEAD / 2 - 8}" text-anchor="middle" dominant-baseline="central">${rich(actor.title)}</text>` +
366 `<text class="d-sub" x="${x}" y="${SEQ_HEAD / 2 + 9}" text-anchor="middle" dominant-baseline="central">${rich(actor.sub)}</text>`
367 : `<text class="d-title" x="${x}" y="${SEQ_HEAD / 2}" text-anchor="middle" dominant-baseline="central">${rich(actor.title)}</text>`;
368 return (
369 `<line class="d-lifeline" x1="${x}" y1="${SEQ_HEAD}" x2="${x}" y2="${height}"/>` +
370 `<rect class="d-node d-tone-${tone}" x="${x - headW / 2}" y="1" width="${headW}" height="${SEQ_HEAD - 2}" rx="9"/>` +
371 title
372 );
373 })
374 .join('');
375
376 return (
377 `<svg viewBox="0 0 ${width} ${height}" width="${width}" height="${height}" role="img" aria-labelledby="${id}-title ${id}-desc" class="d-svg">` +
378 `<title id="${id}-title">${esc(seq.caption)}</title>` +
379 `<desc id="${id}-desc">${esc(describe(seq))}</desc>` +
380 markers(id) +
381 heads +
382 body.join('') +
383 '</svg>'
384 );
385}
386
387function badge(x: number, y: number, n: number): string {
388 return (
389 `<circle class="d-badge" cx="${x}" cy="${y}" r="8"/>` +
390 `<text class="d-badge-text" x="${x}" y="${y}" text-anchor="middle" dominant-baseline="central">${n}</text>`
391 );
392}
393
394// ── As text ────────────────────────────────────────────────────────────────
395
396/**
397 * The diagram as Markdown: for the page's Markdown twin, which agents and
398 * "Copy page" read. A graph lists its boxes by frame, then its arrows; a
399 * sequence lists its messages with the numbers the picture shows.
400 */
401export function toMarkdown(spec: Spec): string {
402 const lines = [`**Diagram: ${spec.caption}**`, ''];
403 if (spec.kind === 'sequence') {
404 const name = new Map(spec.actors.map((a) => [a.id, plain(a.title)]));
405 let n = 0;
406 for (const step of spec.steps) {
407 if ('phase' in step) lines.push('', `*${plain(step.phase)}*`, '');
408 else if ('note' in step) lines.push(` - Note (${step.over.map((id) => name.get(id)).join(', ')}): ${step.note}`);
409 else {
410 n++;
411 const arrow = step.reply ? '⇠' : step.async ? '⇢' : '→';
412 lines.push(`${n}. ${name.get(step.from)} ${arrow} ${name.get(step.to)}: ${step.label}`);
413 }
414 }
415 return lines.join('\n');
416 }
417 const inGroup = (node: GraphNode, g: GraphGroup) =>
418 node.x >= g.x && node.y >= g.y && node.x + (node.w ?? NODE_W) <= g.x + g.w && node.y + (node.h ?? NODE_H) <= g.y + g.h;
419 const named = (node: GraphNode) => `${node.title}${node.sub ? ` (${node.sub})` : ''}`;
420 const placed = new Set<string>();
421 for (const g of spec.groups ?? []) {
422 const members = spec.nodes.filter((node) => !placed.has(node.id) && inGroup(node, g));
423 if (!members.length) continue;
424 members.forEach((node) => placed.add(node.id));
425 lines.push(`- ${g.label}: ${members.map(named).join('; ')}`);
426 }
427 const loose = spec.nodes.filter((node) => !placed.has(node.id));
428 if (loose.length) lines.push(`- ${loose.map(named).join('; ')}`);
429 lines.push('', 'Arrows:', '');
430 const title = new Map(spec.nodes.map((node) => [node.id, plain(node.title)]));
431 for (const g of spec.groups ?? []) if (g.id) title.set(g.id, plain(g.label));
432 for (const edge of spec.edges) {
433 const arrow = edge.both ? '↔' : edge.async ? '⇢' : '→';
434 lines.push(`- ${title.get(edge.from)} ${arrow} ${title.get(edge.to)}${edge.label ? `: ${edge.label}` : ''}${edge.async ? ' (asynchronous)' : ''}`);
435 }
436 return lines.join('\n');
437}
438
439/** One paragraph for the SVG's <desc>: the text twin without its Markdown. */
440function describe(spec: Spec): string {
441 return plain(toMarkdown(spec).replace(/\*\*/g, '').replace(/\*/g, ''))
442 .split('\n')
443 .filter(Boolean)
444 .join(' ');
445}
446
447export function render(id: string, spec: Spec): string {
448 return spec.kind === 'graph' ? renderGraph(id, spec) : renderSequence(id, spec);
449}