reladraw 0.2.0 → 0.4.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,135 @@ 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);
165
+ // A node with children is colored as a backdrop however they are placed,
166
+ // including a lone badge beside its text. Every rule that tried to tell a
167
+ // badge from contents was a guess; this one is visible in the source.
110
168
  const container = node.children.length > 0;
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. This follows the band, not the
192
+ // colors: a badged leaf is a backdrop but its text still centers.
193
+ const textStyle = textStyleFor(node.textAttrs, node.line, node.banded ? 'start' : 'middle', node.banded ? 'top-left' : 'center');
194
+ parts.push(sized(textBlock(node.lines, node.x, node.y, textHeight, size, node.textBox, {
195
+ color: text,
196
+ align: textStyle.align,
197
+ ink,
198
+ }), size, fontSize));
199
+ for (const child of node.children) {
200
+ kids.push(drawNode(child, theme, measurer, fontSize, markup, url));
180
201
  }
181
- return parts.join('\n');
202
+ return { own: parts, kids };
182
203
  }
183
204
  /**
184
205
  * How far the dog-ear cuts into the top-right corner of a `document`.
185
206
  *
186
207
  * Twice the corner radius, so it is the same size on every box however wide.
187
208
  * 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
209
+ * on those two wide dump boxes almost disappears — the idea was right and
189
210
  * only the scaling was wrong.
190
211
  */
191
212
  const FOLD = CORNER * 2;
@@ -246,12 +267,12 @@ function drawIcon(icon, x, y, side, theme) {
246
267
  ' </g>',
247
268
  ].join('\n');
248
269
  }
