@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.
- package/README.md +14 -0
- package/dist/color.d.ts +17 -0
- package/dist/color.js +24 -0
- package/dist/geometry.test.d.ts +1 -0
- package/dist/geometry.test.js +155 -0
- package/dist/icons.d.ts +69 -0
- package/dist/icons.js +103 -0
- package/dist/icons.test.d.ts +1 -0
- package/dist/icons.test.js +140 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +9 -0
- package/dist/mark.d.ts +42 -0
- package/dist/mark.js +102 -0
- package/dist/primitives.d.ts +75 -0
- package/dist/primitives.js +126 -0
- package/dist/primitives.test.d.ts +1 -0
- package/dist/primitives.test.js +134 -0
- package/dist/render.d.ts +97 -0
- package/dist/render.js +1085 -0
- package/dist/render.test.d.ts +1 -0
- package/dist/render.test.js +179 -0
- package/dist/specimen.d.ts +2 -0
- package/dist/specimen.gen.d.ts +1 -0
- package/dist/specimen.gen.js +9 -0
- package/dist/specimen.js +81 -0
- package/dist/stylesheet.d.ts +95 -0
- package/dist/stylesheet.js +987 -0
- package/dist/stylesheet.test.d.ts +1 -0
- package/dist/stylesheet.test.js +265 -0
- package/dist/text.d.ts +28 -0
- package/dist/text.js +89 -0
- package/dist/tokens.d.ts +104 -0
- package/dist/tokens.js +142 -0
- package/dist/tokens.test.d.ts +1 -0
- package/dist/tokens.test.js +125 -0
- package/fonts/IBMPlexMono-Regular-Latin1.woff2 +0 -0
- package/fonts/IBMPlexMono-SemiBold-Latin1.woff2 +0 -0
- package/fonts/IBMPlexSans-Italic-Latin1.woff2 +0 -0
- package/fonts/IBMPlexSans-Medium-Latin1.woff2 +0 -0
- package/fonts/IBMPlexSans-Regular-Latin1.woff2 +0 -0
- package/fonts/IBMPlexSans-SemiBold-Latin1.woff2 +0 -0
- package/fonts/LICENSE.txt +93 -0
- 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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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
|
+
}
|