@runbooks/design 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +14 -0
  2. package/dist/color.d.ts +17 -0
  3. package/dist/color.js +24 -0
  4. package/dist/geometry.test.d.ts +1 -0
  5. package/dist/geometry.test.js +155 -0
  6. package/dist/icons.d.ts +69 -0
  7. package/dist/icons.js +103 -0
  8. package/dist/icons.test.d.ts +1 -0
  9. package/dist/icons.test.js +140 -0
  10. package/dist/index.d.ts +9 -0
  11. package/dist/index.js +9 -0
  12. package/dist/mark.d.ts +42 -0
  13. package/dist/mark.js +102 -0
  14. package/dist/primitives.d.ts +75 -0
  15. package/dist/primitives.js +126 -0
  16. package/dist/primitives.test.d.ts +1 -0
  17. package/dist/primitives.test.js +134 -0
  18. package/dist/render.d.ts +97 -0
  19. package/dist/render.js +1085 -0
  20. package/dist/render.test.d.ts +1 -0
  21. package/dist/render.test.js +179 -0
  22. package/dist/specimen.d.ts +2 -0
  23. package/dist/specimen.gen.d.ts +1 -0
  24. package/dist/specimen.gen.js +9 -0
  25. package/dist/specimen.js +81 -0
  26. package/dist/stylesheet.d.ts +95 -0
  27. package/dist/stylesheet.js +987 -0
  28. package/dist/stylesheet.test.d.ts +1 -0
  29. package/dist/stylesheet.test.js +265 -0
  30. package/dist/text.d.ts +28 -0
  31. package/dist/text.js +89 -0
  32. package/dist/tokens.d.ts +104 -0
  33. package/dist/tokens.js +142 -0
  34. package/dist/tokens.test.d.ts +1 -0
  35. package/dist/tokens.test.js +125 -0
  36. package/fonts/IBMPlexMono-Regular-Latin1.woff2 +0 -0
  37. package/fonts/IBMPlexMono-SemiBold-Latin1.woff2 +0 -0
  38. package/fonts/IBMPlexSans-Italic-Latin1.woff2 +0 -0
  39. package/fonts/IBMPlexSans-Medium-Latin1.woff2 +0 -0
  40. package/fonts/IBMPlexSans-Regular-Latin1.woff2 +0 -0
  41. package/fonts/IBMPlexSans-SemiBold-Latin1.woff2 +0 -0
  42. package/fonts/LICENSE.txt +93 -0
  43. package/package.json +40 -0
