reladraw 0.1.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,9 +18,9 @@ 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
- // gives each icon its own hue — the drive is grey, the laptop periwinkle, the
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
24
25
  // system. One pair for the whole set is the deliberate difference: an icon
25
26
  // should read as part of the diagram's palette, not as clip art dropped in.
@@ -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,11 +65,11 @@ 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 arrowColours = new Set(layout.links.map((link) => colourOf(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>',
71
- ...[...arrowColours].map((colour) => arrowMarker(colour)),
72
+ ...[...arrowColors].map((color) => arrowMarker(color)),
72
73
  ' </defs>',
73
74
  ` <rect x="${canvas.x}" y="${canvas.y}" width="${canvas.width}" height="${canvas.height}" fill="${theme.background}"/>`,
74
75
  ...body,
@@ -77,109 +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
- colour: colourOf(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 centred.
98
- const drawn = [drawIcon(shape.body, node.x + (node.width - glyphSide) / 2, node.y, glyphSide, theme)];
99
- if (node.lines.some((line) => line.length > 0)) {
100
- drawn.push(sized(textBlock(node.lines, node.x, node.y + glyphSide + ICON_GAP, node.width, textHeight, size, {
101
- colour: colourOf(node.appearance, theme.text),
102
- subColour: subtextOf(node.appearance, theme),
103
- align: 'middle',
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;
111
- const stroke = colourOf(node.appearance, container ? theme.containerStroke : theme.boxStroke);
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;
169
+ const border = borderOf(node.appearance, container ? theme.containerStroke : theme.boxStroke);
112
170
  const fill = fillOf(node.appearance, container ? theme.containerFill : theme.boxFill);
113
- const subColour = subtextOf(node.appearance, theme);
171
+ // A box is the one kind with two inkable parts, which is why its text needs
172
+ // a word of its own — `border:` cannot stand in for it.
173
+ const text = textColorOf(node.textAttrs, theme, theme.text);
114
174
  // Deck copies sit behind the front face, furthest back drawn first.
115
- for (let depth = node.deckLabels.length; depth >= 1; depth -= 1) {
175
+ for (let depth = node.deckTexts.length; depth >= 1; depth -= 1) {
116
176
  const x = face.x - depth * DECK_STEP;
117
177
  const y = face.y - depth * DECK_STEP;
118
- parts.push(` <path d="${outlinePath(shape.outline, x, y, face.width, face.height)}" fill="${theme.containerFill}" stroke="${stroke}"/>`);
119
- const label = node.deckLabels[depth - 1];
120
- if (label !== undefined) {
121
- parts.push(sized(textBlock([label], x + PAD, y + PAD, face.width - PAD * 2, textHeight, size, {
122
- colour: theme.text,
123
- align: 'start',
124
- }), size, fontSize));
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));
125
182
  }
126
183
  }
127
- parts.push(` <path d="${outlinePath(shape.outline, face.x, face.y, face.width, face.height)}" fill="${fill}" stroke="${stroke}"/>`);
128
- for (const extra of outlineDetail(shape.outline, face.x, face.y, face.width, face.height)) {
129
- parts.push(` <path d="${extra}" fill="none" stroke="${stroke}"/>`);
130
- }
131
- // The icon takes a column on the right and the label lays out in what is
132
- // left, which is the room the resolver already reserved for exactly this.
133
- const icon = iconFor(node.appearance, node.line);
134
- const iconSide = icon === undefined ? 0 : glyphSide;
135
- const hasLabel = node.lines.some((line) => line.length > 0);
136
- const iconRoom = icon === undefined ? 0 : iconSide + (hasLabel ? ICON_GAP : 0);
137
- if (!container) {
138
- // A leaf centres its label in the box, both ways — in the room beside the
139
- // icon rather than the whole box, so the two sit side by side.
140
- const top = face.y + (face.height - node.lines.length * textHeight) / 2;
141
- parts.push(sized(textBlock(node.lines, face.x, top, face.width - iconRoom, textHeight, size, {
142
- colour: theme.text,
143
- subColour,
144
- align: 'middle',
145
- }), size, fontSize));
146
- }
147
- else {
148
- // The label and the icon share a band at one end of the box, and the
149
- // resolver has already given the contents the other end. A heading is
150
- // ranged left at the top; a caption is centred at the bottom.
151
- const band = Math.max(node.lines.length * textHeight, iconSide);
152
- const bandTop = labelStyle.at === 'top' ? face.y + PAD : face.y + face.height - PAD - band;
153
- parts.push(sized(textBlock(node.lines, face.x + PAD, bandTop, face.width - PAD * 2 - iconRoom, textHeight, size, {
154
- colour: theme.text,
155
- subColour,
156
- align: labelStyle.align,
157
- }), size, fontSize));
158
- for (const child of node.children) {
159
- parts.push(drawNode(child, theme, measurer, fontSize));
160
- }
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)) {
186
+ parts.push(` <path d="${extra}" fill="none" stroke="${border}"/>`);
161
187
  }
162
- if (icon !== undefined) {
163
- // A container's icon rides in the label's band, at whichever end that is; a
164
- // leaf's label is centred, so the icon centres with it. Both follow the
165
- // label rather than being placed by a rule of their own, which is what
166
- // keeps an icon reading as part of the title block and not as a sticker.
167
- const left = face.x + face.width - PAD - iconSide;
168
- const top = container
169
- ? labelStyle.at === 'top'
170
- ? face.y + PAD
171
- : face.y + face.height - PAD - iconSide
172
- : face.y + (face.height - iconSide) / 2;
173
- parts.push(drawIcon(icon, left, top, iconSide, theme));
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));
174
200
  }
175
- return parts.join('\n');
201
+ return { own: parts, kids };
176
202
  }
177
203
  /**
178
204
  * How far the dog-ear cuts into the top-right corner of a `document`.
179
205
  *
180
206
  * Twice the corner radius, so it is the same size on every box however wide.
181
207
  * The reference sizes its fold as a fraction of the box, which is why the fold
182
- * on those two wide `pg_dump` boxes almost disappears — the idea was right and
208
+ * on those two wide dump boxes almost disappears — the idea was right and
183
209
  * only the scaling was wrong.
184
210
  */
185
211
  const FOLD = CORNER * 2;
@@ -226,12 +252,12 @@ function outlineDetail(shape, x, y, w, h) {
226
252
  /** One icon, scaled from its own grid onto a square of `side` at `x, y`. */
227
253
  function drawIcon(icon, x, y, side, theme) {
228
254
  const scale = side / icon.grid;
229
- const colour = (tone) => tone === 'ink' ? theme.iconInk : tone === 'shade' ? theme.iconShade : theme.background;
255
+ const color = (tone) => tone === 'ink' ? theme.iconInk : tone === 'shade' ? theme.iconShade : theme.background;
230
256
  const paths = icon.paths.map((path) => {
231
- const fill = path.fill === undefined ? 'none' : colour(path.fill);
257
+ const fill = path.fill === undefined ? 'none' : color(path.fill);
232
258
  const stroke = path.stroke === undefined
233
259
  ? ''
234
- : ` stroke="${colour(path.stroke)}" stroke-width="${ICON_STROKE}" stroke-linejoin="round"`;
260
+ : ` stroke="${color(path.stroke)}" stroke-width="${ICON_STROKE}" stroke-linejoin="round"`;
235
261
  return ` <path d="${path.d}" fill="${fill}"${stroke}/>`;
236
262
  });
237
263
  return [
@@ -240,15 +266,15 @@ function drawIcon(icon, x, y, side, theme) {
240
266
  ' </g>',
241
267
  ].join('\n');
242
268
  }
243
- // --- links -------------------------------------------------------------------
244
- function drawLink(link, ends, corridor, theme, measurer, fontSize) {
269
+ // --- edges -------------------------------------------------------------------
270
+ function drawEdge(edge, ends, corridor, theme, measurer, fontSize, markup) {
245
271
  const { start, end } = ends;
246
- const colour = colourOf(link.appearance, theme.link);
247
- const markerEnd = ` marker-end="url(#${markerId(colour)})"`;
248
- const markerStart = link.both ? ` marker-start="url(#${markerId(colour)}-back)"` : '';
272
+ const color = lineOf(edge.appearance, theme.edge);
273
+ const markerEnd = ` marker-end="url(#${markerId(color)})"`;
274
+ const markerStart = edge.both ? ` marker-start="url(#${markerId(color)}-back)"` : '';
249
275
  // A named side is a statement about how the line should leave or arrive, so
250
276
  // it is drawn as a curve that actually does leave and arrive that way. With
251
- // neither side named there is nothing to honour and the line stays straight.
277
+ // neither side named there is nothing to honor and the line stays straight.
252
278
  const curved = start.side !== undefined || end.side !== undefined;
253
279
  const parts = [];
254
280
  // What the line actually covers, so the canvas can be sized to hold it. A
@@ -260,8 +286,8 @@ function drawLink(link, ends, corridor, theme, measurer, fontSize) {
260
286
  if (corridor) {
261
287
  const path = corridorPath(start, end, corridor);
262
288
  ink = union(ink, path.ink);
263
- parts.push(` <path d="${path.d}" fill="none" stroke="${colour}" stroke-width="${LINE_WIDTH}"${markerEnd}${markerStart}/>`);
264
- // The label goes on the straight run rather than at the midpoint of the
289
+ parts.push(` <path d="${path.d}" fill="none" stroke="${color}" stroke-width="${LINE_WIDTH}"${markerEnd}${markerStart}/>`);
290
+ // The text goes on the straight run rather than at the midpoint of the
265
291
  // whole path, so it sits in the gap the author asked the line to travel.
266
292
  midX = path.mid.x;
267
293
  midY = path.mid.y;
@@ -277,9 +303,9 @@ function drawLink(link, ends, corridor, theme, measurer, fontSize) {
277
303
  const by = (ends.bow?.y ?? 0) * lift;
278
304
  const c1 = { x: start.x + start.tx * reach + bx, y: start.y + start.ty * reach + by };
279
305
  const c2 = { x: end.x + end.tx * reach + bx, y: end.y + end.ty * reach + by };
280
- parts.push(` <path d="M ${round(start.x)} ${round(start.y)} C ${round(c1.x)} ${round(c1.y)}, ${round(c2.x)} ${round(c2.y)}, ${round(end.x)} ${round(end.y)}" fill="none" stroke="${colour}" stroke-width="${LINE_WIDTH}"${markerEnd}${markerStart}/>`);
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}/>`);
281
307
  ink = union(ink, cubicExtent(start, c1, c2, end));
282
- // 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.
283
309
  midX = (start.x + 3 * c1.x + 3 * c2.x + end.x) / 8;
284
310
  midY = (start.y + 3 * c1.y + 3 * c2.y + end.y) / 8;
285
311
  }
@@ -298,28 +324,29 @@ function drawLink(link, ends, corridor, theme, measurer, fontSize) {
298
324
  x: end.x - run.x + ends.bow.x * lift,
299
325
  y: end.y - run.y + ends.bow.y * lift,
300
326
  };
301
- parts.push(` <path d="M ${round(start.x)} ${round(start.y)} C ${round(c1.x)} ${round(c1.y)}, ${round(c2.x)} ${round(c2.y)}, ${round(end.x)} ${round(end.y)}" fill="none" stroke="${colour}" stroke-width="${LINE_WIDTH}"${markerEnd}${markerStart}/>`);
327
+ 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}/>`);
302
328
  ink = union(ink, cubicExtent(start, c1, c2, end));
303
329
  midX = (start.x + 3 * c1.x + 3 * c2.x + end.x) / 8;
304
330
  midY = (start.y + 3 * c1.y + 3 * c2.y + end.y) / 8;
305
331
  }
306
332
  else {
307
- parts.push(` <line x1="${round(start.x)}" y1="${round(start.y)}" x2="${round(end.x)}" y2="${round(end.y)}" stroke="${colour}" stroke-width="${LINE_WIDTH}"${markerEnd}${markerStart}/>`);
333
+ parts.push(` <line x1="${round(start.x)}" y1="${round(start.y)}" x2="${round(end.x)}" y2="${round(end.y)}" stroke="${color}" stroke-width="${LINE_WIDTH}"${markerEnd}${markerStart}/>`);
308
334
  midX = (start.x + end.x) / 2;
309
335
  midY = (start.y + end.y) / 2;
310
336
  }
311
- if (link.label !== undefined) {
312
- // A link label breaks on ` / ` exactly as a box label does, so a two-line
313
- // caption on an arrow needs no vocabulary of its own. The block is centred
314
- // on the midpoint, which keeps a one-line label where it has always been.
315
- const size = fontSizeFor('link', link.appearance, fontSize, link.line);
337
+ if (edge.text !== undefined) {
338
+ // An edge text breaks on ` / ` exactly as a box text does, so a two-line
339
+ // caption on an arrow needs no vocabulary of its own. The block is centered
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);
316
342
  const textHeight = measurer.lineHeight(size);
317
- const { width, lines } = measurer.measure(link.label, size);
343
+ const lines = edge.lines;
344
+ const width = widestLine(lines, measurer, size);
318
345
  const height = lines.length * textHeight;
319
346
  const top = midY - height / 2;
320
- // 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
321
348
  // chip of its own: an outlined box reads as a node, which is the one thing
322
- // a label on a line is not.
349
+ // a text on a line is not.
323
350
  parts.push(` <rect x="${round(midX - width / 2 - 5)}" y="${round(top)}" width="${round(width + 10)}" height="${round(height)}" fill="${theme.background}"/>`);
324
351
  ink = union(ink, {
325
352
  minX: midX - width / 2 - 5,
@@ -327,15 +354,16 @@ function drawLink(link, ends, corridor, theme, measurer, fontSize) {
327
354
  maxX: midX + width / 2 + 5,
328
355
  maxY: top + height,
329
356
  });
330
- parts.push(sized(textBlock(lines, midX - width / 2, top, width, textHeight, size, {
331
- // A coloured link carries its meaning into its label; an uncoloured
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
332
359
  // one leaves the words to read as ordinary text.
333
- colour: colourOf(link.appearance, theme.text),
360
+ color: textColorOf(edge.textAttrs, theme, lineOf(edge.appearance, theme.text)),
334
361
  align: 'middle',
362
+ ink: (run, own) => runInk(run, own, markup, theme),
335
363
  }), size, fontSize));
336
364
  }
337
365
  // The stroke straddles the path, so half of it lies outside the geometry.
338
- return { svg: parts.join('\n'), ink: grow(ink, LINE_WIDTH / 2) };
366
+ return { svg: linked(parts.join('\n'), edge.attrs['url']), ink: grow(ink, LINE_WIDTH / 2) };
339
367
  }
340
368
  function union(a, b) {
341
369
  return {
@@ -365,7 +393,7 @@ function extentOfPoints(points) {
365
393
  * What a cubic actually covers, which is not what its control points cover. A
366
394
  * handle reaching 140 pixels up carries the curve only about three quarters of
367
395
  * that, and sizing the page off the handles would leave a visible band of empty
368
- * canvas above every curved link. Solved rather than sampled: the extremes are
396
+ * canvas above every curved edge. Solved rather than sampled: the extremes are
369
397
  * the ends plus wherever the derivative — a quadratic — crosses zero.
370
398
  */
371
399
  function cubicExtent(p0, c1, c2, p3) {
@@ -399,60 +427,63 @@ function cubicExtent(p0, c1, c2, p3) {
399
427
  const [minY, maxY] = span(p0.y, c1.y, c2.y, p3.y);
400
428
  return { minX, minY, maxX, maxY };
401
429
  }
402
- /** Walk out from the centre of a box toward a point, stopping at the border. */
403
- function edgePoint(box, toward) {
404
- const centre = centreOf(box);
405
- const dx = toward.x - centre.x;
406
- const dy = toward.y - centre.y;
430
+ /** Walk out from the center of a box toward a point, stopping at the border. */
431
+ function sidePoint(box, toward) {
432
+ const center = centerOf(box);
433
+ const dx = toward.x - center.x;
434
+ const dy = toward.y - center.y;
407
435
  if (dx === 0 && dy === 0)
408
- return centre;
436
+ return center;
409
437
  const scaleX = dx === 0 ? Infinity : box.width / 2 / Math.abs(dx);
410
438
  const scaleY = dy === 0 ? Infinity : box.height / 2 / Math.abs(dy);
411
439
  const scale = Math.min(scaleX, scaleY);
412
- return { x: centre.x + dx * scale, y: centre.y + dy * scale };
440
+ return { x: center.x + dx * scale, y: center.y + dy * scale };
413
441
  }
414
- // --- where a link meets a box -------------------------------------------------
415
- 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'];
416
447
  /**
417
- * Work out where every link meets every box.
448
+ * Work out where every edge meets every box.
418
449
  *
419
450
  * An author names a *side* — `to: top` — and never a point on it. Alone on a
420
- * side a link lands at its centre; sharing the side with others, the points
451
+ * side an edge lands at its center; sharing the side with others, the points
421
452
  * spread so they do not sit on top of each other. Which one goes where is
422
- * derived from where the far ends actually are, never chosen: of two links
453
+ * derived from where the far ends actually are, never chosen: of two edges
423
454
  * arriving at one top edge, the one coming from further left arrives further
424
455
  * left. That is the same rule as box non-overlap — the tool separates things by
425
456
  * default, and reads the direction off the solved layout rather than asking.
426
457
  *
427
- * 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
428
459
  * nothing to read, and a `Bundle` supplies the order instead — see there.
429
460
  */
430
- function planEndpoints(links, measurer, fontSize) {
461
+ function planEndpoints(edges, measurer, fontSize) {
431
462
  const claims = new Map();
432
463
  const achieved = new Map();
433
464
  const named = new Map();
434
- const bundles = planBundles(links, measurer, fontSize);
435
- const spreads = planSpreads(links, measurer, fontSize);
436
- for (const link of links) {
437
- named.set(link, {});
438
- const fromSide = sideAttr(link, 'from');
439
- const toSide = sideAttr(link, 'to');
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');
440
471
  if (fromSide) {
441
- claim(claims, link.from, fromSide, {
442
- link,
472
+ claim(claims, edge.from, fromSide, {
473
+ edge,
443
474
  which: 'start',
444
475
  side: fromSide,
445
- toward: centreOf(faceOf(link.to)),
446
- 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),
447
478
  });
448
479
  }
449
480
  if (toSide) {
450
- claim(claims, link.to, toSide, {
451
- link,
481
+ claim(claims, edge.to, toSide, {
482
+ edge,
452
483
  which: 'end',
453
484
  side: toSide,
454
- toward: centreOf(faceOf(link.from)),
455
- 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),
456
487
  });
457
488
  }
458
489
  }
@@ -463,18 +494,18 @@ function planEndpoints(links, measurer, fontSize) {
463
494
  const along = side === 'top' || side === 'bottom' ? 'x' : 'y';
464
495
  const span = along === 'x' ? face.width : face.height;
465
496
  const origin = along === 'x' ? face.x : face.y;
466
- // 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
467
498
  // that share one, which are precisely the ones the first key cannot.
468
499
  const ordered = [...group].sort((a, b) => a.toward[along] - b.toward[along] || (a.rank ?? 0) - (b.rank ?? 0));
469
- // A bundle's lanes have to hold whole labels apart rather than the points
500
+ // A bundle's lanes have to hold whole texts apart rather than the points
470
501
  // of two arrows, so its step is the one that governs the side it lands on.
471
- const wanted = Math.max(ATTACH_STEP, ...group.map((entry) => bundles.get(entry.link)?.step ?? 0));
502
+ const wanted = Math.max(ATTACH_STEP, ...group.map((entry) => bundles.get(entry.edge)?.step ?? 0));
472
503
  const usable = Math.max(0, span - ATTACH_MARGIN * 2);
473
504
  const step = ordered.length > 1 ? Math.min(wanted, usable / (ordered.length - 1)) : 0;
474
505
  const first = origin + span / 2 - (step * (ordered.length - 1)) / 2;
475
506
  ordered.forEach((entry, index) => {
476
507
  const at = first + index * step;
477
- named.get(entry.link)[entry.which] = anchorOn(face, side, at);
508
+ named.get(entry.edge)[entry.which] = anchorOn(face, side, at);
478
509
  });
479
510
  // What the side could actually give, which is less than `wanted` when it
480
511
  // is too short for the group. `bowBundles` makes up the difference.
@@ -488,34 +519,34 @@ function planEndpoints(links, measurer, fontSize) {
488
519
  // Fill in the ends the author said nothing about, now that the named ones
489
520
  // are known: an unnamed end aims at wherever its partner ended up.
490
521
  const ends = new Map();
491
- for (const link of links) {
492
- const partial = named.get(link);
493
- const fromFace = faceOf(link.from);
494
- const toFace = faceOf(link.to);
495
- // Several links between one pair of boxes with no side named anywhere: the
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
496
527
  // line each would have drawn alone, moved aside so they do not coincide.
497
- const spread = spreads.get(link);
528
+ const spread = spreads.get(edge);
498
529
  if (spread) {
499
- ends.set(link, { ...parallelEnds(fromFace, toFace, spread.offset), bow: spread.bow });
530
+ ends.set(edge, { ...parallelEnds(fromFace, toFace, spread.offset), bow: spread.bow });
500
531
  continue;
501
532
  }
502
533
  // With neither end named this is the straight line it always was, each end
503
- // aiming at the other box's centre.
504
- const start = partial.start ?? free(fromFace, partial.end ?? centreOf(toFace));
505
- const end = partial.end ?? free(toFace, partial.start ?? centreOf(fromFace));
506
- ends.set(link, { start, end, bow: bows.get(link) });
534
+ // aiming at the other box's center.
535
+ const start = partial.start ?? free(fromFace, partial.end ?? centerOf(toFace));
536
+ const end = partial.end ?? free(toFace, partial.start ?? centerOf(fromFace));
537
+ ends.set(edge, { start, end, bow: bows.get(edge) });
507
538
  }
508
539
  return ends;
509
540
  }
510
541
  /**
511
- * 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
512
543
  * lane order and lane width each group needs.
513
544
  *
514
- * 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
515
546
  * statement about two specific edges, and an end with no side named has not
516
547
  * picked one yet.
517
548
  */
518
- function planBundles(links, measurer, fontSize) {
549
+ function planBundles(edges, measurer, fontSize) {
519
550
  const ids = new Map();
520
551
  const idOf = (node) => {
521
552
  let id = ids.get(node);
@@ -526,13 +557,13 @@ function planBundles(links, measurer, fontSize) {
526
557
  return id;
527
558
  };
528
559
  const groups = new Map();
529
- for (const link of links) {
530
- const fromSide = sideAttr(link, 'from');
531
- const toSide = sideAttr(link, 'to');
532
- if (!fromSide || !toSide || link.from === link.to)
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)
533
564
  continue;
534
- const a = { node: link.from, side: fromSide };
535
- const b = { node: link.to, side: toSide };
565
+ const a = { node: edge.from, side: fromSide };
566
+ const b = { node: edge.to, side: toSide };
536
567
  const keyA = `${idOf(a.node)}:${a.side}`;
537
568
  const keyB = `${idOf(b.node)}:${b.side}`;
538
569
  // The pair is unordered — `a -> b` and `b -> a` join the same two edges —
@@ -542,35 +573,35 @@ function planBundles(links, measurer, fontSize) {
542
573
  const ends = swap ? [b, a] : [a, b];
543
574
  const group = groups.get(key);
544
575
  if (group)
545
- group.links.push(link);
576
+ group.edges.push(edge);
546
577
  else
547
- groups.set(key, { ends, links: [link] });
578
+ groups.set(key, { ends, edges: [edge] });
548
579
  }
549
580
  const bundles = new Map();
550
581
  for (const group of groups.values()) {
551
- if (group.links.length < 2)
582
+ if (group.edges.length < 2)
552
583
  continue;
553
584
  const [first, second] = group.ends;
554
585
  const t0 = tangentOf(first.side);
555
586
  const t1 = tangentOf(second.side);
556
- const from = sideCentre(first);
557
- const to = sideCentre(second);
587
+ const from = sideCenter(first);
588
+ const to = sideCenter(second);
558
589
  const run = { x: to.x - from.x, y: to.y - from.y };
559
590
  // Nesting is a matter of which side of the line each end steps toward. Step
560
591
  // both ends to the same side of the run and the whole line translates;
561
592
  // step them to opposite sides and it pivots, which is a crossing.
562
593
  const aligned = cross(run, t0) * cross(run, t1) >= 0;
563
594
  const sense = aligned ? 1 : -1;
564
- // 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
565
596
  // lane each takes is then read off the diagram rather than off the order the
566
597
  // author happened to type them in: a line keeps to one side of its own run.
567
- // Links pointing the same way have no such signal and fall back to the file.
568
- const order = new Map(group.links.map((link, index) => [link, index]));
569
- const lanes = [...group.links].sort((a, b) => Number(a.from !== first.node) - Number(b.from !== first.node) ||
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) ||
570
601
  order.get(a) - order.get(b));
571
- // 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
572
603
  // by `step` along the other, so the midpoint of the line — which is where
573
- // its label goes — moves by the average of the two.
604
+ // its text goes — moves by the average of the two.
574
605
  const drift = { x: (t0.x + sense * t1.x) / 2, y: (t0.y + sense * t1.y) / 2 };
575
606
  const bundle = {
576
607
  ends: group.ends,
@@ -578,41 +609,41 @@ function planBundles(links, measurer, fontSize) {
578
609
  aligned,
579
610
  step: Math.max(ATTACH_STEP, laneStep(lanes, drift, measurer, fontSize)),
580
611
  };
581
- for (const link of lanes)
582
- bundles.set(link, bundle);
612
+ for (const edge of lanes)
613
+ bundles.set(edge, bundle);
583
614
  }
584
615
  return bundles;
585
616
  }
586
617
  /**
587
- * 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
588
619
  * boxes and none of them names a side.
589
620
  *
590
- * An unnamed end has no side to spread along: it aims at the far box's centre
591
- * and attaches wherever that ray crosses the border, so every link in such a
621
+ * An unnamed end has no side to spread along: it aims at the far box's center
622
+ * and attaches wherever that ray crosses the border, so every edge in such a
592
623
  * group produces the *same* ray and they are drawn on top of one another —
593
- * one visible line, every label stacked on one point. `planEndpoints` cannot
624
+ * one visible line, every text stacked on one point. `planEndpoints` cannot
594
625
  * see this and `planBundles` will not, since a bundle is a statement about two
595
626
  * named edges.
596
627
  *
597
628
  * The repair keeps the attachment rule exactly as it is and only stops two
598
- * links using it at the same place: the line a link would have drawn alone is
629
+ * edges using it at the same place: the line an edge would have drawn alone is
599
630
  * translated across its own run by a lane, which is the straight-line version
600
- * of the nesting a bundle already gives curves. A lone link is in no group and
631
+ * of the nesting a bundle already gives curves. A lone edge is in no group and
601
632
  * so is untouched.
602
633
  *
603
634
  * Where the boxes are too small to hold the group at full spacing, the ends
604
635
  * are squeezed evenly to fit the edge — there is nowhere further to attach —
605
636
  * and the shortfall is made up in the middle instead: each line bows across
606
- * its run by exactly what its endpoints could not give it, so the labels, which
637
+ * its run by exactly what its endpoints could not give it, so the texts, which
607
638
  * ride at the midpoints, come apart even though the arrows do not. The bow is
608
639
  * therefore derived rather than styled, and it is zero whenever the edge was
609
640
  * long enough, which is why the ordinary case is still a straight line.
610
641
  *
611
- * 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
612
643
  * inside that corridor is `planCorridors`' business, and `aimFreeEnds` will
613
644
  * re-aim these ends at the corridor afterwards regardless.
614
645
  */
615
- function planSpreads(links, measurer, fontSize) {
646
+ function planSpreads(edges, measurer, fontSize) {
616
647
  const ids = new Map();
617
648
  const idOf = (node) => {
618
649
  let id = ids.get(node);
@@ -623,47 +654,47 @@ function planSpreads(links, measurer, fontSize) {
623
654
  return id;
624
655
  };
625
656
  const groups = new Map();
626
- for (const link of links) {
627
- if (sideAttr(link, 'from') || sideAttr(link, 'to'))
657
+ for (const edge of edges) {
658
+ if (sideAttr(edge, 'from') || sideAttr(edge, 'to'))
628
659
  continue;
629
- if (link.from === link.to || link.between)
660
+ if (edge.from === edge.to || edge.between)
630
661
  continue;
631
- const a = idOf(link.from);
632
- const b = idOf(link.to);
662
+ const a = idOf(edge.from);
663
+ const b = idOf(edge.to);
633
664
  const swap = b < a;
634
665
  const key = swap ? `${b}|${a}` : `${a}|${b}`;
635
- const first = swap ? link.to : link.from;
666
+ const first = swap ? edge.to : edge.from;
636
667
  const group = groups.get(key);
637
668
  if (group)
638
- group.links.push(link);
669
+ group.edges.push(edge);
639
670
  else
640
- groups.set(key, { first, links: [link] });
671
+ groups.set(key, { first, edges: [edge] });
641
672
  }
642
673
  const spreads = new Map();
643
674
  for (const group of groups.values()) {
644
- if (group.links.length < 2)
675
+ if (group.edges.length < 2)
645
676
  continue;
646
- const from = centreOf(faceOf(group.first));
647
- const sample = group.links[0];
677
+ const from = centerOf(faceOf(group.first));
678
+ const sample = group.edges[0];
648
679
  const other = sample.from === group.first ? sample.to : sample.from;
649
- const to = centreOf(faceOf(other));
680
+ const to = centerOf(faceOf(other));
650
681
  const dx = to.x - from.x;
651
682
  const dy = to.y - from.y;
652
683
  const length = Math.hypot(dx, dy) || 1;
653
- // Translating the line moves its midpoint — where the label goes — by
684
+ // Translating the line moves its midpoint — where the text goes — by
654
685
  // exactly this, so it is the drift `laneStep` needs.
655
686
  const across = { x: -dy / length, y: dx / length };
656
- // 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
657
688
  // keep to one side of their own run, so a reciprocal pair reads as a
658
- // circulation, and only links pointing the same way fall back to the file.
659
- const order = new Map(group.links.map((link, index) => [link, index]));
660
- const lanes = [...group.links].sort((a, b) => Number(a.from !== group.first) - Number(b.from !== group.first) ||
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) ||
661
692
  order.get(a) - order.get(b));
662
693
  // How far a lane may be shifted before its line no longer passes through the
663
694
  // box at all. `exitAlong` clamps beyond that, which piles the outer lanes
664
- // onto a corner and puts their labels back on top of each other — so the
695
+ // onto a corner and puts their texts back on top of each other — so the
665
696
  // group is squeezed evenly instead, exactly as `planEndpoints` squeezes a
666
- // 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.
667
698
  const reach = (node) => {
668
699
  const face = faceOf(node);
669
700
  const byX = across.x === 0 ? Infinity : face.width / 2 / Math.abs(across.x);
@@ -672,22 +703,22 @@ function planSpreads(links, measurer, fontSize) {
672
703
  };
673
704
  // Which way lane 0 lies is arbitrary, so fix it the way the rest of the
674
705
  // renderer does — toward increasing x, or increasing y where the run is
675
- // horizontal. Without this the first link written is topmost on a rightward
706
+ // horizontal. Without this the first edge written is topmost on a rightward
676
707
  // run and rightmost on a downward one, for no reason a reader could see.
677
708
  const orient = across.x < 0 || (across.x === 0 && across.y < 0) ? -1 : 1;
678
709
  const usable = 2 * Math.min(reach(group.first), reach(other));
679
710
  const wanted = Math.max(ATTACH_STEP, laneStep(lanes, across, measurer, fontSize));
680
711
  const step = Math.min(wanted, usable / (lanes.length - 1));
681
- lanes.forEach((link, index) => {
712
+ lanes.forEach((edge, index) => {
682
713
  const place = index - (lanes.length - 1) / 2;
683
714
  // The lane is measured across the pair's own run, which has one direction;
684
- // a link written the other way round travels the opposite way and would
715
+ // an edge written the other way round travels the opposite way and would
685
716
  // otherwise take the same offset to the opposite side, putting a
686
717
  // reciprocal pair back on one line. Negated, both keep to their own left,
687
718
  // which is the circulation a bundle already draws.
688
- const sense = (link.from === group.first ? 1 : -1) * orient;
719
+ const sense = (edge.from === group.first ? 1 : -1) * orient;
689
720
  const shortfall = place * (wanted - step) * sense;
690
- spreads.set(link, {
721
+ spreads.set(edge, {
691
722
  offset: place * step * sense,
692
723
  bow: { x: across.x * shortfall, y: across.y * shortfall },
693
724
  });
@@ -696,11 +727,11 @@ function planSpreads(links, measurer, fontSize) {
696
727
  return spreads;
697
728
  }
698
729
  /**
699
- * The bow each bundled link needs, where the sides it was given were too short
700
- * to hold the group at the spacing its labels asked for.
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.
701
732
  *
702
733
  * A named side is squeezed exactly as an unnamed group's edge is — the step
703
- * shrinks to `usable / (n - 1)` and the labels ride down on top of each other —
734
+ * shrinks to `usable / (n - 1)` and the texts ride down on top of each other —
704
735
  * and until this existed, naming the two sides the tool would have chosen
705
736
  * anyway made the picture strictly worse than saying nothing. That is not a
706
737
  * line worth defending, so the same repair applies: a lane's midpoint is not
@@ -733,35 +764,35 @@ function bowBundles(bundles, achieved) {
733
764
  };
734
765
  if (short.x === 0 && short.y === 0)
735
766
  continue;
736
- bundle.lanes.forEach((link, index) => {
767
+ bundle.lanes.forEach((edge, index) => {
737
768
  const place = index - (bundle.lanes.length - 1) / 2;
738
- bows.set(link, { x: short.x * place, y: short.y * place });
769
+ bows.set(edge, { x: short.x * place, y: short.y * place });
739
770
  });
740
771
  }
741
772
  return bows;
742
773
  }
743
774
  /**
744
- * 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.
745
776
  *
746
- * 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
747
778
  * on *either* axis — hence the smaller of the two answers. `drift` is how far
748
779
  * the midpoint travels per unit of step, and it is never zero: the two ends
749
780
  * cancel only when both sides run the same way, and two such sides are always
750
781
  * `aligned`, which adds rather than subtracts.
751
782
  */
752
783
  function laneStep(lanes, drift, measurer, fontSize) {
753
- const labelled = lanes.filter((link) => link.label !== undefined);
754
- if (labelled.length < 2)
784
+ const withText = lanes.filter((edge) => edge.text !== undefined);
785
+ if (withText.length < 2)
755
786
  return 0;
756
- const need = (axis) => Math.max(...labelled.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)));
757
788
  const along = (axis, reach) => reach === 0 ? Infinity : need(axis) / Math.abs(reach);
758
789
  return Math.min(along('x', drift.x), along('y', drift.y));
759
790
  }
760
- /** Which lane of its bundle a link's end at this side takes, if it is in one. */
761
- function rankIn(bundle, link, node, side) {
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) {
762
793
  if (!bundle)
763
794
  return undefined;
764
- const lane = bundle.lanes.indexOf(link);
795
+ const lane = bundle.lanes.indexOf(edge);
765
796
  const [first, second] = bundle.ends;
766
797
  if (node === first.node && side === first.side)
767
798
  return lane;
@@ -774,7 +805,7 @@ function tangentOf(side) {
774
805
  return side === 'top' || side === 'bottom' ? { x: 1, y: 0 } : { x: 0, y: 1 };
775
806
  }
776
807
  /** The midpoint of one side of a box. */
777
- function sideCentre(end) {
808
+ function sideCenter(end) {
778
809
  const face = faceOf(end.node);
779
810
  const along = end.side === 'top' || end.side === 'bottom' ? face.width : face.height;
780
811
  const origin = end.side === 'top' || end.side === 'bottom' ? face.x : face.y;
@@ -810,20 +841,20 @@ function anchorOn(face, side, at) {
810
841
  }
811
842
  /** An end with no side named: leave from the border, pointing at the far end. */
812
843
  function free(face, toward) {
813
- const point = edgePoint(face, toward);
814
- const centre = centreOf(face);
815
- const dx = point.x - centre.x;
816
- const dy = point.y - centre.y;
844
+ const point = sidePoint(face, toward);
845
+ const center = centerOf(face);
846
+ const dx = point.x - center.x;
847
+ const dy = point.y - center.y;
817
848
  const length = Math.hypot(dx, dy) || 1;
818
849
  return { x: point.x, y: point.y, tx: dx / length, ty: dy / length };
819
850
  }
820
851
  /**
821
852
  * Walk from a point inside a box along a direction, stopping at the border.
822
853
  *
823
- * `edgePoint` walks from the centre, which is the only place a single line
854
+ * `sidePoint` walks from the center, which is the only place a single line
824
855
  * passes through. A fanned-out group's lines are parallel to that one and
825
856
  * beside it, so each needs the border crossing of its own line rather than of
826
- * the centre's — which is what keeps the group parallel instead of splayed.
857
+ * the center's — which is what keeps the group parallel instead of splayed.
827
858
  */
828
859
  function exitAlong(box, from, dir) {
829
860
  // A shift wider than the box leaves the origin outside it; clamping back in
@@ -838,19 +869,19 @@ function exitAlong(box, from, dir) {
838
869
  return { x: x + dir.x * Math.max(0, t), y: y + dir.y * Math.max(0, t) };
839
870
  }
840
871
  /**
841
- * 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
842
873
  * own run.
843
874
  *
844
875
  * The whole line is translated rather than each end being nudged along its
845
- * border, so the result is genuinely parallel to the line the link would have
876
+ * border, so the result is genuinely parallel to the line the edge would have
846
877
  * drawn alone, exactly `offset` away from it. Where each end lands then falls
847
878
  * out of that: level boxes put both points further along the same two edges,
848
879
  * and a diagonal pair whose line leaves through a corner puts one point on each
849
880
  * of the two edges meeting there. Neither is a case in the code.
850
881
  */
851
882
  function parallelEnds(from, to, offset) {
852
- const a = centreOf(from);
853
- const b = centreOf(to);
883
+ const a = centerOf(from);
884
+ const b = centerOf(to);
854
885
  const dx = b.x - a.x;
855
886
  const dy = b.y - a.y;
856
887
  const length = Math.hypot(dx, dy) || 1;
@@ -922,61 +953,61 @@ function gapBetween(a, b, aName, bName, wanted, line) {
922
953
  };
923
954
  }
924
955
  /**
925
- * Route every link that named a gap.
956
+ * Route every edge that named a gap.
926
957
  *
927
- * 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
928
959
  * ordered the same derived way — by where their ends actually sit, so the two
929
- * arriving at Dropbox's left edge in one order run through the corridor in that
960
+ * arriving at the hub's left edge in one order run through the corridor in that
930
961
  * same order and never cross.
931
962
  */
932
- function planCorridors(links, ends, measurer, fontSize) {
963
+ function planCorridors(edges, ends, measurer, fontSize) {
933
964
  const plans = new Map();
934
965
  const groups = new Map();
935
- for (const link of links) {
936
- if (!link.between)
966
+ for (const edge of edges) {
967
+ if (!edge.between)
937
968
  continue;
938
- const [first, second] = link.between.nodes;
939
- const gap = gapBetween(faceOf(first), faceOf(second), first.name, second.name, link.between.axis, link.line);
969
+ const [first, second] = edge.between.nodes;
970
+ const gap = gapBetween(faceOf(first), faceOf(second), first.name, second.name, edge.between.axis, edge.line);
940
971
  // The pair names one gap however the author ordered them. The axis is in
941
- // the key because a diagonal pair really does have two, and two links may
972
+ // the key because a diagonal pair really does have two, and two edges may
942
973
  // legitimately name the same pair and take different ones.
943
974
  const key = [gap.axis, ...[first.name, second.name].sort()].join(' ');
944
975
  const group = groups.get(key);
945
976
  if (group)
946
- group.members.push(link);
977
+ group.members.push(edge);
947
978
  else
948
- groups.set(key, { ...gap, members: [link] });
979
+ groups.set(key, { ...gap, members: [edge] });
949
980
  }
950
981
  for (const group of groups.values()) {
951
- const along = (link) => {
952
- const { start, end } = ends.get(link);
982
+ const along = (edge) => {
983
+ const { start, end } = ends.get(edge);
953
984
  return (start[group.axis] + end[group.axis]) / 2;
954
985
  };
955
986
  const ordered = [...group.members].sort((a, b) => along(a) - along(b));
956
987
  // Lanes are spread as attachments on a side are, including the squeeze when
957
988
  // there is not enough room — see `planEndpoints`. The step is wider here,
958
- // because a lane carries a whole label rather than the point of an arrow,
959
- // and two lanes closer together than a label is deep would draw the labels
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
960
991
  // over each other. Still derived, not chosen: it is the size of what is
961
992
  // actually running along the corridor.
962
993
  const span = group.hi - group.lo;
963
994
  const usable = Math.max(0, span - ATTACH_MARGIN * 2);
964
- const want = Math.max(ATTACH_STEP, ...ordered.map((link) => laneExtent(link, group.axis, measurer, fontSize)));
995
+ const want = Math.max(ATTACH_STEP, ...ordered.map((edge) => laneExtent(edge, group.axis, measurer, fontSize)));
965
996
  const step = ordered.length > 1 ? Math.min(want, usable / (ordered.length - 1)) : 0;
966
997
  const firstLane = group.lo + span / 2 - (step * (ordered.length - 1)) / 2;
967
- ordered.forEach((link, index) => {
968
- const { start, end } = ends.get(link);
998
+ ordered.forEach((edge, index) => {
999
+ const { start, end } = ends.get(edge);
969
1000
  const run = group.axis === 'y' ? 'x' : 'y';
970
- // The corridor binds only where the link is actually passing the pair, so
971
- // its reach is the overlap of the pair's extent with the link's own.
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.
972
1003
  const enterAt = Math.max(group.across[0], Math.min(start[run], end[run]));
973
1004
  const leaveAt = Math.min(group.across[1], Math.max(start[run], end[run]));
974
1005
  if (leaveAt <= enterAt) {
975
- const [a, b] = link.between.nodes;
976
- throw new SourceError(`this link never passes between "${a.name}" and "${b.name}"`, link.line);
1006
+ const [a, b] = edge.between.nodes;
1007
+ throw new SourceError(`this edge never passes between "${a.name}" and "${b.name}"`, edge.line);
977
1008
  }
978
1009
  const forward = end[run] >= start[run];
979
- plans.set(link, {
1010
+ plans.set(edge, {
980
1011
  axis: group.axis,
981
1012
  lane: firstLane + index * step,
982
1013
  enter: forward ? enterAt : leaveAt,
@@ -987,45 +1018,45 @@ function planCorridors(links, ends, measurer, fontSize) {
987
1018
  return plans;
988
1019
  }
989
1020
  /**
990
- * An end whose side the author did not name aims at the far box's centre, which
1021
+ * An end whose side the author did not name aims at the far box's center, which
991
1022
  * is the wrong thing to aim at once the line has been told to go somewhere else
992
1023
  * on the way. Point those ends at the corridor instead.
993
1024
  */
994
- function aimFreeEnds(links, ends, corridors) {
995
- for (const link of links) {
996
- const plan = corridors.get(link);
1025
+ function aimFreeEnds(edges, ends, corridors) {
1026
+ for (const edge of edges) {
1027
+ const plan = corridors.get(edge);
997
1028
  if (!plan)
998
1029
  continue;
999
- const current = ends.get(link);
1030
+ const current = ends.get(edge);
1000
1031
  const start = current.start.side === undefined
1001
- ? free(faceOf(link.from), corridorPoint(plan, plan.enter))
1032
+ ? free(faceOf(edge.from), corridorPoint(plan, plan.enter))
1002
1033
  : current.start;
1003
1034
  const end = current.end.side === undefined
1004
- ? free(faceOf(link.to), corridorPoint(plan, plan.leave))
1035
+ ? free(faceOf(edge.to), corridorPoint(plan, plan.leave))
1005
1036
  : current.end;
1006
- ends.set(link, { start, end });
1037
+ ends.set(edge, { start, end });
1007
1038
  }
1008
1039
  }
1009
1040
  /**
1010
- * How much room a link's label takes across the corridor — its depth in a
1011
- * horizontal channel, its width in a vertical one. Zero for an unlabelled link,
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,
1012
1043
  * which needs no more than the arrow spacing.
1013
1044
  *
1014
- * `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
1015
1046
  * axis wanted here is the one the channel is measured on rather than the one the
1016
- * link runs along — a channel measured vertically carries links running
1017
- * horizontally, and what has to fit between two lanes of it is a label's depth.
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.
1018
1049
  */
1019
- function laneExtent(link, axis, measurer, fontSize) {
1020
- if (link.label === undefined)
1050
+ function laneExtent(edge, axis, measurer, fontSize) {
1051
+ if (edge.lines === undefined)
1021
1052
  return 0;
1022
- return labelExtent(link.label, link.appearance, axis, measurer, fontSize, link.line);
1053
+ return textExtent(edge.lines, edge.textAttrs, axis, measurer, fontSize, edge.line);
1023
1054
  }
1024
1055
  function corridorPoint(plan, at) {
1025
1056
  return plan.axis === 'y' ? { x: at, y: plan.lane } : { x: plan.lane, y: at };
1026
1057
  }
1027
1058
  /**
1028
- * 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
1029
1060
  * straight run along the gap, and a curve out of the gap to its end. It is
1030
1061
  * three pieces rather than one cubic because a single curve has no way to stay
1031
1062
  * inside an interval over part of its length — which is the whole claim the
@@ -1064,28 +1095,28 @@ function corridorReach(from, to, axis) {
1064
1095
  const distance = Math.hypot(to.x - from.x, to.y - from.y);
1065
1096
  return Math.min(140, Math.max(8, Math.min(distance * 0.4, run / 2)));
1066
1097
  }
1067
- function sideAttr(link, key) {
1068
- const value = link.attrs[key];
1098
+ function sideAttr(edge, key) {
1099
+ const value = edge.attrs[key];
1069
1100
  if (value === undefined)
1070
1101
  return undefined;
1071
- if (!SIDES.includes(value)) {
1072
- 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);
1073
1104
  }
1074
1105
  return value;
1075
1106
  }
1076
- function arrowMarker(colour) {
1077
- const id = markerId(colour);
1107
+ function arrowMarker(color) {
1108
+ const id = markerId(color);
1078
1109
  return [
1079
1110
  ` <marker id="${id}" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="${ARROW_MARKER_WIDTH}" markerHeight="${ARROW_MARKER_WIDTH}" orient="auto-start-reverse">`,
1080
- ` <path d="M 0 0 L 10 5 L 0 10 z" fill="${colour}"/>`,
1111
+ ` <path d="M 0 0 L 10 5 L 0 10 z" fill="${color}"/>`,
1081
1112
  ' </marker>',
1082
1113
  ` <marker id="${id}-back" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="${ARROW_MARKER_WIDTH}" markerHeight="${ARROW_MARKER_WIDTH}" orient="auto-start-reverse">`,
1083
- ` <path d="M 0 0 L 10 5 L 0 10 z" fill="${colour}"/>`,
1114
+ ` <path d="M 0 0 L 10 5 L 0 10 z" fill="${color}"/>`,
1084
1115
  ' </marker>',
1085
1116
  ].join('\n');
1086
1117
  }
1087
- function markerId(colour) {
1088
- return `arrow-${colour.replace(/[^a-zA-Z0-9]/g, '')}`;
1118
+ function markerId(color) {
1119
+ return `arrow-${color.replace(/[^a-zA-Z0-9]/g, '')}`;
1089
1120
  }
1090
1121
  /** The rectangle actually drawn. Differs from the node box only for a deck. */
1091
1122
  function faceOf(node) {
@@ -1096,7 +1127,7 @@ function faceOf(node) {
1096
1127
  height: node.height - node.inset,
1097
1128
  };
1098
1129
  }
1099
- function centreOf(box) {
1130
+ function centerOf(box) {
1100
1131
  return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
1101
1132
  }
1102
1133
  /**
@@ -1109,42 +1140,92 @@ function sized(block, size, fontSize) {
1109
1140
  return block;
1110
1141
  return ` <g font-size="${size}px">\n${block}\n </g>`;
1111
1142
  }
1112
- function textBlock(lines, x, top, width, lineHeight, fontSize, style) {
1113
- const anchorX = style.align === 'middle' ? x + width / 2 : style.align === 'end' ? x + width : x;
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;
1114
1167
  return lines
1115
1168
  .map((line, index) => {
1116
- if (line.length === 0)
1169
+ if (plain(line).length === 0)
1117
1170
  return '';
1118
1171
  const baseline = top + index * lineHeight + lineHeight / 2 + fontSize * 0.35;
1119
- // A label's first line is its name; anything after it is a qualifier, and
1120
- // `subtext:` is how a box says that qualifier should read as secondary.
1121
- const colour = index === 0 ? style.colour : style.subColour ?? style.colour;
1122
- return ` <text x="${round(anchorX)}" y="${round(baseline)}" fill="${colour}" text-anchor="${style.align}">${escapeXml(line)}</text>`;
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>`;
1123
1187
  })
1124
1188
  .filter((element) => element.length > 0)
1125
1189
  .join('\n');
1126
1190
  }
1127
1191
  /**
1128
- * A colour is written as the viewer will receive it — `#14532d`, or any CSS
1129
- * colour. The renderer keeps no list of colour words of its own, so a diagram
1130
- * is never limited to the ones somebody remembered to add here.
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.
1131
1197
  */
1132
- function colourOf(appearance, fallback) {
1133
- return appearance['stroke'] ?? fallback;
1198
+ function runInk(run, own, markup, theme) {
1199
+ if (run.style === undefined)
1200
+ return own;
1201
+ return namedColor(markup[run.style], theme);
1134
1202
  }
1135
1203
  /**
1136
- * The colour for every label line after the first, or undefined when the box
1137
- * said nothing and all its lines should read alike. `muted` is the one reserved
1138
- * word: it defers to the theme, so a label's qualifier stays readable when the
1139
- * theme changes. Anything else is a colour, same as `stroke` and `fill` take.
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.
1140
1207
  */
1141
- function subtextOf(appearance, theme) {
1142
- const named = appearance['subtext'];
1143
- if (named === undefined)
1144
- return undefined;
1145
- if (named === 'muted')
1146
- return theme.mutedText;
1147
- return named;
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
+ }
1215
+ /**
1216
+ * A color is written as the viewer will receive it — `#14532d`, or any CSS
1217
+ * color. The renderer keeps no list of color words of its own, so a diagram
1218
+ * is never limited to the ones somebody remembered to add here.
1219
+ *
1220
+ * Each names the part it colors, so each reads exactly one key. The word these
1221
+ * replaced, `stroke:`, named no part and meant a different one on every kind,
1222
+ * which is why a box's text could not be colored at all until `text:`.
1223
+ */
1224
+ function borderOf(appearance, fallback) {
1225
+ return appearance['border'] ?? fallback;
1226
+ }
1227
+ function lineOf(appearance, fallback) {
1228
+ return appearance['line'] ?? fallback;
1148
1229
  }
1149
1230
  function fillOf(appearance, fallback) {
1150
1231
  return appearance['fill'] ?? fallback;