reladraw 0.2.0 → 0.3.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 CHANGED
@@ -1,8 +1,9 @@
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';
1
+ import { ARROW_MARKER_WIDTH, ATTACH_MARGIN, ATTACH_STEP, DECK_STEP, DEFAULT_FONT_SIZE, ICON_LINES, LINE_WIDTH, PAD, fontSizeFor, textExtent, textStyleFor, widestLine, } from './constants.js';
2
2
  import { describeAxis } from './ast.js';
3
3
  import { SourceError } from './errors.js';
4
- import { ICON_STROKE, iconFor, shapeFor } from './icons.js';
4
+ import { ICON_STROKE } from './icons.js';
5
5
  import { monospaceMeasurer } from './measure.js';
6
+ import { plain } from './text.js';
6
7
  /**
7
8
  * Sampled out of `examples/reference/arch.png` rather than invented,
8
9
  * so the benchmark render and the drawing it is measured against differ by
@@ -17,7 +18,7 @@ export const DARK_THEME = {
17
18
  containerStroke: '#25242f',
18
19
  text: '#d9d9d9',
19
20
  mutedText: '#8b8b8b',
20
- link: '#5c5c7c',
21
+ edge: '#5c5c7c',
21
22
  // Both sampled off the reference's machine glyphs. Note that the reference
22
23
  // gives each icon its own hue — the drive is gray, the laptop periwinkle, the
23
24
  // workstation violet — which is a drawing tool's per-shape default and not a
@@ -38,24 +39,24 @@ export function render(layout, options = {}) {
38
39
  const theme = stated === undefined ? base : { ...base, background: stated };
39
40
  const body = [];
40
41
  for (const root of layout.roots) {
41
- body.push(drawNode(root, theme, measurer, fontSize));
42
+ body.push(drawNode(root, theme, measurer, fontSize, layout.markup));
42
43
  }
43
- // Everything the boxes cover. Links are added to it as they are drawn.
44
+ // Everything the boxes cover. Edges are added to it as they are drawn.
44
45
  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
+ // Endpoints are planned for every edge at once, because where an edge meets a
46
47
  // 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
+ // the same reason in the other direction: which lane of a gap an edge takes
48
49
  // 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);
50
+ const ends = planEndpoints(layout.edges, measurer, fontSize);
51
+ const corridors = planCorridors(layout.edges, ends, measurer, fontSize);
52
+ aimFreeEnds(layout.edges, ends, corridors);
53
+ for (const edge of layout.edges) {
54
+ const drawn = drawEdge(edge, ends.get(edge), corridors.get(edge), theme, measurer, fontSize, layout.markup);
54
55
  body.push(drawn.svg);
55
56
  ink = union(ink, grow(drawn.ink, layout.margin));
56
57
  }
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
58
+ // An edge's geometry is measured rather than solved for, so the resolver sized
59
+ // the canvas from the boxes alone. A curve out of a `top` side, or a text
59
60
  // riding above one, lands outside that — so the page grows to hold it and the
60
61
  // origin moves with it, rather than the drawing being quietly clipped.
61
62
  const canvas = {
@@ -64,7 +65,7 @@ export function render(layout, options = {}) {
64
65
  width: Math.ceil(ink.maxX) - Math.floor(ink.minX),
65
66
  height: Math.ceil(ink.maxY) - Math.floor(ink.minY),
66
67
  };
67
- const arrowColors = new Set(layout.links.map((link) => lineOf(link.appearance, theme.link)));
68
+ const arrowColors = new Set(layout.edges.map((edge) => lineOf(edge.appearance, theme.edge)));
68
69
  return [
69
70
  `<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
71
  ' <defs>',
@@ -77,115 +78,134 @@ export function render(layout, options = {}) {
77
78
  ].join('\n');
78
79
  }
79
80
  // --- nodes -------------------------------------------------------------------
80
- function drawNode(node, theme, measurer, fontSize) {
81
- // A note is set smaller than a box label by default, and `size:` overrides
81
+ /**
82
+ * `<a href>` around whatever a node or an edge draws, when it named a
83
+ * destination.
84
+ *
85
+ * SVG has this natively, so a standalone SVG stays standalone and a rasteriser
86
+ * drops it, leaving a PNG unharmed. Plain `href` and not `xlink:href`: the
87
+ * SVG 1.1 spelling would need an `xmlns:xlink` on every drawing whether or not
88
+ * anything in it links anywhere, and every current browser takes the SVG 2 one.
89
+ *
90
+ * `target="_blank"` always, because the playground inlines the SVG into its own
91
+ * page and a click inside it would otherwise navigate the playground away;
92
+ * `rel="noopener"` goes with it as it does anywhere else.
93
+ *
94
+ * **An `<a>` is never nested inside another.** Nesting is the obvious way to let
95
+ * a container carry a destination while a child carries its own, and it does not
96
+ * work: Chrome draws nothing at all inside the inner one, so the child simply
97
+ * disappears from the picture. Every node's anchor therefore wraps only what
98
+ * that node draws — outline and text — and its children are emitted beside
99
+ * it, each wrapping itself. The reading comes out the same anyway, because the
100
+ * container's filled outline lies under the children and catches every click
101
+ * that does not land on one of them.
102
+ *
103
+ * A destination therefore reaches down the tree instead of enclosing it: a child
104
+ * that names none of its own is drawn inside an anchor carrying its container's,
105
+ * and one that names its own overrules it. That is the reading nesting would
106
+ * have given — the container catches every click its children do not — reached
107
+ * by repeating the destination rather than by wrapping.
108
+ */
109
+ function linked(svg, url) {
110
+ if (url === undefined || svg.length === 0)
111
+ return svg;
112
+ // An `&` between query parameters is ordinary in a url and illegal raw in an
113
+ // attribute, so the value is escaped as markup rather than merely quoted.
114
+ return ` <a href=${quote(escapeXml(url))} target="_blank" rel="noopener">\n${svg}\n </a>`;
115
+ }
116
+ function drawNode(node, theme, measurer, fontSize, markup, inherited) {
117
+ const url = node.attrs['url'] ?? inherited;
118
+ const { own, kids } = nodeSvg(node, theme, measurer, fontSize, markup, url);
119
+ return [linked(own.join('\n'), url), ...kids]
120
+ .filter((part) => part.length > 0)
121
+ .join('\n');
122
+ }
123
+ /**
124
+ * What a node draws, in two pieces: its own ink and its children's. They are
125
+ * kept apart so the node's `<a>` can wrap what is the node's without
126
+ * swallowing what is a child's.
127
+ */
128
+ function nodeSvg(node, theme, measurer, fontSize, markup, url) {
129
+ // A note is set smaller than a box text by default, and `size:` overrides
82
130
  // that on anything. Only this node's own text takes the size — children are
83
131
  // drawn by their own call and carry whatever they say themselves.
84
- const size = fontSizeFor(node.kind, node.appearance, fontSize, node.line);
132
+ const size = fontSizeFor(node.kind, node.textAttrs, fontSize, node.line);
85
133
  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
- color: textOf(node.appearance, theme.text),
89
- align: 'start',
90
- }), size, fontSize);
134
+ const blockWidth = widestLine(node.lines, measurer, size);
135
+ const ink = (run, own) => runInk(run, own, markup, theme);
136
+ if (node.body.kind === 'none') {
137
+ const style = textStyleFor(node.textAttrs, node.line, 'start', 'center');
138
+ return { kids: [], own: [sized(textBlock(node.lines, node.x, node.y, textHeight, size, node.textBox, {
139
+ color: textColorOf(node.textAttrs, theme, theme.text),
140
+ align: style.align,
141
+ ink,
142
+ }), size, fontSize)] };
91
143
  }
