Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| architecture and such | 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. */ | |
| 20 | export type Tone = 'default' | 'accent' | 'store' | 'external' | 'success' | 'warn'; | |
| 21 | ||
| 22 | export type Side = 'top' | 'bottom' | 'left' | 'right'; | |
| 23 | ||
| 24 | export 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 | ||
| 38 | export 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 | ||
| 49 | export 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 | ||
| 69 | export 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 | ||
| 79 | export type SequenceActor = { id: string; title: string; sub?: string; tone?: Tone }; | |
| 80 | ||
| 81 | export 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 | ||
| 89 | export 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 | ||
| 98 | export type Spec = Graph | Sequence; | |
| 99 | ||
| 100 | // ── Text ─────────────────────────────────────────────────────────────────── | |
| 101 | ||
| 102 | const esc = (text: string) => text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); | |
| 103 | ||
| 104 | /** Text with `code` spans as tspans in the mono face. */ | |
| 105 | function 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. */ | |
| 113 | export 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. */ | |
| 122 | const plain = (text: string) => text.replace(/`/g, ''); | |
| 123 | ||
| 124 | // ── Graph ────────────────────────────────────────────────────────────────── | |
| 125 | ||
| 126 | const NODE_W = 150; | |
| 127 | const NODE_H = 52; | |
| 128 | ||
| 129 | type Box = { x: number; y: number; w: number; h: number }; | |
| 130 | ||
| 131 | function box(node: GraphNode): Box { | |
| 132 | return { x: node.x, y: node.y, w: node.w ?? NODE_W, h: node.h ?? NODE_H }; | |
| 133 | } | |
| 134 | ||
| 135 | function 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. */ | |
| 149 | function 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 | ||
| 155 | const vertical = (side: Side) => side === 'top' || side === 'bottom'; | |
| 156 | ||
| 157 | /** The corners an arrow passes through, at right angles. */ | |
| 158 | export 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. */ | |
| 179 | function 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. */ | |
| 199 | function 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 | ||
| 214 | function 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 | ||
| 223 | function 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 | ||
| 238 | function 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 | ||
| 261 | export 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 | ||
| 303 | const SEQ_HEAD = 50; | |
| 304 | const SEQ_ROW = 42; | |
| 305 | ||
| 306 | export 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 | ||
| 387 | function 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 | */ | |
| 401 | export 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. */ | |
| 440 | function describe(spec: Spec): string { | |
| 441 | return plain(toMarkdown(spec).replace(/\*\*/g, '').replace(/\*/g, '')) | |
| 442 | .split('\n') | |
| 443 | .filter(Boolean) | |
| 444 | .join(' '); | |
| 445 | } | |
| 446 | ||
| 447 | export function render(id: string, spec: Spec): string { | |
| 448 | return spec.kind === 'graph' ? renderGraph(id, spec) : renderSequence(id, spec); | |
| 449 | } |