249
- // --- links -------------------------------------------------------------------
250
- function drawLink(link, ends, corridor, theme, measurer, fontSize) {
270
+ // --- edges -------------------------------------------------------------------
271
+ function drawEdge(edge, ends, corridor, theme, measurer, fontSize, markup) {
251
272
  const { start, end } = ends;
252
- const color = lineOf(link.appearance, theme.link);
273
+ const color = lineOf(edge.appearance, theme.edge);
253
274
  const markerEnd = ` marker-end="url(#${markerId(color)})"`;
254
- const markerStart = link.both ? ` marker-start="url(#${markerId(color)}-back)"` : '';
275
+ const markerStart = edge.both ? ` marker-start="url(#${markerId(color)}-back)"` : '';
255
276
  // A named side is a statement about how the line should leave or arrive, so
256
277
  // it is drawn as a curve that actually does leave and arrive that way. With
257
278
  // neither side named there is nothing to honor and the line stays straight.
@@ -267,7 +288,7 @@ function drawLink(link, ends, corridor, theme, measurer, fontSize) {
267
288
  const path = corridorPath(start, end, corridor);
268
289
  ink = union(ink, path.ink);
269
290
  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
291
+ // The text goes on the straight run rather than at the midpoint of the
271
292
  // whole path, so it sits in the gap the author asked the line to travel.
272
293
  midX = path.mid.x;
273
294
  midY = path.mid.y;
@@ -285,7 +306,7 @@ function drawLink(link, ends, corridor, theme, measurer, fontSize) {
285
306
  const c2 = { x: end.x + end.tx * reach + bx, y: end.y + end.ty * reach + by };
286
307
  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
308
  ink = union(ink, cubicExtent(start, c1, c2, end));
288
- // The point halfway along a cubic, which is where the label belongs.
309
+ // The point halfway along a cubic, which is where the text belongs.
289
310
  midX = (start.x + 3 * c1.x + 3 * c2.x + end.x) / 8;
290
311
  midY = (start.y + 3 * c1.y + 3 * c2.y + end.y) / 8;
291
312
  }
@@ -314,18 +335,19 @@ function drawLink(link, ends, corridor, theme, measurer, fontSize) {
314
335
  midX = (start.x + end.x) / 2;
315
336
  midY = (start.y + end.y) / 2;
316
337
  }
317
- if (link.label !== undefined) {
318
- // A link label breaks on ` / ` exactly as a box label does, so a two-line
338
+ if (edge.text !== undefined) {
339
+ // An edge text breaks on ` / ` exactly as a box text does, so a two-line
319
340
  // 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);
341
+ // on the midpoint, which keeps a one-line text where it has always been.
342
+ const size = fontSizeFor('edge', edge.textAttrs, fontSize, edge.line);
322
343
  const textHeight = measurer.lineHeight(size);
323
- const { width, lines } = measurer.measure(link.label, size);
344
+ const lines = edge.lines;
345
+ const width = widestLine(lines, measurer, size);
324
346
  const height = lines.length * textHeight;
325
347
  const top = midY - height / 2;
326
- // The label knocks a hole in whatever it lands on rather than sitting in a
348
+ // The text knocks a hole in whatever it lands on rather than sitting in a
327
349
  // chip of its own: an outlined box reads as a node, which is the one thing
328
- // a label on a line is not.
350
+ // a text on a line is not.
329
351
  parts.push(` <rect x="${round(midX - width / 2 - 5)}" y="${round(top)}" width="${round(width + 10)}" height="${round(height)}" fill="${theme.background}"/>`);
330
352
  ink = union(ink, {
331
353
  minX: midX - width / 2 - 5,
@@ -333,15 +355,16 @@ function drawLink(link, ends, corridor, theme, measurer, fontSize) {
333
355
  maxX: midX + width / 2 + 5,
334
356
  maxY: top + height,
335
357
  });
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
358
+ parts.push(sized(textBlock(lines, midX - width / 2, top, textHeight, size, { x: 0, y: 0, width, height }, {
359
+ // A colored edge carries its meaning into its text; an uncolored
338
360
  // one leaves the words to read as ordinary text.
339
- color: textOf(link.appearance, lineOf(link.appearance, theme.text)),
361
+ color: textColorOf(edge.textAttrs, theme, lineOf(edge.appearance, theme.text)),
340
362
  align: 'middle',
363
+ ink: (run, own) => runInk(run, own, markup, theme),
341
364
  }), size, fontSize));
342
365
  }
343
366
  // 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) };
367
+ return { svg: linked(parts.join('\n'), edge.attrs['url']), ink: grow(ink, LINE_WIDTH / 2) };
345
368
  }
346
369
  function union(a, b) {
347
370
  return {
@@ -371,7 +394,7 @@ function extentOfPoints(points) {
371
394
  * What a cubic actually covers, which is not what its control points cover. A
372
395
  * handle reaching 140 pixels up carries the curve only about three quarters of
373
396
  * 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
397
+ * canvas above every curved edge. Solved rather than sampled: the extremes are
375
398
  * the ends plus wherever the derivative — a quadratic — crosses zero.
376
399
  */
377
400
  function cubicExtent(p0, c1, c2, p3) {
@@ -406,7 +429,7 @@ function cubicExtent(p0, c1, c2, p3) {
406
429
  return { minX, minY, maxX, maxY };
407
430
  }
408
431
  /** Walk out from the center of a box toward a point, stopping at the border. */
409
- function edgePoint(box, toward) {
432
+ function sidePoint(box, toward) {
410
433
  const center = centerOf(box);
411
434
  const dx = toward.x - center.x;
412
435
  const dy = toward.y - center.y;
@@ -417,48 +440,51 @@ function edgePoint(box, toward) {
417
440
  const scale = Math.min(scaleX, scaleY);
418
441
  return { x: center.x + dx * scale, y: center.y + dy * scale };
419
442
  }
420
- // --- where a link meets a box -------------------------------------------------
421
- const SIDES = ['top', 'bottom', 'left', 'right'];
443
+ // --- where an edge meets a box -------------------------------------------------
444
+ // The four sides an edge may attach to. Deliberately not `ATTACH_SIDES` from
445
+ // `ast.ts`, which carries `center` as well because an alignment can share a
446
+ // center line and an attachment cannot sit on one.
447
+ const ATTACH_SIDES = ['top', 'bottom', 'left', 'right'];
422
448
  /**
423
- * Work out where every link meets every box.
449
+ * Work out where every edge meets every box.
424
450
  *
425
451
  * 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
452
+ * side an edge lands at its center; sharing the side with others, the points
427
453
  * 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
454
+ * derived from where the far ends actually are, never chosen: of two edges
429
455
  * arriving at one top edge, the one coming from further left arrives further
430
456
  * left. That is the same rule as box non-overlap — the tool separates things by
431
457
  * default, and reads the direction off the solved layout rather than asking.
432
458
  *
433
- * Where several links run between the *same* pair of sides that rule has
459
+ * Where several edges run between the *same* pair of sides that rule has
434
460
  * nothing to read, and a `Bundle` supplies the order instead — see there.
435
461
  */
436
- function planEndpoints(links, measurer, fontSize) {
462
+ function planEndpoints(edges, measurer, fontSize) {
437
463
  const claims = new Map();
438
464
  const achieved = new Map();
439
465
  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');
466
+ const bundles = planBundles(edges, measurer, fontSize);
467
+ const spreads = planSpreads(edges, measurer, fontSize);
468
+ for (const edge of edges) {
469
+ named.set(edge, {});
470
+ const fromSide = sideAttr(edge, 'from');
471
+ const toSide = sideAttr(edge, 'to');
446
472
  if (fromSide) {
447
- claim(claims, link.from, fromSide, {
448
- link,
473
+ claim(claims, edge.from, fromSide, {
474
+ edge,
449
475
  which: 'start',
450
476
  side: fromSide,
451
- toward: centerOf(faceOf(link.to)),
452
- rank: rankIn(bundles.get(link), link, link.from, fromSide),
477
+ toward: centerOf(faceOf(edge.to)),
478
+ rank: rankIn(bundles.get(edge), edge, edge.from, fromSide),
453
479
  });
454
480
  }
455
481
  if (toSide) {
456
- claim(claims, link.to, toSide, {
457
- link,
482
+ claim(claims, edge.to, toSide, {
483
+ edge,
458
484
  which: 'end',
459
485
  side: toSide,
460
- toward: centerOf(faceOf(link.from)),
461
- rank: rankIn(bundles.get(link), link, link.to, toSide),
486
+ toward: centerOf(faceOf(edge.from)),
487
+ rank: rankIn(bundles.get(edge), edge, edge.to, toSide),
462
488
  });
463
489
  }
464
490
  }
@@ -469,18 +495,18 @@ function planEndpoints(links, measurer, fontSize) {
469
495
  const along = side === 'top' || side === 'bottom' ? 'x' : 'y';
470
496
  const span = along === 'x' ? face.width : face.height;
471
497
  const origin = along === 'x' ? face.x : face.y;
472
- // Far ends first, as ever; a bundle's own lane order settles the links
498
+ // Far ends first, as ever; a bundle's own lane order settles the edges
473
499
  // that share one, which are precisely the ones the first key cannot.
474
500
  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
501
+ // A bundle's lanes have to hold whole texts apart rather than the points
476
502
  // 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));
503
+ const wanted = Math.max(ATTACH_STEP, ...group.map((entry) => bundles.get(entry.edge)?.step ?? 0));
478
504
  const usable = Math.max(0, span - ATTACH_MARGIN * 2);
479
505
  const step = ordered.length > 1 ? Math.min(wanted, usable / (ordered.length - 1)) : 0;
480
506
  const first = origin + span / 2 - (step * (ordered.length - 1)) / 2;
481
507
  ordered.forEach((entry, index) => {
482
508
  const at = first + index * step;
483
- named.get(entry.link)[entry.which] = anchorOn(face, side, at);
509
+ named.get(entry.edge)[entry.which] = anchorOn(face, side, at);
484
510
  });
485
511
  // What the side could actually give, which is less than `wanted` when it
486
512
  // is too short for the group. `bowBundles` makes up the difference.
@@ -494,34 +520,34 @@ function planEndpoints(links, measurer, fontSize) {
494
520
  // Fill in the ends the author said nothing about, now that the named ones
495
521
  // are known: an unnamed end aims at wherever its partner ended up.
496
522
  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
523
+ for (const edge of edges) {
524
+ const partial = named.get(edge);
525
+ const fromFace = faceOf(edge.from);
526
+ const toFace = faceOf(edge.to);
527
+ // Several edges between one pair of boxes with no side named anywhere: the
502
528
  // line each would have drawn alone, moved aside so they do not coincide.
503
- const spread = spreads.get(link);
529
+ const spread = spreads.get(edge);
504
530
  if (spread) {
505
- ends.set(link, { ...parallelEnds(fromFace, toFace, spread.offset), bow: spread.bow });
531
+ ends.set(edge, { ...parallelEnds(fromFace, toFace, spread.offset), bow: spread.bow });
506
532
  continue;
507
533
  }
508
534
  // With neither end named this is the straight line it always was, each end
509
535
  // aiming at the other box's center.
510
536
  const start = partial.start ?? free(fromFace, partial.end ?? centerOf(toFace));
511
537
  const end = partial.end ?? free(toFace, partial.start ?? centerOf(fromFace));
512
- ends.set(link, { start, end, bow: bows.get(link) });
538
+ ends.set(edge, { start, end, bow: bows.get(edge) });
513
539
  }
514
540
  return ends;
515
541
  }
516
542
  /**
517
- * Group the links that run between the same pair of sides, and work out the
543
+ * Group the edges that run between the same pair of sides, and work out the
518
544
  * lane order and lane width each group needs.
519
545
  *
520
- * Only a link whose author named *both* sides can be in a bundle: a bundle is a
546
+ * Only an edge whose author named *both* sides can be in a bundle: a bundle is a
521
547
  * statement about two specific edges, and an end with no side named has not
522
548
  * picked one yet.
523
549
  */
524
- function planBundles(links, measurer, fontSize) {
550
+ function planBundles(edges, measurer, fontSize) {
525
551
  const ids = new Map();
526
552
  const idOf = (node) => {
527
553
  let id = ids.get(node);
@@ -532,13 +558,13 @@ function planBundles(links, measurer, fontSize) {
532
558
  return id;
533
559
  };
534
560
  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)
561
+ for (const edge of edges) {
562
+ const fromSide = sideAttr(edge, 'from');
563
+ const toSide = sideAttr(edge, 'to');
564
+ if (!fromSide || !toSide || edge.from === edge.to)
539
565
  continue;
540
- const a = { node: link.from, side: fromSide };
541
- const b = { node: link.to, side: toSide };
566
+ const a = { node: edge.from, side: fromSide };
567
+ const b = { node: edge.to, side: toSide };
542
568
  const keyA = `${idOf(a.node)}:${a.side}`;
543
569
  const keyB = `${idOf(b.node)}:${b.side}`;
544
570
  // The pair is unordered — `a -> b` and `b -> a` join the same two edges —
@@ -548,13 +574,13 @@ function planBundles(links, measurer, fontSize) {
548
574
  const ends = swap ? [b, a] : [a, b];
549
575
  const group = groups.get(key);
550
576
  if (group)
551
- group.links.push(link);
577
+ group.edges.push(edge);
552
578
  else
553
- groups.set(key, { ends, links: [link] });
579
+ groups.set(key, { ends, edges: [edge] });
554
580
  }
555
581
  const bundles = new Map();
556
582
  for (const group of groups.values()) {
557
- if (group.links.length < 2)
583
+ if (group.edges.length < 2)
558
584
  continue;
559
585
  const [first, second] = group.ends;
560
586
  const t0 = tangentOf(first.side);
@@ -567,16 +593,16 @@ function planBundles(links, measurer, fontSize) {
567
593
  // step them to opposite sides and it pivots, which is a crossing.
568
594
  const aligned = cross(run, t0) * cross(run, t1) >= 0;
569
595
  const sense = aligned ? 1 : -1;
570
- // Two links leaving in opposite directions are the ordinary case, and which
596
+ // Two edges leaving in opposite directions are the ordinary case, and which
571
597
  // lane each takes is then read off the diagram rather than off the order the
572
598
  // 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) ||
599
+ // Edges pointing the same way have no such signal and fall back to the file.
600
+ const order = new Map(group.edges.map((edge, index) => [edge, index]));
601
+ const lanes = [...group.edges].sort((a, b) => Number(a.from !== first.node) - Number(b.from !== first.node) ||
576
602
  order.get(a) - order.get(b));
577
- // One lane apart moves a link's start by `step` along one side and its end
603
+ // One lane apart moves an edge's start by `step` along one side and its end
578
604
  // 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.
605
+ // its text goes — moves by the average of the two.
580
606
  const drift = { x: (t0.x + sense * t1.x) / 2, y: (t0.y + sense * t1.y) / 2 };
581
607
  const bundle = {
582
608
  ends: group.ends,
@@ -584,41 +610,41 @@ function planBundles(links, measurer, fontSize) {
584
610
  aligned,
585
611
  step: Math.max(ATTACH_STEP, laneStep(lanes, drift, measurer, fontSize)),
586
612
  };
587
- for (const link of lanes)
588
- bundles.set(link, bundle);
613
+ for (const edge of lanes)
614
+ bundles.set(edge, bundle);
589
615
  }
590
616
  return bundles;
591
617
  }
592
618
  /**
593
- * The sideways offset each link takes when several run between the same two
619
+ * The sideways offset each edge takes when several run between the same two
594
620
  * boxes and none of them names a side.
595
621
  *
596
622
  * 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
623
+ * and attaches wherever that ray crosses the border, so every edge in such a
598
624
  * 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
625
+ * one visible line, every text stacked on one point. `planEndpoints` cannot
600
626
  * see this and `planBundles` will not, since a bundle is a statement about two
601
627
  * named edges.
602
628
  *
603
629
  * 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
630
+ * edges using it at the same place: the line an edge would have drawn alone is
605
631
  * 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
632
+ * of the nesting a bundle already gives curves. A lone edge is in no group and
607
633
  * so is untouched.
608
634
  *
609
635
  * Where the boxes are too small to hold the group at full spacing, the ends
610
636
  * are squeezed evenly to fit the edge — there is nowhere further to attach —
611
637
  * 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
638
+ * its run by exactly what its endpoints could not give it, so the texts, which
613
639
  * ride at the midpoints, come apart even though the arrows do not. The bow is
614
640
  * therefore derived rather than styled, and it is zero whenever the edge was
615
641
  * long enough, which is why the ordinary case is still a straight line.
616
642
  *
617
- * A `between` link is left out. Its route is the corridor it named, its lane
643
+ * A `between` edge is left out. Its route is the corridor it named, its lane
618
644
  * inside that corridor is `planCorridors`' business, and `aimFreeEnds` will
619
645
  * re-aim these ends at the corridor afterwards regardless.
620
646
  */
621
- function planSpreads(links, measurer, fontSize) {
647
+ function planSpreads(edges, measurer, fontSize) {
622
648
  const ids = new Map();
623
649
  const idOf = (node) => {
624
650
  let id = ids.get(node);
@@ -629,47 +655,47 @@ function planSpreads(links, measurer, fontSize) {
629
655
  return id;
630
656
  };
631
657
  const groups = new Map();
632
- for (const link of links) {
633
- if (sideAttr(link, 'from') || sideAttr(link, 'to'))
658
+ for (const edge of edges) {
659
+ if (sideAttr(edge, 'from') || sideAttr(edge, 'to'))
634
660
  continue;
635
- if (link.from === link.to || link.between)
661
+ if (edge.from === edge.to || edge.between)
636
662
  continue;
637
- const a = idOf(link.from);
638
- const b = idOf(link.to);
663
+ const a = idOf(edge.from);
664
+ const b = idOf(edge.to);
639
665
  const swap = b < a;
640
666
  const key = swap ? `${b}|${a}` : `${a}|${b}`;
641
- const first = swap ? link.to : link.from;
667
+ const first = swap ? edge.to : edge.from;
642
668
  const group = groups.get(key);
643
669
  if (group)
644
- group.links.push(link);
670
+ group.edges.push(edge);
645
671
  else
646
- groups.set(key, { first, links: [link] });
672
+ groups.set(key, { first, edges: [edge] });
647
673
  }
648
674
  const spreads = new Map();
649
675
  for (const group of groups.values()) {
650
- if (group.links.length < 2)
676
+ if (group.edges.length < 2)
651
677
  continue;
652
678
  const from = centerOf(faceOf(group.first));
653
- const sample = group.links[0];
679
+ const sample = group.edges[0];
654
680
  const other = sample.from === group.first ? sample.to : sample.from;
655
681
  const to = centerOf(faceOf(other));
656
682
  const dx = to.x - from.x;
657
683
  const dy = to.y - from.y;
658
684
  const length = Math.hypot(dx, dy) || 1;
659
- // Translating the line moves its midpoint — where the label goes — by
685
+ // Translating the line moves its midpoint — where the text goes — by
660
686
  // exactly this, so it is the drift `laneStep` needs.
661
687
  const across = { x: -dy / length, y: dx / length };
662
- // The same derived order a bundle uses: links pointing opposite ways each
688
+ // The same derived order a bundle uses: edges pointing opposite ways each
663
689
  // 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) ||
690
+ // circulation, and only edges pointing the same way fall back to the file.
691
+ const order = new Map(group.edges.map((edge, index) => [edge, index]));
692
+ const lanes = [...group.edges].sort((a, b) => Number(a.from !== group.first) - Number(b.from !== group.first) ||
667
693
  order.get(a) - order.get(b));
668
694
  // How far a lane may be shifted before its line no longer passes through the
669
695
  // 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
696
+ // onto a corner and puts their texts back on top of each other — so the
671
697
  // group is squeezed evenly instead, exactly as `planEndpoints` squeezes a
672
- // side too short for the links arriving on it, and just as silently.
698
+ // side too short for the edges arriving on it, and just as silently.
673
699
  const reach = (node) => {
674
700
  const face = faceOf(node);
675
701
  const byX = across.x === 0 ? Infinity : face.width / 2 / Math.abs(across.x);
@@ -678,22 +704,22 @@ function planSpreads(links, measurer, fontSize) {
678
704
  };
679
705
  // Which way lane 0 lies is arbitrary, so fix it the way the rest of the
680
706
  // 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
707
+ // horizontal. Without this the first edge written is topmost on a rightward
682
708
  // run and rightmost on a downward one, for no reason a reader could see.
683
709
  const orient = across.x < 0 || (across.x === 0 && across.y < 0) ? -1 : 1;
684
710
  const usable = 2 * Math.min(reach(group.first), reach(other));
685
711
  const wanted = Math.max(ATTACH_STEP, laneStep(lanes, across, measurer, fontSize));
686
712
  const step = Math.min(wanted, usable / (lanes.length - 1));
687
- lanes.forEach((link, index) => {
713
+ lanes.forEach((edge, index) => {
688
714
  const place = index - (lanes.length - 1) / 2;
689
715
  // 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
716
+ // an edge written the other way round travels the opposite way and would
691
717
  // otherwise take the same offset to the opposite side, putting a
692
718
  // reciprocal pair back on one line. Negated, both keep to their own left,
693
719
  // which is the circulation a bundle already draws.
694
- const sense = (link.from === group.first ? 1 : -1) * orient;
720
+ const sense = (edge.from === group.first ? 1 : -1) * orient;
695
721
  const shortfall = place * (wanted - step) * sense;
696
- spreads.set(link, {
722
+ spreads.set(edge, {
697
723
  offset: place * step * sense,
698
724
  bow: { x: across.x * shortfall, y: across.y * shortfall },
699
725
  });
@@ -702,11 +728,11 @@ function planSpreads(links, measurer, fontSize) {
702
728
  return spreads;
703
729
  }
704
730
  /**
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.
731
+ * The bow each bundled edge needs, where the sides it was given were too short
732
+ * to hold the group at the spacing its texts asked for.
707
733
  *
708
734
  * 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 —
735
+ * shrinks to `usable / (n - 1)` and the texts ride down on top of each other —
710
736
  * and until this existed, naming the two sides the tool would have chosen
711
737
  * anyway made the picture strictly worse than saying nothing. That is not a
712
738
  * line worth defending, so the same repair applies: a lane's midpoint is not
@@ -739,35 +765,35 @@ function bowBundles(bundles, achieved) {
739
765
  };
740
766
  if (short.x === 0 && short.y === 0)
741
767
  continue;
742
- bundle.lanes.forEach((link, index) => {
768
+ bundle.lanes.forEach((edge, index) => {
743
769
  const place = index - (bundle.lanes.length - 1) / 2;
744
- bows.set(link, { x: short.x * place, y: short.y * place });
770
+ bows.set(edge, { x: short.x * place, y: short.y * place });
745
771
  });
746
772
  }
747
773
  return bows;
748
774
  }
749
775
  /**
750
- * How far apart adjacent lanes must sit for their labels to clear each other.
776
+ * How far apart adjacent lanes must sit for their texts to clear each other.
751
777
  *
752
- * The labels are knockout rectangles, so two of them clear when they are apart
778
+ * The texts are knockout rectangles, so two of them clear when they are apart
753
779
  * on *either* axis — hence the smaller of the two answers. `drift` is how far
754
780
  * the midpoint travels per unit of step, and it is never zero: the two ends
755
781
  * cancel only when both sides run the same way, and two such sides are always
756
782
  * `aligned`, which adds rather than subtracts.
757
783
  */
758
784
  function laneStep(lanes, drift, measurer, fontSize) {
759
- const labeled = lanes.filter((link) => link.label !== undefined);
760
- if (labeled.length < 2)
785
+ const withText = lanes.filter((edge) => edge.text !== undefined);
786
+ if (withText.length < 2)
761
787
  return 0;
762
- const need = (axis) => Math.max(...labeled.map((link) => labelExtent(link.label, link.appearance, axis, measurer, fontSize, link.line)));
788
+ const need = (axis) => Math.max(...withText.map((edge) => textExtent(edge.lines, edge.textAttrs, axis, measurer, fontSize, edge.line)));
763
789
  const along = (axis, reach) => reach === 0 ? Infinity : need(axis) / Math.abs(reach);
764
790
  return Math.min(along('x', drift.x), along('y', drift.y));
765
791
  }
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) {
792
+ /** Which lane of its bundle an edge's end at this side takes, if it is in one. */
793
+ function rankIn(bundle, edge, node, side) {
768
794
  if (!bundle)
769
795
  return undefined;
770
- const lane = bundle.lanes.indexOf(link);
796
+ const lane = bundle.lanes.indexOf(edge);
771
797
  const [first, second] = bundle.ends;
772
798
  if (node === first.node && side === first.side)
773
799
  return lane;
@@ -816,7 +842,7 @@ function anchorOn(face, side, at) {
816
842
  }
817
843
  /** An end with no side named: leave from the border, pointing at the far end. */
818
844
  function free(face, toward) {
819
- const point = edgePoint(face, toward);
845
+ const point = sidePoint(face, toward);
820
846
  const center = centerOf(face);
821
847
  const dx = point.x - center.x;
822
848
  const dy = point.y - center.y;
@@ -826,7 +852,7 @@ function free(face, toward) {
826
852
  /**
827
853
  * Walk from a point inside a box along a direction, stopping at the border.
828
854
  *
829
- * `edgePoint` walks from the center, which is the only place a single line
855
+ * `sidePoint` walks from the center, which is the only place a single line
830
856
  * passes through. A fanned-out group's lines are parallel to that one and
831
857
  * beside it, so each needs the border crossing of its own line rather than of
832
858
  * the center's — which is what keeps the group parallel instead of splayed.
@@ -844,11 +870,11 @@ function exitAlong(box, from, dir) {
844
870
  return { x: x + dir.x * Math.max(0, t), y: y + dir.y * Math.max(0, t) };
845
871
  }
846
872
  /**
847
- * Both ends of a link that named no side, moved `offset` sideways across its
873
+ * Both ends of an edge that named no side, moved `offset` sideways across its
848
874
  * own run.
849
875
  *
850
876
  * 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
877
+ * border, so the result is genuinely parallel to the line the edge would have
852
878
  * drawn alone, exactly `offset` away from it. Where each end lands then falls
853
879
  * out of that: level boxes put both points further along the same two edges,
854
880
  * and a diagonal pair whose line leaves through a corner puts one point on each
@@ -928,61 +954,61 @@ function gapBetween(a, b, aName, bName, wanted, line) {
928
954
  };
929
955
  }
930
956
  /**
931
- * Route every link that named a gap.
957
+ * Route every edge that named a gap.
932
958
  *
933
- * Links sharing one gap share its lanes, spread like attachments on a side and
959
+ * Edges sharing one gap share its lanes, spread like attachments on a side and
934
960
  * 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
961
+ * arriving at the hub's left edge in one order run through the corridor in that
936
962
  * same order and never cross.
937
963
  */
938
- function planCorridors(links, ends, measurer, fontSize) {
964
+ function planCorridors(edges, ends, measurer, fontSize) {
939
965
  const plans = new Map();
940
966
  const groups = new Map();
941
- for (const link of links) {
942
- if (!link.between)
967
+ for (const edge of edges) {
968
+ if (!edge.between)
943
969
  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);
970
+ const [first, second] = edge.between.nodes;
971
+ const gap = gapBetween(faceOf(first), faceOf(second), first.name, second.name, edge.between.axis, edge.line);
946
972
  // 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
973
+ // the key because a diagonal pair really does have two, and two edges may
948
974
  // legitimately name the same pair and take different ones.
949
975
  const key = [gap.axis, ...[first.name, second.name].sort()].join(' ');
950
976
  const group = groups.get(key);
951
977
  if (group)
952
- group.members.push(link);
978
+ group.members.push(edge);
953
979
  else
954
- groups.set(key, { ...gap, members: [link] });
980
+ groups.set(key, { ...gap, members: [edge] });
955
981
  }
956
982
  for (const group of groups.values()) {
957
- const along = (link) => {
958
- const { start, end } = ends.get(link);
983
+ const along = (edge) => {
984
+ const { start, end } = ends.get(edge);
959
985
  return (start[group.axis] + end[group.axis]) / 2;
960
986
  };
961
987
  const ordered = [...group.members].sort((a, b) => along(a) - along(b));
962
988
  // Lanes are spread as attachments on a side are, including the squeeze when
963
989
  // 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
990
+ // because a lane carries a whole text rather than the point of an arrow,
991
+ // and two lanes closer together than a text is deep would draw the texts
966
992
  // over each other. Still derived, not chosen: it is the size of what is
967
993
  // actually running along the corridor.
968
994
  const span = group.hi - group.lo;
969
995
  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)));
996
+ const want = Math.max(ATTACH_STEP, ...ordered.map((edge) => laneExtent(edge, group.axis, measurer, fontSize)));
971
997
  const step = ordered.length > 1 ? Math.min(want, usable / (ordered.length - 1)) : 0;
972
998
  const firstLane = group.lo + span / 2 - (step * (ordered.length - 1)) / 2;
973
- ordered.forEach((link, index) => {
974
- const { start, end } = ends.get(link);
999
+ ordered.forEach((edge, index) => {
1000
+ const { start, end } = ends.get(edge);
975
1001
  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.
1002
+ // The corridor binds only where the edge is actually passing the pair, so
1003
+ // its reach is the overlap of the pair's extent with the edge's own.
978
1004
  const enterAt = Math.max(group.across[0], Math.min(start[run], end[run]));
979
1005
  const leaveAt = Math.min(group.across[1], Math.max(start[run], end[run]));
980
1006
  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);
1007
+ const [a, b] = edge.between.nodes;
1008
+ throw new SourceError(`this edge never passes between "${a.name}" and "${b.name}"`, edge.line);
983
1009
  }
984
1010
  const forward = end[run] >= start[run];
985
- plans.set(link, {
1011
+ plans.set(edge, {
986
1012
  axis: group.axis,
987
1013
  lane: firstLane + index * step,
988
1014
  enter: forward ? enterAt : leaveAt,
@@ -997,41 +1023,41 @@ function planCorridors(links, ends, measurer, fontSize) {
997
1023
  * is the wrong thing to aim at once the line has been told to go somewhere else
998
1024
  * on the way. Point those ends at the corridor instead.
999
1025
  */
1000
- function aimFreeEnds(links, ends, corridors) {
1001
- for (const link of links) {
1002
- const plan = corridors.get(link);
1026
+ function aimFreeEnds(edges, ends, corridors) {
1027
+ for (const edge of edges) {
1028
+ const plan = corridors.get(edge);
1003
1029
  if (!plan)
1004
1030
  continue;
1005
- const current = ends.get(link);
1031
+ const current = ends.get(edge);
1006
1032
  const start = current.start.side === undefined
1007
- ? free(faceOf(link.from), corridorPoint(plan, plan.enter))
1033
+ ? free(faceOf(edge.from), corridorPoint(plan, plan.enter))
1008
1034
  : current.start;
1009
1035
  const end = current.end.side === undefined
1010
- ? free(faceOf(link.to), corridorPoint(plan, plan.leave))
1036
+ ? free(faceOf(edge.to), corridorPoint(plan, plan.leave))
1011
1037
  : current.end;
1012
- ends.set(link, { start, end });
1038
+ ends.set(edge, { start, end });
1013
1039
  }
1014
1040
  }
1015
1041
  /**
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,
1042
+ * How much room an edge's text takes across the corridor — its depth in a
1043
+ * horizontal channel, its width in a vertical one. Zero for an edge with no text,
1018
1044
  * which needs no more than the arrow spacing.
1019
1045
  *
1020
- * `labelExtent` measures the knockout along whichever axis it is handed, and the
1046
+ * `textExtent` measures the knockout along whichever axis it is handed, and the
1021
1047
  * 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.
1048
+ * edge runs along — a channel measured vertically carries edges running
1049
+ * horizontally, and what has to fit between two lanes of it is a text's depth.
1024
1050
  */
1025
- function laneExtent(link, axis, measurer, fontSize) {
1026
- if (link.label === undefined)
1051
+ function laneExtent(edge, axis, measurer, fontSize) {
1052
+ if (edge.lines === undefined)
1027
1053
  return 0;
1028
- return labelExtent(link.label, link.appearance, axis, measurer, fontSize, link.line);
1054
+ return textExtent(edge.lines, edge.textAttrs, axis, measurer, fontSize, edge.line);
1029
1055
  }
1030
1056
  function corridorPoint(plan, at) {
1031
1057
  return plan.axis === 'y' ? { x: at, y: plan.lane } : { x: plan.lane, y: at };
1032
1058
  }
1033
1059
  /**
1034
- * The path a corridor link takes: a curve out of its start into the gap, the
1060
+ * The path a corridor edge takes: a curve out of its start into the gap, the
1035
1061
  * straight run along the gap, and a curve out of the gap to its end. It is
1036
1062
  * three pieces rather than one cubic because a single curve has no way to stay
1037
1063
  * inside an interval over part of its length — which is the whole claim the
@@ -1070,12 +1096,12 @@ function corridorReach(from, to, axis) {
1070
1096
  const distance = Math.hypot(to.x - from.x, to.y - from.y);
1071
1097
  return Math.min(140, Math.max(8, Math.min(distance * 0.4, run / 2)));
1072
1098
  }
1073
- function sideAttr(link, key) {
1074
- const value = link.attrs[key];
1099
+ function sideAttr(edge, key) {
1100
+ const value = edge.attrs[key];
1075
1101
  if (value === undefined)
1076
1102
  return undefined;
1077
- if (!SIDES.includes(value)) {
1078
- throw new SourceError(`"${key}: ${value}" is not a side — use ${SIDES.join(', ')}`, link.line);
1103
+ if (!ATTACH_SIDES.includes(value)) {
1104
+ throw new SourceError(`"${key}: ${value}" is not a side — use ${ATTACH_SIDES.join(', ')}`, edge.line);
1079
1105
  }
1080
1106
  return value;
1081
1107
  }
@@ -1115,21 +1141,78 @@ function sized(block, size, fontSize) {
1115
1141
  return block;
1116
1142
  return ` <g font-size="${size}px">\n${block}\n </g>`;
1117
1143
  }
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;
1144
+ /**
1145
+ * Draw a block of text into the room it was given.
1146
+ *
1147
+ * Two independent questions, which is why there are two words for them. `side`
1148
+ * is where the block sits across that room, from the horizontal half of the
1149
+ * text's `at`. `align` is how the block's own lines range against each other,
1150
+ * which matters whenever they are of unequal length and is a different thing
1151
+ * from where the block is.
1152
+ *
1153
+ * Where the block is as wide as the room — which is every text whose box is
1154
+ * sized from it, so nearly all of them — the two coincide and `side` changes
1155
+ * nothing.
1156
+ */
1157
+ function textBlock(lines, x, y, lineHeight, fontSize, box, style) {
1158
+ // `box` is the ink the text occupies, worked out by the resolver — the one
1159
+ // place that decides where a text sits, because `hub text` is a placement
1160
+ // target and the answer has to be a number before anything is solved.
1161
+ const blockLeft = x + box.x;
1162
+ const top = y + box.y;
1163
+ const anchorX = style.align === 'middle'
1164
+ ? blockLeft + box.width / 2
1165
+ : style.align === 'end'
1166
+ ? blockLeft + box.width
1167
+ : blockLeft;
1120
1168
  return lines
1121
1169
  .map((line, index) => {
1122
- if (line.length === 0)
1170
+ if (plain(line).length === 0)
1123
1171
  return '';
1124
1172
  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>`;
1173
+ // One `<text>` per line, with a `<tspan>` per run inside it, so the runs
1174
+ // flow from the line's own anchor and a mark never moves a character.
1175
+ // A line drawn in one color says so on the `<text>` and emits no spans at
1176
+ // all, which is what keeps a whole quiet line identical to what the
1177
+ // `subtext:` it replaced produced.
1178
+ const colors = line.map((run) => style.ink(run, style.color));
1179
+ const uniform = colors.every((color) => color === colors[0]);
1180
+ const body = uniform
1181
+ ? escapeXml(plain(line))
1182
+ : line
1183
+ .map((run, run_index) => colors[run_index] === style.color
1184
+ ? escapeXml(run.text)
1185
+ : `<tspan fill="${colors[run_index]}">${escapeXml(run.text)}</tspan>`)
1186
+ .join('');
1187
+ return ` <text x="${round(anchorX)}" y="${round(baseline)}" fill="${uniform ? colors[0] ?? style.color : style.color}" text-anchor="${style.align}">${body}</text>`;
1129
1188
  })
1130
1189
  .filter((element) => element.length > 0)
1131
1190
  .join('\n');
1132
1191
  }
1192
+ /**
1193
+ * The color a marked-up run is drawn in. The mark names a *style*, never a
1194
+ * color, so the word borrows a meaning the file already has rather than
1195
+ * restating a value that goes stale the day the thing it means is recolored.
1196
+ * The resolver has already refused a mark naming a style that does not exist or
1197
+ * that says nothing about text.
1198
+ */
1199
+ function runInk(run, own, markup, theme) {
1200
+ if (run.style === undefined)
1201
+ return own;
1202
+ return namedColor(markup[run.style], theme);
1203
+ }
1204
+ /**
1205
+ * A text's own color. `muted` is the one reserved word: it defers to the theme,
1206
+ * so a quiet line stays readable when the theme changes. Anything else is a
1207
+ * color, the same as `fill:` and `border:` take.
1208
+ */
1209
+ function textColorOf(textAttrs, theme, fallback) {
1210
+ const value = textAttrs['color'];
1211
+ return value === undefined ? fallback : namedColor(value, theme);
1212
+ }
1213
+ function namedColor(value, theme) {
1214
+ return value === 'muted' ? theme.mutedText : value;
1215
+ }
1133
1216
  /**
1134
1217
  * A color is written as the viewer will receive it — `#14532d`, or any CSS
1135
1218
  * color. The renderer keeps no list of color words of its own, so a diagram
@@ -1145,23 +1228,6 @@ function borderOf(appearance, fallback) {
1145
1228
  function lineOf(appearance, fallback) {
1146
1229
  return appearance['line'] ?? fallback;
1147
1230
  }
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
1231
  function fillOf(appearance, fallback) {
1166
1232
  return appearance['fill'] ?? fallback;
1167
1233
  }