92
- const shape = shapeFor(node.appearance, node.line);
93
- const labelStyle = labelStyleFor(node.label, node.line);
94
144
  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 centered.
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
- color: textOf(node.appearance, theme.text),
102
- subColor: subtextOf(node.appearance, theme),
103
- align: 'middle',
145
+ if (node.body.kind === 'icon') {
146
+ // No outline, no fill, no padding — the node is the picture. The text, if
147
+ // there is one, sits under it. `at`'s vertical half has nothing to say
148
+ // here — the caption is under the picture and nowhere else — so only its
149
+ // horizontal half is read.
150
+ const style = textStyleFor(node.textAttrs, node.line, 'middle', 'center');
151
+ const drawn = [drawIcon(node.body.icon, node.x + (node.width - glyphSide) / 2, node.y, glyphSide, theme)];
152
+ if (node.lines.some((line) => plain(line).length > 0)) {
153
+ drawn.push(sized(textBlock(node.lines, node.x, node.y, textHeight, size, node.textBox, {
154
+ color: textColorOf(node.textAttrs, theme, theme.text),
155
+ align: style.align,
156
+ ink,
104
157
  }), size, fontSize));
105
158
  }
106
- return drawn.join('\n');
159
+ return { own: drawn, kids: [] };
107
160
  }
161
+ const outline = node.body.outline;
108
162
  const parts = [];
163
+ const kids = [];
109
164
  const face = faceOf(node);
110
- const container = node.children.length > 0;
165
+ // A container *looks* like one because things stack beside its text, not
166
+ // because it has children: a node whose only child sits beside its text is
167
+ // drawn as the leaf it reads as.
168
+ const container = node.banded;
111
169
  const border = borderOf(node.appearance, container ? theme.containerStroke : theme.boxStroke);
112
170
  const fill = fillOf(node.appearance, container ? theme.containerFill : theme.boxFill);
113
171
  // A box is the one kind with two inkable parts, which is why its text needs
114
172
  // a word of its own — `border:` cannot stand in for it.
115
- const text = textOf(node.appearance, theme.text);
116
- const subColor = subtextOf(node.appearance, theme);
173
+ const text = textColorOf(node.textAttrs, theme, theme.text);
117
174
  // Deck copies sit behind the front face, furthest back drawn first.
