| 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 | } |