reladraw 0.0.1 → 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/dist/render.js ADDED
@@ -0,0 +1,1164 @@
1
+ import { ARROW_MARKER_WIDTH, ATTACH_MARGIN, ATTACH_STEP, DECK_STEP, DEFAULT_FONT_SIZE, ICON_GAP, ICON_LINES, LINE_WIDTH, PAD, fontSizeFor, labelExtent, labelStyleFor, } from './constants.js';
2
+ import { describeAxis } from './ast.js';
3
+ import { SourceError } from './errors.js';
4
+ import { ICON_STROKE, iconFor, shapeFor } from './icons.js';
5
+ import { monospaceMeasurer } from './measure.js';
6
+ /**
7
+ * Sampled out of `examples/reference/arch.png` rather than invented,
8
+ * so the benchmark render and the drawing it is measured against differ by
9
+ * geometry and typography alone. A container is a shade off the page and barely
10
+ * outlined; a leaf is the navy that carries the diagram's weight.
11
+ */
12
+ export const DARK_THEME = {
13
+ background: '#111111',
14
+ boxFill: '#191728',
15
+ boxStroke: '#4f5367',
16
+ containerFill: '#191920',
17
+ containerStroke: '#25242f',
18
+ text: '#d9d9d9',
19
+ mutedText: '#8b8b8b',
20
+ link: '#5c5c7c',
21
+ // Both sampled off the reference's machine glyphs. Note that the reference
22
+ // gives each icon its own hue — the drive is grey, the laptop periwinkle, the
23
+ // workstation violet — which is a drawing tool's per-shape default and not a
24
+ // system. One pair for the whole set is the deliberate difference: an icon
25
+ // should read as part of the diagram's palette, not as clip art dropped in.
26
+ iconInk: '#8d8d8e',
27
+ iconShade: '#3e3d58',
28
+ };
29
+ const CORNER = 8;
30
+ /** Turn solved geometry into a standalone SVG document. */
31
+ export function render(layout, options = {}) {
32
+ const measurer = options.measurer ?? monospaceMeasurer();
33
+ const fontSize = options.fontSize ?? DEFAULT_FONT_SIZE;
34
+ // `diagram background:` is the author overruling the theme for this one
35
+ // drawing, so it is folded in here and everything downstream sees one theme.
36
+ const base = options.theme ?? DARK_THEME;
37
+ const stated = layout.diagram['background'];
38
+ const theme = stated === undefined ? base : { ...base, background: stated };
39
+ const body = [];
40
+ for (const root of layout.roots) {
41
+ body.push(drawNode(root, theme, measurer, fontSize));
42
+ }
43
+ // Everything the boxes cover. Links are added to it as they are drawn.
44
+ let ink = { minX: 0, minY: 0, maxX: layout.width, maxY: layout.height };
45
+ // Endpoints are planned for every link at once, because where a link meets a
46
+ // side depends on what else meets that same side. Corridors come after, for
47
+ // the same reason in the other direction: which lane of a gap a link takes
48
+ // is ordered by where its ends turned out to be.
49
+ const ends = planEndpoints(layout.links, measurer, fontSize);
50
+ const corridors = planCorridors(layout.links, ends, measurer, fontSize);
51
+ aimFreeEnds(layout.links, ends, corridors);
52
+ for (const link of layout.links) {
53
+ const drawn = drawLink(link, ends.get(link), corridors.get(link), theme, measurer, fontSize);
54
+ body.push(drawn.svg);
55
+ ink = union(ink, grow(drawn.ink, layout.margin));
56
+ }
57
+ // A link's geometry is measured rather than solved for, so the resolver sized
58
+ // the canvas from the boxes alone. A curve out of a `top` side, or a label
59
+ // riding above one, lands outside that — so the page grows to hold it and the
60
+ // origin moves with it, rather than the drawing being quietly clipped.
61
+ const canvas = {
62
+ x: Math.floor(ink.minX),
63
+ y: Math.floor(ink.minY),
64
+ width: Math.ceil(ink.maxX) - Math.floor(ink.minX),
65
+ height: Math.ceil(ink.maxY) - Math.floor(ink.minY),
66
+ };
67
+ const arrowColours = new Set(layout.links.map((link) => colourOf(link.appearance, theme.link)));
68
+ return [
69
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${canvas.width}" height="${canvas.height}" viewBox="${canvas.x} ${canvas.y} ${canvas.width} ${canvas.height}" font-family=${quote(measurer.fontFamily)} font-size="${fontSize}px">`,
70
+ ' <defs>',
71
+ ...[...arrowColours].map((colour) => arrowMarker(colour)),
72
+ ' </defs>',
73
+ ` <rect x="${canvas.x}" y="${canvas.y}" width="${canvas.width}" height="${canvas.height}" fill="${theme.background}"/>`,
74
+ ...body,
75
+ '</svg>',
76
+ '',
77
+ ].join('\n');
78
+ }
79
+ // --- nodes -------------------------------------------------------------------
80
+ function drawNode(node, theme, measurer, fontSize) {
81
+ // A note is set smaller than a box label by default, and `size:` overrides
82
+ // that on anything. Only this node's own text takes the size — children are
83
+ // drawn by their own call and carry whatever they say themselves.
84
+ const size = fontSizeFor(node.kind, node.appearance, fontSize, node.line);
85
+ const textHeight = measurer.lineHeight(size);
86
+ if (node.kind === 'note') {
87
+ return sized(textBlock(node.lines, node.x, node.y, node.width, textHeight, size, {
88
+ colour: colourOf(node.appearance, theme.text),
89
+ align: 'start',
90
+ }), size, fontSize);
91
+ }
92
+ const shape = shapeFor(node.appearance, node.line);
93
+ const labelStyle = labelStyleFor(node.label, node.line);
94
+ const glyphSide = ICON_LINES * textHeight;
95
+ if (shape.body !== undefined) {
96
+ // No outline, no fill, no padding — the node is the picture. The label, if
97
+ // there is one, sits under it and centred.
98
+ const drawn = [drawIcon(shape.body, node.x + (node.width - glyphSide) / 2, node.y, glyphSide, theme)];
99
+ if (node.lines.some((line) => line.length > 0)) {
100
+ drawn.push(sized(textBlock(node.lines, node.x, node.y + glyphSide + ICON_GAP, node.width, textHeight, size, {
101
+ colour: colourOf(node.appearance, theme.text),
102
+ subColour: subtextOf(node.appearance, theme),
103
+ align: 'middle',
104
+ }), size, fontSize));
105
+ }
106
+ return drawn.join('\n');
107
+ }
108
+ const parts = [];
109
+ const face = faceOf(node);
110
+ const container = node.children.length > 0;
111
+ const stroke = colourOf(node.appearance, container ? theme.containerStroke : theme.boxStroke);
112
+ const fill = fillOf(node.appearance, container ? theme.containerFill : theme.boxFill);
113
+ const subColour = subtextOf(node.appearance, theme);
114
+ // Deck copies sit behind the front face, furthest back drawn first.
115
+ for (let depth = node.deckLabels.length; depth >= 1; depth -= 1) {
116
+ const x = face.x - depth * DECK_STEP;
117
+ const y = face.y - depth * DECK_STEP;
118
+ parts.push(` <path d="${outlinePath(shape.outline, x, y, face.width, face.height)}" fill="${theme.containerFill}" stroke="${stroke}"/>`);
119
+ const label = node.deckLabels[depth - 1];
120
+ if (label !== undefined) {
121
+ parts.push(sized(textBlock([label], x + PAD, y + PAD, face.width - PAD * 2, textHeight, size, {
122
+ colour: theme.text,
123
+ align: 'start',
124
+ }), size, fontSize));
125
+ }
126
+ }
127
+ parts.push(` <path d="${outlinePath(shape.outline, face.x, face.y, face.width, face.height)}" fill="${fill}" stroke="${stroke}"/>`);
128
+ for (const extra of outlineDetail(shape.outline, face.x, face.y, face.width, face.height)) {
129
+ parts.push(` <path d="${extra}" fill="none" stroke="${stroke}"/>`);
130
+ }
131
+ // The icon takes a column on the right and the label lays out in what is
132
+ // left, which is the room the resolver already reserved for exactly this.
133
+ const icon = iconFor(node.appearance, node.line);
134
+ const iconSide = icon === undefined ? 0 : glyphSide;
135
+ const hasLabel = node.lines.some((line) => line.length > 0);
136
+ const iconRoom = icon === undefined ? 0 : iconSide + (hasLabel ? ICON_GAP : 0);
137
+ if (!container) {
138
+ // A leaf centres its label in the box, both ways — in the room beside the
139
+ // icon rather than the whole box, so the two sit side by side.
140
+ const top = face.y + (face.height - node.lines.length * textHeight) / 2;
141
+ parts.push(sized(textBlock(node.lines, face.x, top, face.width - iconRoom, textHeight, size, {
142
+ colour: theme.text,
143
+ subColour,
144
+ align: 'middle',
145
+ }), size, fontSize));
146
+ }
147
+ else {
148
+ // The label and the icon share a band at one end of the box, and the
149
+ // resolver has already given the contents the other end. A heading is
150
+ // ranged left at the top; a caption is centred at the bottom.
151
+ const band = Math.max(node.lines.length * textHeight, iconSide);
152
+ const bandTop = labelStyle.at === 'top' ? face.y + PAD : face.y + face.height - PAD - band;
153
+ parts.push(sized(textBlock(node.lines, face.x + PAD, bandTop, face.width - PAD * 2 - iconRoom, textHeight, size, {
154
+ colour: theme.text,
155
+ subColour,
156
+ align: labelStyle.align,
157
+ }), size, fontSize));
158
+ for (const child of node.children) {
159
+ parts.push(drawNode(child, theme, measurer, fontSize));
160
+ }
161
+ }
162
+ if (icon !== undefined) {
163
+ // A container's icon rides in the label's band, at whichever end that is; a
164
+ // leaf's label is centred, so the icon centres with it. Both follow the
165
+ // label rather than being placed by a rule of their own, which is what
166
+ // keeps an icon reading as part of the title block and not as a sticker.
167
+ const left = face.x + face.width - PAD - iconSide;
168
+ const top = container
169
+ ? labelStyle.at === 'top'
170
+ ? face.y + PAD
171
+ : face.y + face.height - PAD - iconSide
172
+ : face.y + (face.height - iconSide) / 2;
173
+ parts.push(drawIcon(icon, left, top, iconSide, theme));
174
+ }
175
+ return parts.join('\n');
176
+ }
177
+ /**
178
+ * How far the dog-ear cuts into the top-right corner of a `document`.
179
+ *
180
+ * Twice the corner radius, so it is the same size on every box however wide.
181
+ * The reference sizes its fold as a fraction of the box, which is why the fold
182
+ * on those two wide `pg_dump` boxes almost disappears — the idea was right and
183
+ * only the scaling was wrong.
184
+ */
185
+ const FOLD = CORNER * 2;
186
+ /** The node's outline, as path data. */
187
+ function outlinePath(shape, x, y, w, h) {
188
+ const r = CORNER;
189
+ if (shape === 'document') {
190
+ // Every corner rounded but the top-right one, which is cut away and folded.
191
+ return [
192
+ `M${round(x + r)} ${round(y)}`,
193
+ `H${round(x + w - FOLD)}`,
194
+ `L${round(x + w)} ${round(y + FOLD)}`,
195
+ `V${round(y + h - r)}`,
196
+ `a${r} ${r} 0 0 1 ${-r} ${r}`,
197
+ `H${round(x + r)}`,
198
+ `a${r} ${r} 0 0 1 ${-r} ${-r}`,
199
+ `V${round(y + r)}`,
200
+ `a${r} ${r} 0 0 1 ${r} ${-r}`,
201
+ 'Z',
202
+ ].join(' ');
203
+ }
204
+ return [
205
+ `M${round(x + r)} ${round(y)}`,
206
+ `H${round(x + w - r)}`,
207
+ `a${r} ${r} 0 0 1 ${r} ${r}`,
208
+ `V${round(y + h - r)}`,
209
+ `a${r} ${r} 0 0 1 ${-r} ${r}`,
210
+ `H${round(x + r)}`,
211
+ `a${r} ${r} 0 0 1 ${-r} ${-r}`,
212
+ `V${round(y + r)}`,
213
+ `a${r} ${r} 0 0 1 ${r} ${-r}`,
214
+ 'Z',
215
+ ].join(' ');
216
+ }
217
+ /** Lines drawn inside the outline: the flap of a fold, and nothing else so far. */
218
+ function outlineDetail(shape, x, y, w, h) {
219
+ void h;
220
+ if (shape !== 'document')
221
+ return [];
222
+ return [
223
+ `M${round(x + w - FOLD)} ${round(y)} V${round(y + FOLD)} H${round(x + w)}`,
224
+ ];
225
+ }
226
+ /** One icon, scaled from its own grid onto a square of `side` at `x, y`. */
227
+ function drawIcon(icon, x, y, side, theme) {
228
+ const scale = side / icon.grid;
229
+ const colour = (tone) => tone === 'ink' ? theme.iconInk : tone === 'shade' ? theme.iconShade : theme.background;
230
+ const paths = icon.paths.map((path) => {
231
+ const fill = path.fill === undefined ? 'none' : colour(path.fill);
232
+ const stroke = path.stroke === undefined
233
+ ? ''
234
+ : ` stroke="${colour(path.stroke)}" stroke-width="${ICON_STROKE}" stroke-linejoin="round"`;
235
+ return ` <path d="${path.d}" fill="${fill}"${stroke}/>`;
236
+ });
237
+ return [
238
+ ` <g transform="translate(${round(x)} ${round(y)}) scale(${round(scale * 1000) / 1000})">`,
239
+ ...paths,
240
+ ' </g>',
241
+ ].join('\n');
242
+ }
243
+ // --- links -------------------------------------------------------------------
244
+ function drawLink(link, ends, corridor, theme, measurer, fontSize) {
245
+ const { start, end } = ends;
246
+ const colour = colourOf(link.appearance, theme.link);
247
+ const markerEnd = ` marker-end="url(#${markerId(colour)})"`;
248
+ const markerStart = link.both ? ` marker-start="url(#${markerId(colour)}-back)"` : '';
249
+ // A named side is a statement about how the line should leave or arrive, so
250
+ // it is drawn as a curve that actually does leave and arrive that way. With
251
+ // neither side named there is nothing to honour and the line stays straight.
252
+ const curved = start.side !== undefined || end.side !== undefined;
253
+ const parts = [];
254
+ // What the line actually covers, so the canvas can be sized to hold it. A
255
+ // curve leaving a `top` side rides above every box in the drawing, and the
256
+ // node bounds know nothing about it.
257
+ let ink = extentOfPoints([start, end]);
258
+ let midX;
259
+ let midY;
260
+ if (corridor) {
261
+ const path = corridorPath(start, end, corridor);
262
+ ink = union(ink, path.ink);
263
+ parts.push(` <path d="${path.d}" fill="none" stroke="${colour}" stroke-width="${LINE_WIDTH}"${markerEnd}${markerStart}/>`);
264
+ // The label goes on the straight run rather than at the midpoint of the
265
+ // whole path, so it sits in the gap the author asked the line to travel.
266
+ midX = path.mid.x;
267
+ midY = path.mid.y;
268
+ }
269
+ else if (curved) {
270
+ const reach = controlReach(start, end);
271
+ // A bundle whose sides were too short to spread it takes the rest of the
272
+ // room in the middle, exactly as a straight group does — see `bowBundles`.
273
+ // Displacing both control points equally moves the curve's middle by three
274
+ // quarters as much, so the bow is scaled up by the inverse of that.
275
+ const lift = 4 / 3;
276
+ const bx = (ends.bow?.x ?? 0) * lift;
277
+ const by = (ends.bow?.y ?? 0) * lift;
278
+ const c1 = { x: start.x + start.tx * reach + bx, y: start.y + start.ty * reach + by };
279
+ const c2 = { x: end.x + end.tx * reach + bx, y: end.y + end.ty * reach + by };
280
+ parts.push(` <path d="M ${round(start.x)} ${round(start.y)} C ${round(c1.x)} ${round(c1.y)}, ${round(c2.x)} ${round(c2.y)}, ${round(end.x)} ${round(end.y)}" fill="none" stroke="${colour}" stroke-width="${LINE_WIDTH}"${markerEnd}${markerStart}/>`);
281
+ ink = union(ink, cubicExtent(start, c1, c2, end));
282
+ // The point halfway along a cubic, which is where the label belongs.
283
+ midX = (start.x + 3 * c1.x + 3 * c2.x + end.x) / 8;
284
+ midY = (start.y + 3 * c1.y + 3 * c2.y + end.y) / 8;
285
+ }
286
+ else if (ends.bow && (ends.bow.x !== 0 || ends.bow.y !== 0)) {
287
+ // A straight line that could not get the room it needed at its ends, so it
288
+ // takes it in the middle. Both control points carry the same displacement,
289
+ // which keeps the arc symmetric; a cubic's middle moves three quarters of
290
+ // the way its controls do, so the displacement is the bow scaled up by that.
291
+ const lift = 4 / 3;
292
+ const run = { x: (end.x - start.x) / 3, y: (end.y - start.y) / 3 };
293
+ const c1 = {
294
+ x: start.x + run.x + ends.bow.x * lift,
295
+ y: start.y + run.y + ends.bow.y * lift,
296
+ };
297
+ const c2 = {
298
+ x: end.x - run.x + ends.bow.x * lift,
299
+ y: end.y - run.y + ends.bow.y * lift,
300
+ };
301
+ parts.push(` <path d="M ${round(start.x)} ${round(start.y)} C ${round(c1.x)} ${round(c1.y)}, ${round(c2.x)} ${round(c2.y)}, ${round(end.x)} ${round(end.y)}" fill="none" stroke="${colour}" stroke-width="${LINE_WIDTH}"${markerEnd}${markerStart}/>`);
302
+ ink = union(ink, cubicExtent(start, c1, c2, end));
303
+ midX = (start.x + 3 * c1.x + 3 * c2.x + end.x) / 8;
304
+ midY = (start.y + 3 * c1.y + 3 * c2.y + end.y) / 8;
305
+ }
306
+ else {
307
+ parts.push(` <line x1="${round(start.x)}" y1="${round(start.y)}" x2="${round(end.x)}" y2="${round(end.y)}" stroke="${colour}" stroke-width="${LINE_WIDTH}"${markerEnd}${markerStart}/>`);
308
+ midX = (start.x + end.x) / 2;
309
+ midY = (start.y + end.y) / 2;
310
+ }
311
+ if (link.label !== undefined) {
312
+ // A link label breaks on ` / ` exactly as a box label does, so a two-line
313
+ // caption on an arrow needs no vocabulary of its own. The block is centred
314
+ // on the midpoint, which keeps a one-line label where it has always been.
315
+ const size = fontSizeFor('link', link.appearance, fontSize, link.line);
316
+ const textHeight = measurer.lineHeight(size);
317
+ const { width, lines } = measurer.measure(link.label, size);
318
+ const height = lines.length * textHeight;
319
+ const top = midY - height / 2;
320
+ // The label knocks a hole in whatever it lands on rather than sitting in a
321
+ // chip of its own: an outlined box reads as a node, which is the one thing
322
+ // a label on a line is not.
323
+ parts.push(` <rect x="${round(midX - width / 2 - 5)}" y="${round(top)}" width="${round(width + 10)}" height="${round(height)}" fill="${theme.background}"/>`);
324
+ ink = union(ink, {
325
+ minX: midX - width / 2 - 5,
326
+ minY: top,
327
+ maxX: midX + width / 2 + 5,
328
+ maxY: top + height,
329
+ });
330
+ parts.push(sized(textBlock(lines, midX - width / 2, top, width, textHeight, size, {
331
+ // A coloured link carries its meaning into its label; an uncoloured
332
+ // one leaves the words to read as ordinary text.
333
+ colour: colourOf(link.appearance, theme.text),
334
+ align: 'middle',
335
+ }), size, fontSize));
336
+ }
337
+ // The stroke straddles the path, so half of it lies outside the geometry.
338
+ return { svg: parts.join('\n'), ink: grow(ink, LINE_WIDTH / 2) };
339
+ }
340
+ function union(a, b) {
341
+ return {
342
+ minX: Math.min(a.minX, b.minX),
343
+ minY: Math.min(a.minY, b.minY),
344
+ maxX: Math.max(a.maxX, b.maxX),
345
+ maxY: Math.max(a.maxY, b.maxY),
346
+ };
347
+ }
348
+ function grow(extent, by) {
349
+ return {
350
+ minX: extent.minX - by,
351
+ minY: extent.minY - by,
352
+ maxX: extent.maxX + by,
353
+ maxY: extent.maxY + by,
354
+ };
355
+ }
356
+ function extentOfPoints(points) {
357
+ return {
358
+ minX: Math.min(...points.map((p) => p.x)),
359
+ minY: Math.min(...points.map((p) => p.y)),
360
+ maxX: Math.max(...points.map((p) => p.x)),
361
+ maxY: Math.max(...points.map((p) => p.y)),
362
+ };
363
+ }
364
+ /**
365
+ * What a cubic actually covers, which is not what its control points cover. A
366
+ * handle reaching 140 pixels up carries the curve only about three quarters of
367
+ * that, and sizing the page off the handles would leave a visible band of empty
368
+ * canvas above every curved link. Solved rather than sampled: the extremes are
369
+ * the ends plus wherever the derivative — a quadratic — crosses zero.
370
+ */
371
+ function cubicExtent(p0, c1, c2, p3) {
372
+ const span = (a, b, c, d) => {
373
+ const values = [a, d];
374
+ // The derivative of the cubic, written as a quadratic in t.
375
+ const qa = 3 * (-a + 3 * b - 3 * c + d);
376
+ const qb = 6 * (a - 2 * b + c);
377
+ const qc = 3 * (b - a);
378
+ const roots = [];
379
+ if (Math.abs(qa) < 1e-9) {
380
+ if (Math.abs(qb) > 1e-9)
381
+ roots.push(-qc / qb);
382
+ }
383
+ else {
384
+ const disc = qb * qb - 4 * qa * qc;
385
+ if (disc >= 0) {
386
+ const root = Math.sqrt(disc);
387
+ roots.push((-qb + root) / (2 * qa), (-qb - root) / (2 * qa));
388
+ }
389
+ }
390
+ for (const t of roots) {
391
+ if (t <= 0 || t >= 1)
392
+ continue;
393
+ const u = 1 - t;
394
+ values.push(u * u * u * a + 3 * u * u * t * b + 3 * u * t * t * c + t * t * t * d);
395
+ }
396
+ return [Math.min(...values), Math.max(...values)];
397
+ };
398
+ const [minX, maxX] = span(p0.x, c1.x, c2.x, p3.x);
399
+ const [minY, maxY] = span(p0.y, c1.y, c2.y, p3.y);
400
+ return { minX, minY, maxX, maxY };
401
+ }
402
+ /** Walk out from the centre of a box toward a point, stopping at the border. */
403
+ function edgePoint(box, toward) {
404
+ const centre = centreOf(box);
405
+ const dx = toward.x - centre.x;
406
+ const dy = toward.y - centre.y;
407
+ if (dx === 0 && dy === 0)
408
+ return centre;
409
+ const scaleX = dx === 0 ? Infinity : box.width / 2 / Math.abs(dx);
410
+ const scaleY = dy === 0 ? Infinity : box.height / 2 / Math.abs(dy);
411
+ const scale = Math.min(scaleX, scaleY);
412
+ return { x: centre.x + dx * scale, y: centre.y + dy * scale };
413
+ }
414
+ // --- where a link meets a box -------------------------------------------------
415
+ const SIDES = ['top', 'bottom', 'left', 'right'];
416
+ /**
417
+ * Work out where every link meets every box.
418
+ *
419
+ * An author names a *side* — `to: top` — and never a point on it. Alone on a
420
+ * side a link lands at its centre; sharing the side with others, the points
421
+ * spread so they do not sit on top of each other. Which one goes where is
422
+ * derived from where the far ends actually are, never chosen: of two links
423
+ * arriving at one top edge, the one coming from further left arrives further
424
+ * left. That is the same rule as box non-overlap — the tool separates things by
425
+ * default, and reads the direction off the solved layout rather than asking.
426
+ *
427
+ * Where several links run between the *same* pair of sides that rule has
428
+ * nothing to read, and a `Bundle` supplies the order instead — see there.
429
+ */
430
+ function planEndpoints(links, measurer, fontSize) {
431
+ const claims = new Map();
432
+ const achieved = new Map();
433
+ const named = new Map();
434
+ const bundles = planBundles(links, measurer, fontSize);
435
+ const spreads = planSpreads(links, measurer, fontSize);
436
+ for (const link of links) {
437
+ named.set(link, {});
438
+ const fromSide = sideAttr(link, 'from');
439
+ const toSide = sideAttr(link, 'to');
440
+ if (fromSide) {
441
+ claim(claims, link.from, fromSide, {
442
+ link,
443
+ which: 'start',
444
+ side: fromSide,
445
+ toward: centreOf(faceOf(link.to)),
446
+ rank: rankIn(bundles.get(link), link, link.from, fromSide),
447
+ });
448
+ }
449
+ if (toSide) {
450
+ claim(claims, link.to, toSide, {
451
+ link,
452
+ which: 'end',
453
+ side: toSide,
454
+ toward: centreOf(faceOf(link.from)),
455
+ rank: rankIn(bundles.get(link), link, link.to, toSide),
456
+ });
457
+ }
458
+ }
459
+ // Place every claimed side, spreading the points that share one.
460
+ for (const [node, bySide] of claims) {
461
+ const face = faceOf(node);
462
+ for (const [side, group] of bySide) {
463
+ const along = side === 'top' || side === 'bottom' ? 'x' : 'y';
464
+ const span = along === 'x' ? face.width : face.height;
465
+ const origin = along === 'x' ? face.x : face.y;
466
+ // Far ends first, as ever; a bundle's own lane order settles the links
467
+ // that share one, which are precisely the ones the first key cannot.
468
+ const ordered = [...group].sort((a, b) => a.toward[along] - b.toward[along] || (a.rank ?? 0) - (b.rank ?? 0));
469
+ // A bundle's lanes have to hold whole labels apart rather than the points
470
+ // of two arrows, so its step is the one that governs the side it lands on.
471
+ const wanted = Math.max(ATTACH_STEP, ...group.map((entry) => bundles.get(entry.link)?.step ?? 0));
472
+ const usable = Math.max(0, span - ATTACH_MARGIN * 2);
473
+ const step = ordered.length > 1 ? Math.min(wanted, usable / (ordered.length - 1)) : 0;
474
+ const first = origin + span / 2 - (step * (ordered.length - 1)) / 2;
475
+ ordered.forEach((entry, index) => {
476
+ const at = first + index * step;
477
+ named.get(entry.link)[entry.which] = anchorOn(face, side, at);
478
+ });
479
+ // What the side could actually give, which is less than `wanted` when it
480
+ // is too short for the group. `bowBundles` makes up the difference.
481
+ let steps = achieved.get(node);
482
+ if (!steps)
483
+ achieved.set(node, (steps = new Map()));
484
+ steps.set(side, step);
485
+ }
486
+ }
487
+ const bows = bowBundles(bundles, achieved);
488
+ // Fill in the ends the author said nothing about, now that the named ones
489
+ // are known: an unnamed end aims at wherever its partner ended up.
490
+ const ends = new Map();
491
+ for (const link of links) {
492
+ const partial = named.get(link);
493
+ const fromFace = faceOf(link.from);
494
+ const toFace = faceOf(link.to);
495
+ // Several links between one pair of boxes with no side named anywhere: the
496
+ // line each would have drawn alone, moved aside so they do not coincide.
497
+ const spread = spreads.get(link);
498
+ if (spread) {
499
+ ends.set(link, { ...parallelEnds(fromFace, toFace, spread.offset), bow: spread.bow });
500
+ continue;
501
+ }
502
+ // With neither end named this is the straight line it always was, each end
503
+ // aiming at the other box's centre.
504
+ const start = partial.start ?? free(fromFace, partial.end ?? centreOf(toFace));
505
+ const end = partial.end ?? free(toFace, partial.start ?? centreOf(fromFace));
506
+ ends.set(link, { start, end, bow: bows.get(link) });
507
+ }
508
+ return ends;
509
+ }
510
+ /**
511
+ * Group the links that run between the same pair of sides, and work out the
512
+ * lane order and lane width each group needs.
513
+ *
514
+ * Only a link whose author named *both* sides can be in a bundle: a bundle is a
515
+ * statement about two specific edges, and an end with no side named has not
516
+ * picked one yet.
517
+ */
518
+ function planBundles(links, measurer, fontSize) {
519
+ const ids = new Map();
520
+ const idOf = (node) => {
521
+ let id = ids.get(node);
522
+ if (id === undefined) {
523
+ id = ids.size;
524
+ ids.set(node, id);
525
+ }
526
+ return id;
527
+ };
528
+ const groups = new Map();
529
+ for (const link of links) {
530
+ const fromSide = sideAttr(link, 'from');
531
+ const toSide = sideAttr(link, 'to');
532
+ if (!fromSide || !toSide || link.from === link.to)
533
+ continue;
534
+ const a = { node: link.from, side: fromSide };
535
+ const b = { node: link.to, side: toSide };
536
+ const keyA = `${idOf(a.node)}:${a.side}`;
537
+ const keyB = `${idOf(b.node)}:${b.side}`;
538
+ // The pair is unordered — `a -> b` and `b -> a` join the same two edges —
539
+ // so the key is canonical and the ends are stored in that same order.
540
+ const swap = keyB < keyA;
541
+ const key = swap ? `${keyB}|${keyA}` : `${keyA}|${keyB}`;
542
+ const ends = swap ? [b, a] : [a, b];
543
+ const group = groups.get(key);
544
+ if (group)
545
+ group.links.push(link);
546
+ else
547
+ groups.set(key, { ends, links: [link] });
548
+ }
549
+ const bundles = new Map();
550
+ for (const group of groups.values()) {
551
+ if (group.links.length < 2)
552
+ continue;
553
+ const [first, second] = group.ends;
554
+ const t0 = tangentOf(first.side);
555
+ const t1 = tangentOf(second.side);
556
+ const from = sideCentre(first);
557
+ const to = sideCentre(second);
558
+ const run = { x: to.x - from.x, y: to.y - from.y };
559
+ // Nesting is a matter of which side of the line each end steps toward. Step
560
+ // both ends to the same side of the run and the whole line translates;
561
+ // step them to opposite sides and it pivots, which is a crossing.
562
+ const aligned = cross(run, t0) * cross(run, t1) >= 0;
563
+ const sense = aligned ? 1 : -1;
564
+ // Two links leaving in opposite directions are the ordinary case, and which
565
+ // lane each takes is then read off the diagram rather than off the order the
566
+ // author happened to type them in: a line keeps to one side of its own run.
567
+ // Links pointing the same way have no such signal and fall back to the file.
568
+ const order = new Map(group.links.map((link, index) => [link, index]));
569
+ const lanes = [...group.links].sort((a, b) => Number(a.from !== first.node) - Number(b.from !== first.node) ||
570
+ order.get(a) - order.get(b));
571
+ // One lane apart moves a link's start by `step` along one side and its end
572
+ // by `step` along the other, so the midpoint of the line — which is where
573
+ // its label goes — moves by the average of the two.
574
+ const drift = { x: (t0.x + sense * t1.x) / 2, y: (t0.y + sense * t1.y) / 2 };
575
+ const bundle = {
576
+ ends: group.ends,
577
+ lanes,
578
+ aligned,
579
+ step: Math.max(ATTACH_STEP, laneStep(lanes, drift, measurer, fontSize)),
580
+ };
581
+ for (const link of lanes)
582
+ bundles.set(link, bundle);
583
+ }
584
+ return bundles;
585
+ }
586
+ /**
587
+ * The sideways offset each link takes when several run between the same two
588
+ * boxes and none of them names a side.
589
+ *
590
+ * An unnamed end has no side to spread along: it aims at the far box's centre
591
+ * and attaches wherever that ray crosses the border, so every link in such a
592
+ * group produces the *same* ray and they are drawn on top of one another —
593
+ * one visible line, every label stacked on one point. `planEndpoints` cannot
594
+ * see this and `planBundles` will not, since a bundle is a statement about two
595
+ * named edges.
596
+ *
597
+ * The repair keeps the attachment rule exactly as it is and only stops two
598
+ * links using it at the same place: the line a link would have drawn alone is
599
+ * translated across its own run by a lane, which is the straight-line version
600
+ * of the nesting a bundle already gives curves. A lone link is in no group and
601
+ * so is untouched.
602
+ *
603
+ * Where the boxes are too small to hold the group at full spacing, the ends
604
+ * are squeezed evenly to fit the edge — there is nowhere further to attach —
605
+ * and the shortfall is made up in the middle instead: each line bows across
606
+ * its run by exactly what its endpoints could not give it, so the labels, which
607
+ * ride at the midpoints, come apart even though the arrows do not. The bow is
608
+ * therefore derived rather than styled, and it is zero whenever the edge was
609
+ * long enough, which is why the ordinary case is still a straight line.
610
+ *
611
+ * A `between` link is left out. Its route is the corridor it named, its lane
612
+ * inside that corridor is `planCorridors`' business, and `aimFreeEnds` will
613
+ * re-aim these ends at the corridor afterwards regardless.
614
+ */
615
+ function planSpreads(links, measurer, fontSize) {
616
+ const ids = new Map();
617
+ const idOf = (node) => {
618
+ let id = ids.get(node);
619
+ if (id === undefined) {
620
+ id = ids.size;
621
+ ids.set(node, id);
622
+ }
623
+ return id;
624
+ };
625
+ const groups = new Map();
626
+ for (const link of links) {
627
+ if (sideAttr(link, 'from') || sideAttr(link, 'to'))
628
+ continue;
629
+ if (link.from === link.to || link.between)
630
+ continue;
631
+ const a = idOf(link.from);
632
+ const b = idOf(link.to);
633
+ const swap = b < a;
634
+ const key = swap ? `${b}|${a}` : `${a}|${b}`;
635
+ const first = swap ? link.to : link.from;
636
+ const group = groups.get(key);
637
+ if (group)
638
+ group.links.push(link);
639
+ else
640
+ groups.set(key, { first, links: [link] });
641
+ }
642
+ const spreads = new Map();
643
+ for (const group of groups.values()) {
644
+ if (group.links.length < 2)
645
+ continue;
646
+ const from = centreOf(faceOf(group.first));
647
+ const sample = group.links[0];
648
+ const other = sample.from === group.first ? sample.to : sample.from;
649
+ const to = centreOf(faceOf(other));
650
+ const dx = to.x - from.x;
651
+ const dy = to.y - from.y;
652
+ const length = Math.hypot(dx, dy) || 1;
653
+ // Translating the line moves its midpoint — where the label goes — by
654
+ // exactly this, so it is the drift `laneStep` needs.
655
+ const across = { x: -dy / length, y: dx / length };
656
+ // The same derived order a bundle uses: links pointing opposite ways each
657
+ // keep to one side of their own run, so a reciprocal pair reads as a
658
+ // circulation, and only links pointing the same way fall back to the file.
659
+ const order = new Map(group.links.map((link, index) => [link, index]));
660
+ const lanes = [...group.links].sort((a, b) => Number(a.from !== group.first) - Number(b.from !== group.first) ||
661
+ order.get(a) - order.get(b));
662
+ // How far a lane may be shifted before its line no longer passes through the
663
+ // box at all. `exitAlong` clamps beyond that, which piles the outer lanes
664
+ // onto a corner and puts their labels back on top of each other — so the
665
+ // group is squeezed evenly instead, exactly as `planEndpoints` squeezes a
666
+ // side too short for the links arriving on it, and just as silently.
667
+ const reach = (node) => {
668
+ const face = faceOf(node);
669
+ const byX = across.x === 0 ? Infinity : face.width / 2 / Math.abs(across.x);
670
+ const byY = across.y === 0 ? Infinity : face.height / 2 / Math.abs(across.y);
671
+ return Math.max(0, Math.min(byX, byY) - ATTACH_MARGIN);
672
+ };
673
+ // Which way lane 0 lies is arbitrary, so fix it the way the rest of the
674
+ // renderer does — toward increasing x, or increasing y where the run is
675
+ // horizontal. Without this the first link written is topmost on a rightward
676
+ // run and rightmost on a downward one, for no reason a reader could see.
677
+ const orient = across.x < 0 || (across.x === 0 && across.y < 0) ? -1 : 1;
678
+ const usable = 2 * Math.min(reach(group.first), reach(other));
679
+ const wanted = Math.max(ATTACH_STEP, laneStep(lanes, across, measurer, fontSize));
680
+ const step = Math.min(wanted, usable / (lanes.length - 1));
681
+ lanes.forEach((link, index) => {
682
+ const place = index - (lanes.length - 1) / 2;
683
+ // The lane is measured across the pair's own run, which has one direction;
684
+ // a link written the other way round travels the opposite way and would
685
+ // otherwise take the same offset to the opposite side, putting a
686
+ // reciprocal pair back on one line. Negated, both keep to their own left,
687
+ // which is the circulation a bundle already draws.
688
+ const sense = (link.from === group.first ? 1 : -1) * orient;
689
+ const shortfall = place * (wanted - step) * sense;
690
+ spreads.set(link, {
691
+ offset: place * step * sense,
692
+ bow: { x: across.x * shortfall, y: across.y * shortfall },
693
+ });
694
+ });
695
+ }
696
+ return spreads;
697
+ }
698
+ /**
699
+ * The bow each bundled link needs, where the sides it was given were too short
700
+ * to hold the group at the spacing its labels asked for.
701
+ *
702
+ * A named side is squeezed exactly as an unnamed group's edge is — the step
703
+ * shrinks to `usable / (n - 1)` and the labels ride down on top of each other —
704
+ * and until this existed, naming the two sides the tool would have chosen
705
+ * anyway made the picture strictly worse than saying nothing. That is not a
706
+ * line worth defending, so the same repair applies: a lane's midpoint is not
707
+ * on an edge and is free to move, and each line makes up in the middle exactly
708
+ * what its two ends could not give it.
709
+ *
710
+ * The shortfall is a vector because the two ends move along different sides.
711
+ * `drift` is how far a lane's midpoint travels per unit of step — the average
712
+ * of the two ends' displacements, which is what `laneStep` sized the step
713
+ * against — so the room a lane wanted is `drift * step`, the room it got is the
714
+ * same average taken over the steps the two sides actually managed, and the
715
+ * bow is the difference. It is zero whenever both sides were long enough,
716
+ * which is why nothing that already fitted has moved.
717
+ */
718
+ function bowBundles(bundles, achieved) {
719
+ const bows = new Map();
720
+ const stepOn = (end) => achieved.get(end.node)?.get(end.side) ?? 0;
721
+ for (const bundle of new Set(bundles.values())) {
722
+ const [first, second] = bundle.ends;
723
+ const t0 = tangentOf(first.side);
724
+ const t1 = tangentOf(second.side);
725
+ const sense = bundle.aligned ? 1 : -1;
726
+ const drift = { x: (t0.x + sense * t1.x) / 2, y: (t0.y + sense * t1.y) / 2 };
727
+ const s0 = stepOn(first);
728
+ const s1 = stepOn(second);
729
+ const got = { x: (s0 * t0.x + sense * s1 * t1.x) / 2, y: (s0 * t0.y + sense * s1 * t1.y) / 2 };
730
+ const short = {
731
+ x: drift.x * bundle.step - got.x,
732
+ y: drift.y * bundle.step - got.y,
733
+ };
734
+ if (short.x === 0 && short.y === 0)
735
+ continue;
736
+ bundle.lanes.forEach((link, index) => {
737
+ const place = index - (bundle.lanes.length - 1) / 2;
738
+ bows.set(link, { x: short.x * place, y: short.y * place });
739
+ });
740
+ }
741
+ return bows;
742
+ }
743
+ /**
744
+ * How far apart adjacent lanes must sit for their labels to clear each other.
745
+ *
746
+ * The labels are knockout rectangles, so two of them clear when they are apart
747
+ * on *either* axis — hence the smaller of the two answers. `drift` is how far
748
+ * the midpoint travels per unit of step, and it is never zero: the two ends
749
+ * cancel only when both sides run the same way, and two such sides are always
750
+ * `aligned`, which adds rather than subtracts.
751
+ */
752
+ function laneStep(lanes, drift, measurer, fontSize) {
753
+ const labelled = lanes.filter((link) => link.label !== undefined);
754
+ if (labelled.length < 2)
755
+ return 0;
756
+ const need = (axis) => Math.max(...labelled.map((link) => labelExtent(link.label, link.appearance, axis, measurer, fontSize, link.line)));
757
+ const along = (axis, reach) => reach === 0 ? Infinity : need(axis) / Math.abs(reach);
758
+ return Math.min(along('x', drift.x), along('y', drift.y));
759
+ }
760
+ /** Which lane of its bundle a link's end at this side takes, if it is in one. */
761
+ function rankIn(bundle, link, node, side) {
762
+ if (!bundle)
763
+ return undefined;
764
+ const lane = bundle.lanes.indexOf(link);
765
+ const [first, second] = bundle.ends;
766
+ if (node === first.node && side === first.side)
767
+ return lane;
768
+ if (node === second.node && side === second.side)
769
+ return bundle.aligned ? lane : -lane;
770
+ return undefined;
771
+ }
772
+ /** The unit vector along a side, pointing the way that coordinate increases. */
773
+ function tangentOf(side) {
774
+ return side === 'top' || side === 'bottom' ? { x: 1, y: 0 } : { x: 0, y: 1 };
775
+ }
776
+ /** The midpoint of one side of a box. */
777
+ function sideCentre(end) {
778
+ const face = faceOf(end.node);
779
+ const along = end.side === 'top' || end.side === 'bottom' ? face.width : face.height;
780
+ const origin = end.side === 'top' || end.side === 'bottom' ? face.x : face.y;
781
+ return anchorOn(face, end.side, origin + along / 2);
782
+ }
783
+ function cross(a, b) {
784
+ return a.x * b.y - a.y * b.x;
785
+ }
786
+ function claim(claims, node, side, entry) {
787
+ let bySide = claims.get(node);
788
+ if (!bySide) {
789
+ bySide = new Map();
790
+ claims.set(node, bySide);
791
+ }
792
+ const group = bySide.get(side);
793
+ if (group)
794
+ group.push(entry);
795
+ else
796
+ bySide.set(side, [entry]);
797
+ }
798
+ /** The point `at` along one side of a box, with the outward normal for that side. */
799
+ function anchorOn(face, side, at) {
800
+ switch (side) {
801
+ case 'top':
802
+ return { x: at, y: face.y, tx: 0, ty: -1, side };
803
+ case 'bottom':
804
+ return { x: at, y: face.y + face.height, tx: 0, ty: 1, side };
805
+ case 'left':
806
+ return { x: face.x, y: at, tx: -1, ty: 0, side };
807
+ case 'right':
808
+ return { x: face.x + face.width, y: at, tx: 1, ty: 0, side };
809
+ }
810
+ }
811
+ /** An end with no side named: leave from the border, pointing at the far end. */
812
+ function free(face, toward) {
813
+ const point = edgePoint(face, toward);
814
+ const centre = centreOf(face);
815
+ const dx = point.x - centre.x;
816
+ const dy = point.y - centre.y;
817
+ const length = Math.hypot(dx, dy) || 1;
818
+ return { x: point.x, y: point.y, tx: dx / length, ty: dy / length };
819
+ }
820
+ /**
821
+ * Walk from a point inside a box along a direction, stopping at the border.
822
+ *
823
+ * `edgePoint` walks from the centre, which is the only place a single line
824
+ * passes through. A fanned-out group's lines are parallel to that one and
825
+ * beside it, so each needs the border crossing of its own line rather than of
826
+ * the centre's — which is what keeps the group parallel instead of splayed.
827
+ */
828
+ function exitAlong(box, from, dir) {
829
+ // A shift wider than the box leaves the origin outside it; clamping back in
830
+ // is the graceful answer, and the crowding it signals is a diagnostic.
831
+ const x = Math.min(Math.max(from.x, box.x), box.x + box.width);
832
+ const y = Math.min(Math.max(from.y, box.y), box.y + box.height);
833
+ const tx = dir.x === 0 ? Infinity : ((dir.x > 0 ? box.x + box.width : box.x) - x) / dir.x;
834
+ const ty = dir.y === 0 ? Infinity : ((dir.y > 0 ? box.y + box.height : box.y) - y) / dir.y;
835
+ const t = Math.min(tx, ty);
836
+ if (!Number.isFinite(t))
837
+ return { x, y };
838
+ return { x: x + dir.x * Math.max(0, t), y: y + dir.y * Math.max(0, t) };
839
+ }
840
+ /**
841
+ * Both ends of a link that named no side, moved `offset` sideways across its
842
+ * own run.
843
+ *
844
+ * The whole line is translated rather than each end being nudged along its
845
+ * border, so the result is genuinely parallel to the line the link would have
846
+ * drawn alone, exactly `offset` away from it. Where each end lands then falls
847
+ * out of that: level boxes put both points further along the same two edges,
848
+ * and a diagonal pair whose line leaves through a corner puts one point on each
849
+ * of the two edges meeting there. Neither is a case in the code.
850
+ */
851
+ function parallelEnds(from, to, offset) {
852
+ const a = centreOf(from);
853
+ const b = centreOf(to);
854
+ const dx = b.x - a.x;
855
+ const dy = b.y - a.y;
856
+ const length = Math.hypot(dx, dy) || 1;
857
+ const dir = { x: dx / length, y: dy / length };
858
+ const across = { x: -dir.y * offset, y: dir.x * offset };
859
+ const startAt = exitAlong(from, { x: a.x + across.x, y: a.y + across.y }, dir);
860
+ const endAt = exitAlong(to, { x: b.x + across.x, y: b.y + across.y }, { x: -dir.x, y: -dir.y });
861
+ return {
862
+ start: { x: startAt.x, y: startAt.y, tx: dir.x, ty: dir.y },
863
+ end: { x: endAt.x, y: endAt.y, tx: -dir.x, ty: -dir.y },
864
+ };
865
+ }
866
+ /** How far the control points sit off the ends. Proportional, but bounded. */
867
+ function controlReach(start, end) {
868
+ const distance = Math.hypot(end.x - start.x, end.y - start.y);
869
+ return Math.max(24, Math.min(140, distance * 0.4));
870
+ }
871
+ /** The free interval between two boxes on one axis, or nothing if they overlap. */
872
+ function clearance(aStart, aSize, bStart, bSize) {
873
+ if (aStart + aSize < bStart)
874
+ return { lo: aStart + aSize, hi: bStart };
875
+ if (bStart + bSize < aStart)
876
+ return { lo: bStart + bSize, hi: aStart };
877
+ return undefined;
878
+ }
879
+ /**
880
+ * Which gap `between a and b` means, and how far along it reaches.
881
+ *
882
+ * The axis is derived wherever the pair leaves only one answer, the same way a
883
+ * separation direction is: one node is above the other, or one is left of the
884
+ * other, and whichever it is says which axis the gap binds. Most pairs are like
885
+ * that, and for them the file says nothing about axes at all.
886
+ *
887
+ * A pair sitting diagonally has two gaps and needs the author to pick, which is
888
+ * what `wanted` carries. That is a tie-break rather than part of the statement:
889
+ * where it is not needed it may still be written, and is then checked rather
890
+ * than ignored, because a word that silently does nothing looks like a bug in
891
+ * the tool.
892
+ */
893
+ function gapBetween(a, b, aName, bName, wanted, line) {
894
+ const pair = `"${aName}" and "${bName}"`;
895
+ const found = {
896
+ y: clearance(a.y, a.height, b.y, b.height),
897
+ x: clearance(a.x, a.width, b.x, b.width),
898
+ };
899
+ if (wanted !== undefined && found[wanted] === undefined) {
900
+ const other = wanted === 'y' ? 'x' : 'y';
901
+ throw new SourceError(found[other]
902
+ ? `${pair} have no gap between them ${describeAxis(wanted)} — they are apart ${describeAxis(other)}, so drop the word or say "${describeAxis(other)}"`
903
+ : `${pair} touch or overlap, so there is no gap between them to pass through`, line);
904
+ }
905
+ if (wanted === undefined && found.y && found.x) {
906
+ throw new SourceError(`${pair} are apart both vertically and horizontally, so I cannot tell which gap you mean — write "between ${aName} and ${bName} vertically" for the gap above and below them, or "horizontally" for the gap beside them`, line);
907
+ }
908
+ // Whichever was asked for, or whichever is the only one there is.
909
+ const axis = wanted ?? (found.y ? 'y' : 'x');
910
+ const gap = found[axis];
911
+ if (!gap) {
912
+ throw new SourceError(`${pair} touch or overlap, so there is no gap between them to pass through`, line);
913
+ }
914
+ // The corridor reaches as far as the pair does on the other axis: that is the
915
+ // stretch over which the line is actually passing them.
916
+ return {
917
+ axis,
918
+ ...gap,
919
+ across: axis === 'y'
920
+ ? [Math.min(a.x, b.x), Math.max(a.x + a.width, b.x + b.width)]
921
+ : [Math.min(a.y, b.y), Math.max(a.y + a.height, b.y + b.height)],
922
+ };
923
+ }
924
+ /**
925
+ * Route every link that named a gap.
926
+ *
927
+ * Links sharing one gap share its lanes, spread like attachments on a side and
928
+ * ordered the same derived way — by where their ends actually sit, so the two
929
+ * arriving at Dropbox's left edge in one order run through the corridor in that
930
+ * same order and never cross.
931
+ */
932
+ function planCorridors(links, ends, measurer, fontSize) {
933
+ const plans = new Map();
934
+ const groups = new Map();
935
+ for (const link of links) {
936
+ if (!link.between)
937
+ continue;
938
+ const [first, second] = link.between.nodes;
939
+ const gap = gapBetween(faceOf(first), faceOf(second), first.name, second.name, link.between.axis, link.line);
940
+ // The pair names one gap however the author ordered them. The axis is in
941
+ // the key because a diagonal pair really does have two, and two links may
942
+ // legitimately name the same pair and take different ones.
943
+ const key = [gap.axis, ...[first.name, second.name].sort()].join(' ');
944
+ const group = groups.get(key);
945
+ if (group)
946
+ group.members.push(link);
947
+ else
948
+ groups.set(key, { ...gap, members: [link] });
949
+ }
950
+ for (const group of groups.values()) {
951
+ const along = (link) => {
952
+ const { start, end } = ends.get(link);
953
+ return (start[group.axis] + end[group.axis]) / 2;
954
+ };
955
+ const ordered = [...group.members].sort((a, b) => along(a) - along(b));
956
+ // Lanes are spread as attachments on a side are, including the squeeze when
957
+ // there is not enough room — see `planEndpoints`. The step is wider here,
958
+ // because a lane carries a whole label rather than the point of an arrow,
959
+ // and two lanes closer together than a label is deep would draw the labels
960
+ // over each other. Still derived, not chosen: it is the size of what is
961
+ // actually running along the corridor.
962
+ const span = group.hi - group.lo;
963
+ const usable = Math.max(0, span - ATTACH_MARGIN * 2);
964
+ const want = Math.max(ATTACH_STEP, ...ordered.map((link) => laneExtent(link, group.axis, measurer, fontSize)));
965
+ const step = ordered.length > 1 ? Math.min(want, usable / (ordered.length - 1)) : 0;
966
+ const firstLane = group.lo + span / 2 - (step * (ordered.length - 1)) / 2;
967
+ ordered.forEach((link, index) => {
968
+ const { start, end } = ends.get(link);
969
+ const run = group.axis === 'y' ? 'x' : 'y';
970
+ // The corridor binds only where the link is actually passing the pair, so
971
+ // its reach is the overlap of the pair's extent with the link's own.
972
+ const enterAt = Math.max(group.across[0], Math.min(start[run], end[run]));
973
+ const leaveAt = Math.min(group.across[1], Math.max(start[run], end[run]));
974
+ if (leaveAt <= enterAt) {
975
+ const [a, b] = link.between.nodes;
976
+ throw new SourceError(`this link never passes between "${a.name}" and "${b.name}"`, link.line);
977
+ }
978
+ const forward = end[run] >= start[run];
979
+ plans.set(link, {
980
+ axis: group.axis,
981
+ lane: firstLane + index * step,
982
+ enter: forward ? enterAt : leaveAt,
983
+ leave: forward ? leaveAt : enterAt,
984
+ });
985
+ });
986
+ }
987
+ return plans;
988
+ }
989
+ /**
990
+ * An end whose side the author did not name aims at the far box's centre, which
991
+ * is the wrong thing to aim at once the line has been told to go somewhere else
992
+ * on the way. Point those ends at the corridor instead.
993
+ */
994
+ function aimFreeEnds(links, ends, corridors) {
995
+ for (const link of links) {
996
+ const plan = corridors.get(link);
997
+ if (!plan)
998
+ continue;
999
+ const current = ends.get(link);
1000
+ const start = current.start.side === undefined
1001
+ ? free(faceOf(link.from), corridorPoint(plan, plan.enter))
1002
+ : current.start;
1003
+ const end = current.end.side === undefined
1004
+ ? free(faceOf(link.to), corridorPoint(plan, plan.leave))
1005
+ : current.end;
1006
+ ends.set(link, { start, end });
1007
+ }
1008
+ }
1009
+ /**
1010
+ * How much room a link's label takes across the corridor — its depth in a
1011
+ * horizontal channel, its width in a vertical one. Zero for an unlabelled link,
1012
+ * which needs no more than the arrow spacing.
1013
+ *
1014
+ * `labelExtent` measures the knockout along whichever axis it is handed, and the
1015
+ * axis wanted here is the one the channel is measured on rather than the one the
1016
+ * link runs along — a channel measured vertically carries links running
1017
+ * horizontally, and what has to fit between two lanes of it is a label's depth.
1018
+ */
1019
+ function laneExtent(link, axis, measurer, fontSize) {
1020
+ if (link.label === undefined)
1021
+ return 0;
1022
+ return labelExtent(link.label, link.appearance, axis, measurer, fontSize, link.line);
1023
+ }
1024
+ function corridorPoint(plan, at) {
1025
+ return plan.axis === 'y' ? { x: at, y: plan.lane } : { x: plan.lane, y: at };
1026
+ }
1027
+ /**
1028
+ * The path a corridor link takes: a curve out of its start into the gap, the
1029
+ * straight run along the gap, and a curve out of the gap to its end. It is
1030
+ * three pieces rather than one cubic because a single curve has no way to stay
1031
+ * inside an interval over part of its length — which is the whole claim the
1032
+ * author is making.
1033
+ */
1034
+ function corridorPath(start, end, plan) {
1035
+ const p1 = corridorPoint(plan, plan.enter);
1036
+ const p2 = corridorPoint(plan, plan.leave);
1037
+ const forward = plan.leave >= plan.enter ? 1 : -1;
1038
+ // Along the run the line travels one way, so that is its tangent at both ends
1039
+ // of the straight stretch — it enters the gap already going where the gap goes.
1040
+ const rt = plan.axis === 'y' ? { tx: forward, ty: 0 } : { tx: 0, ty: forward };
1041
+ const r1 = corridorReach(start, p1, plan.axis);
1042
+ const c1 = { x: start.x + start.tx * r1, y: start.y + start.ty * r1 };
1043
+ const c2 = { x: p1.x - rt.tx * r1, y: p1.y - rt.ty * r1 };
1044
+ const r2 = corridorReach(p2, end, plan.axis);
1045
+ const c3 = { x: p2.x + rt.tx * r2, y: p2.y + rt.ty * r2 };
1046
+ const c4 = { x: end.x + end.tx * r2, y: end.y + end.ty * r2 };
1047
+ const d = [
1048
+ `M ${round(start.x)} ${round(start.y)}`,
1049
+ `C ${round(c1.x)} ${round(c1.y)}, ${round(c2.x)} ${round(c2.y)}, ${round(p1.x)} ${round(p1.y)}`,
1050
+ `L ${round(p2.x)} ${round(p2.y)}`,
1051
+ `C ${round(c3.x)} ${round(c3.y)}, ${round(c4.x)} ${round(c4.y)}, ${round(end.x)} ${round(end.y)}`,
1052
+ ].join(' ');
1053
+ const ink = union(cubicExtent(start, c1, c2, p1), cubicExtent(p2, c3, c4, end));
1054
+ return { d, mid: { x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2 }, ink };
1055
+ }
1056
+ /**
1057
+ * How far the handles reach on an approach curve. Bounded by half the distance
1058
+ * available along the run as well as by the straight-line distance: both ends of
1059
+ * this curve point along the run, so handles longer than that would reach past
1060
+ * each other and bulge the line back the way it came.
1061
+ */
1062
+ function corridorReach(from, to, axis) {
1063
+ const run = Math.abs(axis === 'y' ? to.x - from.x : to.y - from.y);
1064
+ const distance = Math.hypot(to.x - from.x, to.y - from.y);
1065
+ return Math.min(140, Math.max(8, Math.min(distance * 0.4, run / 2)));
1066
+ }
1067
+ function sideAttr(link, key) {
1068
+ const value = link.attrs[key];
1069
+ if (value === undefined)
1070
+ return undefined;
1071
+ if (!SIDES.includes(value)) {
1072
+ throw new SourceError(`"${key}: ${value}" is not a side — use ${SIDES.join(', ')}`, link.line);
1073
+ }
1074
+ return value;
1075
+ }
1076
+ function arrowMarker(colour) {
1077
+ const id = markerId(colour);
1078
+ return [
1079
+ ` <marker id="${id}" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="${ARROW_MARKER_WIDTH}" markerHeight="${ARROW_MARKER_WIDTH}" orient="auto-start-reverse">`,
1080
+ ` <path d="M 0 0 L 10 5 L 0 10 z" fill="${colour}"/>`,
1081
+ ' </marker>',
1082
+ ` <marker id="${id}-back" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="${ARROW_MARKER_WIDTH}" markerHeight="${ARROW_MARKER_WIDTH}" orient="auto-start-reverse">`,
1083
+ ` <path d="M 0 0 L 10 5 L 0 10 z" fill="${colour}"/>`,
1084
+ ' </marker>',
1085
+ ].join('\n');
1086
+ }
1087
+ function markerId(colour) {
1088
+ return `arrow-${colour.replace(/[^a-zA-Z0-9]/g, '')}`;
1089
+ }
1090
+ /** The rectangle actually drawn. Differs from the node box only for a deck. */
1091
+ function faceOf(node) {
1092
+ return {
1093
+ x: node.x + node.inset,
1094
+ y: node.y + node.inset,
1095
+ width: node.width - node.inset,
1096
+ height: node.height - node.inset,
1097
+ };
1098
+ }
1099
+ function centreOf(box) {
1100
+ return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
1101
+ }
1102
+ /**
1103
+ * Wrap a block of text in its own size, but only when that differs from the
1104
+ * document's — everything at the default size inherits it from the <svg>
1105
+ * element, so an ordinary diagram's output is unchanged.
1106
+ */
1107
+ function sized(block, size, fontSize) {
1108
+ if (size === fontSize || block.length === 0)
1109
+ return block;
1110
+ return ` <g font-size="${size}px">\n${block}\n </g>`;
1111
+ }
1112
+ function textBlock(lines, x, top, width, lineHeight, fontSize, style) {
1113
+ const anchorX = style.align === 'middle' ? x + width / 2 : style.align === 'end' ? x + width : x;
1114
+ return lines
1115
+ .map((line, index) => {
1116
+ if (line.length === 0)
1117
+ return '';
1118
+ const baseline = top + index * lineHeight + lineHeight / 2 + fontSize * 0.35;
1119
+ // A label's first line is its name; anything after it is a qualifier, and
1120
+ // `subtext:` is how a box says that qualifier should read as secondary.
1121
+ const colour = index === 0 ? style.colour : style.subColour ?? style.colour;
1122
+ return ` <text x="${round(anchorX)}" y="${round(baseline)}" fill="${colour}" text-anchor="${style.align}">${escapeXml(line)}</text>`;
1123
+ })
1124
+ .filter((element) => element.length > 0)
1125
+ .join('\n');
1126
+ }
1127
+ /**
1128
+ * A colour is written as the viewer will receive it — `#14532d`, or any CSS
1129
+ * colour. The renderer keeps no list of colour words of its own, so a diagram
1130
+ * is never limited to the ones somebody remembered to add here.
1131
+ */
1132
+ function colourOf(appearance, fallback) {
1133
+ return appearance['stroke'] ?? fallback;
1134
+ }
1135
+ /**
1136
+ * The colour for every label line after the first, or undefined when the box
1137
+ * said nothing and all its lines should read alike. `muted` is the one reserved
1138
+ * word: it defers to the theme, so a label's qualifier stays readable when the
1139
+ * theme changes. Anything else is a colour, same as `stroke` and `fill` take.
1140
+ */
1141
+ function subtextOf(appearance, theme) {
1142
+ const named = appearance['subtext'];
1143
+ if (named === undefined)
1144
+ return undefined;
1145
+ if (named === 'muted')
1146
+ return theme.mutedText;
1147
+ return named;
1148
+ }
1149
+ function fillOf(appearance, fallback) {
1150
+ return appearance['fill'] ?? fallback;
1151
+ }
1152
+ function round(value) {
1153
+ return Math.round(value * 100) / 100;
1154
+ }
1155
+ /**
1156
+ * Escapes what has to be escaped in element content, and no more. A double
1157
+ * quote is legal there, and some SVG renderers mishandle `&quot;` in text.
1158
+ */
1159
+ function escapeXml(text) {
1160
+ return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
1161
+ }
1162
+ function quote(value) {
1163
+ return `"${value.replace(/"/g, "'")}"`;
1164
+ }