118
- for (let depth = node.deckLabels.length; depth >= 1; depth -= 1) {
175
+ for (let depth = node.deckTexts.length; depth >= 1; depth -= 1) {
119
176
  const x = face.x - depth * DECK_STEP;
120
177
  const y = face.y - depth * DECK_STEP;
121
- parts.push(` <path d="${outlinePath(shape.outline, x, y, face.width, face.height)}" fill="${theme.containerFill}" stroke="${border}"/>`);
122
- const label = node.deckLabels[depth - 1];
123
- if (label !== undefined) {
124
- parts.push(sized(textBlock([label], x + PAD, y + PAD, face.width - PAD * 2, textHeight, size, {
125
- color: text,
126
- align: 'start',
127
- }), size, fontSize));
178
+ parts.push(` <path d="${outlinePath(outline, x, y, face.width, face.height)}" fill="${theme.containerFill}" stroke="${border}"/>`);
179
+ const copy = node.deckTexts[depth - 1];
180
+ if (copy !== undefined) {
181
+ parts.push(sized(textBlock([[{ text: copy }]], x, y, textHeight, size, { x: PAD, y: PAD, width: face.width - PAD * 2, height: textHeight }, { color: text, align: 'start', ink }), size, fontSize));
128
182
  }
129
183
  }
130
- parts.push(` <path d="${outlinePath(shape.outline, face.x, face.y, face.width, face.height)}" fill="${fill}" stroke="${border}"/>`);
131
- for (const extra of outlineDetail(shape.outline, face.x, face.y, face.width, face.height)) {
184
+ parts.push(` <path d="${outlinePath(outline, face.x, face.y, face.width, face.height)}" fill="${fill}" stroke="${border}"/>`);
185
+ for (const extra of outlineDetail(outline, face.x, face.y, face.width, face.height)) {
132
186
  parts.push(` <path d="${extra}" fill="none" stroke="${border}"/>`);
133
187
  }
134
- // The icon takes a column on the right and the label lays out in what is
135
- // left, which is the room the resolver already reserved for exactly this.
136
- const icon = iconFor(node.appearance, node.line);
137
- const iconSide = icon === undefined ? 0 : glyphSide;
138
- const hasLabel = node.lines.some((line) => line.length > 0);
139
- const iconRoom = icon === undefined ? 0 : iconSide + (hasLabel ? ICON_GAP : 0);
140
- if (!container) {
141
- // A leaf centers its label in the box, both ways — in the room beside the
142
- // icon rather than the whole box, so the two sit side by side. Centered
143
- // across is only the default: a label of several lines may say `align`, and
144
- // there is genuine slack between lines of unequal length to range them in.
145
- const leafAlign = labelStyleFor(node.label, node.line, 'middle').align;
146
- const top = face.y + (face.height - node.lines.length * textHeight) / 2;
147
- parts.push(sized(textBlock(node.lines, face.x, top, face.width - iconRoom, textHeight, size, {
148
- color: text,
149
- subColor,
150
- align: leafAlign,
151
- }), size, fontSize));
152
- }
153
- else {
154
- // The label and the icon share a band at one end of the box, and the
155
- // resolver has already given the contents the other end. A heading is
156
- // ranged left at the top; a caption is centered at the bottom.
157
- const band = Math.max(node.lines.length * textHeight, iconSide);
158
- const bandTop = labelStyle.at === 'top' ? face.y + PAD : face.y + face.height - PAD - band;
159
- parts.push(sized(textBlock(node.lines, face.x + PAD, bandTop, face.width - PAD * 2 - iconRoom, textHeight, size, {
160
- color: text,
161
- subColor,
162
- align: labelStyle.align,
163
- }), size, fontSize));
164
- for (const child of node.children) {
165
- parts.push(drawNode(child, theme, measurer, fontSize));
166
- }
167
- }
168
- if (icon !== undefined) {
169
- // A container's icon rides in the label's band, at whichever end that is; a
170
- // leaf's label is centered, so the icon centers with it. Both follow the
171
- // label rather than being placed by a rule of their own, which is what
172
- // keeps an icon reading as part of the title block and not as a sticker.
173
- const left = face.x + face.width - PAD - iconSide;
174
- const top = container
175
- ? labelStyle.at === 'top'
176
- ? face.y + PAD
177
- : face.y + face.height - PAD - iconSide
178
- : face.y + (face.height - iconSide) / 2;
179
- parts.push(drawIcon(icon, left, top, iconSide, theme));
188
+ // A leaf's text defaults to the middle of its box, a container's to the top
189
+ // left of the band; both then read `at` for where it really goes. Where the
190
+ // text sits is the resolver's answer, in `textBox`; only the alignment of
191
+ // its lines against each other is read here.
192
+ const textStyle = textStyleFor(node.textAttrs, node.line, container ? 'start' : 'middle', container ? 'top-left' : 'center');
193
+ parts.push(sized(textBlock(node.lines, node.x, node.y, textHeight, size, node.textBox, {
194
+ color: text,
195
+ align: textStyle.align,
196
+ ink,
197
+ }), size, fontSize));
198
+ for (const child of node.children) {
199
+ kids.push(drawNode(child, theme, measurer, fontSize, markup, url));
180
200
  }
181
- return parts.join('\n');
201
+ return { own: parts, kids };
182
202
  }
183
203
  /**
184
204
  * How far the dog-ear cuts into the top-right corner of a `document`.
185
205
  *
186
206
  * Twice the corner radius, so it is the same size on every box however wide.
187
207
  * The reference sizes its fold as a fraction of the box, which is why the fold
188
- * on those two wide `pg_dump` boxes almost disappears — the idea was right and
208
+ * on those two wide dump boxes almost disappears — the idea was right and
189
209
  * only the scaling was wrong.
190
210
  */
191
211
  const FOLD = CORNER * 2;
@@ -246,12 +266,12 @@ function drawIcon(icon, x, y, side, theme) {
246
266
  ' </g>',
247
267
  ].join('\n');
248
268
  }
249
- // --- links -------------------------------------------------------------------
250
- function drawLink(link, ends, corridor, theme, measurer, fontSize) {
269
+ // --- edges -------------------------------------------------------------------
270
+ function drawEdge(edge, ends, corridor, theme, measurer, fontSize, markup) {
251
271
  const { start, end } = ends;
252
- const color = lineOf(link.appearance, theme.link);
272
+ const color = lineOf(edge.appearance, theme.edge);
253
273
  const markerEnd = ` marker-end="url(#${markerId(color)})"`;
254
- const markerStart = link.both ? ` marker-start="url(#${markerId(color)}-back)"` : '';
274
+ const markerStart = edge.both ? ` marker-start="url(#${markerId(color)}-back)"` : '';
255
275
  // A named side is a statement about how the line should leave or arrive, so
256
276
  // it is drawn as a curve that actually does leave and arrive that way. With
257
277
  // neither side named there is nothing to honor and the line stays straight.
@@ -267,7 +287,7 @@ function drawLink(link, ends, corridor, theme, measurer, fontSize) {
267
287
  const path = corridorPath(start, end, corridor);
268
288
  ink = union(ink, path.ink);
269
289
  parts.push(` <path d="${path.d}" fill="none" stroke="${color}" stroke-width="${LINE_WIDTH}"${markerEnd}${markerStart}/>`);
270
- // The label goes on the straight run rather than at the midpoint of the
290
+ // The text goes on the straight run rather than at the midpoint of the
271
291
  // whole path, so it sits in the gap the author asked the line to travel.
272
292
  midX = path.mid.x;
273
293
  midY = path.mid.y;
@@ -285,7 +305,7 @@ function drawLink(link, ends, corridor, theme, measurer, fontSize) {
285
305
  const c2 = { x: end.x + end.tx * reach + bx, y: end.y + end.ty * reach + by };
286
306
  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="${color}" stroke-width="${LINE_WIDTH}"${markerEnd}${markerStart}/>`);
287
307
  ink = union(ink, cubicExtent(start, c1, c2, end));
288
- // The point halfway along a cubic, which is where the label belongs.
308
+ // The point halfway along a cubic, which is where the text belongs.
289
309
  midX = (start.x + 3 * c1.x + 3 * c2.x + end.x) / 8;
290
310
  midY = (start.y + 3 * c1.y + 3 * c2.y + end.y) / 8;
291
311
  }
@@ -314,18 +334,19 @@ function drawLink(link, ends, corridor, theme, measurer, fontSize) {
314
334
  midX = (start.x + end.x) / 2;
315
335
  midY = (start.y + end.y) / 2;
316
336
  }
317
- if (link.label !== undefined) {
318
- // A link label breaks on ` / ` exactly as a box label does, so a two-line
337
+ if (edge.text !== undefined) {
338
+ // An edge text breaks on ` / ` exactly as a box text does, so a two-line
319
339
  // caption on an arrow needs no vocabulary of its own. The block is centered
320
- // on the midpoint, which keeps a one-line label where it has always been.
321
- const size = fontSizeFor('link', link.appearance, fontSize, link.line);
340
+ // on the midpoint, which keeps a one-line text where it has always been.
341
+ const size = fontSizeFor('edge', edge.textAttrs, fontSize, edge.line);
322
342
  const textHeight = measurer.lineHeight(size);
323
- const { width, lines } = measurer.measure(link.label, size);
343
+ const lines = edge.lines;
344
+ const width = widestLine(lines, measurer, size);
324
345
  const height = lines.length * textHeight;
325
346
  const top = midY - height / 2;
326
- // The label knocks a hole in whatever it lands on rather than sitting in a
347
+ // The text knocks a hole in whatever it lands on rather than sitting in a
327
348
  // chip of its own: an outlined box reads as a node, which is the one thing
328
- // a label on a line is not.
349
+ // a text on a line is not.
329
350
  parts.push(` <rect x="${round(midX - width / 2 - 5)}" y="${round(top)}" width="${round(width + 10)}" height="${round(height)}" fill="${theme.background}"/>`);
330
351
  ink = union(ink, {
331
352
  minX: midX - width / 2 - 5,
@@ -333,15 +354,16 @@ function drawLink(link, ends, corridor, theme, measurer, fontSize) {
333
354
  maxX: midX + width / 2 + 5,
334
355
  maxY: top + height,
335
356
  });
336
- parts.push(sized(textBlock(lines, midX - width / 2, top, width, textHeight, size, {
337
- // A colored link carries its meaning into its label; an uncolored
357
+ parts.push(sized(textBlock(lines, midX - width / 2, top, textHeight, size, { x: 0, y: 0, width, height }, {
358
+ // A colored edge carries its meaning into its text; an uncolored
338
359
  // one leaves the words to read as ordinary text.
339
- color: textOf(link.appearance, lineOf(link.appearance, theme.text)),
360
+ color: textColorOf(edge.textAttrs, theme, lineOf(edge.appearance, theme.text)),
340
361
  align: 'middle',
362
+ ink: (run, own) => runInk(run, own, markup, theme),
341
363
  }), size, fontSize));
342
364
  }
343
365
  // The stroke straddles the path, so half of it lies outside the geometry.
344
- return { svg: parts.join('\n'), ink: grow(ink, LINE_WIDTH / 2) };
366
+ return { svg: linked(parts.join('\n'), edge.attrs['url']), ink: grow(ink, LINE_WIDTH / 2) };
345
367
  }
346
368
  function union(a, b) {
347
369
  return {
@@ -371,7 +393,7 @@ function extentOfPoints(points) {
371
393
  * What a cubic actually covers, which is not what its control points cover. A
372
394
  * handle reaching 140 pixels up carries the curve only about three quarters of
373
395
  * that, and sizing the page off the handles would leave a visible band of empty
374
- * canvas above every curved link. Solved rather than sampled: the extremes are
396
+ * canvas above every curved edge. Solved rather than sampled: the extremes are
375
397
  * the ends plus wherever the derivative — a quadratic — crosses zero.
376
398
  */
377
399
  function cubicExtent(p0, c1, c2, p3) {
@@ -406,7 +428,7 @@ function cubicExtent(p0, c1, c2, p3) {
406
428
  return { minX, minY, maxX, maxY };
407
429
  }
408
430
  /** Walk out from the center of a box toward a point, stopping at the border. */
409
- function edgePoint(box, toward) {
431
+ function sidePoint(box, toward) {
410
432
  const center = centerOf(box);
411
433
  const dx = toward.x - center.x;
412
434
  const dy = toward.y - center.y;
@@ -417,48 +439,51 @@ function edgePoint(box, toward) {
417
439
  const scale = Math.min(scaleX, scaleY);
418
440
  return { x: center.x + dx * scale, y: center.y + dy * scale };
419
441
  }
420
- // --- where a link meets a box -------------------------------------------------
421
- const SIDES = ['top', 'bottom', 'left', 'right'];
442
+ // --- where an edge meets a box -------------------------------------------------
443
+ // The four sides an edge may attach to. Deliberately not `ATTACH_SIDES` from
444
+ // `ast.ts`, which carries `center` as well because an alignment can share a
445
+ // center line and an attachment cannot sit on one.
446
+ const ATTACH_SIDES = ['top', 'bottom', 'left', 'right'];
422
447
  /**
423
- * Work out where every link meets every box.
448
+ * Work out where every edge meets every box.
424
449
  *
425
450
  * An author names a *side* — `to: top` — and never a point on it. Alone on a
426
- * side a link lands at its center; sharing the side with others, the points
451
+ * side an edge lands at its center; sharing the side with others, the points
427
452
  * spread so they do not sit on top of each other. Which one goes where is
428
- * derived from where the far ends actually are, never chosen: of two links
453
+ * derived from where the far ends actually are, never chosen: of two edges
429
454
  * arriving at one top edge, the one coming from further left arrives further
430
455
  * left. That is the same rule as box non-overlap — the tool separates things by
431
456
  * default, and reads the direction off the solved layout rather than asking.
432
457
  *
433
- * Where several links run between the *same* pair of sides that rule has
458
+ * Where several edges run between the *same* pair of sides that rule has
434
459
  * nothing to read, and a `Bundle` supplies the order instead — see there.
435
460
  */
436
- function planEndpoints(links, measurer, fontSize) {
461
+ function planEndpoints(edges, measurer, fontSize) {
437
462
  const claims = new Map();
438
463
  const achieved = new Map();
439
464
  const named = new Map();
440
- const bundles = planBundles(links, measurer, fontSize);
441
- const spreads = planSpreads(links, measurer, fontSize);
442
- for (const link of links) {
443
- named.set(link, {});
444
- const fromSide = sideAttr(link, 'from');
445
- const toSide = sideAttr(link, 'to');
465
+ const bundles = planBundles(edges, measurer, fontSize);
466
+ const spreads = planSpreads(edges, measurer, fontSize);
467
+ for (const edge of edges) {
468
+ named.set(edge, {});
469
+ const fromSide = sideAttr(edge, 'from');
470
+ const toSide = sideAttr(edge, 'to');
446
471
  if (fromSide) {
447
- claim(claims, link.from, fromSide, {
448
- link,
472
+ claim(claims, edge.from, fromSide, {
473
+ edge,
449
474
  which: 'start',
450
475
  side: fromSide,
451
- toward: centerOf(faceOf(link.to)),
452
- rank: rankIn(bundles.get(link), link, link.from, fromSide),
476
+ toward: centerOf(faceOf(edge.to)),
477
+ rank: rankIn(bundles.get(edge), edge, edge.from, fromSide),
453
478
  });
454
479
  }
455
480
  if (toSide) {
456
- claim(claims, link.to, toSide, {
457
- link,
481
+ claim(claims, edge.to, toSide, {
482
+ edge,
458
483
  which: 'end',
459
484
  side: toSide,
460
- toward: centerOf(faceOf(link.from)),
461
- rank: rankIn(bundles.get(link), link, link.to, toSide),
485
+ toward: centerOf(faceOf(edge.from)),
486
+ rank: rankIn(bundles.get(edge), edge, edge.to, toSide),
462
487
  });
463
488
  }
464
489
  }
@@ -469,18 +494,18 @@ function planEndpoints(links, measurer, fontSize) {
469
494
  const along = side === 'top' || side === 'bottom' ? 'x' : 'y';
470
495
  const span = along === 'x' ? face.width : face.height;
471
496
  const origin = along === 'x' ? face.x : face.y;
472
- // Far ends first, as ever; a bundle's own lane order settles the links
497
+ // Far ends first, as ever; a bundle's own lane order settles the edges
473
498
  // that share one, which are precisely the ones the first key cannot.
474
499
  const ordered = [...group].sort((a, b) => a.toward[along] - b.toward[along] || (a.rank ?? 0) - (b.rank ?? 0));
475
- // A bundle's lanes have to hold whole labels apart rather than the points
500
+ // A bundle's lanes have to hold whole texts apart rather than the points
476
501
  // of two arrows, so its step is the one that governs the side it lands on.
477
- const wanted = Math.max(ATTACH_STEP, ...group.map((entry) => bundles.get(entry.link)?.step ?? 0));
502
+ const wanted = Math.max(ATTACH_STEP, ...group.map((entry) => bundles.get(entry.edge)?.step ?? 0));
478
503
  const usable = Math.max(0, span - ATTACH_MARGIN * 2);
479
504
  const step = ordered.length > 1 ? Math.min(wanted, usable / (ordered.length - 1)) : 0;
480
505
  const first = origin + span / 2 - (step * (ordered.length - 1)) / 2;
481
506
  ordered.forEach((entry, index) => {
482
507
  const at = first + index * step;
483
- named.get(entry.link)[entry.which] = anchorOn(face, side, at);
508
+ named.get(entry.edge)[entry.which] = anchorOn(face, side, at);
484
509
  });
485
510
  // What the side could actually give, which is less than `wanted` when it
486
511
  // is too short for the group. `bowBundles` makes up the difference.
@@ -494,34 +519,34 @@ function planEndpoints(links, measurer, fontSize) {
494
519
  // Fill in the ends the author said nothing about, now that the named ones
495
520
  // are known: an unnamed end aims at wherever its partner ended up.
496
521
  const ends = new Map();
497
- for (const link of links) {
498
- const partial = named.get(link);
499
- const fromFace = faceOf(link.from);
500
- const toFace = faceOf(link.to);
501
- // Several links between one pair of boxes with no side named anywhere: the
522
+ for (const edge of edges) {
523
+ const partial = named.get(edge);
524
+ const fromFace = faceOf(edge.from);
525
+ const toFace = faceOf(edge.to);
526
+ // Several edges between one pair of boxes with no side named anywhere: the
502
527
  // line each would have drawn alone, moved aside so they do not coincide.
503
- const spread = spreads.get(link);
528
+ const spread = spreads.get(edge);
504
529
  if (spread) {
505
- ends.set(link, { ...parallelEnds(fromFace, toFace, spread.offset), bow: spread.bow });
530
+ ends.set(edge, { ...parallelEnds(fromFace, toFace, spread.offset), bow: spread.bow });
506
531
  continue;
507
532
  }
508
533
  // With neither end named this is the straight line it always was, each end
509
534
  // aiming at the other box's center.
510
535
  const start = partial.start ?? free(fromFace, partial.end ?? centerOf(toFace));
511
536
  const end = partial.end ?? free(toFace, partial.start ?? centerOf(fromFace));
512
- ends.set(link, { start, end, bow: bows.get(link) });
537
+ ends.set(edge, { start, end, bow: bows.get(edge) });
513
538
  }
514
539
  return ends;
515
540
  }
516
541
  /**
517
- * Group the links that run between the same pair of sides, and work out the
542
+ * Group the edges that run between the same pair of sides, and work out the
518
543
  * lane order and lane width each group needs.
519
544
  *
520
- * Only a link whose author named *both* sides can be in a bundle: a bundle is a
545
+ * Only an edge whose author named *both* sides can be in a bundle: a bundle is a
521
546
  * statement about two specific edges, and an end with no side named has not
522
547
  * picked one yet.
523
548
  */
524
- function planBundles(links, measurer, fontSize) {
549
+ function planBundles(edges, measurer, fontSize) {
525
550
  const ids = new Map();
526
551
  const idOf = (node) => {
527
552
  let id = ids.get(node);
@@ -532,13 +557,13 @@ function planBundles(links, measurer, fontSize) {
532
557
  return id;
533
558
  };
534
559
  const groups = new Map();
535
- for (const link of links) {
536
- const fromSide = sideAttr(link, 'from');
537
- const toSide = sideAttr(link, 'to');
538
- if (!fromSide || !toSide || link.from === link.to)
560
+ for (const edge of edges) {
561
+ const fromSide = sideAttr(edge, 'from');
562
+ const toSide = sideAttr(edge, 'to');
563
+ if (!fromSide || !toSide || edge.from === edge.to)
539
564
  continue;
540
- const a = { node: link.from, side: fromSide };
541
- const b = { node: link.to, side: toSide };
565
+ const a = { node: edge.from, side: fromSide };
566
+ const b = { node: edge.to, side: toSide };
542
567
  const keyA = `${idOf(a.node)}:${a.side}`;
543
568
  const keyB = `${idOf(b.node)}:${b.side}`;
544
569
  // The pair is unordered — `a -> b` and `b -> a` join the same two edges —
@@ -548,13 +573,13 @@ function planBundles(links, measurer, fontSize) {
548
573
  const ends = swap ? [b, a] : [a, b];
549
574
  const group = groups.get(key);
550
575
  if (group)
551
- group.links.push(link);
576
+ group.edges.push(edge);
552
577
  else
553
- groups.set(key, { ends, links: [link] });
578
+ groups.set(key, { ends, edges: [edge] });
554
579
  }
555
580
  const bundles = new Map();
556
581
  for (const group of groups.values()) {
557
- if (group.links.length < 2)
582
+ if (group.edges.length < 2)
558
583
  continue;
559
584
  const [first, second] = group.ends;
560
585
  const t0 = tangentOf(first.side);
@@ -567,16 +592,16 @@ function planBundles(links, measurer, fontSize) {
567
592
  // step them to opposite sides and it pivots, which is a crossing.
568
593
  const aligned = cross(run, t0) * cross(run, t1) >= 0;
569
594
  const sense = aligned ? 1 : -1;
570
- // Two links leaving in opposite directions are the ordinary case, and which
595
+ // Two edges leaving in opposite directions are the ordinary case, and which
571
596
  // lane each takes is then read off the diagram rather than off the order the
572
597
  // author happened to type them in: a line keeps to one side of its own run.
573
- // Links pointing the same way have no such signal and fall back to the file.
574
- const order = new Map(group.links.map((link, index) => [link, index]));
575
- const lanes = [...group.links].sort((a, b) => Number(a.from !== first.node) - Number(b.from !== first.node) ||
598
+ // Edges pointing the same way have no such signal and fall back to the file.
599
+ const order = new Map(group.edges.map((edge, index) => [edge, index]));
600
+ const lanes = [...group.edges].sort((a, b) => Number(a.from !== first.node) - Number(b.from !== first.node) ||
576
601
  order.get(a) - order.get(b));
577
- // One lane apart moves a link's start by `step` along one side and its end
602
+ // One lane apart moves an edge's start by `step` along one side and its end
578
603
  // by `step` along the other, so the midpoint of the line — which is where
579
- // its label goes — moves by the average of the two.
604
+ // its text goes — moves by the average of the two.
580
605
  const drift = { x: (t0.x + sense * t1.x) / 2, y: (t0.y + sense * t1.y) / 2 };
581
606
  const bundle = {
582
607
  ends: group.ends,
@@ -584,41 +609,41 @@ function planBundles(links, measurer, fontSize) {
584
609
  aligned,
585
610
  step: Math.max(ATTACH_STEP, laneStep(lanes, drift, measurer, fontSize)),
586
611
  };
587
- for (const link of lanes)
588
- bundles.set(link, bundle);
612
+ for (const edge of lanes)
613
+ bundles.set(edge, bundle);
589
614
  }
590
615
  return bundles;
591
616
  }
592
617
  /**
593
- * The sideways offset each link takes when several run between the same two
618
+ * The sideways offset each edge takes when several run between the same two
594
619
  * boxes and none of them names a side.
595
620
  *
596
621
  * An unnamed end has no side to spread along: it aims at the far box's center
597
- * and attaches wherever that ray crosses the border, so every link in such a
622
+ * and attaches wherever that ray crosses the border, so every edge in such a
598
623
  * group produces the *same* ray and they are drawn on top of one another —
599
- * one visible line, every label stacked on one point. `planEndpoints` cannot
624
+ * one visible line, every text stacked on one point. `planEndpoints` cannot
600
625
  * see this and `planBundles` will not, since a bundle is a statement about two
601
626
  * named edges.
602
627
  *
603
628
  * The repair keeps the attachment rule exactly as it is and only stops two
604
- * links using it at the same place: the line a link would have drawn alone is
629
+ * edges using it at the same place: the line an edge would have drawn alone is
605
630
  * translated across its own run by a lane, which is the straight-line version
606
- * of the nesting a bundle already gives curves. A lone link is in no group and
631
+ * of the nesting a bundle already gives curves. A lone edge is in no group and
607
632
  * so is untouched.
608
633
  *
609
634
  * Where the boxes are too small to hold the group at full spacing, the ends
610
635
  * are squeezed evenly to fit the edge — there is nowhere further to attach —
611
636
  * and the shortfall is made up in the middle instead: each line bows across
612
- * its run by exactly what its endpoints could not give it, so the labels, which
637
+ * its run by exactly what its endpoints could not give it, so the texts, which
613
638
  * ride at the midpoints, come apart even though the arrows do not. The bow is
614
639
  * therefore derived rather than styled, and it is zero whenever the edge was
615
640
  * long enough, which is why the ordinary case is still a straight line.
616
641
  *
617
- * A `between` link is left out. Its route is the corridor it named, its lane
642
+ * A `between` edge is left out. Its route is the corridor it named, its lane
618
643
  * inside that corridor is `planCorridors`' business, and `aimFreeEnds` will
619
644
  * re-aim these ends at the corridor afterwards regardless.
620
645
  */
621
- function planSpreads(links, measurer, fontSize) {
646
+ function planSpreads(edges, measurer, fontSize) {
622
647
  const ids = new Map();
623
648
  const idOf = (node) => {
624
649
  let id = ids.get(node);
@@ -629,47 +654,47 @@ function planSpreads(links, measurer, fontSize) {
629
654
  return id;
630
655
  };
631
656
  const groups = new Map();
632
- for (const link of links) {
633
- if (sideAttr(link, 'from') || sideAttr(link, 'to'))
657
+ for (const edge of edges) {
658
+ if (sideAttr(edge, 'from') || sideAttr(edge, 'to'))
634
659
  continue;
635
- if (link.from === link.to || link.between)
660
+ if (edge.from === edge.to || edge.between)
636
661
  continue;
637
- const a = idOf(link.from);
638
- const b = idOf(link.to);
662
+ const a = idOf(edge.from);
663
+ const b = idOf(edge.to);
639
664
  const swap = b < a;
640
665
  const key = swap ? `${b}|${a}` : `${a}|${b}`;
641
- const first = swap ? link.to : link.from;
666
+ const first = swap ? edge.to : edge.from;
642
667
  const group = groups.get(key);
643
668
  if (group)
644
- group.links.push(link);
669
+ group.edges.push(edge);
645
670
  else
646
- groups.set(key, { first, links: [link] });
671
+ groups.set(key, { first, edges: [edge] });
647
672
  }
648
673
  const spreads = new Map();
649
674
  for (const group of groups.values()) {
650
- if (group.links.length < 2)
675
+ if (group.edges.length < 2)
651
676
  continue;
652
677
  const from = centerOf(faceOf(group.first));
653
- const sample = group.links[0];
678
+ const sample = group.edges[0];
654
679
  const other = sample.from === group.first ? sample.to : sample.from;
655
680
  const to = centerOf(faceOf(other));
656
681
  const dx = to.x - from.x;
657
682
  const dy = to.y - from.y;
658
683
  const length = Math.hypot(dx, dy) || 1;
659
- // Translating the line moves its midpoint — where the label goes — by
684
+ // Translating the line moves its midpoint — where the text goes — by
660
685
  // exactly this, so it is the drift `laneStep` needs.
661
686
  const across = { x: -dy / length, y: dx / length };
662
- // The same derived order a bundle uses: links pointing opposite ways each
687
+ // The same derived order a bundle uses: edges pointing opposite ways each
663
688
  // keep to one side of their own run, so a reciprocal pair reads as a
664
- // circulation, and only links pointing the same way fall back to the file.
665
- const order = new Map(group.links.map((link, index) => [link, index]));
666
- const lanes = [...group.links].sort((a, b) => Number(a.from !== group.first) - Number(b.from !== group.first) ||
689
+ // circulation, and only edges pointing the same way fall back to the file.
690
+ const order = new Map(group.edges.map((edge, index) => [edge, index]));
691
+ const lanes = [...group.edges].sort((a, b) => Number(a.from !== group.first) - Number(b.from !== group.first) ||
667
692
  order.get(a) - order.get(b));
668
693
  // How far a lane may be shifted before its line no longer passes through the
669
694
  // box at all. `exitAlong` clamps beyond that, which piles the outer lanes
670
- // onto a corner and puts their labels back on top of each other — so the
695
+ // onto a corner and puts their texts back on top of each other — so the
671
696
  // group is squeezed evenly instead, exactly as `planEndpoints` squeezes a
672
- // side too short for the links arriving on it, and just as silently.
697
+ // side too short for the edges arriving on it, and just as silently.
673
698
  const reach = (node) => {
674
699
  const face = faceOf(node);
675
700
  const byX = across.x === 0 ? Infinity : face.width / 2 / Math.abs(across.x);
@@ -678,22 +703,22 @@ function planSpreads(links, measurer, fontSize) {
678
703
  };
679
704
  // Which way lane 0 lies is arbitrary, so fix it the way the rest of the
680
705
  // renderer does — toward increasing x, or increasing y where the run is
681
- // horizontal. Without this the first link written is topmost on a rightward
706
+ // horizontal. Without this the first edge written is topmost on a rightward
682
707
  // run and rightmost on a downward one, for no reason a reader could see.
683
708
  const orient = across.x < 0 || (across.x === 0 && across.y < 0) ? -1 : 1;
684
709
  const usable = 2 * Math.min(reach(group.first), reach(other));
685
710
  const wanted = Math.max(ATTACH_STEP, laneStep(lanes, across, measurer, fontSize));
686
711
  const step = Math.min(wanted, usable / (lanes.length - 1));
687
- lanes.forEach((link, index) => {
712
+ lanes.forEach((edge, index) => {
688
713
  const place = index - (lanes.length - 1) / 2;
689
714
  // The lane is measured across the pair's own run, which has one direction;
690
- // a link written the other way round travels the opposite way and would
715
+ // an edge written the other way round travels the opposite way and would
691
716
  // otherwise take the same offset to the opposite side, putting a
692
717
  // reciprocal pair back on one line. Negated, both keep to their own left,
693
718
  // which is the circulation a bundle already draws.
694
- const sense = (link.from === group.first ? 1 : -1) * orient;
719
+ const sense = (edge.from === group.first ? 1 : -1) * orient;
695
720
  const shortfall = place * (wanted - step) * sense;
696
- spreads.set(link, {
721
+ spreads.set(edge, {
697
722
  offset: place * step * sense,
698
723
  bow: { x: across.x * shortfall, y: across.y * shortfall },
699
724
  });
@@ -702,11 +727,11 @@ function planSpreads(links, measurer, fontSize) {
702
727
  return spreads;
703
728
  }
704
729
  /**
705
- * The bow each bundled link needs, where the sides it was given were too short
706
- * to hold the group at the spacing its labels asked for.
730
+ * The bow each bundled edge needs, where the sides it was given were too short
731
+ * to hold the group at the spacing its texts asked for.
707
732
  *
708
733
  * A named side is squeezed exactly as an unnamed group's edge is — the step
709
- * shrinks to `usable / (n - 1)` and the labels ride down on top of each other —
734
+ * shrinks to `usable / (n - 1)` and the texts ride down on top of each other —
710
735
  * and until this existed, naming the two sides the tool would have chosen
711
736
  * anyway made the picture strictly worse than saying nothing. That is not a
712
737
  * line worth defending, so the same repair applies: a lane's midpoint is not
@@ -739,35 +764,35 @@ function bowBundles(bundles, achieved) {
739
764
  };
740
765
  if (short.x === 0 && short.y === 0)
741
766
  continue;
742
- bundle.lanes.forEach((link, index) => {
767
+ bundle.lanes.forEach((edge, index) => {
743
768
  const place = index - (bundle.lanes.length - 1) / 2;
744
- bows.set(link, { x: short.x * place, y: short.y * place });
769
+ bows.set(edge, { x: short.x * place, y: short.y * place });
745
770
  });
746
771
  }
747
772
  return bows;
748
773
  }
749
774
  /**
750
- * How far apart adjacent lanes must sit for their labels to clear each other.
775
+ * How far apart adjacent lanes must sit for their texts to clear each other.
751
776
  *
752
- * The labels are knockout rectangles, so two of them clear when they are apart
777
+ * The texts are knockout rectangles, so two of them clear when they are apart
753
778
  * on *either* axis — hence the smaller of the two answers. `drift` is how far
754
779
  * the midpoint travels per unit of step, and it is never zero: the two ends
755
780
  * cancel only when both sides run the same way, and two such sides are always
756
781
  * `aligned`, which adds rather than subtracts.
757
782
  */
758
783
  function laneStep(lanes, drift, measurer, fontSize) {
759
- const labeled = lanes.filter((link) => link.label !== undefined);
760
- if (labeled.length < 2)
784
+ const withText = lanes.filter((edge) => edge.text !== undefined);
785
+ if (withText.length < 2)
761
786
  return 0;
762
- const need = (axis) => Math.max(...labeled.map((link) => labelExtent(link.label, link.appearance, axis, measurer, fontSize, link.line)));
787
+ const need = (axis) => Math.max(...withText.map((edge) => textExtent(edge.lines, edge.textAttrs, axis, measurer, fontSize, edge.line)));
763
788
  const along = (axis, reach) => reach === 0 ? Infinity : need(axis) / Math.abs(reach);
764
789
  return Math.min(along('x', drift.x), along('y', drift.y));
765
790
  }
766
- /** Which lane of its bundle a link's end at this side takes, if it is in one. */
767
- function rankIn(bundle, link, node, side) {
791
+ /** Which lane of its bundle an edge's end at this side takes, if it is in one. */
792
+ function rankIn(bundle, edge, node, side) {
768
793
  if (!bundle)
769
794
  return undefined;
770
- const lane = bundle.lanes.indexOf(link);
795
+ const lane = bundle.lanes.indexOf(edge);
771
796
  const [first, second] = bundle.ends;
772
797
  if (node === first.node && side === first.side)
773
798
  return lane;
@@ -816,7 +841,7 @@ function anchorOn(face, side, at) {
816
841
  }
817
842
  /** An end with no side named: leave from the border, pointing at the far end. */
818
843
  function free(face, toward) {
819
- const point = edgePoint(face, toward);
844
+ const point = sidePoint(face, toward);
820
845
  const center = centerOf(face);
821
846
  const dx = point.x - center.x;
822
847
  const dy = point.y - center.y;
@@ -826,7 +851,7 @@ function free(face, toward) {
826
851
  /**
827
852
  * Walk from a point inside a box along a direction, stopping at the border.
828
853
  *
829
- * `edgePoint` walks from the center, which is the only place a single line
854
+ * `sidePoint` walks from the center, which is the only place a single line
830
855
  * passes through. A fanned-out group's lines are parallel to that one and
831
856
  * beside it, so each needs the border crossing of its own line rather than of
832
857
  * the center's — which is what keeps the group parallel instead of splayed.
@@ -844,11 +869,11 @@ function exitAlong(box, from, dir) {
844
869
  return { x: x + dir.x * Math.max(0, t), y: y + dir.y * Math.max(0, t) };
845
870
  }
846
871
  /**
847
- * Both ends of a link that named no side, moved `offset` sideways across its
872
+ * Both ends of an edge that named no side, moved `offset` sideways across its
848
873
  * own run.
849
874
  *
850
875
  * The whole line is translated rather than each end being nudged along its
851
- * border, so the result is genuinely parallel to the line the link would have
876
+ * border, so the result is genuinely parallel to the line the edge would have
852
877
  * drawn alone, exactly `offset` away from it. Where each end lands then falls
853
878
  * out of that: level boxes put both points further along the same two edges,
854
879
  * and a diagonal pair whose line leaves through a corner puts one point on each
@@ -928,61 +953,61 @@ function gapBetween(a, b, aName, bName, wanted, line) {
928
953
  };
929
954
  }
930
955
  /**
931
- * Route every link that named a gap.
956
+ * Route every edge that named a gap.
932
957
  *
933
- * Links sharing one gap share its lanes, spread like attachments on a side and
958
+ * Edges sharing one gap share its lanes, spread like attachments on a side and
934
959
  * ordered the same derived way — by where their ends actually sit, so the two
935
- * arriving at Dropbox's left edge in one order run through the corridor in that
960
+ * arriving at the hub's left edge in one order run through the corridor in that
936
961
  * same order and never cross.
937
962
  */
938
- function planCorridors(links, ends, measurer, fontSize) {
963
+ function planCorridors(edges, ends, measurer, fontSize) {
939
964
  const plans = new Map();
940
965
  const groups = new Map();
941
- for (const link of links) {
942
- if (!link.between)
966
+ for (const edge of edges) {
967
+ if (!edge.between)
943
968
  continue;
944
- const [first, second] = link.between.nodes;
945
- const gap = gapBetween(faceOf(first), faceOf(second), first.name, second.name, link.between.axis, link.line);
969
+ const [first, second] = edge.between.nodes;
970
+ const gap = gapBetween(faceOf(first), faceOf(second), first.name, second.name, edge.between.axis, edge.line);
946
971
  // The pair names one gap however the author ordered them. The axis is in
947
- // the key because a diagonal pair really does have two, and two links may
972
+ // the key because a diagonal pair really does have two, and two edges may
948
973
  // legitimately name the same pair and take different ones.
949
974
  const key = [gap.axis, ...[first.name, second.name].sort()].join(' ');
950
975
  const group = groups.get(key);
951
976
  if (group)
952
- group.members.push(link);
977
+ group.members.push(edge);
953
978
  else
954
- groups.set(key, { ...gap, members: [link] });
979
+ groups.set(key, { ...gap, members: [edge] });
955
980
  }
956
981
  for (const group of groups.values()) {
957
- const along = (link) => {
958
- const { start, end } = ends.get(link);
982
+ const along = (edge) => {
983
+ const { start, end } = ends.get(edge);
959
984
  return (start[group.axis] + end[group.axis]) / 2;
960
985
  };
961
986
  const ordered = [...group.members].sort((a, b) => along(a) - along(b));
962
987
  // Lanes are spread as attachments on a side are, including the squeeze when
963
988
  // there is not enough room — see `planEndpoints`. The step is wider here,
964
- // because a lane carries a whole label rather than the point of an arrow,
965
- // and two lanes closer together than a label is deep would draw the labels
989
+ // because a lane carries a whole text rather than the point of an arrow,
990
+ // and two lanes closer together than a text is deep would draw the texts
966
991
  // over each other. Still derived, not chosen: it is the size of what is
967
992
  // actually running along the corridor.
968
993
  const span = group.hi - group.lo;
969
994
  const usable = Math.max(0, span - ATTACH_MARGIN * 2);
970
- const want = Math.max(ATTACH_STEP, ...ordered.map((link) => laneExtent(link, group.axis, measurer, fontSize)));
995
+ const want = Math.max(ATTACH_STEP, ...ordered.map((edge) => laneExtent(edge, group.axis, measurer, fontSize)));
971
996
  const step = ordered.length > 1 ? Math.min(want, usable / (ordered.length - 1)) : 0;
972
997
  const firstLane = group.lo + span / 2 - (step * (ordered.length - 1)) / 2;
973
- ordered.forEach((link, index) => {
974
- const { start, end } = ends.get(link);
998
+ ordered.forEach((edge, index) => {
999
+ const { start, end } = ends.get(edge);
975
1000
  const run = group.axis === 'y' ? 'x' : 'y';
976
- // The corridor binds only where the link is actually passing the pair, so
977
- // its reach is the overlap of the pair's extent with the link's own.
1001
+ // The corridor binds only where the edge is actually passing the pair, so
1002
+ // its reach is the overlap of the pair's extent with the edge's own.
978
1003
  const enterAt = Math.max(group.across[0], Math.min(start[run], end[run]));
979
1004
  const leaveAt = Math.min(group.across[1], Math.max(start[run], end[run]));
980
1005
  if (leaveAt <= enterAt) {
981
- const [a, b] = link.between.nodes;
982
- throw new SourceError(`this link never passes between "${a.name}" and "${b.name}"`, link.line);
1006
+ const [a, b] = edge.between.nodes;
1007
+ throw new SourceError(`this edge never passes between "${a.name}" and "${b.name}"`, edge.line);
983
1008
  }
984
1009
  const forward = end[run] >= start[run];
985
- plans.set(link, {
1010
+ plans.set(edge, {
986
1011
  axis: group.axis,
987
1012
  lane: firstLane + index * step,
988
1013
  enter: forward ? enterAt : leaveAt,
@@ -997,41 +1022,41 @@ function planCorridors(links, ends, measurer, fontSize) {
997
1022
  * is the wrong thing to aim at once the line has been told to go somewhere else
998
1023
  * on the way. Point those ends at the corridor instead.
999
1024
  */
1000
- function aimFreeEnds(links, ends, corridors) {
1001
- for (const link of links) {
1002
- const plan = corridors.get(link);
1025
+ function aimFreeEnds(edges, ends, corridors) {
1026
+ for (const edge of edges) {
1027
+ const plan = corridors.get(edge);
1003
1028
  if (!plan)
1004
1029
  continue;
1005
- const current = ends.get(link);
1030
+ const current = ends.get(edge);
1006
1031
  const start = current.start.side === undefined
1007
- ? free(faceOf(link.from), corridorPoint(plan, plan.enter))
1032
+ ? free(faceOf(edge.from), corridorPoint(plan, plan.enter))
1008
1033
  : current.start;
1009
1034
  const end = current.end.side === undefined
1010
- ? free(faceOf(link.to), corridorPoint(plan, plan.leave))
1035
+ ? free(faceOf(edge.to), corridorPoint(plan, plan.leave))
1011
1036
  : current.end;
1012
- ends.set(link, { start, end });
1037
+ ends.set(edge, { start, end });
1013
1038
  }
1014
1039
  }
1015
1040
  /**
1016
- * How much room a link's label takes across the corridor — its depth in a
1017
- * horizontal channel, its width in a vertical one. Zero for an unlabeled link,
1041
+ * How much room an edge's text takes across the corridor — its depth in a
1042
+ * horizontal channel, its width in a vertical one. Zero for an edge with no text,
1018
1043
  * which needs no more than the arrow spacing.
1019
1044
  *
1020
- * `labelExtent` measures the knockout along whichever axis it is handed, and the
1045
+ * `textExtent` measures the knockout along whichever axis it is handed, and the
1021
1046
  * axis wanted here is the one the channel is measured on rather than the one the
1022
- * link runs along — a channel measured vertically carries links running
1023
- * horizontally, and what has to fit between two lanes of it is a label's depth.
1047
+ * edge runs along — a channel measured vertically carries edges running
1048
+ * horizontally, and what has to fit between two lanes of it is a text's depth.
1024
1049
  */
1025
- function laneExtent(link, axis, measurer, fontSize) {
1026
- if (link.label === undefined)
1050
+ function laneExtent(edge, axis, measurer, fontSize) {
1051
+ if (edge.lines === undefined)
1027
1052
  return 0;
1028
- return labelExtent(link.label, link.appearance, axis, measurer, fontSize, link.line);
1053
+ return textExtent(edge.lines, edge.textAttrs, axis, measurer, fontSize, edge.line);
1029
1054
  }
1030
1055
  function corridorPoint(plan, at) {
1031
1056
  return plan.axis === 'y' ? { x: at, y: plan.lane } : { x: plan.lane, y: at };
1032
1057
  }
1033
1058
  /**
1034
- * The path a corridor link takes: a curve out of its start into the gap, the
1059
+ * The path a corridor edge takes: a curve out of its start into the gap, the
1035
1060
  * straight run along the gap, and a curve out of the gap to its end. It is
1036
1061
  * three pieces rather than one cubic because a single curve has no way to stay
1037
1062
  * inside an interval over part of its length — which is the whole claim the
@@ -1070,12 +1095,12 @@ function corridorReach(from, to, axis) {
1070
1095
  const distance = Math.hypot(to.x - from.x, to.y - from.y);
1071
1096
  return Math.min(140, Math.max(8, Math.min(distance * 0.4, run / 2)));
1072
1097
  }
1073
- function sideAttr(link, key) {
1074
- const value = link.attrs[key];
1098
+ function sideAttr(edge, key) {
1099
+ const value = edge.attrs[key];
1075
1100
  if (value === undefined)
1076
1101
  return undefined;
1077
- if (!SIDES.includes(value)) {
1078
- throw new SourceError(`"${key}: ${value}" is not a side — use ${SIDES.join(', ')}`, link.line);
1102
+ if (!ATTACH_SIDES.includes(value)) {
1103
+ throw new SourceError(`"${key}: ${value}" is not a side — use ${ATTACH_SIDES.join(', ')}`, edge.line);
1079
1104
  }
1080
1105
  return value;
1081
1106
  }
@@ -1115,21 +1140,78 @@ function sized(block, size, fontSize) {
1115
1140
  return block;
1116
1141
  return ` <g font-size="${size}px">\n${block}\n </g>`;
1117
1142
  }
1118
- function textBlock(lines, x, top, width, lineHeight, fontSize, style) {
1119
- const anchorX = style.align === 'middle' ? x + width / 2 : style.align === 'end' ? x + width : x;
1143
+ /**
1144
+ * Draw a block of text into the room it was given.
1145
+ *
1146
+ * Two independent questions, which is why there are two words for them. `side`
1147
+ * is where the block sits across that room, from the horizontal half of the
1148
+ * text's `at`. `align` is how the block's own lines range against each other,
1149
+ * which matters whenever they are of unequal length and is a different thing
1150
+ * from where the block is.
1151
+ *
1152
+ * Where the block is as wide as the room — which is every text whose box is
1153
+ * sized from it, so nearly all of them — the two coincide and `side` changes
1154
+ * nothing.
1155
+ */
1156
+ function textBlock(lines, x, y, lineHeight, fontSize, box, style) {
1157
+ // `box` is the ink the text occupies, worked out by the resolver — the one
1158
+ // place that decides where a text sits, because `hub text` is a placement
1159
+ // target and the answer has to be a number before anything is solved.
1160
+ const blockLeft = x + box.x;
1161
+ const top = y + box.y;
1162
+ const anchorX = style.align === 'middle'
1163
+ ? blockLeft + box.width / 2
1164
+ : style.align === 'end'
1165
+ ? blockLeft + box.width
1166
+ : blockLeft;
1120
1167
  return lines
1121
1168
  .map((line, index) => {
1122
- if (line.length === 0)
1169
+ if (plain(line).length === 0)
1123
1170
  return '';
1124
1171
  const baseline = top + index * lineHeight + lineHeight / 2 + fontSize * 0.35;
1125
- // A label's first line is its name; anything after it is a qualifier, and
1126
- // `subtext:` is how a box says that qualifier should read as secondary.
1127
- const color = index === 0 ? style.color : style.subColor ?? style.color;
1128
- return ` <text x="${round(anchorX)}" y="${round(baseline)}" fill="${color}" text-anchor="${style.align}">${escapeXml(line)}</text>`;
1172
+ // One `<text>` per line, with a `<tspan>` per run inside it, so the runs
1173
+ // flow from the line's own anchor and a mark never moves a character.
1174
+ // A line drawn in one color says so on the `<text>` and emits no spans at
1175
+ // all, which is what keeps a whole quiet line identical to what the
1176
+ // `subtext:` it replaced produced.
1177
+ const colors = line.map((run) => style.ink(run, style.color));
1178
+ const uniform = colors.every((color) => color === colors[0]);
1179
+ const body = uniform
1180
+ ? escapeXml(plain(line))
1181
+ : line
1182
+ .map((run, run_index) => colors[run_index] === style.color
1183
+ ? escapeXml(run.text)
1184
+ : `<tspan fill="${colors[run_index]}">${escapeXml(run.text)}</tspan>`)
1185
+ .join('');
1186
+ return ` <text x="${round(anchorX)}" y="${round(baseline)}" fill="${uniform ? colors[0] ?? style.color : style.color}" text-anchor="${style.align}">${body}</text>`;
1129
1187
  })
1130
1188
  .filter((element) => element.length > 0)
1131
1189
  .join('\n');
1132
1190
  }
1191
+ /**
1192
+ * The color a marked-up run is drawn in. The mark names a *style*, never a
1193
+ * color, so the word borrows a meaning the file already has rather than
1194
+ * restating a value that goes stale the day the thing it means is recolored.
1195
+ * The resolver has already refused a mark naming a style that does not exist or
1196
+ * that says nothing about text.
1197
+ */
1198
+ function runInk(run, own, markup, theme) {
1199
+ if (run.style === undefined)
1200
+ return own;
1201
+ return namedColor(markup[run.style], theme);
1202
+ }
1203
+ /**
1204
+ * A text's own color. `muted` is the one reserved word: it defers to the theme,
1205
+ * so a quiet line stays readable when the theme changes. Anything else is a
1206
+ * color, the same as `fill:` and `border:` take.
1207
+ */
1208
+ function textColorOf(textAttrs, theme, fallback) {
1209
+ const value = textAttrs['color'];
1210
+ return value === undefined ? fallback : namedColor(value, theme);
1211
+ }
1212
+ function namedColor(value, theme) {
1213
+ return value === 'muted' ? theme.mutedText : value;
1214
+ }
1133
1215
  /**
1134
1216
  * A color is written as the viewer will receive it — `#14532d`, or any CSS
1135
1217
  * color. The renderer keeps no list of color words of its own, so a diagram
@@ -1145,23 +1227,6 @@ function borderOf(appearance, fallback) {
1145
1227
  function lineOf(appearance, fallback) {
1146
1228
  return appearance['line'] ?? fallback;
1147
1229
  }
1148
- function textOf(appearance, fallback) {
1149
- return appearance['text'] ?? fallback;
1150
- }
1151
- /**
1152
- * The color for every label line after the first, or undefined when the box
1153
- * said nothing and all its lines should read alike. `muted` is the one reserved
1154
- * word: it defers to the theme, so a label's qualifier stays readable when the
1155
- * theme changes. Anything else is a color, same as `text` and `fill` take.
1156
- */
1157
- function subtextOf(appearance, theme) {
1158
- const named = appearance['subtext'];
1159
- if (named === undefined)
1160
- return undefined;
1161
- if (named === 'muted')
1162
- return theme.mutedText;
1163
- return named;
1164
- }
1165
1230
  function fillOf(appearance, fallback) {
1166
1231
  return appearance['fill'] ?? fallback;
1167
1232
  }