| 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) |
| 166 | return [ |
| 167 | [x1, y1], |
| 168 | [x2, y2], |
| 169 | ]; |
| 170 | const ym = edge.bend ?? (y1 + y2) / 2; |
| 171 | return [ |
| 172 | [x1, y1], |
| 173 | [x1, ym], |
| 174 | [x2, ym], |
| 175 | [x2, y2], |
| 176 | ]; |
| 177 | } |
| 178 | if (!vertical(s1) && !vertical(s2)) { |
| 179 | if (Math.abs(y1 - y2) < 0.5) |
| 180 | return [ |
| 181 | [x1, y1], |
| 182 | [x2, y2], |
| 183 | ]; |
| 184 | const xm = edge.bend ?? (x1 + x2) / 2; |
| 185 | return [ |
| 186 | [x1, y1], |
| 187 | [xm, y1], |
| 188 | [xm, y2], |
| 189 | [x2, y2], |
| 190 | ]; |
| 191 | } |
| 192 | // One of each: a single turn. |
| 193 | return vertical(s1) |
| 194 | ? [ |
| 195 | [x1, y1], |
| 196 | [x1, y2], |
| 197 | [x2, y2], |
| 198 | ] |
| 199 | : [ |
| 200 | [x1, y1], |
| 201 | [x2, y1], |
| 202 | [x2, y2], |
| 203 | ]; |
| 204 | } |
| 205 | |
| 206 | /** A path through the points with its corners rounded. */ |
| 207 | function rounded(points: [number, number][], radius = 7): string { |
| 208 | let d = `M${points[0][0]},${points[0][1]}`; |
| 209 | for (let i = 1; i < points.length - 1; i++) { |
| 210 | const [px, py] = points[i - 1]; |
| 211 | const [cx, cy] = points[i]; |
| 212 | const [nx, ny] = points[i + 1]; |
| 213 | const inLen = Math.hypot(cx - px, cy - py); |
| 214 | const outLen = Math.hypot(nx - cx, ny - cy); |
| 215 | const r = Math.min(radius, inLen / 2, outLen / 2); |
| 216 | const ax = cx - ((cx - px) / inLen) * r; |
| 217 | const ay = cy - ((cy - py) / inLen) * r; |
| 218 | const bx = cx + ((nx - cx) / outLen) * r; |
| 219 | const by = cy + ((ny - cy) / outLen) * r; |
| 220 | d += ` L${ax},${ay} Q${cx},${cy} ${bx},${by}`; |
| 221 | } |
| 222 | const [lx, ly] = points[points.length - 1]; |
| 223 | return `${d} L${lx},${ly}`; |
| 224 | } |
| 225 | |
| 226 | /** The middle of the arrow's longest leg. */ |
| 227 | function labelPoint(points: [number, number][]): [number, number] { |
| 228 | let best = 0; |
| 229 | let at: [number, number] = points[0]; |
| 230 | for (let i = 1; i < points.length; i++) { |
| 231 | const [ax, ay] = points[i - 1]; |
| 232 | const [bx, by] = points[i]; |
| 233 | const len = Math.hypot(bx - ax, by - ay); |
| 234 | if (len > best) { |
| 235 | best = len; |
| 236 | at = [(ax + bx) / 2, (ay + by) / 2]; |
| 237 | } |
| 238 | } |
| 239 | return at; |
| 240 | } |
| 241 | |
| 242 | function pill(x: number, y: number, text: string, size: number, cls: string): string { |
| 243 | const w = textWidth(text, size) + 12; |
| 244 | const h = size + 8; |
| 245 | return ( |
| 246 | `<rect class="${cls}-bg" x="${x - w / 2}" y="${y - h / 2}" width="${w}" height="${h}" rx="${h / 2}"/>` + |
| 247 | `<text class="${cls}" x="${x}" y="${y}" font-size="${size}" text-anchor="middle" dominant-baseline="central">${rich(text)}</text>` |
| 248 | ); |
| 249 | } |
| 250 | |
| 251 | function markers(id: string): string { |
| 252 | const tones: Tone[] = ["default", "accent", "success", "warn"]; |
| 253 | return ( |
| 254 | "<defs>" + |
| 255 | tones |
| 256 | .map( |
| 257 | (tone) => |
| 258 | `<marker id="${id}-arrow-${tone}" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">` + |
| 259 | `<path d="M1,1 L9,5 L1,9 z" class="d-head d-tone-${tone}"/></marker>`, |
| 260 | ) |
| 261 | .join("") + |
| 262 | "</defs>" |
| 263 | ); |
| 264 | } |
| 265 | |
| 266 | function renderNode(node: GraphNode): string { |
| 267 | const b = box(node); |
| 268 | const tone = node.tone ?? (node.shape === "store" ? "store" : "default"); |
| 269 | let shape: string; |
| 270 | if (node.shape === "store") { |
| 271 | // A cylinder: the body, then the rim of its top. |
| 272 | const ry = 6; |
| 273 | shape = |
| 274 | `<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"/>` + |
| 275 | `<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}"/>`; |
| 276 | } else { |
| 277 | shape = `<rect class="d-node d-tone-${tone}" x="${b.x}" y="${b.y}" width="${b.w}" height="${b.h}" rx="9"/>`; |
| 278 | } |
| 279 | const cx = b.x + b.w / 2; |
| 280 | const top = node.shape === "store" ? 6 : 0; |
| 281 | const cy = b.y + top / 2 + b.h / 2; |
| 282 | const text = node.sub |
| 283 | ? `<text class="d-title" x="${cx}" y="${cy - 8}" text-anchor="middle" dominant-baseline="central">${rich(node.title)}</text>` + |
| 284 | `<text class="d-sub" x="${cx}" y="${cy + 9}" text-anchor="middle" dominant-baseline="central">${rich(node.sub)}</text>` |
| 285 | : `<text class="d-title" x="${cx}" y="${cy}" text-anchor="middle" dominant-baseline="central">${rich(node.title)}</text>`; |
| 286 | return `<g class="d-box">${shape}${text}</g>`; |
| 287 | } |
| 288 | |
| 289 | export function renderGraph(id: string, graph: Graph): string { |
| 290 | const boxes = new Map<string, Box>(graph.nodes.map((node) => [node.id, box(node)])); |
| 291 | for (const g of graph.groups ?? []) if (g.id) boxes.set(g.id, g); |
| 292 | const groups = (graph.groups ?? []) |
| 293 | .map( |
| 294 | (g) => |
| 295 | `<rect class="d-group d-tone-${g.tone ?? "default"}" x="${g.x}" y="${g.y}" width="${g.w}" height="${g.h}" rx="14"/>` + |
| 296 | `<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>`, |
| 297 | ) |
| 298 | .join(""); |
| 299 | const edges: string[] = []; |
| 300 | const labels: string[] = []; |
| 301 | for (const edge of graph.edges) { |
| 302 | const from = boxes.get(edge.from); |
| 303 | const to = boxes.get(edge.to); |
| 304 | if (!from || !to) throw new Error(`Diagram ${id}: an arrow joins ${edge.from} to ${edge.to}, and one of them is not a box.`); |
| 305 | const points = route(edge, from, to); |
| 306 | const tone = edge.tone ?? "default"; |
| 307 | const marker = `url(#${id}-arrow-${tone})`; |
| 308 | edges.push( |
| 309 | `<path class="d-edge d-tone-${tone}${edge.async ? " d-async" : ""}" d="${rounded(points)}" marker-end="${marker}"${edge.both ? ` marker-start="${marker}"` : ""}/>`, |
| 310 | ); |
| 311 | if (edge.label) { |
| 312 | const [lx, ly] = edge.labelAt ?? labelPoint(points); |
| 313 | labels.push(pill(lx, ly, edge.label, 10.5, "d-label")); |
| 314 | } |
| 315 | } |
| 316 | return ( |
| 317 | `<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">` + |
| 318 | `<title id="${id}-title">${esc(graph.caption)}</title>` + |
| 319 | `<desc id="${id}-desc">${esc(describe(graph))}</desc>` + |
| 320 | markers(id) + |
| 321 | groups + |
| 322 | edges.join("") + |
| 323 | graph.nodes.map(renderNode).join("") + |
| 324 | labels.join("") + |
| 325 | "</svg>" |
| 326 | ); |
| 327 | } |
| 328 | |
| 329 | // ── Sequence ─────────────────────────────────────────────────────────────── |
| 330 | |
| 331 | const SEQ_HEAD = 50; |
| 332 | const SEQ_ROW = 42; |
| 333 | |
| 334 | export function renderSequence(id: string, seq: Sequence): string { |
| 335 | const width = seq.width ?? 760; |
| 336 | const margin = 16; |
| 337 | const col = (width - margin * 2) / seq.actors.length; |
| 338 | const xs = new Map(seq.actors.map((actor, i) => [actor.id, margin + col * (i + 0.5)])); |
| 339 | const at = (actor: string) => { |
| 340 | const x = xs.get(actor); |
| 341 | if (x === undefined) throw new Error(`Diagram ${id}: no actor named ${actor}.`); |
| 342 | return x; |
| 343 | }; |
| 344 | const headW = Math.min(col - 14, 168); |
| 345 | |
| 346 | const body: string[] = []; |
| 347 | let y = SEQ_HEAD + 26; |
| 348 | let n = 0; |
| 349 | for (const step of seq.steps) { |
| 350 | if ("phase" in step) { |
| 351 | y += 4; |
| 352 | body.push(`<line class="d-phase" x1="${margin}" y1="${y}" x2="${width - margin}" y2="${y}"/>`); |
| 353 | body.push(pill(width / 2, y, step.phase, 10.5, "d-phase-label")); |
| 354 | y += 30; |
| 355 | continue; |
| 356 | } |
| 357 | if ("note" in step) { |
| 358 | const sx = step.over.map(at); |
| 359 | const left = Math.max(margin, Math.min(...sx) - Math.min(col / 2 - 8, 92)); |
| 360 | const right = Math.min(width - margin, Math.max(...sx) + Math.min(col / 2 - 8, 92)); |
| 361 | body.push(`<rect class="d-note" x="${left}" y="${y - 12}" width="${right - left}" height="26" rx="6"/>`); |
| 362 | body.push(`<text class="d-note-text" x="${(left + right) / 2}" y="${y + 1}" text-anchor="middle" dominant-baseline="central">${rich(step.note)}</text>`); |
| 363 | y += 38; |
| 364 | continue; |
| 365 | } |
| 366 | n++; |
| 367 | const x1 = at(step.from); |
| 368 | const x2 = at(step.to); |
| 369 | const cls = `d-msg${step.reply || step.async ? " d-async" : ""}${step.reply ? " d-reply" : ""}`; |
| 370 | const marker = `url(#${id}-arrow-${step.reply ? "default" : "accent"})`; |
| 371 | if (x1 === x2) { |
| 372 | // To itself: a loop out to the right and back. |
| 373 | const loop = 30; |
| 374 | body.push(`<path class="${cls}" d="M${x1},${y} H${x1 + loop} V${y + 16} H${x1 + 3}" marker-end="${marker}"/>`); |
| 375 | body.push(badge(x1 - 14, y + 8, n)); |
| 376 | body.push(`<text class="d-msg-label" x="${x1 + loop + 8}" y="${y + 8}" dominant-baseline="central">${rich(step.label)}</text>`); |
| 377 | y += SEQ_ROW + 10; |
| 378 | continue; |
| 379 | } |
| 380 | const dir = x2 > x1 ? 1 : -1; |
| 381 | body.push(`<line class="${cls}" x1="${x1}" y1="${y}" x2="${x2 - dir * 3}" y2="${y}" marker-end="${marker}"/>`); |
| 382 | body.push(`<text class="d-msg-label" x="${(x1 + x2) / 2}" y="${y - 9}" text-anchor="middle">${rich(step.label)}</text>`); |
| 383 | body.push(badge(x1 - dir * 13, y, n)); |
| 384 | y += SEQ_ROW; |
| 385 | } |
| 386 | const height = y + 4; |
| 387 | |
| 388 | const heads = seq.actors |
| 389 | .map((actor) => { |
| 390 | const x = at(actor.id); |
| 391 | const tone = actor.tone ?? "default"; |
| 392 | const title = actor.sub |
| 393 | ? `<text class="d-title" x="${x}" y="${SEQ_HEAD / 2 - 8}" text-anchor="middle" dominant-baseline="central">${rich(actor.title)}</text>` + |
| 394 | `<text class="d-sub" x="${x}" y="${SEQ_HEAD / 2 + 9}" text-anchor="middle" dominant-baseline="central">${rich(actor.sub)}</text>` |
| 395 | : `<text class="d-title" x="${x}" y="${SEQ_HEAD / 2}" text-anchor="middle" dominant-baseline="central">${rich(actor.title)}</text>`; |
| 396 | return ( |
| 397 | `<line class="d-lifeline" x1="${x}" y1="${SEQ_HEAD}" x2="${x}" y2="${height}"/>` + |
| 398 | `<rect class="d-node d-tone-${tone}" x="${x - headW / 2}" y="1" width="${headW}" height="${SEQ_HEAD - 2}" rx="9"/>` + |
| 399 | title |
| 400 | ); |
| 401 | }) |
| 402 | .join(""); |
| 403 | |
| 404 | return ( |
| 405 | `<svg viewBox="0 0 ${width} ${height}" width="${width}" height="${height}" role="img" aria-labelledby="${id}-title ${id}-desc" class="d-svg">` + |
| 406 | `<title id="${id}-title">${esc(seq.caption)}</title>` + |
| 407 | `<desc id="${id}-desc">${esc(describe(seq))}</desc>` + |
| 408 | markers(id) + |
| 409 | heads + |
| 410 | body.join("") + |
| 411 | "</svg>" |
| 412 | ); |
| 413 | } |
| 414 | |
| 415 | function badge(x: number, y: number, n: number): string { |
| 416 | return ( |
| 417 | `<circle class="d-badge" cx="${x}" cy="${y}" r="8"/>` + |
| 418 | `<text class="d-badge-text" x="${x}" y="${y}" text-anchor="middle" dominant-baseline="central">${n}</text>` |
| 419 | ); |
| 420 | } |
| 421 | |
| 422 | // ── As text ──────────────────────────────────────────────────────────────── |
| 423 | |
| 424 | /** |
| 425 | * The diagram as Markdown: for the page's Markdown twin, which agents and |
| 426 | * "Copy page" read. A graph lists its boxes by frame, then its arrows; a |
| 427 | * sequence lists its messages with the numbers the picture shows. |
| 428 | */ |
| 429 | export function toMarkdown(spec: Spec): string { |
| 430 | const lines = [`**Diagram: ${spec.caption}**`, ""]; |
| 431 | if (spec.kind === "sequence") { |
| 432 | const name = new Map(spec.actors.map((a) => [a.id, plain(a.title)])); |
| 433 | let n = 0; |
| 434 | for (const step of spec.steps) { |
| 435 | if ("phase" in step) lines.push("", `*${plain(step.phase)}*`, ""); |
| 436 | else if ("note" in step) lines.push(` - Note (${step.over.map((id) => name.get(id)).join(", ")}): ${step.note}`); |
| 437 | else { |
| 438 | n++; |
| 439 | const arrow = step.reply ? "⇠" : step.async ? "⇢" : "→"; |
| 440 | lines.push(`${n}. ${name.get(step.from)} ${arrow} ${name.get(step.to)}: ${step.label}`); |
| 441 | } |
| 442 | } |
| 443 | return lines.join("\n"); |
| 444 | } |
| 445 | const inGroup = (node: GraphNode, g: GraphGroup) => |
| 446 | 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; |
| 447 | const named = (node: GraphNode) => `${node.title}${node.sub ? ` (${node.sub})` : ""}`; |
| 448 | const placed = new Set<string>(); |
| 449 | for (const g of spec.groups ?? []) { |
| 450 | const members = spec.nodes.filter((node) => !placed.has(node.id) && inGroup(node, g)); |
| 451 | if (!members.length) continue; |
| 452 | members.forEach((node) => placed.add(node.id)); |
| 453 | lines.push(`- ${g.label}: ${members.map(named).join("; ")}`); |
| 454 | } |
| 455 | const loose = spec.nodes.filter((node) => !placed.has(node.id)); |
| 456 | if (loose.length) lines.push(`- ${loose.map(named).join("; ")}`); |
| 457 | lines.push("", "Arrows:", ""); |
| 458 | const title = new Map(spec.nodes.map((node) => [node.id, plain(node.title)])); |
| 459 | for (const g of spec.groups ?? []) if (g.id) title.set(g.id, plain(g.label)); |
| 460 | for (const edge of spec.edges) { |
| 461 | const arrow = edge.both ? "↔" : edge.async ? "⇢" : "→"; |
| 462 | lines.push(`- ${title.get(edge.from)} ${arrow} ${title.get(edge.to)}${edge.label ? `: ${edge.label}` : ""}${edge.async ? " (asynchronous)" : ""}`); |
| 463 | } |
| 464 | return lines.join("\n"); |
| 465 | } |
| 466 | |
| 467 | /** One paragraph for the SVG's <desc>: the text twin without its Markdown. */ |
| 468 | function describe(spec: Spec): string { |
| 469 | return plain(toMarkdown(spec).replace(/\*\*/g, "").replace(/\*/g, "")).split("\n").filter(Boolean).join(" "); |
| 470 | } |
| 471 | |
| 472 | export function render(id: string, spec: Spec): string { |
| 473 | return spec.kind === "graph" ? renderGraph(id, spec) : renderSequence(id, spec); |
| 474 | } |