package/dist/render.js ADDED
@@ -0,0 +1,1085 @@
1
+ import { layout, topologicalOrder } from "@runbooks/graph";
2
+ import { PALETTES, riskColor, RISK_LABELS, FONT_STACKS } from "./tokens.js";
3
+ import { nodeShape, edgeStyle, stateStyle, RISK_HATCH } from "./primitives.js";
4
+ import { measure, wrap } from "./text.js";
5
+ /**
6
+ * Risk on paper (§18.2, D-02).
7
+ *
8
+ * Hue is risk's channel and print does not have it. `RISK_HATCH` has always named a
9
+ * pattern per level and `data-hatch` has always carried the name — and nothing ever drew
10
+ * one, while the print rule set `fill:none` on exactly those nodes. So a page printed from
11
+ * this catalog showed a destructive step and a read-only step as the same grey outline:
12
+ * the channel that carries the one property a reader must not miss was removed, and its
13
+ * declared substitute was never painted.
14
+ *
15
+ * Black on transparent, because the substitute exists for a sheet of paper that has no
16
+ * colour left to lose. Ids carry the graph's prefix: a catalog page holds twenty graphs
17
+ * and a bare id would make the twentieth borrow the first one's fill.
18
+ */
19
+ function hatchPatterns(prefix) {
20
+ const pattern = (name, size, body) => `<pattern id="${prefix}-hatch-${name}" width="${size}" height="${size}" patternUnits="userSpaceOnUse">${body}</pattern>`;
21
+ return (pattern("diagonal-thin", 6, `<path d="M 0 6 L 6 0" stroke="#000" stroke-width="0.8" fill="none"/>`) +
22
+ pattern("diagonal-dense", 3, `<path d="M 0 3 L 3 0" stroke="#000" stroke-width="0.9" fill="none"/>`) +
23
+ pattern("cross", 4, `<path d="M 0 4 L 4 0 M 0 0 L 4 4" stroke="#000" stroke-width="0.9" fill="none"/>`));
24
+ }
25
+ /** Each level's fill on paper. `none` stays unfilled: read-only is the absence of hatching. */
26
+ function hatchPrintRules(prefix) {
27
+ const filled = ["diagonal-thin", "diagonal-dense", "cross"]
28
+ .map((name) => `.runbook-graph [data-hatch="${name}"]{fill:url(#${prefix}-hatch-${name});fill-opacity:1}`)
29
+ .join("");
30
+ return `@media print{.runbook-graph [data-hatch="none"]{fill:none}${filled}}`;
31
+ }
32
+ /**
33
+ * Geometry per mode.
34
+ *
35
+ * `minW`/`maxW` bound the fitted width rather than fixing it. The gaps are what the
36
+ * elbows and the edge labels are routed in, so they are generous on purpose: a diagram
37
+ * that is 15% larger and legible beats a compact one nobody can read during an incident.
38
+ */
39
+ const GEOM = {
40
+ page: { minW: 132, maxW: 260, padX: 18, lineH: 19, minH: 56, gapFlow: 104, gapCross: 72, font: 14, labels: true },
41
+ fullscreen: { minW: 152, maxW: 300, padX: 20, lineH: 21, minH: 64, gapFlow: 124, gapCross: 88, font: 15, labels: true },
42
+ mini: { minW: 26, maxW: 26, padX: 0, lineH: 0, minH: 9, gapFlow: 10, gapCross: 9, font: 0, labels: false },
43
+ };
44
+ /** How much of a node's width its own shape spends on something other than the label. */
45
+ const SHAPE_INSET = {
46
+ check: { left: 8, right: 0 },
47
+ decision: { left: 0, right: 14 },
48
+ escalate: { left: 0, right: 22 },
49
+ action: { left: 0, right: 0 },
50
+ wait: { left: 0, right: 0 },
51
+ start: { left: 0, right: 0 },
52
+ end: { left: 0, right: 0 },
53
+ };
54
+ const MAX_LINES = 2;
55
+ /**
56
+ * The length of the 45° cut at a turn.
57
+ *
58
+ * Every segment runs at 0° or 90°, and a corner is a corner: two segments meeting at a
59
+ * point. The first version cut each turn with a short 45° diagonal, on the theory that a
60
+ * mitre reads more deliberately than a right angle. At the sizes these are drawn it does
61
+ * not — an 8px cut on a 90° turn is too small to read as an angle and too big to read as
62
+ * a point, so every corner looked blunted rather than square, which is the one thing a
63
+ * right angle is for. The only diagonals left in the drawing are the bridge ramps, where
64
+ * 45° is doing actual work.
65
+ */
66
+ /** How far short of the target a line stops, so the arrowhead sits beside the box. */
67
+ /** The widest a card's silhouette may be drawn. A card is 320 and a graph does not set it. */
68
+ const MINI_MAX_WIDTH = 320;
69
+ /** How far two edge labels have to be before they read as two labels. */
70
+ const LABEL_CLEAR = 9;
71
+ /**
72
+ * How far a turn keeps from the box on either side of it.
73
+ *
74
+ * The same rule at both ends of a route. Six pixels of clearance on the way out drew a
75
+ * whisker on the underside of a box; six on the way in drew an arrowhead floating below
76
+ * one, because the last leg is trimmed by the arrow gap and three pixels of line under a
77
+ * head is not an arrival. A leg has to be long enough to have a direction.
78
+ */
79
+ const TURN_CLEAR = 16;
80
+ const ARROW_GAP = 3;
81
+ /**
82
+ * The bridge drawn where two lines cross: half its width, and how far it steps aside.
83
+ *
84
+ * A square step, four right angles. It was a trapezoid — up a 45° ramp, a short flat, down
85
+ * another — which is a legal shape in a drawing of 0°, 45° and 90° and still the wrong one:
86
+ * at six pixels wide the flat is invisible, so the two ramps read as a single blunt kink
87
+ * rather than as anything deliberate, and three crossings in a row read as a torn edge. A
88
+ * right angle at this size is unambiguous, and the hop is the one place a reader has to
89
+ * see immediately that the line did not turn.
90
+ */
91
+ const BRIDGE = 6;
92
+ const RISE = 5;
93
+ /** Kept inside a range, and tolerant of a range narrower than the value it is given. */
94
+ function clamp(value, low, high) {
95
+ return low > high ? (low + high) / 2 : Math.min(Math.max(value, low), high);
96
+ }
97
+ const escape = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
98
+ export function renderGraph(graph, options) {
99
+ const mode = options.mode ?? "page";
100
+ const g = GEOM[mode];
101
+ const theme = options.theme;
102
+ const base = PALETTES[theme];
103
+ /**
104
+ * Colours as custom properties, with the drawn theme's value as the fallback.
105
+ *
106
+ * One drawing serves both themes: inside a page it takes whatever `data-theme` is in
107
+ * force, and opened on its own — the SVG export, an embed — it keeps the colours it was
108
+ * rendered with. Without this, the graph is fixed at build time and a reader who
109
+ * switches the theme gets a white picture on a black page, which is exactly what
110
+ * happened the day the catalog turned dark.
111
+ */
112
+ const palette = {
113
+ background: `var(--rb-background, ${base.background})`,
114
+ surface: `var(--rb-surface, ${base.surface})`,
115
+ border: `var(--rb-border, ${base.border})`,
116
+ text: `var(--rb-text, ${base.text})`,
117
+ textSecondary: `var(--rb-text-secondary, ${base.textSecondary})`,
118
+ trust: `var(--rb-trust, ${base.trust})`,
119
+ };
120
+ const prefix = options.idPrefix ?? "g";
121
+ const direction = options.direction ?? "vertical";
122
+ const laid = options.positions ?? layout(graph).positions;
123
+ /**
124
+ * Horizontal is the same layering read along the other axis: the layer becomes the
125
+ * column and the off-axis rank becomes the row. Transposing here rather than in
126
+ * `layout()` keeps one layering — and keeps a diff's shared coordinates meaningful,
127
+ * since both versions are transposed the same way.
128
+ */
129
+ const positions = direction === "horizontal"
130
+ ? Object.fromEntries(Object.entries(laid).map(([id, p]) => [id, { x: p.y, y: p.x }]))
131
+ : { ...laid };
132
+ const order = topologicalOrder(graph);
133
+ const byId = new Map(graph.nodes.map((n) => [n.id, n]));
134
+ /**
135
+ * Each node sized to its own label.
136
+ *
137
+ * A terminal says "start" or "success" and has no business being as wide as a step
138
+ * whose title is a sentence; a step whose title is a sentence has no business being
139
+ * cut. Both follow from measuring instead of assuming.
140
+ */
141
+ const fitted = new Map();
142
+ for (const node of graph.nodes) {
143
+ if (!g.labels) {
144
+ fitted.set(node.id, { lines: [], width: g.minW, height: g.minH });
145
+ continue;
146
+ }
147
+ const inset = SHAPE_INSET[node.kind] ?? { left: 0, right: 0 };
148
+ const room = g.maxW - g.padX * 2 - inset.left - inset.right;
149
+ const { lines, width } = wrap(node.title, g.font, room, MAX_LINES);
150
+ fitted.set(node.id, {
151
+ lines,
152
+ width: Math.min(g.maxW, Math.max(g.minW, Math.ceil(width) + g.padX * 2 + inset.left + inset.right)),
153
+ height: Math.max(g.minH, lines.length * g.lineH + 20),
154
+ });
155
+ }
156
+ // One row height per layer and one column width per column, so the grid stays square
157
+ // even though the boxes differ: a ragged grid is harder to scan than a wide one.
158
+ const xs = Object.values(positions).map((p) => p.x);
159
+ const minX = Math.min(...xs);
160
+ const cols = Math.max(...xs) - minX + 1;
161
+ const ys = Object.values(positions).map((p) => p.y);
162
+ const minY = Math.min(...ys);
163
+ const rows = Math.max(...ys) - minY + 1;
164
+ const colWidth = Array.from({ length: cols }, () => g.minW);
165
+ const rowHeight = Array.from({ length: rows }, () => g.minH);
166
+ for (const [id, p] of Object.entries(positions)) {
167
+ const box = fitted.get(id);
168
+ if (!box)
169
+ continue;
170
+ const column = p.x - minX;
171
+ colWidth[column] = Math.max(colWidth[column], box.width);
172
+ rowHeight[p.y - minY] = Math.max(rowHeight[p.y - minY], box.height);
173
+ }
174
+ // The flow gap is the one the lanes and the edge labels are routed in, so it is the
175
+ // generous one; the cross gap only holds branches apart.
176
+ const gapX = direction === "horizontal" ? g.gapFlow : g.gapCross;
177
+ const gapY = direction === "horizontal" ? g.gapCross : g.gapFlow;
178
+ const pad = mode === "mini" ? 4 : 24;
179
+ const colLeft = [];
180
+ let cursorX = pad;
181
+ for (let column = 0; column < cols; column++) {
182
+ colLeft.push(cursorX);
183
+ cursorX += colWidth[column] + gapX;
184
+ }
185
+ const rowTop = [];
186
+ let cursorY = pad;
187
+ for (let row = 0; row < rows; row++) {
188
+ rowTop.push(cursorY);
189
+ cursorY += rowHeight[row] + gapY;
190
+ }
191
+ const width = cursorX - gapX + pad;
192
+ const height = cursorY - gapY + pad;
193
+ /** A node's own box, centred in its column and its row. */
194
+ const boxOf = (id) => {
195
+ const p = positions[id];
196
+ const size = fitted.get(id) ?? { lines: [], width: g.minW, height: g.minH };
197
+ const column = p.x - minX;
198
+ const row = p.y - minY;
199
+ const x = colLeft[column] + (colWidth[column] - size.width) / 2;
200
+ const y = rowTop[row] + (rowHeight[row] - size.height) / 2;
201
+ return { x, y, w: size.width, h: size.height, lines: size.lines };
202
+ };
203
+ const labelPlates = [];
204
+ /**
205
+ * The edges worth drawing.
206
+ *
207
+ * `approval` is derived, not authored: the model adds one at every arrival into a gated
208
+ * step, alongside the transition that was already there. Drawn, that is a second line
209
+ * between the same two boxes in the same direction and a second arrowhead on the target
210
+ * — a picture that says "and this one needs approval" about a box that is already
211
+ * wearing a padlock. On the withdrawal record it was five of the fifteen lines.
212
+ *
213
+ * It is dropped only where the pair is already connected. An approval edge that stands
214
+ * alone is the only line saying those two steps are joined at all, and it stays.
215
+ */
216
+ const drawable = graph.edges.filter((e) => {
217
+ if (e.kind !== "approval")
218
+ return true;
219
+ if (mode === "mini")
220
+ return false;
221
+ return !graph.edges.some((other) => other.kind !== "approval" && other.from === e.from && other.to === e.to);
222
+ });
223
+ /**
224
+ * A lane per edge, so two edges leaving the same place do not draw over each other.
225
+ *
226
+ * Numbered within the group that shares a gutter — the column an edge leaves, for a
227
+ * forward edge — and in document order, so the picture is byte-identical across builds.
228
+ */
229
+ const laneOf = new Map();
230
+ const groups = new Map();
231
+ drawable.forEach((e, index) => {
232
+ const a = boxOf(e.from);
233
+ const b = boxOf(e.to);
234
+ const backwards = direction === "horizontal" ? b.x + b.w <= a.x + 1 : b.y + b.h <= a.y + 1;
235
+ const key = backwards
236
+ ? `back:${direction === "horizontal" ? (a.y <= b.y ? "up" : "down") : a.x <= b.x ? "left" : "right"}`
237
+ : `fwd:${direction === "horizontal" ? Math.round(a.x + a.w) : Math.round(a.y + a.h)}`;
238
+ const list = groups.get(key) ?? [];
239
+ list.push(index);
240
+ groups.set(key, list);
241
+ });
242
+ for (const list of groups.values()) {
243
+ list.forEach((index, position) => laneOf.set(index, { lane: position, lanes: list.length }));
244
+ }
245
+ // The extent of the boxes, so a lane can be put outside all of them.
246
+ const drawn = graph.nodes.map((n) => boxOf(n.id));
247
+ const drawnTop = Math.min(...drawn.map((b) => b.y));
248
+ const drawnBottom = Math.max(...drawn.map((b) => b.y + b.h));
249
+ const drawnLeft = Math.min(...drawn.map((b) => b.x));
250
+ const drawnRight = Math.max(...drawn.map((b) => b.x + b.w));
251
+ /**
252
+ * Where a route may travel: a horizontal strip, or a vertical one, that no box occupies.
253
+ *
254
+ * Computed from the boxes rather than from row indices. The first version asked which
255
+ * row a coordinate was in and took the gutter past it, which is right when every box
256
+ * fills its row and wrong the moment they differ in height — it returned a y inside a
257
+ * row, and a long edge then drew its horizontal run straight through three nodes. Boxes
258
+ * are what a line must miss, so boxes are what the answer is derived from.
259
+ */
260
+ const clearBand = (from, to, intervals) => {
261
+ const low = Math.min(from, to);
262
+ const high = Math.max(from, to);
263
+ if (high - low < 8)
264
+ return undefined;
265
+ // Every box edge between the two, as candidate boundaries.
266
+ const blocked = intervals
267
+ .filter((i) => i.end > low && i.start < high)
268
+ .sort((a, b) => a.start - b.start);
269
+ let cursor = low;
270
+ let best;
271
+ for (const interval of [...blocked, { start: high, end: high }]) {
272
+ const gap = interval.start - cursor;
273
+ if (gap > 6 && (!best || gap > best.width)) {
274
+ best = { start: cursor, width: gap };
275
+ }
276
+ cursor = Math.max(cursor, interval.end);
277
+ }
278
+ if (!best)
279
+ return undefined;
280
+ /**
281
+ * Each route that takes this band gets its own line inside it.
282
+ *
283
+ * The band used to answer with its midpoint, which is the same answer for every
284
+ * caller — so on a procedure where five steps all route to one place, five lines came
285
+ * out of five different boxes, converged onto one y six pixels apart, and arrived as
286
+ * a single thick stroke with five arrowheads on it. A band is a strip with width, and
287
+ * the routes are spread across it.
288
+ *
289
+ * Counted per band and in the order routes are computed, which is document order, so
290
+ * the same graph draws the same picture on every build.
291
+ */
292
+ const key = `${Math.round(best.start)}:${Math.round(best.width)}`;
293
+ const taken = used.get(key) ?? 0;
294
+ used.set(key, taken + 1);
295
+ /**
296
+ * Far enough from the box that leaving it is a segment, not a whisker.
297
+ *
298
+ * The first route to take a band used to get six pixels of clearance, so an edge
299
+ * dropped six pixels out of the underside of its box and then ran sideways — which at
300
+ * this scale does not read as "down, then across". It reads as a burr on the box, and
301
+ * three branches leaving one decision read as teeth. A turn has to be far enough from
302
+ * what it left for the eye to see two directions.
303
+ */
304
+ const clearance = Math.min(20, Math.max(10, best.width / 4));
305
+ const step = Math.max(14, Math.min(24, best.width / 5));
306
+ const room = Math.max(0, best.width - clearance * 2);
307
+ return best.start + clearance + (room > 0 ? ((taken * step) % room) : 0);
308
+ };
309
+ /**
310
+ * The middle of the widest-enough gap between the given intervals, nearest to `want`.
311
+ *
312
+ * Gaps at the ends count: outside the drawing is a legitimate place for a corridor, it
313
+ * is only a bad one when there is something closer.
314
+ */
315
+ const nearestGap = (want, intervals) => {
316
+ const sorted = [...intervals].sort((a, b) => a.start - b.start);
317
+ const candidates = [];
318
+ let cursor = -Infinity;
319
+ for (const interval of sorted) {
320
+ if (cursor > -Infinity && interval.start - cursor > 14) {
321
+ candidates.push(cursor + (interval.start - cursor) / 2);
322
+ }
323
+ cursor = Math.max(cursor, interval.end);
324
+ }
325
+ if (sorted.length > 0) {
326
+ candidates.push(sorted[0].start - 22, cursor + 22);
327
+ }
328
+ let best;
329
+ for (const candidate of candidates) {
330
+ if (best === undefined || Math.abs(candidate - want) < Math.abs(best - want))
331
+ best = candidate;
332
+ }
333
+ return best;
334
+ };
335
+ /** How many routes have already taken each band. See `clearBand`. */
336
+ const used = new Map();
337
+ const boxIntervals = drawn.map((b) => ({ start: b.y, end: b.y + b.h }));
338
+ const colIntervals = drawn.map((b) => ({ start: b.x, end: b.x + b.w }));
339
+ const channel = {
340
+ top: drawnTop,
341
+ bottom: drawnBottom,
342
+ left: drawnLeft,
343
+ right: drawnRight,
344
+ bandBetween: (fromY, toY) => clearBand(fromY, toY, boxIntervals),
345
+ /**
346
+ * The clear strip immediately to the left of `x`, from the boxes rather than the grid.
347
+ *
348
+ * This used to be the midpoint of the gutter between two *columns*, which is a
349
+ * different thing: a column is as wide as its widest box, so a narrow box centred in
350
+ * a wide column leaves space inside the column that the grid says is occupied and the
351
+ * drawing says is free — and the descent landed inside a node. What a line has to
352
+ * miss is a box.
353
+ */
354
+ gutterBefore: (x) => {
355
+ const leftOf = drawn.filter((b) => b.x + b.w <= x + 0.5).map((b) => b.x + b.w);
356
+ const edge = leftOf.length > 0 ? Math.max(...leftOf) : x - gapX;
357
+ return edge + Math.max(4, Math.min(gapX / 2, (x - edge) / 2));
358
+ },
359
+ bandBeside: (fromX, toX) => clearBand(fromX, toX, colIntervals),
360
+ nearestColumn: (x) => nearestGap(x, colIntervals),
361
+ nearestRow: (y) => nearestGap(y, boxIntervals),
362
+ columnClear: (x, from, to) => {
363
+ const low = Math.min(from, to);
364
+ const high = Math.max(from, to);
365
+ return !drawn.some((b) => x > b.x + 0.5 && x < b.x + b.w - 0.5 && b.y < high - 0.5 && b.y + b.h > low + 0.5);
366
+ },
367
+ rowClear: (y, from, to) => {
368
+ const low = Math.min(from, to);
369
+ const high = Math.max(from, to);
370
+ return !drawn.some((b) => y > b.y + 0.5 && y < b.y + b.h - 0.5 && b.x < high - 0.5 && b.x + b.w > low + 0.5);
371
+ },
372
+ /** The clear strip immediately above `y`. `gutterBefore` turned, and for its reason. */
373
+ gutterAbove: (y) => {
374
+ const above = drawn.filter((b) => b.y + b.h <= y + 0.5).map((b) => b.y + b.h);
375
+ const edge = above.length > 0 ? Math.max(...above) : y - gapY;
376
+ return edge + Math.max(4, Math.min(gapY / 2, (y - edge) / 2));
377
+ },
378
+ };
379
+ /**
380
+ * How many connections each node has on each side, and which one this is.
381
+ *
382
+ * Counted over the drawn edges in document order, so the spread is the same on every
383
+ * build. `reversed` edges are counted at the end they are drawn from, not the end the
384
+ * model names as the source: a rollback is drawn arriving at the step it undoes.
385
+ */
386
+ const outAt = new Map();
387
+ const inAt = new Map();
388
+ const outOf = new Map();
389
+ const intoOf = new Map();
390
+ drawable.forEach((e, index) => {
391
+ const style = edgeStyle(e.kind);
392
+ const tail = style.reversed ? e.to : e.from;
393
+ const head = style.reversed ? e.from : e.to;
394
+ (outOf.get(tail) ?? outOf.set(tail, []).get(tail)).push(index);
395
+ (intoOf.get(head) ?? intoOf.set(head, []).get(head)).push(index);
396
+ });
397
+ for (const list of outOf.values()) {
398
+ list.forEach((index, position) => outAt.set(index, { index: position, count: list.length }));
399
+ }
400
+ for (const list of intoOf.values()) {
401
+ list.forEach((index, position) => inAt.set(index, { index: position, count: list.length }));
402
+ }
403
+ const routes = drawable.map((e, index) => {
404
+ const style = edgeStyle(e.kind);
405
+ const a = boxOf(e.from);
406
+ const b = boxOf(e.to);
407
+ const [from, to] = style.reversed ? [b, a] : [a, b];
408
+ const { lane, lanes } = laneOf.get(index) ?? { lane: 0, lanes: 1 };
409
+ const out = outAt.get(index);
410
+ const into = inAt.get(index);
411
+ const anchors = { ...(out ? { out } : {}), ...(into ? { in: into } : {}) };
412
+ return direction === "horizontal"
413
+ ? connectorAcross(from, to, gapX, lane, lanes, channel, anchors)
414
+ : connectorDown(from, to, gapY, lane, lanes, channel, anchors);
415
+ });
416
+ const bumps = mode === "mini" ? new Map() : crossings(routes);
417
+ /**
418
+ * The canvas grown to hold the lanes, and everything shifted back inside it.
419
+ *
420
+ * A lane routed clear of every box is often outside the grid the boxes were placed on,
421
+ * so the picture has to make room for it — otherwise the connector that goes round is
422
+ * the one clipped off the edge of the SVG.
423
+ */
424
+ const routePoints = routes.flatMap((route) => route.points);
425
+ const extentLeft = Math.min(pad, ...routePoints.map((point) => point.x));
426
+ const extentTop = Math.min(pad, ...routePoints.map((point) => point.y));
427
+ const extentRight = Math.max(width - pad, ...routePoints.map((point) => point.x));
428
+ const extentBottom = Math.max(height - pad, ...routePoints.map((point) => point.y));
429
+ const shiftX = extentLeft < pad ? pad - extentLeft : 0;
430
+ const shiftY = extentTop < pad ? pad - extentTop : 0;
431
+ const canvasWidth = Math.ceil(extentRight + shiftX + pad);
432
+ const canvasHeight = Math.ceil(extentBottom + shiftY + pad);
433
+ /**
434
+ * Where a label goes, and then nudged apart where two of them landed together.
435
+ *
436
+ * Two branches out of one decision can reach their targets by different routes — one
437
+ * straight across the gutter, one round a band — and each route puts its label at the
438
+ * middle of its own first run, which for a shared stub is the same point twice. The
439
+ * routes are right and the reader needs both of them; it is only the two words that
440
+ * collide, so the words are what moves.
441
+ */
442
+ const labelSpots = routes.map((route) => ({ ...labelSpot(route) }));
443
+ const labelled = drawable
444
+ .map((e, index) => (g.labels && e.label ? index : -1))
445
+ .filter((index) => index >= 0);
446
+ for (const index of labelled) {
447
+ const spot = labelSpots[index];
448
+ const placed = labelled.filter((other) => other < index).map((other) => labelSpots[other]);
449
+ // Downwards until it is clear of every label already placed. A label pushed onto a
450
+ // third is not settled, so the check repeats rather than running once.
451
+ for (let guard = 0; guard < placed.length + 1; guard += 1) {
452
+ const hit = placed.find((other) => Math.abs(spot.x - other.x) <= LABEL_CLEAR && Math.abs(spot.y - other.y) <= LABEL_CLEAR);
453
+ if (!hit)
454
+ break;
455
+ spot.y = hit.y + LABEL_CLEAR + 1;
456
+ }
457
+ }
458
+ /**
459
+ * A silhouette is a fixed size, whatever the procedure's length.
460
+ *
461
+ * A card's mini-graph is a signature, not a diagram: it is read at a glance beside four
462
+ * others, and the thing it has to communicate — does this branch, is there red in it —
463
+ * survives being small. A nine-step procedure laid out at the same node size as a
464
+ * five-step one is simply wider than the card, so the drawing keeps its geometry and the
465
+ * element is given the card's width; the viewBox does the rest.
466
+ */
467
+ const scale = mode === "mini" ? Math.min(1, MINI_MAX_WIDTH / canvasWidth) : 1;
468
+ const drawnWidth = Math.round(canvasWidth * scale);
469
+ const drawnHeight = Math.round(canvasHeight * scale);
470
+ const edges = drawable
471
+ .map((e, index) => {
472
+ const style = edgeStyle(e.kind);
473
+ const route = routes[index];
474
+ const d = toPath(route.points, bumps.get(index) ?? []);
475
+ const stroke = palette.textSecondary;
476
+ if (g.labels && e.label) {
477
+ // On a plate of the background, so the word is readable over whatever it crosses.
478
+ const size = 11;
479
+ const at = labelSpots[index];
480
+ const w = measure(e.label, size) + 8;
481
+ labelPlates.push(`<g data-edge-label="${escape(e.label)}">` +
482
+ `<rect x="${round(at.x - w / 2)}" y="${round(at.y - size / 2 - 3)}" width="${round(w)}" height="${size + 6}" rx="3" fill="${palette.background}"/>` +
483
+ `<text x="${round(at.x)}" y="${round(at.y + 4)}" fill="${stroke}" font-size="${size}" text-anchor="middle" font-family='${FONT_STACKS.mono}'>${escape(e.label)}</text>` +
484
+ `</g>`);
485
+ }
486
+ return (`<g data-edge="${escape(e.from)}-${escape(e.to)}" data-edge-kind="${escape(e.kind)}" data-from="${escape(e.from)}" data-to="${escape(e.to)}">` +
487
+ // A wide transparent copy underneath: a 1px line is not a thing a pointer can
488
+ // find, and an edge you cannot point at cannot be highlighted.
489
+ (mode === "mini" ? "" : `<path d="${d}" fill="none" stroke="transparent" stroke-width="12" data-hit="1"/>`) +
490
+ `<path d="${d}" fill="none" stroke="${stroke}" stroke-width="${style.width}"` +
491
+ (style.dash ? ` stroke-dasharray="${style.dash}"` : "") +
492
+ ` marker-end="url(#${prefix}-arrow)" data-line="1"/>` +
493
+ (style.doubled
494
+ ? `<path d="${d}" fill="none" stroke="${stroke}" stroke-width="${style.width}" transform="translate(0 3)" data-line="1"/>`
495
+ : "") +
496
+ `</g>`);
497
+ })
498
+ .join("");
499
+ const nodes = order
500
+ .map((id, index) => {
501
+ const node = byId.get(id);
502
+ const box = boxOf(id);
503
+ const shape = nodeShape(node.kind, { width: box.w, height: box.h });
504
+ const state = options.states?.[id] ?? "not-reached";
505
+ const s = stateStyle(state);
506
+ const stroke = node.risk
507
+ ? `var(--rb-risk-${node.risk}, ${riskColor(node.risk, theme)})`
508
+ : palette.textSecondary;
509
+ const inset = SHAPE_INSET[node.kind] ?? { left: 0, right: 0 };
510
+ // Centred in the room the shape leaves, not in the shape: a decision's clipped
511
+ // corner and an escalation's flag are drawn over the right-hand edge, and text
512
+ // centred on the whole box slides underneath them.
513
+ const centre = inset.left + (box.w - inset.left - inset.right) / 2;
514
+ const first = box.h / 2 - ((box.lines.length - 1) * g.lineH) / 2 + 4;
515
+ const label = g.labels
516
+ ? box.lines
517
+ .map((line, lineIndex) => `<text x="${round(centre)}" y="${round(first + lineIndex * g.lineH)}" fill="${palette.text}" font-size="${g.font}" text-anchor="middle" font-family='${FONT_STACKS.sans}'>${escape(line)}</text>`)
518
+ .join("")
519
+ : "";
520
+ // The one property in the picture a reader must not miss, drawn as a shape that
521
+ // says what it is rather than as an unlabelled dot in a corner.
522
+ const badgeMark = g.labels && node.requiresApproval ? padlock(box.w - 19, 8, palette.text) : "";
523
+ return (`<g transform="translate(${round(box.x)} ${round(box.y)})" tabindex="${mode === "mini" ? -1 : 0}" data-node="${escape(id)}" data-order="${index}">` +
524
+ `<path d="${shape.path}" fill="${palette.text}" fill-opacity="${s.fillOpacity}" stroke="${stroke}" stroke-width="${s.strokeWidth}"` +
525
+ (shape.outlineDashed ? ' stroke-dasharray="5 3"' : "") +
526
+ (node.risk ? ` data-hatch="${RISK_HATCH[node.risk]}"` : "") +
527
+ `/>` +
528
+ (shape.detail ? `<path d="${shape.detail}" fill="${stroke}"/>` : "") +
529
+ label +
530
+ badgeMark +
531
+ `</g>`);
532
+ })
533
+ .join("");
534
+ const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${canvasWidth} ${canvasHeight}" width="${drawnWidth}" height="${drawnHeight}" aria-hidden="true" focusable="false" class="runbook-graph runbook-graph--${mode}">` +
535
+ `<defs><marker id="${prefix}-arrow" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="6" markerHeight="6" orient="auto">` +
536
+ `<path d="M 0 0 L 8 4 L 0 8 z" fill="${palette.textSecondary}"/></marker>` +
537
+ hatchPatterns(prefix) +
538
+ `</defs>` +
539
+ // Motion only where it shows a change, and none at all when the reader asks for none.
540
+ `<style>@media (prefers-reduced-motion: reduce){.runbook-graph *{transition:none!important;animation:none!important}}` +
541
+ hatchPrintRules(prefix) +
542
+ // Pointing at something says which one thing it is. Weight and opacity only — hue is
543
+ // risk's channel and nothing else may borrow it (§18.2).
544
+ `.runbook-graph [data-edge]{cursor:default}` +
545
+ `.runbook-graph [data-edge]:hover [data-line]{stroke-width:2.6}` +
546
+ `.runbook-graph [data-node]:hover>path:first-of-type,.runbook-graph [data-node]:focus-visible>path:first-of-type{stroke-width:2.6}` +
547
+ `.runbook-graph [data-node]:focus-visible{outline:none}` +
548
+ `.runbook-graph:hover [data-edge]:not(:hover) [data-line]{stroke-opacity:.45}` +
549
+ `.runbook-graph:has([data-edge]:hover) [data-node]{opacity:.75}` +
550
+ /**
551
+ * Pointing at a step picks out everything that touches it.
552
+ *
553
+ * How many ways out a step has is the question a reader asks of a branch, and the
554
+ * lines are spread along its side so they can be counted — but only once the others
555
+ * are out of the way. `:has` is what makes that possible without script; a browser
556
+ * without it still gets the spread lines and the per-edge highlight.
557
+ */
558
+ nodeEdgeRules(graph) +
559
+ `</style>` +
560
+ `<rect width="${canvasWidth}" height="${canvasHeight}" fill="${palette.background}"/>` +
561
+ `<g transform="translate(${round(shiftX)} ${round(shiftY)})">` +
562
+ edges +
563
+ nodes +
564
+ // Above the nodes as well as the edges: a label belongs to the reader, not to a layer.
565
+ labelPlates.join("") +
566
+ `</g>` +
567
+ `</svg>`;
568
+ const boxes = Object.fromEntries(graph.nodes.map((node) => {
569
+ const box = boxOf(node.id);
570
+ return [node.id, { x: box.x + shiftX, y: box.y + shiftY, w: box.w, h: box.h }];
571
+ }));
572
+ return { svg, list: renderList(graph, order, byId), width: drawnWidth, height: drawnHeight, boxes };
573
+ }
574
+ /**
575
+ * A point on a box's side.
576
+ *
577
+ * One connection sits in the middle; several are spread evenly across the middle 70% of
578
+ * the side, which keeps them clear of the rounded ends of a capsule and of the clipped
579
+ * corner of a decision.
580
+ */
581
+ function anchor(start, size, at) {
582
+ if (!at || at.count <= 1)
583
+ return start + size / 2;
584
+ const span = size * 0.7;
585
+ return start + size / 2 - span / 2 + (span * at.index) / (at.count - 1);
586
+ }
587
+ /**
588
+ * A route from one box to another, along free channels only.
589
+ *
590
+ * Every segment is horizontal or vertical, every turn is cut at 45°, and no segment ever
591
+ * enters a box: forward edges leave the right-hand side into the gutter between two
592
+ * columns, take a vertical lane there — gutters hold no nodes, which is what makes going
593
+ * around them structural rather than lucky — and arrive at the left-hand side of the
594
+ * target. Backward edges are the same idea turned: out of the top or bottom, along a
595
+ * lane clear of every row, and back in the same way.
596
+ *
597
+ * `lane` distributes the vertical runs across the gutter so two edges leaving the same
598
+ * column do not draw over each other.
599
+ */
600
+ function connectorAcross(from, to, gap, lane_ = 0, lanes = 1, channel, anchors) {
601
+ /**
602
+ * Where on the box this edge leaves and arrives.
603
+ *
604
+ * Every edge used to leave from the middle of the side, so three branches out of one
605
+ * decision left the same point and a reader could not tell three from one without
606
+ * following each line to its end. Spread along the side, the count is the picture.
607
+ */
608
+ const fromCy = anchor(from.y, from.h, anchors?.out);
609
+ const toCy = anchor(to.y, to.h, anchors?.in);
610
+ const fromRight = from.x + from.w;
611
+ const backwards = to.x + to.w <= from.x + 1;
612
+ // The same case as `connectorDown`'s `sameRow`, turned: a retry, or a target in the
613
+ // same column. It goes round the right-hand side rather than under.
614
+ const sameColumn = to.x < from.x + from.w - 1 && to.x + to.w > from.x + 1;
615
+ if (sameColumn) {
616
+ const x = channel?.bandBeside(fromRight, fromRight + gap) ?? fromRight + gap / 2;
617
+ return {
618
+ points: [
619
+ { x: fromRight, y: fromCy },
620
+ { x, y: fromCy },
621
+ { x, y: toCy },
622
+ { x: to.x + to.w, y: toCy },
623
+ ],
624
+ labelAt: { x, y: (fromCy + toCy) / 2 },
625
+ };
626
+ }
627
+ if (!backwards && Math.abs(fromCy - toCy) < 2) {
628
+ /**
629
+ * Drawn at one height, not two.
630
+ *
631
+ * "Close enough to be straight" was treated as straight and then drawn between the two
632
+ * different heights, which is a line at about 2° — the one slope this drawing is not
633
+ * allowed to have. The anchors are spread along a node's side, so a pair can land
634
+ * within a pixel or two of each other and this became reachable the moment a record
635
+ * had more connections than the corpus used to.
636
+ */
637
+ const y = (fromCy + toCy) / 2;
638
+ return {
639
+ points: [
640
+ { x: fromRight, y },
641
+ { x: to.x, y },
642
+ ],
643
+ labelAt: { x: (fromRight + to.x) / 2, y: y - 9 },
644
+ };
645
+ }
646
+ if (!backwards) {
647
+ /**
648
+ * A strip no box occupies, divided so parallel edges keep apart.
649
+ *
650
+ * "The gutter between the two columns" was the wrong measure: a column is as wide as
651
+ * its widest box and boxes are centred in it, so a narrow box leaves space inside its
652
+ * own column that the grid calls a gutter and a taller sibling in another row is
653
+ * standing in. The clear strip is computed from the boxes; the lanes are spread inside
654
+ * it.
655
+ */
656
+ // See the note in `connectorDown`: the band separates, the lane spread is the fallback,
657
+ // and the result stays between the two boxes either way.
658
+ const strip = channel?.bandBeside(fromRight, to.x);
659
+ const spread = Math.max(10, Math.min(20, (gap - 28) / Math.max(lanes, 1)));
660
+ const wanted = strip ?? fromRight + Math.max(gap / 2, 8) + (lane_ - (lanes - 1) / 2) * spread;
661
+ const x = clamp(wanted, fromRight + TURN_CLEAR, to.x - TURN_CLEAR);
662
+ /**
663
+ * A run along the target's own row crosses whatever else is on it.
664
+ *
665
+ * That is only safe when the target is the next thing along. Reaching further — the
666
+ * `on_fail` from `Confirm the node is NotReady` to `Escalate to the cluster owner`, four
667
+ * columns away — put a horizontal line at the escalation's height straight through
668
+ * `Drain the node`. So a long edge travels in the band between two rows, which holds
669
+ * no boxes by construction, and only turns into the target's row in the gutter
670
+ * immediately before it.
671
+ */
672
+ // See `connectorDown`: the question is whether the run into the target passes a box,
673
+ // and it is asked rather than guessed at.
674
+ const band = channel?.bandBetween(fromCy, toCy);
675
+ const near = channel?.gutterBefore(to.x);
676
+ const adjacent = near === undefined ||
677
+ (channel?.rowClear(toCy, x, to.x) !== false && channel?.columnClear(x, fromCy, toCy) !== false);
678
+ if (adjacent) {
679
+ // The next column along: the run at the target's height crosses nothing, because
680
+ // there is nothing between them.
681
+ return {
682
+ points: [
683
+ { x: fromRight, y: fromCy },
684
+ { x, y: fromCy },
685
+ { x, y: toCy },
686
+ { x: to.x, y: toCy },
687
+ ],
688
+ /**
689
+ * On this edge's own lane, and offset by which lane it is.
690
+ *
691
+ * Two branches leaving one decision leave it at the same point and at the same
692
+ * height, so a label at the midpoint of the shared stub put them on top of each
693
+ * other. The lanes are only a few pixels apart now that they have to fit a clear
694
+ * strip, so the lane index separates the labels rather than the lane position.
695
+ */
696
+ labelAt: { x, y: (fromCy + toCy) / 2 + (lane_ - (lanes - 1) / 2) * 15 },
697
+ };
698
+ }
699
+ /**
700
+ * Further than the next column, so the run has to travel somewhere empty.
701
+ *
702
+ * A band between the rows when there is one, and outside every box when there is not.
703
+ * What this must never do is fall back to a run at the target's own height: on a
704
+ * procedure where three checks all escalate to one step, that drew three horizontals
705
+ * straight through every node in between.
706
+ */
707
+ const lane = band ??
708
+ channel?.nearestRow((fromCy + toCy) / 2) ??
709
+ (fromCy <= toCy
710
+ ? Math.min(channel?.top ?? fromCy, fromCy, toCy) - 20 - lane_ * 12
711
+ : Math.max(channel?.bottom ?? fromCy, fromCy, toCy) + 20 + lane_ * 12);
712
+ return {
713
+ points: [
714
+ { x: fromRight, y: fromCy },
715
+ { x, y: fromCy },
716
+ { x, y: lane },
717
+ { x: near, y: lane },
718
+ { x: near, y: toCy },
719
+ { x: to.x, y: toCy },
720
+ ],
721
+ labelAt: { x, y: (fromCy + lane) / 2 + (lane_ - (lanes - 1) / 2) * 15 },
722
+ };
723
+ }
724
+ /**
725
+ * Against the flow: out of a side, up or down a clear vertical strip, and back in.
726
+ *
727
+ * The first version left by the top or bottom edge and descended at the box's own x to a
728
+ * lane outside everything — which crosses whatever sits below it in that column, and on
729
+ * two of the corpus's records it did. A vertical run has to be in a strip no box
730
+ * occupies, which is the same rule the forward routes follow and is now derived the same
731
+ * way: from the boxes.
732
+ */
733
+ const goingLeft = to.x + to.w <= from.x + 1;
734
+ const x1 = goingLeft ? from.x : from.x + from.w;
735
+ const x2 = goingLeft ? to.x + to.w : to.x;
736
+ const y1 = anchor(from.y, from.h, anchors?.out);
737
+ const y2 = anchor(to.y, to.h, anchors?.in);
738
+ if (Math.abs(y1 - y2) < 2) {
739
+ const y = (y1 + y2) / 2;
740
+ return {
741
+ points: [
742
+ { x: x1, y },
743
+ { x: x2, y },
744
+ ],
745
+ labelAt: { x: (x1 + x2) / 2, y: y - 9 },
746
+ };
747
+ }
748
+ const between = channel?.bandBeside(Math.min(x1, x2), Math.max(x1, x2));
749
+ const outside = goingLeft
750
+ ? Math.min(channel?.left ?? x1, x1, x2) - 24 - lane_ * 12
751
+ : Math.max(channel?.right ?? x1, x1, x2) + 24 + lane_ * 12;
752
+ // See `connectorDown`: a band is only usable if the legs that reach it are clear.
753
+ const banded = between === undefined
754
+ ? undefined
755
+ : clamp(between, Math.min(x1, x2) + TURN_CLEAR, Math.max(x1, x2) - TURN_CLEAR);
756
+ const reachable = banded !== undefined &&
757
+ channel?.rowClear(y1, x1, banded) !== false &&
758
+ channel?.rowClear(y2, banded, x2) !== false;
759
+ const laneX = reachable ? banded : outside;
760
+ return {
761
+ points: [
762
+ { x: x1, y: y1 },
763
+ { x: laneX, y: y1 },
764
+ { x: laneX, y: y2 },
765
+ { x: x2, y: y2 },
766
+ ],
767
+ labelAt: { x: laneX, y: (y1 + y2) / 2 },
768
+ };
769
+ }
770
+ /**
771
+ * The same, for a procedure laid out top to bottom — every rule of `connectorAcross`,
772
+ * turned.
773
+ *
774
+ * This was the naive version of that function for as long as horizontal was the default:
775
+ * lanes measured off the grid rather than off the boxes, no handling for a target further
776
+ * than the next row, and "close enough to straight" drawn between two different x. Making
777
+ * vertical the default made all three visible at once, on five of the corpus's records.
778
+ * Rather than a second set of ideas about routing, it is the same ones with the axes
779
+ * exchanged — there is one rule for how a line gets from one box to another and this is it
780
+ * sideways.
781
+ */
782
+ function connectorDown(from, to, gap, lane_ = 0, lanes = 1, channel, anchors) {
783
+ const fromCx = anchor(from.x, from.w, anchors?.out);
784
+ const toCx = anchor(to.x, to.w, anchors?.in);
785
+ const fromBottom = from.y + from.h;
786
+ const backwards = to.y + to.h <= from.y + 1;
787
+ /**
788
+ * A step whose target shares its row, itself included.
789
+ *
790
+ * A retry edge goes from a step back to that same step, and two steps that were laid
791
+ * out side by side are the same distance down the page — either way there is no space
792
+ * *between* the boxes for a lane, because there is nothing between them. The forward
793
+ * route asked for one anyway and, finding the range inverted, put the line at the
794
+ * middle of it, which is the middle of a box.
795
+ *
796
+ * It goes under the row instead: out of the bottom, across the strip below — clear by
797
+ * construction, it is a band — and back up into the bottom of the target. A retry reads
798
+ * as a loop beneath the step, which is what it is.
799
+ */
800
+ const sameRow = to.y < from.y + from.h - 1 && to.y + to.h > from.y + 1;
801
+ if (sameRow) {
802
+ const y = channel?.bandBetween(fromBottom, fromBottom + gap) ?? fromBottom + gap / 2;
803
+ return {
804
+ points: [
805
+ { x: fromCx, y: fromBottom },
806
+ { x: fromCx, y },
807
+ { x: toCx, y },
808
+ { x: toCx, y: to.y + to.h },
809
+ ],
810
+ labelAt: { x: (fromCx + toCx) / 2, y },
811
+ };
812
+ }
813
+ if (!backwards && Math.abs(fromCx - toCx) < 2) {
814
+ // Drawn at one x, not two: two anchors a pixel apart make a line at about 89°.
815
+ const x = (fromCx + toCx) / 2;
816
+ return {
817
+ points: [
818
+ { x, y: fromBottom },
819
+ { x, y: to.y },
820
+ ],
821
+ labelAt: { x: x + 10, y: (fromBottom + to.y) / 2 },
822
+ };
823
+ }
824
+ if (!backwards) {
825
+ // A horizontal strip no box occupies, with the parallel edges spread inside it.
826
+ /**
827
+ * One line per route, and never outside the space between the two boxes.
828
+ *
829
+ * The band allocator already gives each route its own line, so spreading by lane index
830
+ * on top of it counts the same separation twice — and the two together pushed the
831
+ * first segment of `s1 → s9` four pixels *above* the bottom of `s1`, which is a line
832
+ * that starts inside its own source. The lane spread is the fallback for when there is
833
+ * no band to allocate from, and the result is clamped either way.
834
+ */
835
+ const strip = channel?.bandBetween(fromBottom, to.y);
836
+ const spread = Math.max(10, Math.min(20, (gap - 28) / Math.max(lanes, 1)));
837
+ const wanted = strip ?? fromBottom + Math.max(gap / 2, 8) + (lane_ - (lanes - 1) / 2) * spread;
838
+ const y = clamp(wanted, fromBottom + TURN_CLEAR, to.y - TURN_CLEAR);
839
+ /**
840
+ * A run down the target's own column crosses whatever else is standing in it.
841
+ *
842
+ * So the route asks whether it does, rather than guessing from how far away the target
843
+ * looks. The guess was "is this lane nearer the target than the gutter immediately
844
+ * above it", which was already only a proxy and became a wrong one when lanes moved off
845
+ * the middle of their band: two boxes one directly above the other failed it, and the
846
+ * edge between them went round three sides of the drawing.
847
+ */
848
+ const band = channel?.bandBeside(fromCx, toCx);
849
+ const near = channel?.gutterAbove(to.y);
850
+ const adjacent = near === undefined ||
851
+ (channel?.columnClear(toCx, y, to.y) !== false && channel?.rowClear(y, fromCx, toCx) !== false);
852
+ if (adjacent) {
853
+ return {
854
+ points: [
855
+ { x: fromCx, y: fromBottom },
856
+ { x: fromCx, y },
857
+ { x: toCx, y },
858
+ { x: toCx, y: to.y },
859
+ ],
860
+ labelAt: { x: (fromCx + toCx) / 2 + (lane_ - (lanes - 1) / 2) * 15, y },
861
+ };
862
+ }
863
+ // Further than the next row: travel in a column-gap, and turn into the target's
864
+ // column only in the strip immediately above it.
865
+ const lane = band ??
866
+ channel?.nearestColumn((fromCx + toCx) / 2) ??
867
+ (fromCx <= toCx
868
+ ? Math.min(channel?.left ?? fromCx, fromCx, toCx) - 20 - lane_ * 12
869
+ : Math.max(channel?.right ?? fromCx, fromCx, toCx) + 20 + lane_ * 12);
870
+ return {
871
+ points: [
872
+ { x: fromCx, y: fromBottom },
873
+ { x: fromCx, y },
874
+ { x: lane, y },
875
+ { x: lane, y: near },
876
+ { x: toCx, y: near },
877
+ { x: toCx, y: to.y },
878
+ ],
879
+ labelAt: { x: (fromCx + lane) / 2 + (lane_ - (lanes - 1) / 2) * 15, y },
880
+ };
881
+ }
882
+ /**
883
+ * Against the flow: out of a side, along a clear column, and back in.
884
+ *
885
+ * It used to leave by the top or bottom and travel along a clear horizontal band, which
886
+ * is the wrong shape for this layout: the long run of a backward edge here is vertical,
887
+ * so the legs that reach the band are the verticals — and they run up the boxes' own
888
+ * columns. On a record where a step retries an earlier one, the band chosen sat above the
889
+ * step in between and the line went straight through it. Going outside everything in `y`
890
+ * made it worse: the leg then passed through every box in the column.
891
+ *
892
+ * The long run belongs in a clear column, and the legs are the short horizontals that
893
+ * reach it. That is the same shape `connectorAcross` uses for its backward case, turned.
894
+ */
895
+ const yOut = anchor(from.y, from.h, anchors?.out);
896
+ const yIn = anchor(to.y, to.h, anchors?.in);
897
+ const wanted = channel?.nearestColumn((from.x + from.w / 2 + to.x + to.w / 2) / 2);
898
+ const outsideLeft = Math.min(channel?.left ?? from.x, from.x, to.x) - 24 - lane_ * 12;
899
+ const outsideRight = Math.max(channel?.right ?? from.x + from.w, from.x + from.w, to.x + to.w) + 24 + lane_ * 12;
900
+ const usable = (x) => channel?.rowClear(yOut, sideOf(from, x), x) !== false &&
901
+ channel?.rowClear(yIn, x, sideOf(to, x)) !== false;
902
+ const laneX = wanted !== undefined && usable(wanted)
903
+ ? wanted
904
+ : usable(outsideLeft)
905
+ ? outsideLeft
906
+ : outsideRight;
907
+ return {
908
+ points: [
909
+ { x: sideOf(from, laneX), y: yOut },
910
+ { x: laneX, y: yOut },
911
+ { x: laneX, y: yIn },
912
+ { x: sideOf(to, laneX), y: yIn },
913
+ ],
914
+ labelAt: { x: laneX, y: (yOut + yIn) / 2 },
915
+ };
916
+ }
917
+ /** The edge of a box facing a lane: a route leaves by the side the lane is on. */
918
+ function sideOf(box, laneX) {
919
+ return laneX <= box.x + box.w / 2 ? box.x : box.x + box.w;
920
+ }
921
+ /**
922
+ * Where a horizontal run of one route is crossed by a vertical run of another.
923
+ *
924
+ * Two lines meeting at a point read as a junction — as though the procedure went that
925
+ * way — and a reader tracing a branch loses it at every intersection. Marking the
926
+ * crossing says which line is continuous. The mark is a bridge cut at 45° rather than the
927
+ * usual semicircular hop, so the drawing keeps to three directions throughout.
928
+ */
929
+ function crossings(routes) {
930
+ const horizontals = [];
931
+ const verticals = [];
932
+ routes.forEach((route, index) => {
933
+ for (let i = 0; i + 1 < route.points.length; i++) {
934
+ const a = route.points[i];
935
+ const b = route.points[i + 1];
936
+ if (Math.abs(a.y - b.y) < 0.5 && Math.abs(a.x - b.x) > 1) {
937
+ horizontals.push({ route: index, y: a.y, x1: Math.min(a.x, b.x), x2: Math.max(a.x, b.x) });
938
+ }
939
+ else if (Math.abs(a.x - b.x) < 0.5 && Math.abs(a.y - b.y) > 1) {
940
+ verticals.push({ route: index, x: a.x, y1: Math.min(a.y, b.y), y2: Math.max(a.y, b.y) });
941
+ }
942
+ }
943
+ });
944
+ const bumps = new Map();
945
+ for (const h of horizontals) {
946
+ for (const v of verticals) {
947
+ if (v.route === h.route)
948
+ continue;
949
+ // Strictly inside both, so a shared corner is not reported as a crossing.
950
+ if (v.x <= h.x1 + 4 || v.x >= h.x2 - 4)
951
+ continue;
952
+ if (h.y <= v.y1 + 4 || h.y >= v.y2 - 4)
953
+ continue;
954
+ const list = bumps.get(h.route) ?? [];
955
+ if (!list.some((x) => Math.abs(x - v.x) < 6))
956
+ list.push(v.x);
957
+ bumps.set(h.route, list);
958
+ }
959
+ }
960
+ return bumps;
961
+ }
962
+ /**
963
+ * The polyline as path data: 45° cuts at the corners, 45° bridges at the crossings.
964
+ *
965
+ * The path stops `INSET` short of its last point so the arrowhead lands beside the box
966
+ * rather than on its outline, where it used to overlap the first letter of the label.
967
+ */
968
+ function toPath(points, bumpsAt) {
969
+ if (points.length < 2)
970
+ return "";
971
+ const out = [];
972
+ const trimmed = [...points];
973
+ const last = trimmed[trimmed.length - 1];
974
+ const before = trimmed[trimmed.length - 2];
975
+ const dx = Math.sign(last.x - before.x);
976
+ const dy = Math.sign(last.y - before.y);
977
+ trimmed[trimmed.length - 1] = { x: last.x - dx * ARROW_GAP, y: last.y - dy * ARROW_GAP };
978
+ out.push(`M ${round(trimmed[0].x)} ${round(trimmed[0].y)}`);
979
+ for (let i = 1; i < trimmed.length; i++) {
980
+ const previous = trimmed[i - 1];
981
+ const current = trimmed[i];
982
+ const next = trimmed[i + 1];
983
+ const horizontal = Math.abs(previous.y - current.y) < 0.5;
984
+ const stop = current;
985
+ if (horizontal) {
986
+ const forwards = current.x > previous.x;
987
+ const inRange = bumpsAt
988
+ .filter((x) => (forwards ? x > previous.x + 6 && x < stop.x - 6 : x < previous.x - 6 && x > stop.x + 6))
989
+ .sort((a, b) => (forwards ? a - b : b - a));
990
+ for (const x of inRange) {
991
+ const side = forwards ? 1 : -1;
992
+ out.push(`L ${round(x - side * BRIDGE)} ${round(current.y)}`);
993
+ out.push(`L ${round(x - side * BRIDGE)} ${round(current.y - RISE)}`);
994
+ out.push(`L ${round(x + side * BRIDGE)} ${round(current.y - RISE)}`);
995
+ out.push(`L ${round(x + side * BRIDGE)} ${round(current.y)}`);
996
+ }
997
+ }
998
+ out.push(`L ${round(stop.x)} ${round(stop.y)}`);
999
+ }
1000
+ return out.join(" ");
1001
+ }
1002
+ /**
1003
+ * One rule per node: hovering it dims every line that does not touch it.
1004
+ *
1005
+ * Generated because CSS cannot ask "is this edge attached to the element under the
1006
+ * pointer" — the relationship lives in the graph, so the rule is written out of the
1007
+ * graph. Two selectors per node, and a catalog page draws one graph.
1008
+ */
1009
+ /**
1010
+ * A label goes on the longest run of its route, beside the line rather than on it.
1011
+ *
1012
+ * Each branch of the routers used to name its own spot, which was eight rules for one
1013
+ * decision and produced two failures a reader sees immediately. The word sat *on* the
1014
+ * line, and its background plate cut the line to make it readable — so a label as wide as
1015
+ * the run it sits on erased the whole run, and `unreachable` on the k8s drain became a
1016
+ * word floating between two two-pixel stubs. And the spot was often the shared stub two
1017
+ * branches leave a decision by, so both words landed on the same pixel.
1018
+ *
1019
+ * The longest run is the one with room, and above it (or beside it, for a vertical run)
1020
+ * is where a reader expects the name of a line.
1021
+ */
1022
+ function labelSpot(route) {
1023
+ let best;
1024
+ for (let i = 0; i + 1 < route.points.length; i++) {
1025
+ const a = route.points[i];
1026
+ const b = route.points[i + 1];
1027
+ const length = segmentLength(a, b);
1028
+ if (!best || length > best.length)
1029
+ best = { a, b, length };
1030
+ }
1031
+ if (!best)
1032
+ return route.labelAt;
1033
+ const middle = { x: (best.a.x + best.b.x) / 2, y: (best.a.y + best.b.y) / 2 };
1034
+ const horizontal = Math.abs(best.a.y - best.b.y) < 0.5;
1035
+ return horizontal ? { x: middle.x, y: middle.y - 10 } : { x: middle.x + 30, y: middle.y };
1036
+ }
1037
+ function nodeEdgeRules(graph) {
1038
+ return graph.nodes
1039
+ .map((node) => {
1040
+ const id = cssEscape(node.id);
1041
+ return (`.runbook-graph:has([data-node="${id}"]:hover) [data-edge]:not([data-from="${id}"]):not([data-to="${id}"]) [data-line]{stroke-opacity:.15}` +
1042
+ `.runbook-graph:has([data-node="${id}"]:hover) [data-edge][data-from="${id}"] [data-line],` +
1043
+ `.runbook-graph:has([data-node="${id}"]:hover) [data-edge][data-to="${id}"] [data-line]{stroke-width:2.6}`);
1044
+ })
1045
+ .join("");
1046
+ }
1047
+ /** Node ids are `s1`, `__end_success` and the like; this keeps a selector well-formed. */
1048
+ function cssEscape(id) {
1049
+ return id.replace(/["\\]/g, "\\$&");
1050
+ }
1051
+ function segmentLength(a, b) {
1052
+ return Math.hypot(b.x - a.x, b.y - a.y);
1053
+ }
1054
+ /** A padlock: a shackle over a body. Geometry only, in the text colour. */
1055
+ function padlock(x, y, colour) {
1056
+ return (`<g transform="translate(${round(x)} ${round(y)})" data-badge="approval">` +
1057
+ `<path d="M 3 5 V 3.2 A 2.8 2.8 0 0 1 8.6 3.2 V 5" fill="none" stroke="${colour}" stroke-width="1.3"/>` +
1058
+ `<rect x="0.6" y="5" width="10.4" height="7.4" rx="1.6" fill="none" stroke="${colour}" stroke-width="1.3"/>` +
1059
+ `</g>`);
1060
+ }
1061
+ function round(value) {
1062
+ return Math.round(value * 100) / 100;
1063
+ }
1064
+ /**
1065
+ * The graph as an ordered list.
1066
+ *
1067
+ * Not a fallback but the accessible representation: the SVG is `aria-hidden`, and this
1068
+ * is what a screen reader reads. Each entry states kind, risk and where the step can go,
1069
+ * because those are the three things the picture conveys.
1070
+ */
1071
+ function renderList(graph, order, byId) {
1072
+ const items = order
1073
+ .map((id) => {
1074
+ const node = byId.get(id);
1075
+ const out = graph.edges
1076
+ .filter((e) => e.from === id && e.kind !== "approval")
1077
+ .map((e) => `${e.kind === "branch" ? `if ${e.label}` : e.kind} to ${e.to}`);
1078
+ const risk = node.risk ? `, ${RISK_LABELS[node.risk]} (${node.risk})` : "";
1079
+ const approval = node.requiresApproval ? ", requires approval" : "";
1080
+ const goes = out.length > 0 ? `. Goes ${out.join("; ")}` : ". Terminal";
1081
+ return `<li id="step-${escape(id)}"><span class="step-kind">${node.kind}</span> <span class="step-title">${escape(node.title)}</span>${escape(risk + approval + goes)}</li>`;
1082
+ })
1083
+ .join("");
1084
+ return `<ol class="runbook-graph-steps">${items}</ol>`;
1085
+ }