reladraw 0.16.0 → 0.17.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,10 +1,11 @@
1
- import { ARROW_LENGTH, ARROW_MARKER_WIDTH, ATTACH_MARGIN, ATTACH_STEP, DECK_STEP, DEFAULT_FONT_SIZE, ICON_LINES, PAD, SEPARATION_GAP, arrowLength, fontSizeFor, textExtent, textStyleFor, widestLine, } from './constants.js';
2
- import { describeAxis } from './ast.js';
1
+ import { ARROW_LENGTH, ARROW_MARKER_WIDTH, DEFAULT_FONT_SIZE, ICON_LINES, LINE_WIDTH, PAD, arrowLength, fontSizeFor, textStyleFor, widestLine, } from './constants.js';
3
2
  import { SourceError } from './errors.js';
4
3
  import { ICON_STROKE } from './icons.js';
5
4
  import { monospaceMeasurer } from './measure.js';
6
- import { DARK_THEME, THEMES, textOnFill, themeColor } from './themes.js';
7
- import { searchRoute } from './search.js';
5
+ import { joinedToText } from './model.js';
6
+ import { DARK_THEME, THEMES, overlapFill, regionTint, textOnFill, themeColor } from './themes.js';
7
+ import { crossingPoints, planLines, traceRoute, visibleRuns } from './lines.js';
8
+ import { patchPadding, placeLineTexts } from './linetexts.js';
8
9
  import { plain } from './text.js';
9
10
  const CORNER = 8;
10
11
  /** Turn solved geometry into a standalone SVG document. */
@@ -25,46 +26,71 @@ export function render(layout, options = {}) {
25
26
  ...(page !== undefined && { background: themeColor(page, base) }),
26
27
  ...(words !== undefined && { text: themeColor(words, base) }),
27
28
  };
28
- const body = [];
29
- for (const root of layout.roots) {
30
- body.push(drawNode(root, theme, measurer, fontSize, layout.markup));
31
- }
29
+ // Guides are shown by the viewer (`--guides`), or by a file that explains
30
+ // its own construction (`diagram guides: show`).
31
+ const guides = {
32
+ shown: options.guides ?? layout.diagram['guides'] === 'show',
33
+ color: theme.secondary.color,
34
+ looks: layout.guideLooks,
35
+ };
36
+ // A shown guide line is dashed in the guide color, unless `default guide
37
+ // edge` or its own words say otherwise. Nothing about where it goes changes.
38
+ const edges = layout.edges.map((edge) => {
39
+ if (!edge.guide || !guides.shown)
40
+ return edge;
41
+ const look = guides.looks.edge;
42
+ const pattern = edge.attrs['line.pattern'] ?? look['line.pattern'] ?? 'dashed';
43
+ return {
44
+ ...edge,
45
+ look: { ...edge.look, pattern: pattern },
46
+ appearance: { ...edge.appearance, line: edge.attrs['line'] ?? look['line'] ?? guides.color },
47
+ };
48
+ });
49
+ const hidden = (edge) => edge.guide === true && !guides.shown;
50
+ const boxes = layout.roots.flatMap((root) => drawNode(root, theme, measurer, fontSize, layout.markup, guides));
51
+ const marks = layout.marks.map((mark) => ({
52
+ mark,
53
+ item: { key: mark.name, svg: markSvg(mark, theme, guides, layout.markup, fontSize), fills: markFills(mark, theme) },
54
+ }));
55
+ const lines = [];
32
56
  // Everything the boxes cover. Edges are added to it as they are drawn.
33
57
  let ink = { minX: 0, minY: 0, maxX: layout.width, maxY: layout.height };
34
- // Endpoints are planned for every edge at once, because where an edge meets a
35
- // side depends on what else meets that same side. Corridors come after, for
36
- // the same reason in the other direction: which lane of a gap an edge takes
37
- // is ordered by where its ends turned out to be.
38
- const ends = planEndpoints(layout.edges, measurer, fontSize);
39
- const corridors = planCorridors(layout.edges, ends);
40
- const routes = planWays(layout.edges, layout.nodes, ends, corridors, measurer, fontSize);
41
- // A curved line sweeps along its way, kept off every other line: off the
42
- // sweeps already drawn, and off the ways of those still to come. Routes are
43
- // in the order they were placed, inside lanes first, so a line outside
44
- // another follows the curve of the one inside it rather than its corners.
45
- const sweeps = new Map();
46
- const swept = [];
47
- for (const [edge, route] of routes) {
48
- if (edge.look.path !== 'curved')
49
- continue;
50
- const coming = [...routes]
51
- .filter(([other]) => other !== edge && !sweeps.has(other))
52
- .map(([, way]) => way.points);
53
- const d = sweptPath(route.points, sweepObstacles(edge, layout.nodes), [...swept, ...coming], arrowLength(edge.look.thickness), edge.marks);
54
- sweeps.set(edge, d);
55
- swept.push(traceOf(`d="${d}"`));
56
- }
57
- const drawn = layout.edges.map((edge) => drawEdge(edge, ends.get(edge), routes.get(edge), sweeps.get(edge), layout.nodes, theme, measurer, fontSize, layout.markup));
58
- placeTexts(drawn, layout.nodes);
59
- // A line with a `crossing:` style is cut where it crosses an earlier one, and
60
- // the jump drawn over the cut. The cut is a mask rather than a break in the
61
- // path, so it works the same on a curve as on a corner, and leaves whatever
62
- // lies underneath — a container's fill — showing through the gap.
63
- const crossings = findCrossings(drawn);
58
+ // Every line is worked out from its own statement (design.org 6); the one
59
+ // thing lines share is a side, where several ends spread.
60
+ const routes = planLines(layout);
61
+ const drawn = edges
62
+ .map((edge, index) => ({ edge, route: routes.get(layout.edges[index]) }))
63
+ // A hidden guide line takes its place among the ends on a side, but draws nothing.
64
+ .filter(({ edge }) => !hidden(edge))
65
+ .map(({ edge, route }) => drawEdge(edge, route, theme, measurer, fontSize, layout.markup));
66
+ // The drawing order comes first, since where two lines cross the one drawn
67
+ // later is on top, and `depth:` may have moved either (design.org 6.6, 8.4).
68
+ const keyOf = (edge) => `edge ${edges.indexOf(edge.edge)}`;
69
+ const sequence = order(boxes, marks, drawn.map((edge) => ({ key: keyOf(edge), svg: '' })), layout.depths);
70
+ const place = new Map(sequence.map((item, index) => [item.key, index]));
71
+ const inOrder = [...drawn].sort((a, b) => place.get(keyOf(a)) - place.get(keyOf(b)));
72
+ // `depth: in front of climb` on a line puts it on top only where it crosses the leg climb.
73
+ const onLeg = layout.depths.flatMap((depth) => depth.clauses.flatMap((clause) => clause.legs.map(({ leg, line }) => {
74
+ const subject = drawn.find((edge) => keyOf(edge) === depth.subject);
75
+ const owner = drawn.find((edge) => keyOf(edge) === line);
76
+ const trace = routes.get(layout.edges[Number(line.slice('edge '.length))]).legs.get(leg);
77
+ return subject && owner ? { top: clause.way === 'in front of' ? subject : owner, pair: [subject, owner], trace } : undefined;
78
+ }))).filter((one) => one !== undefined);
79
+ // A line with a `crossing:` style is cut where it crosses a line under it,
80
+ // and the jump drawn over the cut. The cut is a mask rather than a break in
81
+ // the path, so it works the same on a curve as on a corner, and leaves
82
+ // whatever lies underneath — a container's fill — showing through the gap.
83
+ const crossings = findCrossings(inOrder, onLeg);
64
84
  const masks = [];
65
85
  for (const edge of drawn) {
66
86
  const found = crossings.get(edge);
67
- const parts = [edge.line, ...edge.rest];
87
+ // Each patch behind a text matches what is drawn under it, which the drawing order says.
88
+ const beneath = sequence.slice(0, place.get(keyOf(edge)));
89
+ const rest = edge.rest.map((part) => {
90
+ const patch = edge.patches.find((one) => one.marker === part);
91
+ return patch ? patchSvg(patch, beneath, theme) : part;
92
+ });
93
+ const parts = [edge.line, ...rest.filter((part) => part.length > 0)];
68
94
  if (found) {
69
95
  const id = `cut-${masks.length + 1}`;
70
96
  masks.push({ id, holes: found });
@@ -78,9 +104,13 @@ export function render(layout, options = {}) {
78
104
  edge.ink = union(edge.ink, grow(extentOfPoints([crossing.at]), crossing.reach + edge.edge.look.thickness));
79
105
  }
80
106
  }
81
- body.push(linked(parts.join('\n'), edge.edge.attrs['url']));
107
+ lines.push({ key: keyOf(edge), svg: linked(parts.join('\n'), edge.edge.attrs['url']) });
82
108
  ink = union(ink, grow(edge.ink, layout.margin));
83
109
  }
110
+ const lineSvg = new Map(lines.map((line) => [line.key, line.svg]));
111
+ const body = sequence
112
+ .map((item) => lineSvg.get(item.key) ?? item.svg)
113
+ .filter((svg) => svg.length > 0);
84
114
  // An edge's geometry is measured rather than solved for, so the resolver sized
85
115
  // the canvas from the boxes alone. A curve out of a `top` side, or a text
86
116
  // riding above one, lands outside that — so the page grows to hold it and the
@@ -93,17 +123,21 @@ export function render(layout, options = {}) {
93
123
  };
94
124
  // One marker for each mark in each line color the drawing actually uses.
95
125
  const markers = new Map();
96
- for (const edge of layout.edges) {
126
+ for (const edge of edges) {
127
+ if (hidden(edge))
128
+ continue;
97
129
  const color = lineOf(edge.appearance, theme, theme.edge);
98
- for (const mark of [edge.marks.from, edge.marks.to]) {
130
+ for (const which of ['from', 'to']) {
131
+ const mark = edge.marks[which];
132
+ const free = freeEnd(edge, which);
99
133
  if (mark !== 'none')
100
- markers.set(markerId(mark, color), [mark, color]);
134
+ markers.set(markerId(mark, color, free), [mark, color, free]);
101
135
  }
102
136
  }
103
137
  const svg = [
104
138
  `<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">`,
105
139
  ' <defs>',
106
- ...[...markers.values()].map(([mark, color]) => markMarker(mark, color, theme.background)),
140
+ ...[...markers.values()].map(([mark, color, free]) => markMarker(mark, color, theme.background, free)),
107
141
  // Each mask covers the whole page, in page units: the default region is the
108
142
  // line's own bounding box, which a straight level line gives no height, and
109
143
  // the line would vanish entirely.
@@ -184,19 +218,154 @@ function linked(svg, url) {
184
218
  // attribute, so the value is escaped as markup rather than merely quoted.
185
219
  return ` <a href=${quote(escapeXml(url))} target="_blank" rel="noopener">\n${svg}\n </a>`;
186
220
  }
187
- function drawNode(node, theme, measurer, fontSize, markup, inherited) {
221
+ /** Whether a node holds anything besides its heading and what is joined to its text: what a theme draws as a container. */
222
+ function holdsContents(node) {
223
+ return node.children.some((child) => child !== node.heading && !joinedToText(child));
224
+ }
225
+ /** What a node fills of its own. */
226
+ function nodeFills(node, theme) {
227
+ if (node.guide || node.body.kind !== 'shape')
228
+ return [];
229
+ const face = faceOf(node);
230
+ const fills = [];
231
+ const fill = fillOf(node.appearance, theme, holdsContents(node) ? containerFillOf(node, theme) : theme.boxFill);
232
+ if (fill !== 'none')
233
+ fills.push({ ...face, color: fill, opacity: 1 });
234
+ return fills;
235
+ }
236
+ /**
237
+ * The patch behind a line's text (design.org 7.3): the color drawn beneath
238
+ * the text's middle, built up as it was drawn, so a tint over a container's
239
+ * fill is matched as both; or the color its `fill:` states, or none.
240
+ */
241
+ function patchSvg(patch, beneath, theme) {
242
+ const rect = `x="${round(patch.rect.x)}" y="${round(patch.rect.y)}" width="${round(patch.rect.width)}" height="${round(patch.rect.height)}"`;
243
+ if (patch.fill === 'none')
244
+ return '';
245
+ if (patch.fill !== undefined)
246
+ return ` <rect ${rect} fill="${themeColor(patch.fill, theme)}"/>`;
247
+ const { x, y } = patch.center;
248
+ const under = beneath.flatMap((drawn) => drawn.fills ?? []).filter((fill) => {
249
+ if (!fill.round)
250
+ return x >= fill.x && x <= fill.x + fill.width && y >= fill.y && y <= fill.y + fill.height;
251
+ const r = Math.min(fill.width, fill.height) / 2;
252
+ return Math.hypot(x - (fill.x + fill.width / 2), y - (fill.y + fill.height / 2)) <= r;
253
+ });
254
+ let base = -1;
255
+ under.forEach((fill, index) => {
256
+ if (fill.opacity >= 1)
257
+ base = index;
258
+ });
259
+ return [
260
+ ` <rect ${rect} fill="${base < 0 ? theme.background : under[base].color}"/>`,
261
+ ...under.slice(base + 1).map((fill) => ` <rect ${rect} fill="${fill.color}" fill-opacity="${fill.opacity}"/>`),
262
+ ].join('\n');
263
+ }
264
+ /** The dash a shown guide is drawn with, so construction is told apart from the picture. */
265
+ const GUIDE_DASH = '4 3';
266
+ /**
267
+ * A node and everything inside it, in drawing order: the node's own ink, then
268
+ * each child's. Each node's ink is its own item, wrapped in its own `<a>`, so
269
+ * a destination never encloses a child's.
270
+ */
271
+ function drawNode(node, theme, measurer, fontSize, markup, guides, inherited) {
188
272
  const url = node.attrs['url'] ?? inherited;
189
- const { own, kids } = nodeSvg(node, theme, measurer, fontSize, markup, url);
190
- return [linked(own.join('\n'), url), ...kids]
191
- .filter((part) => part.length > 0)
192
- .join('\n');
273
+ let own = [];
274
+ if (!node.guide) {
275
+ own = nodeSvg(node, theme, measurer, fontSize, markup);
276
+ }
277
+ else if (guides.shown) {
278
+ // A shown guide: its outline dashed in the guide color, unfilled, and its
279
+ // text in the same color, unless `default guide node` or its own words say otherwise.
280
+ const look = guides.looks.node;
281
+ const said = (key) => node.attrs[key] ?? look[key];
282
+ const shown = {
283
+ ...node,
284
+ appearance: { ...node.appearance, fill: said('fill') ?? 'none', border: said('border') ?? guides.color },
285
+ textAttrs: { ...node.textAttrs, color: node.textAttrs['color'] ?? look['text.color'] ?? guides.color },
286
+ };
287
+ own = nodeSvg(shown, theme, measurer, fontSize, markup).map((part) => part.replace(/ stroke="([^"]*)"/g, (all, color) => (color === 'none' ? all : `${all} stroke-dasharray="${GUIDE_DASH}"`)));
288
+ if (node.body.kind === 'icon')
289
+ own = [` <g opacity="0.5">`, ...own, ' </g>'];
290
+ }
291
+ if (node.appearance['layout'] === 'venn' && !node.guide)
292
+ return drawVenn(node, theme, measurer, fontSize, markup, guides, url);
293
+ if (node.appearance['layout'] === 'deck' && !node.guide)
294
+ return drawDeck(node, theme, measurer, fontSize, markup, guides, url);
295
+ return [
296
+ { key: node.name, node, svg: linked(own.join('\n'), url), fills: nodeFills(node, theme) },
297
+ ...node.children.flatMap((child) => drawNode(child, theme, measurer, fontSize, markup, guides, url)),
298
+ ];
299
+ }
300
+ /**
301
+ * A deck: no outline of its own unless it states a fill or a border, and its
302
+ * cards drawn back to front, so the first, the front card, is on top.
303
+ */
304
+ function drawDeck(node, theme, measurer, fontSize, markup, guides, url) {
305
+ const outlined = node.appearance['fill'] !== undefined || node.appearance['border'] !== undefined;
306
+ const drawn = outlined ? node : { ...node, appearance: { ...node.appearance, fill: 'none', border: 'none' } };
307
+ const cards = node.children.filter((child) => child !== node.heading && !joinedToText(child));
308
+ // The cards are the same thing, so each looks like the front one unless it says otherwise.
309
+ const front = cards[0];
310
+ const holds = holdsContents(front);
311
+ const look = {
312
+ fill: fillOf(front.appearance, theme, holds ? containerFillOf(front, theme) : theme.boxFill),
313
+ border: borderOf(front.appearance, theme, holds ? theme.containerStroke : theme.boxStroke),
314
+ };
315
+ const like = (card) => card === front ? card : { ...card, appearance: { ...look, ...card.appearance } };
316
+ return [
317
+ { key: node.name, node, svg: linked(nodeSvg(drawn, theme, measurer, fontSize, markup).join('\n'), url), fills: nodeFills(drawn, theme) },
318
+ ...[...cards].reverse().flatMap((card) => drawNode(like(card), theme, measurer, fontSize, markup, guides, url)),
319
+ ...node.children
320
+ .filter((child) => !cards.includes(child))
321
+ .flatMap((child) => drawNode(child, theme, measurer, fontSize, markup, guides, url)),
322
+ ];
193
323
  }
194
324
  /**
195
- * What a node draws, in two pieces: its own ink and its children's. They are
196
- * kept apart so the node's `<a>` can wrap what is the node's without
197
- * swallowing what is a child's.
325
+ * A venn (design.org 5.x): no box of its own unless it states one, and its
326
+ * three parts drawn as one picture. The two sets' fills go down first, then
327
+ * the overlap's over where they cross, then both sets' borders over all three
328
+ * fills, so each border shows whole; then everything the parts hold. Each part
329
+ * has a different fill unless it states its own: the left set the theme's
330
+ * primary-subtle, the right its secondary-subtle, the overlap the two stacked.
198
331
  */
199
- function nodeSvg(node, theme, measurer, fontSize, markup, url) {
332
+ function drawVenn(node, theme, measurer, fontSize, markup, guides, url) {
333
+ const plain = (part, fill) => ({ ...part, appearance: { ...part.appearance, fill, border: 'none' } });
334
+ const own = nodeSvg(plain(node, node.appearance['fill'] ?? 'none'), theme, measurer, fontSize, markup);
335
+ if (node.appearance['border'] !== undefined)
336
+ own.push(borderSvg(node, theme));
337
+ const [left, shared, right] = node.children.filter((child) => child !== node.heading && !joinedToText(child));
338
+ const leftFill = left.appearance['fill'] ?? 'theme-primary-subtle';
339
+ const rightFill = right.appearance['fill'] ?? 'theme-secondary-subtle';
340
+ const sharedFill = shared.appearance['fill'] ?? overlapFill(themeColor(leftFill, theme), theme);
341
+ const item = (part, key, svg, fills = []) => ({ key, node: part, svg: linked(svg.join('\n'), part.attrs['url'] ?? url), fills });
342
+ const filled = (part, fill) => item(part, part.name, nodeSvg(plain(part, fill), theme, measurer, fontSize, markup), nodeFills(plain(part, fill), theme));
343
+ const rest = (part) => part.children.flatMap((child) => drawNode(child, theme, measurer, fontSize, markup, guides, part.attrs['url'] ?? url));
344
+ return [
345
+ { key: node.name, node, svg: linked(own.join('\n'), url), fills: nodeFills(plain(node, node.appearance['fill'] ?? 'none'), theme) },
346
+ filled(left, leftFill),
347
+ filled(right, rightFill),
348
+ filled(shared, sharedFill),
349
+ item(left, `${left.name} border`, [borderSvg(left, theme)]),
350
+ item(right, `${right.name} border`, [borderSvg(right, theme)]),
351
+ ...rest(left),
352
+ ...rest(shared),
353
+ ...rest(right),
354
+ ...node.children
355
+ .filter((child) => child !== left && child !== shared && child !== right)
356
+ .flatMap((child) => drawNode(child, theme, measurer, fontSize, markup, guides, url)),
357
+ ];
358
+ }
359
+ /** A container's border alone, unfilled. */
360
+ function borderSvg(node, theme) {
361
+ if (node.body.kind !== 'shape')
362
+ return '';
363
+ const face = faceOf(node);
364
+ const border = borderOf(node.appearance, theme, theme.containerStroke);
365
+ return ` <path d="${outlinePath(node.body.outline, face.x, face.y, face.width, face.height, node.corners)}" fill="none" stroke="${border}"/>`;
366
+ }
367
+ /** What a node draws of its own, without its children, which are drawn as items of their own. */
368
+ function nodeSvg(node, theme, measurer, fontSize, markup) {
200
369
  // A note is set smaller than a box text by default, and `size:` overrides
201
370
  // that on anything. Only this node's own text takes the size — children are
202
371
  // drawn by their own call and carry whatever they say themselves.
@@ -204,13 +373,22 @@ function nodeSvg(node, theme, measurer, fontSize, markup, url) {
204
373
  const textHeight = measurer.lineHeight(size);
205
374
  const blockWidth = widestLine(node.lines, measurer, size);
206
375
  const ink = (run, own) => runInk(run, own, markup, theme);
376
+ // No outline and no fill: only the text, and the children of an invisible
377
+ // container, are drawn.
207
378
  if (node.body.kind === 'none') {
208
379
  const style = textStyleFor(node.textAttrs, node.line, 'start', 'center');
209
- return { kids: [], own: [sized(textBlock(node.lines, node.x, node.y, textHeight, size, node.textBox, {
210
- color: textColorOf(node.textAttrs, theme, theme.text),
211
- align: style.align,
212
- ink,
213
- }), size, fontSize)] };
380
+ // With no fill of its own, its text reads on whatever box it sits in:
381
+ // a badge's heading text on a box filled theme-primary is that box's text.
382
+ let behind = node.parent;
383
+ while (behind && behind.body.kind === 'none')
384
+ behind = behind.parent;
385
+ const onFill = behind ? textOnFill(behind.appearance['fill'] ?? '', theme) : undefined;
386
+ return [sized(textBlock(node.lines, node.x, node.y, textHeight, size, node.textBox, {
387
+ color: textColorOf(node.textAttrs, theme, onFill ?? theme.text),
388
+ align: style.align,
389
+ ink,
390
+ reads: node.textAttrs['reads'],
391
+ }), size, fontSize)];
214
392
  }
215
393
  const glyphSide = ICON_LINES * textHeight;
216
394
  if (node.body.kind === 'icon') {
@@ -225,36 +403,40 @@ function nodeSvg(node, theme, measurer, fontSize, markup, url) {
225
403
  color: textColorOf(node.textAttrs, theme, theme.text),
226
404
  align: style.align,
227
405
  ink,
406
+ reads: node.textAttrs['reads'],
228
407
  }), size, fontSize));
229
408
  }
230
- return { own: drawn, kids: [] };
409
+ return drawn;
231
410
  }
232
411
  const outline = node.body.outline;
233
412
  const parts = [];
234
- const kids = [];
235
413
  const face = faceOf(node);
236
414
  // A node with children is colored as a backdrop however they are placed,
237
- // including a lone badge beside its text. Every rule that tried to tell a
238
- // badge from contents was a guess; this one is visible in the source.
239
- const container = node.children.length > 0;
415
+ // except its heading, which is its title: a badged box with nothing else in
416
+ // it looks like the boxes beside it. Every rule that tried to tell a badge
417
+ // from contents by looking was a guess; `badge:` and `heading:` say it.
418
+ const container = holdsContents(node);
240
419
  const border = borderOf(node.appearance, theme, container ? theme.containerStroke : theme.boxStroke);
241
- const fill = fillOf(node.appearance, theme, container ? theme.containerFill : theme.boxFill);
420
+ const fill = fillOf(node.appearance, theme, container ? containerFillOf(node, theme) : theme.boxFill);
242
421
  // A box is the one kind with two inkable parts, which is why its text needs
243
422
  // a word of its own — `border:` cannot stand in for it. Left unsaid on a
244
423
  // theme fill, it is whatever reads on that fill: `fill: theme-primary` alone
245
424
  // makes a badge whose text is legible in every theme.
246
425
  const text = textColorOf(node.textAttrs, theme, textOnFill(node.appearance['fill'] ?? '', theme) ?? theme.text);
247
- // Deck copies sit behind the front face, furthest back drawn first.
248
- for (let depth = node.deckTexts.length; depth >= 1; depth -= 1) {
249
- const x = face.x - depth * DECK_STEP;
250
- const y = face.y - depth * DECK_STEP;
251
- parts.push(` <path d="${outlinePath(outline, x, y, face.width, face.height)}" fill="${theme.containerFill}" stroke="${border}"/>`);
252
- const copy = node.deckTexts[depth - 1];
253
- if (copy !== undefined) {
254
- 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));
255
- }
426
+ // A title on the border breaks it: the stroke is masked where the title sits,
427
+ // and the fill, drawn without a stroke, still shows behind the title's inner
428
+ // half.
429
+ const onBorder = node.lines.length > 0 && node.textAttrs['place']?.startsWith('on ') === true;
430
+ if (onBorder) {
431
+ const id = `title-${node.name.replace(/[^\w.-]/g, '_')}`;
432
+ const room = PAD / 2;
433
+ const hole = node.textBox;
434
+ const grown = { x: face.x - PAD, y: face.y - PAD, w: face.width + PAD * 2, h: face.height + PAD * 2 };
435
+ parts.push(` <mask id="${id}" maskUnits="userSpaceOnUse" x="${round(grown.x)}" y="${round(grown.y)}" width="${round(grown.w)}" height="${round(grown.h)}">`, ` <rect x="${round(grown.x)}" y="${round(grown.y)}" width="${round(grown.w)}" height="${round(grown.h)}" fill="white"/>`, ` <rect x="${round(node.x + hole.x - room)}" y="${round(node.y + hole.y)}" width="${round(hole.width + room * 2)}" height="${round(hole.height)}" fill="black"/>`, ' </mask>', ` <path d="${outlinePath(outline, face.x, face.y, face.width, face.height, node.corners)}" fill="${fill}" stroke="none"/>`, ` <path d="${outlinePath(outline, face.x, face.y, face.width, face.height, node.corners)}" fill="none" stroke="${border}" mask="url(#${id})"/>`);
436
+ }
437
+ else {
438
+ parts.push(` <path d="${outlinePath(outline, face.x, face.y, face.width, face.height, node.corners)}" fill="${fill}" stroke="${border}"/>`);
256
439
  }
257
- parts.push(` <path d="${outlinePath(outline, face.x, face.y, face.width, face.height)}" fill="${fill}" stroke="${border}"/>`);
258
440
  for (const extra of outlineDetail(outline, face.x, face.y, face.width, face.height)) {
259
441
  parts.push(` <path d="${extra}" fill="none" stroke="${border}"/>`);
260
442
  }
@@ -262,17 +444,177 @@ function nodeSvg(node, theme, measurer, fontSize, markup, url) {
262
444
  // left of the band; both then read `at` for where it really goes. Where the
263
445
  // text sits is the resolver's answer, in `textBox`; only the alignment of
264
446
  // its lines against each other is read here. This follows the band, not the
265
- // colors: a badged leaf is a backdrop but its text still centers.
266
- const textStyle = textStyleFor(node.textAttrs, node.line, node.banded ? 'start' : 'middle', node.banded ? 'top-left' : 'center');
447
+ // colors: a badged leaf is a backdrop but its text still centers. A box
448
+ // whose only children are joined to its text is a leaf, and its lines center
449
+ // against each other as a leaf's do.
450
+ const titled = node.banded && container;
451
+ const textStyle = textStyleFor(node.textAttrs, node.line, titled ? 'start' : 'middle', titled ? 'top-left' : 'center');
267
452
  parts.push(sized(textBlock(node.lines, node.x, node.y, textHeight, size, node.textBox, {
268
453
  color: text,
269
454
  align: textStyle.align,
270
455
  ink,
456
+ reads: node.textAttrs['reads'],
271
457
  }), size, fontSize));
272
- for (const child of node.children) {
273
- kids.push(drawNode(child, theme, measurer, fontSize, markup, url));
458
+ return parts;
459
+ }
460
+ /**
461
+ * A named point or region (design.org 8.1, 8.3). A region is a light tint
462
+ * with a quiet border, thin, at about half strength, solid, in the theme's
463
+ * primary color; a point is a dot. A guide is drawn only when guides are
464
+ * shown, dashed and unfilled in the guide color.
465
+ */
466
+ function markSvg(mark, theme, guides, markup, fontSize) {
467
+ const said = (key) => mark.appearance[key] ?? (mark.guide ? guides.looks[mark.kind][key] : undefined);
468
+ if (mark.guide && !guides.shown)
469
+ return '';
470
+ const color = (value) => (value === undefined ? undefined : themeColor(value, theme));
471
+ const label = markTextSvg(mark, theme, mark.guide ? guides.color : theme.text, markup, fontSize);
472
+ if (mark.kind === 'point') {
473
+ const fill = color(said('fill')) ?? (mark.guide ? guides.color : theme.edge);
474
+ return ` <circle cx="${round(mark.x)}" cy="${round(mark.y)}" r="3" fill="${fill}"/>${label}`;
475
+ }
476
+ // A region with no height or no width, such as one between two corners on
477
+ // a line, is drawn as that line: a rectangle of no size draws nothing.
478
+ const flat = mark.width < 0.5 || mark.height < 0.5;
479
+ const box = flat
480
+ ? `d="M${round(mark.x)} ${round(mark.y)}L${round(mark.x + mark.width)} ${round(mark.y + mark.height)}"`
481
+ : `x="${round(mark.x)}" y="${round(mark.y)}" width="${round(mark.width)}" height="${round(mark.height)}" rx="4"`;
482
+ const shape = flat ? 'path' : 'rect';
483
+ // A text on the border breaks it, as a title on a box's border does.
484
+ const hole = mark.text && mark.text.textAttrs['place']?.startsWith('on ') ? mark.text.box : undefined;
485
+ const id = `region-${mark.name.replace(/[^\w.-]/g, '_')}`;
486
+ const masked = hole
487
+ ? ` <mask id="${id}" maskUnits="userSpaceOnUse" x="${round(mark.x - PAD)}" y="${round(mark.y - PAD)}" width="${round(mark.width + PAD * 2)}" height="${round(mark.height + PAD * 2)}">\n` +
488
+ ` <rect x="${round(mark.x - PAD)}" y="${round(mark.y - PAD)}" width="${round(mark.width + PAD * 2)}" height="${round(mark.height + PAD * 2)}" fill="white"/>\n` +
489
+ ` <rect x="${round(hole.x - PAD / 2)}" y="${round(hole.y)}" width="${round(hole.width + PAD)}" height="${round(hole.height)}" fill="black"/>\n` +
490
+ ' </mask>\n'
491
+ : '';
492
+ const mask = hole ? ` mask="url(#${id})"` : '';
493
+ if (mark.guide) {
494
+ const fill = color(said('fill')) ?? 'none';
495
+ const border = color(said('border')) ?? guides.color;
496
+ return `${masked} <${shape} ${box} fill="${fill}" stroke="${border}" stroke-dasharray="${GUIDE_DASH}"${mask}/>${label}`;
497
+ }
498
+ const fill = regionFill(mark, theme);
499
+ const border = color(said('border'));
500
+ const opacity = fill.color === 'none' || fill.opacity >= 1 ? '' : ` fill-opacity="${round(fill.opacity)}"`;
501
+ return (`${masked} <${shape} ${box} fill="${fill.color}"${opacity}` +
502
+ ` stroke="${border ?? theme.primary.color}"${border === undefined ? ' stroke-opacity="0.5"' : ''} stroke-width="1"${mask}/>${label}`);
503
+ }
504
+ /**
505
+ * A region's fill (design.org 8.3): what it states, or the theme's tint, at
506
+ * half opacity unless it says otherwise, so overlapping regions show both and
507
+ * a region never hides what it covers.
508
+ */
509
+ function regionFill(mark, theme) {
510
+ const said = mark.appearance['opacity'];
511
+ const opacity = said === undefined ? REGION_OPACITY : Number(said);
512
+ const stated = mark.appearance['fill'];
513
+ if (stated === undefined)
514
+ return regionTint(theme, opacity);
515
+ return { color: themeColor(stated, theme), opacity };
516
+ }
517
+ /** How opaque a region's fill is when it doesn't say. */
518
+ const REGION_OPACITY = 0.5;
519
+ /** What a region fills, for the patch behind a line's text: nothing for a guide, a point or a flat region. */
520
+ function markFills(mark, theme) {
521
+ if (mark.kind !== 'region' || mark.guide || mark.width < 0.5 || mark.height < 0.5)
522
+ return [];
523
+ const fill = regionFill(mark, theme);
524
+ if (fill.color === 'none' || fill.opacity <= 0)
525
+ return [];
526
+ return [{ x: mark.x, y: mark.y, width: mark.width, height: mark.height, ...fill }];
527
+ }
528
+ /** A point's or a region's text (design.org 7.4), on a line of its own after the mark. */
529
+ function markTextSvg(mark, theme, fallback, markup, fontSize) {
530
+ const text = mark.text;
531
+ if (!text)
532
+ return '';
533
+ const align = text.textAttrs['align'];
534
+ const block = textBlock(text.lines, 0, 0, text.lineHeight, text.size, text.box, {
535
+ color: textColorOf(text.textAttrs, theme, fallback),
536
+ align: align === 'left' || align === 'top' ? 'start' : align === 'right' || align === 'bottom' ? 'end' : 'middle',
537
+ ink: (run, own) => runInk(run, own, markup, theme),
538
+ reads: text.textAttrs['reads'],
539
+ });
540
+ return `\n${sized(block, text.size, fontSize)}`;
541
+ }
542
+ /**
543
+ * The drawing sequence (design.org 8.3, 8.4). By default a container comes
544
+ * before its own children, boxes before regions, regions before lines, and
545
+ * points last. A region goes in front of every box it doesn't name and behind
546
+ * what it does: the boxes it names, with what is inside them, are drawn after
547
+ * it. Then each `depth:` moves one thing, with what is inside it, to just
548
+ * before or just after another, and moves nothing else.
549
+ */
550
+ function order(boxes, marks, lines, depths) {
551
+ const inside = (item, names) => {
552
+ for (let at = item.node; at; at = at.parent)
553
+ if (names.has(at.name))
554
+ return true;
555
+ return names.has(item.key);
556
+ };
557
+ let sequence = [...boxes];
558
+ for (const { mark, item } of marks) {
559
+ if (mark.kind !== 'region')
560
+ continue;
561
+ const names = new Set(mark.names);
562
+ const named = sequence.filter((drawn) => inside(drawn, names));
563
+ sequence = [...sequence.filter((drawn) => !inside(drawn, names)), item, ...named];
564
+ }
565
+ sequence.push(...lines, ...marks.filter(({ mark }) => mark.kind === 'point').map(({ item }) => item));
566
+ const position = (key) => sequence.findIndex((drawn) => drawn.key === key);
567
+ /** A thing and what is inside it, which move together. */
568
+ const group = (key) => {
569
+ const head = sequence.find((drawn) => drawn.key === key);
570
+ return sequence.filter((drawn) => drawn === head || (head.node !== undefined && drawn.node !== undefined && drawn.node !== head.node && inside(drawn, new Set([key]))));
571
+ };
572
+ const holds = (outer, inner) => group(outer).some((drawn) => drawn.key === inner && drawn.key !== outer);
573
+ for (const depth of depths) {
574
+ for (const clause of depth.clauses) {
575
+ for (const name of clause.names) {
576
+ if (holds(depth.subject, name) && clause.way === 'in front of') {
577
+ throw new SourceError(`${depth.written}: depth: in front of "${name}", which is inside it — a container is drawn before its own children`, depth.line);
578
+ }
579
+ if (holds(name, depth.subject) && clause.way === 'behind') {
580
+ throw new SourceError(`${depth.written}: depth: behind "${name}", which holds it — a container is drawn before its own children`, depth.line);
581
+ }
582
+ }
583
+ // A hidden guide line is not in the sequence: it neither moves nor is moved against.
584
+ if (position(depth.subject) < 0)
585
+ continue;
586
+ const targets = clause.names.filter((name) => position(name) >= 0 && !holds(depth.subject, name) && !holds(name, depth.subject));
587
+ if (targets.length === 0)
588
+ continue;
589
+ const moving = group(depth.subject);
590
+ sequence = sequence.filter((drawn) => !moving.includes(drawn));
591
+ const at = clause.way === 'behind'
592
+ ? Math.min(...targets.map(position))
593
+ : Math.max(...targets.map((name) => position(name))) + 1;
594
+ sequence.splice(at, 0, ...moving);
595
+ }
596
+ }
597
+ // A later move can undo an earlier one only where the file asks for a cycle.
598
+ for (const depth of depths) {
599
+ for (const clause of depth.clauses) {
600
+ for (const [index, name] of clause.names.entries()) {
601
+ if (holds(depth.subject, name) || holds(name, depth.subject))
602
+ continue;
603
+ // A hidden guide line is not in the sequence, so nothing is in front of or behind it.
604
+ if (position(name) < 0 || position(depth.subject) < 0)
605
+ continue;
606
+ const holdsNow = clause.way === 'behind' ? position(depth.subject) < position(name) : position(depth.subject) > position(name);
607
+ if (holdsNow)
608
+ continue;
609
+ const others = depths
610
+ .filter((other) => other !== depth && (other.subject === name || other.clauses.some((c) => c.names.includes(depth.subject))))
611
+ .map((other) => `${other.written}'s depth: ${other.clauses.map((c) => `${c.way} ${c.shown.join(', ')}`).join(', ')} (line ${other.line})`);
612
+ throw new SourceError(`${depth.written}: depth: ${clause.way} "${clause.shown[index] ?? name}" can't hold together with ${others.length > 0 ? others.join(' and ') : 'the other depth: statements'} — ` +
613
+ 'they ask for a circle in the drawing order, so one of them has to go', depth.line);
614
+ }
615
+ }
274
616
  }
275
- return { own: parts, kids };
617
+ return sequence;
276
618
  }
277
619
  /**
278
620
  * How far the dog-ear cuts into the top-right corner of a `document`.
@@ -283,9 +625,22 @@ function nodeSvg(node, theme, measurer, fontSize, markup, url) {
283
625
  * only the scaling was wrong.
284
626
  */
285
627
  const FOLD = CORNER * 2;
286
- /** The node's outline, as path data. */
287
- function outlinePath(shape, x, y, w, h) {
628
+ /**
629
+ * The node's outline, as path data. `corners: sharp` squares a rectangle's
630
+ * corners and a document's three round ones; the fold and the circle are as
631
+ * they are either way.
632
+ */
633
+ function outlinePath(shape, x, y, w, h, corners) {
288
634
  const r = CORNER;
635
+ if (corners === 'sharp' && shape !== 'circle') {
636
+ return [
637
+ `M${round(x)} ${round(y)}`,
638
+ ...(shape === 'document' ? [`H${round(x + w - FOLD)}`, `L${round(x + w)} ${round(y + FOLD)}`] : [`H${round(x + w)}`]),
639
+ `V${round(y + h)}`,
640
+ `H${round(x)}`,
641
+ 'Z',
642
+ ].join(' ');
643
+ }
289
644
  if (shape === 'circle') {
290
645
  // Two half-turns from the leftmost point, since one arc cannot close.
291
646
  const radius = w / 2;
@@ -361,221 +716,93 @@ function drawIcon(icon, x, y, side, theme) {
361
716
  ' </g>',
362
717
  ].join('\n');
363
718
  }
364
- function drawEdge(edge, ends, route, sweep, nodes, theme, measurer, fontSize, markup) {
365
- const { start, end } = ends;
719
+ function drawEdge(edge, route, theme, measurer, fontSize, markup) {
366
720
  const color = lineOf(edge.appearance, theme, theme.edge);
367
721
  const stroke = strokeOf(color, edge.look);
368
- const markerEnd = edge.marks.to !== 'none' ? ` marker-end="url(#${markerId(edge.marks.to, color)})"` : '';
369
- const markerStart = edge.marks.from !== 'none' ? ` marker-start="url(#${markerId(edge.marks.from, color)})"` : '';
370
- // A named side is a statement about how the line should leave or arrive, so
371
- // it is drawn as a curve that actually does leave and arrive that way. With
372
- // neither side named there is nothing to honor and the line stays straight.
373
- const curved = start.side !== undefined || end.side !== undefined;
374
- const bowed = ends.bow !== undefined && (ends.bow.x !== 0 || ends.bow.y !== 0);
722
+ const markerEnd = edge.marks.to !== 'none' ? ` marker-end="url(#${markerId(edge.marks.to, color, freeEnd(edge, 'to'))})"` : '';
723
+ const markerStart = edge.marks.from !== 'none' ? ` marker-start="url(#${markerId(edge.marks.from, color, freeEnd(edge, 'from'))})"` : '';
724
+ const points = route.points;
725
+ const start = points[0];
726
+ const end = points[points.length - 1];
375
727
  const parts = [];
376
728
  // What the line actually covers, so the canvas can be sized to hold it. A
377
- // curve leaving a `top` side rides above every box in the drawing, and the
378
- // node bounds know nothing about it.
379
- let ink = extentOfPoints([start, end]);
380
- let midX;
381
- let midY;
382
- const jointed = edge.look.path !== 'curved' && (route || curved || bowed);
383
- if (jointed) {
384
- // `path: square` and `path: straight` are the same fixed points — the ends,
385
- // which way each faces, the run a clause asked for — joined by a different
386
- // rule, and both come out as straight pieces meeting at corners.
387
- const textWidth = edge.lines === undefined
388
- ? 0
389
- : widestLine(edge.lines, measurer, fontSizeFor('edge', edge.textAttrs, fontSize, edge.line));
390
- const plan = jointedLine(edge, ends, route, nodes, textWidth);
391
- const d = edge.look.corners === 'rounded'
392
- ? roundedPath(plan.points, arrowLength(edge.look.thickness))
393
- : sharpPath(plan.points);
729
+ // line stepping out of a `top` side rides above every box in the drawing,
730
+ // and the node bounds know nothing about it.
731
+ let ink = extentOfPoints(points);
732
+ if (route.hidden) {
733
+ // A line along a collapsed bundle: drawn up to where it merges and from
734
+ // where it splits, the bus between drawn once by the bundle (6.6). One
735
+ // path of several parts, so its marks stay at its own two ends.
736
+ const d = visibleRuns(route)
737
+ .map((run) => {
738
+ if (run.pieces.some((piece) => 'c1' in piece))
739
+ return piecesPath(run);
740
+ const corners = [run.start, ...run.pieces.map((piece) => piece.to)];
741
+ return edge.look.corners === 'rounded' && corners.length > 2 ? roundedPath(corners, arrowLength(edge.look.thickness)) : sharpPath(corners);
742
+ })
743
+ .join(' ');
394
744
  parts.push(` <path d="${d}" fill="none"${stroke}${markerEnd}${markerStart}/>`);
395
- ink = union(ink, extentOfPoints(plan.points));
396
- midX = plan.mid.x;
397
- midY = plan.mid.y;
745
+ ink = extentOfPoints(traceRoute(route));
398
746
  }
399
- else if (route) {
400
- // A curved line along a found way sweeps through each turn, as widely as
401
- // the room around the turn allows — worked out for every line at once, in
402
- // `render`, since each sweep keeps off the others.
403
- parts.push(` <path d="${sweep}" fill="none"${stroke}${markerEnd}${markerStart}/>`);
404
- // A sweep cuts inside its corners, so the way's own points bound it.
405
- ink = union(ink, extentOfPoints(route.points));
406
- midX = route.mid.x;
407
- midY = route.mid.y;
408
- }
409
- else if (curved) {
410
- const { p0, c1, c2, p3 } = sideCurve(edge, ends);
411
- const lead = p0.x !== start.x || p0.y !== start.y ? `M ${round(start.x)} ${round(start.y)} L` : 'M';
412
- const tail = p3.x !== end.x || p3.y !== end.y ? ` L ${round(end.x)} ${round(end.y)}` : '';
413
- parts.push(` <path d="${lead} ${round(p0.x)} ${round(p0.y)} C ${round(c1.x)} ${round(c1.y)}, ${round(c2.x)} ${round(c2.y)}, ${round(p3.x)} ${round(p3.y)}${tail}" fill="none"${stroke}${markerEnd}${markerStart}/>`);
414
- ink = union(ink, cubicExtent(p0, c1, c2, p3));
415
- // The point halfway along the cubic, which is where the text belongs.
416
- midX = (p0.x + 3 * c1.x + 3 * c2.x + p3.x) / 8;
417
- midY = (p0.y + 3 * c1.y + 3 * c2.y + p3.y) / 8;
747
+ else if (route.pieces.some((piece) => 'c1' in piece)) {
748
+ // A curved line is a spline through its points (design.org 6.3); a
749
+ // straight leg of it, or a run along a side, stays straight.
750
+ const lead = arrowLength(edge.look.thickness);
751
+ const d = piecesPath(route, { start: edge.marks.from !== 'none' ? lead : 0, end: edge.marks.to !== 'none' ? lead : 0 });
752
+ parts.push(` <path d="${d}" fill="none"${stroke}${markerEnd}${markerStart}/>`);
753
+ ink = extentOfPoints(traceRoute(route));
418
754
  }
419
- else if (ends.bow && bowed) {
420
- // A straight line that could not get the room it needed at its ends, so it
421
- // takes it in the middle. Both control points carry the same displacement,
422
- // which keeps the arc symmetric; a cubic's middle moves three quarters of
423
- // the way its controls do, so the displacement is the bow scaled up by that.
424
- const lift = 4 / 3;
425
- const run = { x: (end.x - start.x) / 3, y: (end.y - start.y) / 3 };
426
- const c1 = {
427
- x: start.x + run.x + ends.bow.x * lift,
428
- y: start.y + run.y + ends.bow.y * lift,
429
- };
430
- const c2 = {
431
- x: end.x - run.x + ends.bow.x * lift,
432
- y: end.y - run.y + ends.bow.y * lift,
433
- };
434
- 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}${markerEnd}${markerStart}/>`);
435
- ink = union(ink, cubicExtent(start, c1, c2, end));
436
- midX = (start.x + 3 * c1.x + 3 * c2.x + end.x) / 8;
437
- midY = (start.y + 3 * c1.y + 3 * c2.y + end.y) / 8;
755
+ else if (points.length > 2) {
756
+ const d = edge.look.corners === 'rounded' ? roundedPath(points, arrowLength(edge.look.thickness)) : sharpPath(points);
757
+ parts.push(` <path d="${d}" fill="none"${stroke}${markerEnd}${markerStart}/>`);
438
758
  }
439
759
  else {
440
760
  parts.push(` <line x1="${round(start.x)}" y1="${round(start.y)}" x2="${round(end.x)}" y2="${round(end.y)}"${stroke}${markerEnd}${markerStart}/>`);
441
- midX = (start.x + end.x) / 2;
442
- midY = (start.y + end.y) / 2;
443
761
  }
444
- let text;
445
- if (edge.text !== undefined) {
446
- // An edge text breaks on ` / ` exactly as a box text does, so a two-line
447
- // caption on an arrow needs no vocabulary of its own. The block is centered
448
- // on the midpoint, which keeps a one-line text where it has always been.
449
- const size = fontSizeFor('edge', edge.textAttrs, fontSize, edge.line);
450
- const textHeight = measurer.lineHeight(size);
451
- const lines = edge.lines;
452
- const width = widestLine(lines, measurer, size);
453
- const height = lines.length * textHeight;
454
- text = {
455
- at: { x: midX, y: midY },
456
- width: width + 10,
457
- height,
458
- draw: ({ x, y }) => {
459
- const top = y - height / 2;
460
- return [
461
- // The text knocks a hole in whatever it lands on rather than sitting
462
- // in a chip of its own: an outlined box reads as a node, which is the
463
- // one thing a text on a line is not.
464
- ` <rect x="${round(x - width / 2 - 5)}" y="${round(top)}" width="${round(width + 10)}" height="${round(height)}" fill="${theme.background}"/>`,
465
- sized(textBlock(lines, x - width / 2, top, textHeight, size, { x: 0, y: 0, width, height }, {
466
- // A colored edge carries its meaning into its text; an uncolored
467
- // one leaves the words to read as ordinary text.
468
- color: textColorOf(edge.textAttrs, theme, lineOf(edge.appearance, theme, theme.text)),
469
- align: 'middle',
470
- ink: (run, own) => runInk(run, own, markup, theme),
471
- }), size, fontSize),
472
- ];
473
- },
474
- };
762
+ // Each text is a measurement on the drawn line (design.org 7.3). A text
763
+ // breaks on ` / ` exactly as a box text does, so a two-line caption on an
764
+ // arrow needs no vocabulary of its own.
765
+ const rest = [];
766
+ const patches = [];
767
+ for (const placed of placeLineTexts(edge, route)) {
768
+ const { text, center, angle } = placed;
769
+ const lineHeight = measurer.lineHeight(text.size);
770
+ const { width, height } = text;
771
+ const top = center.y - height / 2;
772
+ const turn = angle === 0 ? '' : ` transform="rotate(${round(angle)} ${round(center.x)} ${round(center.y)})"`;
773
+ // The text knocks a hole in whatever it lands on rather than sitting in
774
+ // a chip of its own: an outlined box reads as a node, which is the one
775
+ // thing a text on a line is not.
776
+ const pad = patchPadding(text, `edge ${edge.from.name} ${edge.arrow} ${edge.to.name}`, edge.line);
777
+ const marker = `\u0000patch ${patches.length}`;
778
+ patches.push({
779
+ marker,
780
+ rect: { x: center.x - width / 2 - pad.x, y: top - pad.y, width: width + pad.x * 2, height: height + pad.y * 2 },
781
+ center,
782
+ ...(text.textAttrs['fill'] !== undefined ? { fill: text.textAttrs['fill'] } : {}),
783
+ });
784
+ rest.push(...(turn ? [' <g' + turn + '>'] : []), marker, sized(textBlock(text.lines, center.x - width / 2, top, lineHeight, text.size, { x: 0, y: 0, width, height }, {
785
+ // A colored edge carries its meaning into its text; an uncolored
786
+ // one leaves the words to read as ordinary text.
787
+ color: textColorOf(text.textAttrs, theme, lineOf(edge.appearance, theme, theme.text)),
788
+ align: 'middle',
789
+ ink: (run, own) => runInk(run, own, markup, theme),
790
+ }), text.size, fontSize), ...(turn ? [' </g>'] : []));
791
+ ink = union(ink, placed.box);
475
792
  }
476
793
  // The stroke straddles the path, so half of it lies outside the geometry.
477
794
  const [line] = parts;
478
795
  return {
479
796
  edge,
480
797
  line: line,
481
- rest: [],
798
+ rest,
482
799
  ink: grow(ink, edge.look.thickness / 2),
483
- trace: traceOf(line),
800
+ trace: route.hidden ? traceRoute(route) : tracesOf(line)[0],
801
+ traces: tracesOf(line),
484
802
  color,
803
+ patches,
485
804
  stroke,
486
- text,
487
- };
488
- }
489
- /** The space a text moved off another keeps from it. */
490
- const TEXT_AIR = 6;
491
- /**
492
- * Put every edge's text where it goes. A text sits where its line put it unless
493
- * that lands on a text placed before it; then it slides along its own line to
494
- * the nearest spot clear of the other texts and of every box. The line never
495
- * moves for it. With no clear spot anywhere along the line, it stays put.
496
- */
497
- function placeTexts(drawn, nodes) {
498
- const placed = [];
499
- const boxAt = (at, text) => ({
500
- minX: at.x - text.width / 2,
501
- minY: at.y - text.height / 2,
502
- maxX: at.x + text.width / 2,
503
- maxY: at.y + text.height / 2,
504
- });
505
- const overlaps = (a, b) => Math.min(a.maxX, b.maxX) - Math.max(a.minX, b.minX) > 0.5 &&
506
- Math.min(a.maxY, b.maxY) - Math.max(a.minY, b.minY) > 0.5;
507
- // A box the text is inside, drawn round it, is a container it rides across,
508
- // not something it lands on.
509
- const onBox = (area, at) => nodes.some((node) => {
510
- const box = extentOfBox(faceOf(node));
511
- const holds = node.children.length > 0 &&
512
- box.minX < at.x && at.x < box.maxX && box.minY < at.y && at.y < box.maxY;
513
- return !holds && overlaps(area, box);
514
- });
515
- for (const edge of drawn) {
516
- const text = edge.text;
517
- if (!text)
518
- continue;
519
- let at = text.at;
520
- if (placed.some((other) => overlaps(other, boxAt(at, text)))) {
521
- // Moved, it keeps a little air from the others rather than touching them.
522
- const free = (point) => {
523
- const area = boxAt(point, text);
524
- return !placed.some((other) => overlaps(grow(other, TEXT_AIR), area)) && !onBox(area, point);
525
- };
526
- at = slideAlong(edge.trace, text.at, free) ?? at;
527
- }
528
- const area = boxAt(at, text);
529
- placed.push(area);
530
- edge.rest = text.draw(at);
531
- // Grown by half the stroke as the line's own ink is, so a text at the edge
532
- // of the page sizes it exactly as it always did.
533
- edge.ink = union(edge.ink, grow(area, edge.edge.look.thickness / 2));
534
- }
535
- }
536
- /**
537
- * The point on a polyline nearest `from`, measured along the line, that `free`
538
- * accepts: tried a few pixels at a time in both directions, nearer first.
539
- * Undefined when no point along it is accepted.
540
- */
541
- function slideAlong(trace, from, free) {
542
- const lengths = [0];
543
- for (let index = 1; index < trace.length; index += 1) {
544
- const [a, b] = [trace[index - 1], trace[index]];
545
- lengths.push(lengths[index - 1] + Math.hypot(b.x - a.x, b.y - a.y));
546
- }
547
- const total = lengths[lengths.length - 1];
548
- const pointAt = (s) => {
549
- let index = 1;
550
- while (index < trace.length - 1 && lengths[index] < s)
551
- index += 1;
552
- const [a, b] = [trace[index - 1], trace[index]];
553
- const piece = lengths[index] - lengths[index - 1];
554
- const t = piece === 0 ? 0 : (s - lengths[index - 1]) / piece;
555
- return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t };
556
805
  };
557
- // Where along the line the text started.
558
- let start = 0;
559
- let nearest = Infinity;
560
- for (let s = 0; s <= total; s += 1) {
561
- const p = pointAt(s);
562
- const off = Math.hypot(p.x - from.x, p.y - from.y);
563
- if (off < nearest) {
564
- nearest = off;
565
- start = s;
566
- }
567
- }
568
- const step = 4;
569
- for (let offset = step; offset <= total; offset += step) {
570
- for (const s of [start + offset, start - offset]) {
571
- if (s < 0 || s > total)
572
- continue;
573
- const p = pointAt(s);
574
- if (free(p))
575
- return p;
576
- }
577
- }
578
- return undefined;
579
806
  }
580
807
  /**
581
808
  * The stroke of a line: its color, its thickness and its pattern. The dashes
@@ -594,287 +821,72 @@ function strokeOf(color, look) {
594
821
  };
595
822
  return ` stroke="${color}" stroke-width="${w}"${dashes[look.pattern]}`;
596
823
  }
597
- /**
598
- * The points of a square or straight line, and where its text sits.
599
- *
600
- * Both start from what the file fixed. A square line leaves each side head-on
601
- * and turns only at right angles; a straight one goes directly from point to
602
- * point, and bends only where a clause — `below c`, `between a and b` — puts a
603
- * point it has to pass through.
604
- */
605
- function jointedLine(edge, ends, route, nodes, textWidth) {
606
- const { start, end } = ends;
607
- const square = edge.look.path === 'square';
608
- const stub = ROUTE_RADIUS + arrowLength(edge.look.thickness);
609
- const fromFace = faceOf(edge.from);
610
- const toFace = faceOf(edge.to);
611
- if (route) {
612
- if (square)
613
- return { points: route.points, mid: route.mid };
614
- if (textWidth === 0) {
615
- const points = straighten(route.points, edge, nodes);
616
- return { points, mid: textSpot(points, textWidth) };
617
- }
618
- // The route pushed its run out far enough to hold the text clear of the
619
- // boxes it passes, and that room is only on the run. So a straight line
620
- // with a text keeps the text's point and straightens either side of it;
621
- // cutting across the run would put the text back against the box.
622
- const [before, after] = splitAt(route.points, route.mid);
623
- const points = tidyRoute([
624
- ...straighten(before, edge, nodes),
625
- ...straighten(after, edge, nodes).slice(1),
626
- ]);
627
- return { points, mid: route.mid };
628
- }
629
- const bow = ends.bow ?? { x: 0, y: 0 };
630
- const bowed = bow.x !== 0 || bow.y !== 0;
631
- if (!square) {
632
- // A bowed line is one of a crowded group, and its middle is the only room
633
- // left for its text, so it bends there.
634
- const middle = { x: (start.x + end.x) / 2 + bow.x, y: (start.y + end.y) / 2 + bow.y };
635
- const points = bowed ? [start, middle, end] : [start, end];
636
- return { points, mid: bowed ? middle : textSpot(points, textWidth) };
637
- }
638
- const out = headingOf(start, fromFace);
639
- const back = headingOf(end, toFace);
640
- // A bow is room for the texts of a crowded group, taken in the middle. Between
641
- // sides that face each other that middle is a run the lanes can widen along;
642
- // at right angles there is none, and the text finds room on a level piece.
643
- if (!bowed || (out.x !== 0) !== (back.x !== 0)) {
644
- const points = tidyRoute(rightAngles(start, out, end, back, stub));
645
- return { points, mid: textSpot(points, textWidth) };
646
- }
647
- // Out of each side, across by the bow, and joined at right angles.
648
- const a = { x: start.x + out.x * stub, y: start.y + out.y * stub };
649
- const b = { x: end.x + back.x * stub, y: end.y + back.y * stub };
650
- const points = tidyRoute(squareUp([start, a, { x: a.x + bow.x, y: a.y + bow.y }, { x: b.x + bow.x, y: b.y + bow.y }, b, end]));
651
- return { points, mid: textSpot(points, textWidth) };
652
- }
653
- /**
654
- * Which way an end points, as one of the four directions. A named side says so;
655
- * an end the renderer placed is on some side of its box, and that is the one.
656
- */
657
- function headingOf(anchor, face) {
658
- if (anchor.side !== undefined)
659
- return { x: anchor.tx, y: anchor.ty };
660
- const candidates = [
661
- [Math.abs(anchor.x - face.x), { x: -1, y: 0 }],
662
- [Math.abs(anchor.x - (face.x + face.width)), { x: 1, y: 0 }],
663
- [Math.abs(anchor.y - face.y), { x: 0, y: -1 }],
664
- [Math.abs(anchor.y - (face.y + face.height)), { x: 0, y: 1 }],
665
- ];
666
- candidates.sort((p, q) => p[0] - q[0]);
667
- return candidates[0][1];
668
- }
669
- /**
670
- * A right-angled line from `from`, leaving along `out`, to `to`, arriving
671
- * against `back` — `back` points out of the far side, so the line's last piece
672
- * travels the opposite way. The fewest turns that leave and arrive head-on:
673
- * none when the two face each other in line, one when they are at right angles
674
- * and the corner lies ahead of both, two in the middle when they face each
675
- * other offset, and a way round past a stub at each end otherwise.
676
- */
677
- function rightAngles(from, out, to, back, stub) {
678
- const across = out.x !== 0;
679
- const parallel = across === (back.x !== 0);
680
- const ahead = (p, q, d) => (q.x - p.x) * d.x + (q.y - p.y) * d.y;
681
- if (parallel) {
682
- const facing = out.x * back.x + out.y * back.y < 0;
683
- if (facing && ahead(from, to, out) > 0) {
684
- if (Math.abs(across ? to.y - from.y : to.x - from.x) < 0.5)
685
- return [from, to];
686
- const m = across ? (from.x + to.x) / 2 : (from.y + to.y) / 2;
687
- return across
688
- ? [from, { x: m, y: from.y }, { x: m, y: to.y }, to]
689
- : [from, { x: from.x, y: m }, { x: to.x, y: m }, to];
690
- }
691
- if (!facing) {
692
- // Both sides face the same way: out past whichever is further, and back.
693
- const far = across
694
- ? (out.x > 0 ? Math.max(from.x, to.x) : Math.min(from.x, to.x)) + out.x * stub
695
- : (out.y > 0 ? Math.max(from.y, to.y) : Math.min(from.y, to.y)) + out.y * stub;
696
- return across
697
- ? [from, { x: far, y: from.y }, { x: far, y: to.y }, to]
698
- : [from, { x: from.x, y: far }, { x: to.x, y: far }, to];
699
- }
700
- // Facing, but the far end is behind: out, across the middle, and in.
701
- const a = { x: from.x + out.x * stub, y: from.y + out.y * stub };
702
- const b = { x: to.x + back.x * stub, y: to.y + back.y * stub };
703
- const m = across ? (from.y + to.y) / 2 : (from.x + to.x) / 2;
704
- return across
705
- ? [from, a, { x: a.x, y: m }, { x: b.x, y: m }, b, to]
706
- : [from, a, { x: m, y: a.y }, { x: m, y: b.y }, b, to];
707
- }
708
- const corner = across ? { x: to.x, y: from.y } : { x: from.x, y: to.y };
709
- if (ahead(from, corner, out) > 0 && ahead(to, corner, back) > 0)
710
- return [from, corner, to];
711
- // The one corner lies behind an end: step out of both sides first and join
712
- // the two stubs with whichever corner does not double back.
713
- const a = { x: from.x + out.x * stub, y: from.y + out.y * stub };
714
- const b = { x: to.x + back.x * stub, y: to.y + back.y * stub };
715
- const options = [
716
- { x: b.x, y: a.y },
717
- { x: a.x, y: b.y },
718
- ];
719
- const turn = options.find((c) => ahead(a, c, out) >= 0 && ahead(b, c, back) >= 0) ?? options[across ? 1 : 0];
720
- return [from, a, turn, b, to];
721
- }
722
- /** Put a right-angled corner between any two points that are not in line. */
723
- function squareUp(points) {
724
- const out = [points[0]];
725
- let alongX = true;
726
- for (let index = 1; index < points.length; index += 1) {
727
- const a = out[out.length - 1];
728
- const b = points[index];
729
- const dx = Math.abs(b.x - a.x) >= 0.5;
730
- const dy = Math.abs(b.y - a.y) >= 0.5;
731
- if (dx && dy) {
732
- out.push(alongX ? { x: b.x, y: a.y } : { x: a.x, y: b.y });
733
- }
734
- else if (dx || dy) {
735
- alongX = dx;
736
- }
737
- out.push(b);
738
- }
739
- return out;
740
- }
741
- /**
742
- * A route's points with every corner dropped that a straight piece can skip.
743
- * The route already keeps each clause; a straight piece may cut a corner only
744
- * where it stays clear of every box the route kept clear of, which keeps the
745
- * clauses too — a piece between two points below a node, touching nothing, is
746
- * below it all the way.
747
- */
748
- function straighten(points, edge, nodes) {
749
- const own = [faceOf(edge.from), faceOf(edge.to)].map((box) => grow(extentOfBox(box), -1));
750
- const others = nodes
751
- .filter((node) => !contains(node, edge.from) && !contains(node, edge.to))
752
- .map((node) => grow(extentOfBox(faceOf(node)), ATTACH_MARGIN / 2));
753
- const clear = (a, b) => ![...own, ...others].some((box) => segmentHits(a, b, box));
754
- const kept = [points[0]];
755
- let at = 0;
756
- while (at < points.length - 1) {
757
- let next = at + 1;
758
- for (let far = points.length - 1; far > at + 1; far -= 1) {
759
- if (clear(points[at], points[far])) {
760
- next = far;
761
- break;
762
- }
763
- }
764
- kept.push(points[next]);
765
- at = next;
766
- }
767
- return kept;
768
- }
769
- /**
770
- * A line cut in two at `at`, a point on one of its pieces, each half keeping
771
- * `at` as its end. A point on no piece cuts at the nearest one.
772
- */
773
- function splitAt(points, at) {
774
- let piece = 0;
775
- let nearest = Infinity;
776
- for (let index = 1; index < points.length; index += 1) {
777
- const a = points[index - 1];
778
- const b = points[index];
779
- const length = Math.hypot(b.x - a.x, b.y - a.y);
780
- const t = length === 0 ? 0 : Math.max(0, Math.min(1, ((at.x - a.x) * (b.x - a.x) + (at.y - a.y) * (b.y - a.y)) / (length * length)));
781
- const off = Math.hypot(a.x + t * (b.x - a.x) - at.x, a.y + t * (b.y - a.y) - at.y);
782
- if (off < nearest) {
783
- nearest = off;
784
- piece = index;
785
- }
786
- }
787
- return [
788
- tidyRoute([...points.slice(0, piece), at]),
789
- tidyRoute([at, ...points.slice(piece)]),
790
- ];
791
- }
792
- function extentOfBox(box) {
793
- return { minX: box.x, minY: box.y, maxX: box.x + box.width, maxY: box.y + box.height };
824
+ function sharpPath(points) {
825
+ return points.map((point, index) => `${index === 0 ? 'M' : 'L'} ${round(point.x)} ${round(point.y)}`).join(' ');
794
826
  }
795
- /** Whether the segment from `a` to `b` passes through the inside of `box`. */
796
- function segmentHits(a, b, box) {
797
- if (box.maxX <= box.minX || box.maxY <= box.minY)
798
- return false;
799
- let t0 = 0;
800
- let t1 = 1;
801
- const dx = b.x - a.x;
802
- const dy = b.y - a.y;
803
- const edges = [
804
- [-dx, a.x - box.minX],
805
- [dx, box.maxX - a.x],
806
- [-dy, a.y - box.minY],
807
- [dy, box.maxY - a.y],
808
- ];
809
- for (const [p, q] of edges) {
810
- if (p === 0) {
811
- if (q <= 0)
812
- return false;
827
+ /** A line's path, from its pieces. */
828
+ function piecesPath(route, leads = { start: 0, end: 0 }) {
829
+ const parts = [`M ${round(route.start.x)} ${round(route.start.y)}`];
830
+ let from = route.start;
831
+ const last = route.pieces.length - 1;
832
+ for (const [index, piece] of route.pieces.entries()) {
833
+ if (!('c1' in piece)) {
834
+ parts.push(`L ${round(piece.to.x)} ${round(piece.to.y)}`);
835
+ from = piece.to;
813
836
  continue;
814
837
  }
815
- const t = q / p;
816
- if (p < 0)
817
- t0 = Math.max(t0, t);
818
- else
819
- t1 = Math.min(t1, t);
820
- if (t0 >= t1)
821
- return false;
822
- }
823
- return true;
824
- }
825
- /**
826
- * Where a jointed line's text rides: the middle of its longest level piece, if
827
- * one is long enough to hold the text with line showing either side, and the
828
- * middle of its longest piece otherwise. A level piece comes first because a
829
- * text knocks a hole as wide as itself, which on an upright piece is a hole far
830
- * wider than the line — and lines grouped on one side sit a text's height apart
831
- * on their level pieces, never a text's width apart on their upright ones.
832
- */
833
- function textSpot(points, textWidth) {
834
- let best = { x: (points[0].x + points[points.length - 1].x) / 2, y: (points[0].y + points[points.length - 1].y) / 2 };
835
- let level;
836
- let longest = -1;
837
- let longestLevel = -1;
838
- for (let index = 1; index < points.length; index += 1) {
839
- const a = points[index - 1];
840
- const b = points[index];
841
- const here = Math.hypot(b.x - a.x, b.y - a.y);
842
- const middle = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
843
- if (here > longest + 0.5) {
844
- longest = here;
845
- best = middle;
846
- }
847
- if (Math.abs(b.y - a.y) < 0.5 && here >= textWidth + ARROW_LENGTH * 2 && here > longestLevel + 0.5) {
848
- longestLevel = here;
849
- level = middle;
838
+ // Under a mark the curve runs straight, so the line goes into the mark
839
+ // along its axis instead of bending out of its side. The handles stay
840
+ // put, on the same axis, so the curve leaves and arrives the same way.
841
+ const { c1, c2, to } = piece;
842
+ const room = Math.hypot(to.x - from.x, to.y - from.y);
843
+ const lead = (length, toward, at) => {
844
+ const span = Math.hypot(toward.x - at.x, toward.y - at.y);
845
+ if (length <= 0 || span < 1e-6 || room < length * 3)
846
+ return undefined;
847
+ return { x: ((toward.x - at.x) / span) * length, y: ((toward.y - at.y) / span) * length };
848
+ };
849
+ const head = index === 0 ? lead(leads.start, c1, from) : undefined;
850
+ const tail = index === last ? lead(leads.end, c2, to) : undefined;
851
+ if (head) {
852
+ from = { x: from.x + head.x, y: from.y + head.y };
853
+ parts.push(`L ${round(from.x)} ${round(from.y)}`);
850
854
  }
855
+ const end = tail ? { x: to.x + tail.x, y: to.y + tail.y } : to;
856
+ parts.push(`C ${round(c1.x)} ${round(c1.y)}, ${round(c2.x)} ${round(c2.y)}, ${round(end.x)} ${round(end.y)}`);
857
+ if (tail)
858
+ parts.push(`L ${round(to.x)} ${round(to.y)}`);
859
+ from = to;
851
860
  }
852
- return level ?? best;
853
- }
854
- function sharpPath(points) {
855
- return points.map((point, index) => `${index === 0 ? 'M' : 'L'} ${round(point.x)} ${round(point.y)}`).join(' ');
861
+ return parts.join(' ');
856
862
  }
857
863
  /**
858
- * A drawn line's element read back as a polyline, curves flattened. Reading
859
- * the element rather than each drawing branch keeping its own record means the
860
- * crossings are found on exactly what was drawn.
864
+ * A drawn line's element read back as polylines, curves flattened, one for
865
+ * each part the path draws (a line along a bus has two). Reading the element
866
+ * rather than each drawing branch keeping its own record means the crossings
867
+ * are found on exactly what was drawn.
861
868
  */
862
- function traceOf(element) {
869
+ function tracesOf(element) {
863
870
  const line = /<line x1="([-\d.]+)" y1="([-\d.]+)" x2="([-\d.]+)" y2="([-\d.]+)"/.exec(element);
864
871
  if (line) {
865
- return [
866
- { x: Number(line[1]), y: Number(line[2]) },
867
- { x: Number(line[3]), y: Number(line[4]) },
868
- ];
872
+ return [[
873
+ { x: Number(line[1]), y: Number(line[2]) },
874
+ { x: Number(line[3]), y: Number(line[4]) },
875
+ ]];
869
876
  }
870
877
  const d = /d="([^"]*)"/.exec(element)?.[1] ?? '';
871
878
  const tokens = d.match(/[MLC]|-?\d+(?:\.\d+)?(?:e-?\d+)?/g) ?? [];
872
- const points = [];
879
+ const parts = [];
880
+ let points = [];
873
881
  let command = 'M';
874
882
  for (let index = 0; index < tokens.length;) {
875
883
  const token = tokens[index];
876
884
  if (/[MLC]/.test(token)) {
877
885
  command = token;
886
+ if (token === 'M' && points.length > 0) {
887
+ parts.push(points);
888
+ points = [];
889
+ }
878
890
  index += 1;
879
891
  continue;
880
892
  }
@@ -899,57 +911,55 @@ function traceOf(element) {
899
911
  points.push(read());
900
912
  }
901
913
  }
902
- return points;
914
+ if (points.length > 0)
915
+ parts.push(points);
916
+ return parts;
903
917
  }
904
918
  /**
905
- * Where each line with a `crossing:` style crosses a line declared before it.
906
- * The later line is the one that jumps, so the file's order says who goes over,
907
- * and a line never jumps where it leaves or arrives — a crossing that close to
908
- * an end is two lines meeting at a box, not passing each other.
919
+ * Where each line with a `crossing:` style crosses a line under it. Lines come
920
+ * in drawing order, and the one drawn later is on top and jumps, so the file's
921
+ * order says who goes over unless `depth:` moved one; a `depth:` naming a leg
922
+ * turns over only the crossings on that leg. A line never jumps where it
923
+ * leaves or arrives — a crossing that close to an end is two lines meeting at
924
+ * a box, not passing each other.
909
925
  */
910
- function findCrossings(drawn) {
926
+ function findCrossings(drawn, onLeg) {
911
927
  const found = new Map();
912
928
  drawn.forEach((later, index) => {
913
- if (later.edge.look.crossing === 'none')
914
- return;
915
- const crossings = [];
916
929
  for (const earlier of drawn.slice(0, index)) {
917
- const reach = 3 + later.edge.look.thickness * 1.5 + earlier.edge.look.thickness / 2;
918
- const clear = reach + arrowLength(later.edge.look.thickness);
919
- const ends = [later.trace[0], later.trace[later.trace.length - 1], earlier.trace[0], earlier.trace[earlier.trace.length - 1]];
920
- for (let i = 1; i < later.trace.length; i += 1) {
921
- const a = later.trace[i - 1];
922
- const b = later.trace[i];
923
- for (let j = 1; j < earlier.trace.length; j += 1) {
924
- const at = intersect(a, b, earlier.trace[j - 1], earlier.trace[j]);
925
- if (!at)
926
- continue;
927
- if (ends.some((end) => Math.hypot(end.x - at.x, end.y - at.y) < clear))
928
- continue;
929
- if (crossings.some((c) => Math.hypot(c.at.x - at.x, c.at.y - at.y) < (c.reach + reach) * 1.2))
930
- continue;
931
- const length = Math.hypot(b.x - a.x, b.y - a.y) || 1;
932
- crossings.push({ at, along: { x: (b.x - a.x) / length, y: (b.y - a.y) / length }, reach });
933
- }
930
+ const turned = onLeg.filter((one) => one.pair.includes(later) && one.pair.includes(earlier));
931
+ const thickness = Math.max(later.edge.look.thickness, earlier.edge.look.thickness);
932
+ const clear = 3 + thickness * 2 + arrowLength(thickness);
933
+ const points = later.traces.flatMap((a) => earlier.traces.flatMap((b) => crossingPoints(a, b, clear)));
934
+ for (const { at, along, other } of points) {
935
+ const leg = turned.find((one) => nearTrace(one.trace, at));
936
+ const [top, under] = leg && leg.top === earlier ? [earlier, later] : [later, earlier];
937
+ if (top.edge.look.crossing === 'none')
938
+ continue;
939
+ const reach = 3 + top.edge.look.thickness * 1.5 + under.edge.look.thickness / 2;
940
+ const crossings = found.get(top) ?? [];
941
+ if (crossings.some((c) => Math.hypot(c.at.x - at.x, c.at.y - at.y) < (c.reach + reach) * 1.2))
942
+ continue;
943
+ crossings.push({ at, along: top === later ? along : other, reach });
944
+ found.set(top, crossings);
934
945
  }
935
946
  }
936
- if (crossings.length > 0)
937
- found.set(later, crossings);
938
947
  });
939
948
  return found;
940
949
  }
941
- /** Where two segments cross, if they do. Lines that only touch or run together do not. */
942
- function intersect(a, b, c, d) {
943
- const r = { x: b.x - a.x, y: b.y - a.y };
944
- const s = { x: d.x - c.x, y: d.y - c.y };
945
- const denominator = r.x * s.y - r.y * s.x;
946
- if (Math.abs(denominator) < 1e-9)
947
- return undefined;
948
- const t = ((c.x - a.x) * s.y - (c.y - a.y) * s.x) / denominator;
949
- const u = ((c.x - a.x) * r.y - (c.y - a.y) * r.x) / denominator;
950
- if (t <= 0 || t >= 1 || u <= 0 || u >= 1)
951
- return undefined;
952
- return { x: a.x + r.x * t, y: a.y + r.y * t };
950
+ /** Whether a point lies on a polyline. */
951
+ function nearTrace(trace, at) {
952
+ for (let index = 1; index < trace.length; index += 1) {
953
+ const a = trace[index - 1];
954
+ const b = trace[index];
955
+ const length = Math.hypot(b.x - a.x, b.y - a.y);
956
+ if (length === 0)
957
+ continue;
958
+ const t = Math.max(0, Math.min(1, ((at.x - a.x) * (b.x - a.x) + (at.y - a.y) * (b.y - a.y)) / (length * length)));
959
+ if (Math.hypot(a.x + (b.x - a.x) * t - at.x, a.y + (b.y - a.y) * t - at.y) < 1.5)
960
+ return true;
961
+ }
962
+ return false;
953
963
  }
954
964
  /**
955
965
  * The jump drawn over one crossing: a half circle for `arc`, three sides of a
@@ -999,1946 +1009,100 @@ function extentOfPoints(points) {
999
1009
  maxY: Math.max(...points.map((p) => p.y)),
1000
1010
  };
1001
1011
  }
1012
+ /** The most a square line's corner is rounded by. */
1013
+ const ROUTE_RADIUS = 20;
1002
1014
  /**
1003
- * What a cubic actually covers, which is not what its control points cover. A
1004
- * handle reaching 140 pixels up carries the curve only about three quarters of
1005
- * that, and sizing the page off the handles would leave a visible band of empty
1006
- * canvas above every curved edge. Solved rather than sampled: the extremes are
1007
- * the ends plus wherever the derivative — a quadratic — crosses zero.
1015
+ * A line through `points` with every corner rounded. A corner takes at most
1016
+ * half of each piece it shares with a neighboring corner, and the whole of a
1017
+ * piece at either end short of the arrowhead, so two corners never overlap and
1018
+ * the head always lands on a straight piece.
1008
1019
  */
1009
- function cubicExtent(p0, c1, c2, p3) {
1010
- const span = (a, b, c, d) => {
1011
- const values = [a, d];
1012
- // The derivative of the cubic, written as a quadratic in t.
1013
- const qa = 3 * (-a + 3 * b - 3 * c + d);
1014
- const qb = 6 * (a - 2 * b + c);
1015
- const qc = 3 * (b - a);
1016
- const roots = [];
1017
- if (Math.abs(qa) < 1e-9) {
1018
- if (Math.abs(qb) > 1e-9)
1019
- roots.push(-qc / qb);
1020
- }
1021
- else {
1022
- const disc = qb * qb - 4 * qa * qc;
1023
- if (disc >= 0) {
1024
- const root = Math.sqrt(disc);
1025
- roots.push((-qb + root) / (2 * qa), (-qb - root) / (2 * qa));
1026
- }
1027
- }
1028
- for (const t of roots) {
1029
- if (t <= 0 || t >= 1)
1030
- continue;
1031
- const u = 1 - t;
1032
- values.push(u * u * u * a + 3 * u * u * t * b + 3 * u * t * t * c + t * t * t * d);
1033
- }
1034
- return [Math.min(...values), Math.max(...values)];
1035
- };
1036
- const [minX, maxX] = span(p0.x, c1.x, c2.x, p3.x);
1037
- const [minY, maxY] = span(p0.y, c1.y, c2.y, p3.y);
1038
- return { minX, minY, maxX, maxY };
1039
- }
1040
- /** Walk out from the center of a box toward a point, stopping at the border. */
1041
- function sidePoint(box, toward) {
1042
- const center = centerOf(box);
1043
- const dx = toward.x - center.x;
1044
- const dy = toward.y - center.y;
1045
- if (dx === 0 && dy === 0)
1046
- return center;
1047
- if (box.round) {
1048
- const scale = box.width / 2 / Math.hypot(dx, dy);
1049
- return { x: center.x + dx * scale, y: center.y + dy * scale };
1020
+ function roundedPath(points, arrow = ARROW_LENGTH) {
1021
+ const parts = [`M ${round(points[0].x)} ${round(points[0].y)}`];
1022
+ const kappa = 0.5523; // a cubic's handle, as a share of the radius, for a quarter circle
1023
+ for (let index = 1; index + 1 < points.length; index += 1) {
1024
+ const [before, corner, after] = [points[index - 1], points[index], points[index + 1]];
1025
+ const inward = Math.hypot(corner.x - before.x, corner.y - before.y);
1026
+ const outward = Math.hypot(after.x - corner.x, after.y - corner.y);
1027
+ const radius = Math.max(0, Math.min(ROUTE_RADIUS, index === 1 ? inward - arrow : inward / 2, index + 2 === points.length ? outward - arrow : outward / 2));
1028
+ const din = { x: (corner.x - before.x) / inward, y: (corner.y - before.y) / inward };
1029
+ const dout = { x: (after.x - corner.x) / outward, y: (after.y - corner.y) / outward };
1030
+ const enter = { x: corner.x - din.x * radius, y: corner.y - din.y * radius };
1031
+ const leave = { x: corner.x + dout.x * radius, y: corner.y + dout.y * radius };
1032
+ parts.push(`L ${round(enter.x)} ${round(enter.y)}`, `C ${round(enter.x + din.x * radius * kappa)} ${round(enter.y + din.y * radius * kappa)}, ` +
1033
+ `${round(leave.x - dout.x * radius * kappa)} ${round(leave.y - dout.y * radius * kappa)}, ` +
1034
+ `${round(leave.x)} ${round(leave.y)}`);
1050
1035
  }
1051
- const scaleX = dx === 0 ? Infinity : box.width / 2 / Math.abs(dx);
1052
- const scaleY = dy === 0 ? Infinity : box.height / 2 / Math.abs(dy);
1053
- const scale = Math.min(scaleX, scaleY);
1054
- return { x: center.x + dx * scale, y: center.y + dy * scale };
1036
+ const last = points[points.length - 1];
1037
+ parts.push(`L ${round(last.x)} ${round(last.y)}`);
1038
+ return parts.join(' ');
1055
1039
  }
1056
- // --- where an edge meets a box -------------------------------------------------
1057
- // The four sides an edge may attach to. Deliberately not `ATTACH_SIDES` from
1058
- // `ast.ts`, which carries `center` as well because an alignment can share a
1059
- // center line and an attachment cannot sit on one.
1060
- const ATTACH_SIDES = ['top', 'bottom', 'left', 'right'];
1061
1040
  /**
1062
- * Work out where every edge meets every box.
1063
- *
1064
- * An author names a *side* — `to: top` — and never a point on it. Alone on a
1065
- * side an edge lands at its center; sharing the side with others, the points
1066
- * spread so they do not sit on top of each other. Which one goes where is
1067
- * derived from where the far ends actually are, never chosen: of two edges
1068
- * arriving at one top edge, the one coming from further left arrives further
1069
- * left. That is the same rule as box non-overlap — the tool separates things by
1070
- * default, and reads the direction off the solved layout rather than asking.
1041
+ * The shape of each mark, in a 10 by 10 box whose right edge is the end of the
1042
+ * line, pointing along it. The marker scales with the stroke, so a unit is 0.7
1043
+ * of the line's width and an outline of 1.4 units is as thick as the line.
1071
1044
  *
1072
- * Where several edges run between the *same* pair of sides that rule has
1073
- * nothing to read, and a `Bundle` supplies the order instead — see there.
1045
+ * An outlined mark is filled with the page's background so the line running
1046
+ * under it does not show through. Over a filled container that is the wrong
1047
+ * color inside the outline; nothing has hit it yet.
1074
1048
  */
1075
- function planEndpoints(edges, measurer, fontSize) {
1076
- const claims = new Map();
1077
- const achieved = new Map();
1078
- const named = new Map();
1079
- const bundles = planBundles(edges, measurer, fontSize);
1080
- const spreads = planSpreads(edges, measurer, fontSize);
1081
- for (const edge of edges) {
1082
- named.set(edge, {});
1083
- const fromSide = sideAttr(edge, 'from');
1084
- const toSide = sideAttr(edge, 'to');
1085
- if (fromSide) {
1086
- claim(claims, edge.from, fromSide, {
1087
- edge,
1088
- which: 'start',
1089
- side: fromSide,
1090
- toward: centerOf(faceOf(edge.to)),
1091
- rank: rankIn(bundles.get(edge), edge, edge.from, fromSide),
1092
- });
1093
- }
1094
- if (toSide) {
1095
- claim(claims, edge.to, toSide, {
1096
- edge,
1097
- which: 'end',
1098
- side: toSide,
1099
- toward: centerOf(faceOf(edge.from)),
1100
- rank: rankIn(bundles.get(edge), edge, edge.to, toSide),
1101
- });
1102
- }
1103
- }
1104
- // Place every claimed side, spreading the points that share one.
1105
- for (const [node, bySide] of claims) {
1106
- const face = faceOf(node);
1107
- for (const [side, group] of bySide) {
1108
- const along = side === 'top' || side === 'bottom' ? 'x' : 'y';
1109
- const span = along === 'x' ? face.width : face.height;
1110
- const origin = along === 'x' ? face.x : face.y;
1111
- // Far ends first, as ever; a bundle's own lane order settles the edges
1112
- // that share one, which are precisely the ones the first key cannot.
1113
- const ordered = [...group].sort((a, b) => a.toward[along] - b.toward[along] || (a.rank ?? 0) - (b.rank ?? 0));
1114
- // A bundle's lanes have to hold whole texts apart rather than the points
1115
- // of two arrows, so its step is the one that governs the side it lands on.
1116
- const wanted = Math.max(ATTACH_STEP, ...group.map((entry) => bundles.get(entry.edge)?.step ?? 0));
1117
- const usable = Math.max(0, span - ATTACH_MARGIN * 2);
1118
- const step = ordered.length > 1 ? Math.min(wanted, usable / (ordered.length - 1)) : 0;
1119
- const first = origin + span / 2 - (step * (ordered.length - 1)) / 2;
1120
- ordered.forEach((entry, index) => {
1121
- const at = first + index * step;
1122
- named.get(entry.edge)[entry.which] = anchorOn(face, side, at);
1123
- });
1124
- // What the side could actually give, which is less than `wanted` when it
1125
- // is too short for the group. `bowBundles` makes up the difference.
1126
- let steps = achieved.get(node);
1127
- if (!steps)
1128
- achieved.set(node, (steps = new Map()));
1129
- steps.set(side, step);
1130
- }
1131
- }
1132
- const bows = bowBundles(bundles, achieved);
1133
- // Fill in the ends the author said nothing about, now that the named ones
1134
- // are known: an unnamed end aims at wherever its partner ended up.
1135
- const ends = new Map();
1136
- for (const edge of edges) {
1137
- const partial = named.get(edge);
1138
- const fromFace = faceOf(edge.from);
1139
- const toFace = faceOf(edge.to);
1140
- // Several edges between one pair of boxes with no side named anywhere: the
1141
- // line each would have drawn alone, moved aside so they do not coincide.
1142
- const spread = spreads.get(edge);
1143
- if (spread) {
1144
- ends.set(edge, { ...parallelEnds(fromFace, toFace, spread.offset), bow: spread.bow });
1145
- continue;
1146
- }
1147
- // With neither end named this is the straight line it always was, each end
1148
- // aiming at the other box's center.
1149
- const start = partial.start ?? free(fromFace, partial.end ?? centerOf(toFace));
1150
- const end = partial.end ?? free(toFace, partial.start ?? centerOf(fromFace));
1151
- ends.set(edge, { start, end, bow: bows.get(edge) });
1049
+ function markShape(mark, color, background) {
1050
+ const outline = `fill="${background}" stroke="${color}" stroke-width="1.4"`;
1051
+ switch (mark) {
1052
+ case 'arrow':
1053
+ return `<path d="M 0 0 L 10 5 L 0 10 z" fill="${color}"/>`;
1054
+ case 'oarrow':
1055
+ return `<path d="M 0.7 1.2 L 9.3 5 L 0.7 8.8 z" ${outline}/>`;
1056
+ case 'dot':
1057
+ return `<circle cx="5" cy="5" r="4" fill="${color}"/>`;
1058
+ case 'odot':
1059
+ return `<circle cx="5" cy="5" r="3.4" ${outline}/>`;
1060
+ case 'diamond':
1061
+ return `<path d="M 0 5 L 5 1.2 L 10 5 L 5 8.8 z" fill="${color}"/>`;
1062
+ case 'odiamond':
1063
+ return `<path d="M 0.9 5 L 5 1.9 L 9.1 5 L 5 8.1 z" ${outline}/>`;
1064
+ case 'bar':
1065
+ return `<rect x="5.5" y="0.5" width="1.4" height="9" fill="${color}"/>`;
1066
+ case 'none':
1067
+ return '';
1152
1068
  }
1153
- return ends;
1154
1069
  }
1155
1070
  /**
1156
- * Group the edges that run between the same pair of sides, and work out the
1157
- * lane order and lane width each group needs.
1158
- *
1159
- * Only an edge whose author named *both* sides can be in a bundle: a bundle is a
1160
- * statement about two specific edges, and an end with no side named has not
1161
- * picked one yet.
1071
+ * Whether a line's end is free of any box: on another line, at a named point,
1072
+ * or a bundle's own end. A dot there is a junction, centered on the point.
1162
1073
  */
1163
- function planBundles(edges, measurer, fontSize) {
1164
- const ids = new Map();
1165
- const idOf = (node) => {
1166
- let id = ids.get(node);
1167
- if (id === undefined) {
1168
- id = ids.size;
1169
- ids.set(node, id);
1170
- }
1171
- return id;
1172
- };
1173
- const groups = new Map();
1174
- for (const edge of edges) {
1175
- const fromSide = sideAttr(edge, 'from');
1176
- const toSide = sideAttr(edge, 'to');
1177
- if (!fromSide || !toSide || edge.from === edge.to)
1178
- continue;
1179
- const a = { node: edge.from, side: fromSide };
1180
- const b = { node: edge.to, side: toSide };
1181
- const keyA = `${idOf(a.node)}:${a.side}`;
1182
- const keyB = `${idOf(b.node)}:${b.side}`;
1183
- // The pair is unordered — `a -> b` and `b -> a` join the same two edges —
1184
- // so the key is canonical and the ends are stored in that same order.
1185
- const swap = keyB < keyA;
1186
- const key = swap ? `${keyB}|${keyA}` : `${keyA}|${keyB}`;
1187
- const ends = swap ? [b, a] : [a, b];
1188
- const group = groups.get(key);
1189
- if (group)
1190
- group.edges.push(edge);
1191
- else
1192
- groups.set(key, { ends, edges: [edge] });
1193
- }
1194
- const bundles = new Map();
1195
- for (const group of groups.values()) {
1196
- if (group.edges.length < 2)
1197
- continue;
1198
- const [first, second] = group.ends;
1199
- const t0 = tangentOf(first.side);
1200
- const t1 = tangentOf(second.side);
1201
- const from = sideCenter(first);
1202
- const to = sideCenter(second);
1203
- const run = { x: to.x - from.x, y: to.y - from.y };
1204
- // Nesting is a matter of which side of the line each end steps toward. Step
1205
- // both ends to the same side of the run and the whole line translates;
1206
- // step them to opposite sides and it pivots, which is a crossing.
1207
- const aligned = cross(run, t0) * cross(run, t1) >= 0;
1208
- const sense = aligned ? 1 : -1;
1209
- // Two edges leaving in opposite directions are the ordinary case, and which
1210
- // lane each takes is then read off the diagram rather than off the order the
1211
- // author happened to type them in: a line keeps to one side of its own run.
1212
- // Edges pointing the same way have no such signal and fall back to the file.
1213
- const order = new Map(group.edges.map((edge, index) => [edge, index]));
1214
- const lanes = [...group.edges].sort((a, b) => Number(a.from !== first.node) - Number(b.from !== first.node) ||
1215
- order.get(a) - order.get(b));
1216
- // One lane apart moves an edge's start by `step` along one side and its end
1217
- // by `step` along the other, so the midpoint of the line — which is where
1218
- // its text goes — moves by the average of the two.
1219
- const drift = { x: (t0.x + sense * t1.x) / 2, y: (t0.y + sense * t1.y) / 2 };
1220
- const bundle = {
1221
- ends: group.ends,
1222
- lanes,
1223
- aligned,
1224
- step: Math.max(ATTACH_STEP, laneStep(lanes, drift, measurer, fontSize)),
1225
- };
1226
- for (const edge of lanes)
1227
- bundles.set(edge, bundle);
1228
- }
1229
- return bundles;
1074
+ function freeEnd(edge, which) {
1075
+ return edge.bundle !== undefined || edge.onLine?.[which] !== undefined || edge.onMark?.[which] === 'point';
1230
1076
  }
1231
1077
  /**
1232
- * The sideways offset each edge takes when several run between the same two
1233
- * boxes and none of them names a side.
1234
- *
1235
- * An unnamed end has no side to spread along: it aims at the far box's center
1236
- * and attaches wherever that ray crosses the border, so every edge in such a
1237
- * group produces the *same* ray and they are drawn on top of one another —
1238
- * one visible line, every text stacked on one point. `planEndpoints` cannot
1239
- * see this and `planBundles` will not, since a bundle is a statement about two
1240
- * named edges.
1241
- *
1242
- * The repair keeps the attachment rule exactly as it is and only stops two
1243
- * edges using it at the same place: the line an edge would have drawn alone is
1244
- * translated across its own run by a lane, which is the straight-line version
1245
- * of the nesting a bundle already gives curves. A lone edge is in no group and
1246
- * so is untouched.
1247
- *
1248
- * Where the boxes are too small to hold the group at full spacing, the ends
1249
- * are squeezed evenly to fit the edge — there is nowhere further to attach —
1250
- * and the shortfall is made up in the middle instead: each line bows across
1251
- * its run by exactly what its endpoints could not give it, so the texts, which
1252
- * ride at the midpoints, come apart even though the arrows do not. The bow is
1253
- * therefore derived rather than styled, and it is zero whenever the edge was
1254
- * long enough, which is why the ordinary case is still a straight line.
1255
- *
1256
- * A `between` edge is left out. Its way through the gap it named, lane
1257
- * included, is searched for in `planWays`, which picks a side for any end
1258
- * naming none.
1078
+ * One marker, drawn at either end: `auto-start-reverse` turns it round at the
1079
+ * start. A dot keeps an ordinary line's size on any line, since scaled with a
1080
+ * thick line it became a blob. At a box it sits just outside the border; at a
1081
+ * free end it is centered on the point, since set back like an arrowhead, two
1082
+ * dots meeting at one point from either side ran together.
1259
1083
  */
1260
- function planSpreads(edges, measurer, fontSize) {
1261
- const ids = new Map();
1262
- const idOf = (node) => {
1263
- let id = ids.get(node);
1264
- if (id === undefined) {
1265
- id = ids.size;
1266
- ids.set(node, id);
1267
- }
1268
- return id;
1269
- };
1270
- const groups = new Map();
1271
- for (const edge of edges) {
1272
- if (sideAttr(edge, 'from') || sideAttr(edge, 'to'))
1273
- continue;
1274
- if (edge.from === edge.to || edge.between)
1275
- continue;
1276
- const a = idOf(edge.from);
1277
- const b = idOf(edge.to);
1278
- const swap = b < a;
1279
- const key = swap ? `${b}|${a}` : `${a}|${b}`;
1280
- const first = swap ? edge.to : edge.from;
1281
- const group = groups.get(key);
1282
- if (group)
1283
- group.edges.push(edge);
1284
- else
1285
- groups.set(key, { first, edges: [edge] });
1286
- }
1287
- const spreads = new Map();
1288
- for (const group of groups.values()) {
1289
- if (group.edges.length < 2)
1290
- continue;
1291
- const from = centerOf(faceOf(group.first));
1292
- const sample = group.edges[0];
1293
- const other = sample.from === group.first ? sample.to : sample.from;
1294
- const to = centerOf(faceOf(other));
1295
- const dx = to.x - from.x;
1296
- const dy = to.y - from.y;
1297
- const length = Math.hypot(dx, dy) || 1;
1298
- // Translating the line moves its midpoint — where the text goes — by
1299
- // exactly this, so it is the drift `laneStep` needs.
1300
- const across = { x: -dy / length, y: dx / length };
1301
- // The same derived order a bundle uses: edges pointing opposite ways each
1302
- // keep to one side of their own run, so a reciprocal pair reads as a
1303
- // circulation, and only edges pointing the same way fall back to the file.
1304
- const order = new Map(group.edges.map((edge, index) => [edge, index]));
1305
- const lanes = [...group.edges].sort((a, b) => Number(a.from !== group.first) - Number(b.from !== group.first) ||
1306
- order.get(a) - order.get(b));
1307
- // How far a lane may be shifted before its line no longer passes through the
1308
- // box at all. `exitAlong` clamps beyond that, which piles the outer lanes
1309
- // onto a corner and puts their texts back on top of each other — so the
1310
- // group is squeezed evenly instead, exactly as `planEndpoints` squeezes a
1311
- // side too short for the edges arriving on it, and just as silently.
1312
- const reach = (node) => {
1313
- const face = faceOf(node);
1314
- const byX = across.x === 0 ? Infinity : face.width / 2 / Math.abs(across.x);
1315
- const byY = across.y === 0 ? Infinity : face.height / 2 / Math.abs(across.y);
1316
- return Math.max(0, Math.min(byX, byY) - ATTACH_MARGIN);
1317
- };
1318
- // Which way lane 0 lies is arbitrary, so fix it the way the rest of the
1319
- // renderer does — toward increasing x, or increasing y where the run is
1320
- // horizontal. Without this the first edge written is topmost on a rightward
1321
- // run and rightmost on a downward one, for no reason a reader could see.
1322
- const orient = across.x < 0 || (across.x === 0 && across.y < 0) ? -1 : 1;
1323
- const usable = 2 * Math.min(reach(group.first), reach(other));
1324
- const wanted = Math.max(ATTACH_STEP, laneStep(lanes, across, measurer, fontSize));
1325
- const step = Math.min(wanted, usable / (lanes.length - 1));
1326
- lanes.forEach((edge, index) => {
1327
- const place = index - (lanes.length - 1) / 2;
1328
- // The lane is measured across the pair's own run, which has one direction;
1329
- // an edge written the other way round travels the opposite way and would
1330
- // otherwise take the same offset to the opposite side, putting a
1331
- // reciprocal pair back on one line. Negated, both keep to their own left,
1332
- // which is the circulation a bundle already draws.
1333
- const sense = (edge.from === group.first ? 1 : -1) * orient;
1334
- const shortfall = place * (wanted - step) * sense;
1335
- spreads.set(edge, {
1336
- offset: place * step * sense,
1337
- bow: { x: across.x * shortfall, y: across.y * shortfall },
1338
- });
1339
- });
1340
- }
1341
- return spreads;
1342
- }
1343
- /**
1344
- * The bow each bundled edge needs, where the sides it was given were too short
1345
- * to hold the group at the spacing its texts asked for.
1346
- *
1347
- * A named side is squeezed exactly as an unnamed group's edge is — the step
1348
- * shrinks to `usable / (n - 1)` and the texts ride down on top of each other —
1349
- * and until this existed, naming the two sides the tool would have chosen
1350
- * anyway made the picture strictly worse than saying nothing. That is not a
1351
- * line worth defending, so the same repair applies: a lane's midpoint is not
1352
- * on an edge and is free to move, and each line makes up in the middle exactly
1353
- * what its two ends could not give it.
1354
- *
1355
- * The shortfall is a vector because the two ends move along different sides.
1356
- * `drift` is how far a lane's midpoint travels per unit of step — the average
1357
- * of the two ends' displacements, which is what `laneStep` sized the step
1358
- * against — so the room a lane wanted is `drift * step`, the room it got is the
1359
- * same average taken over the steps the two sides actually managed, and the
1360
- * bow is the difference. It is zero whenever both sides were long enough,
1361
- * which is why nothing that already fitted has moved.
1362
- */
1363
- function bowBundles(bundles, achieved) {
1364
- const bows = new Map();
1365
- const stepOn = (end) => achieved.get(end.node)?.get(end.side) ?? 0;
1366
- for (const bundle of new Set(bundles.values())) {
1367
- const [first, second] = bundle.ends;
1368
- const t0 = tangentOf(first.side);
1369
- const t1 = tangentOf(second.side);
1370
- const sense = bundle.aligned ? 1 : -1;
1371
- const drift = { x: (t0.x + sense * t1.x) / 2, y: (t0.y + sense * t1.y) / 2 };
1372
- const s0 = stepOn(first);
1373
- const s1 = stepOn(second);
1374
- const got = { x: (s0 * t0.x + sense * s1 * t1.x) / 2, y: (s0 * t0.y + sense * s1 * t1.y) / 2 };
1375
- const short = {
1376
- x: drift.x * bundle.step - got.x,
1377
- y: drift.y * bundle.step - got.y,
1378
- };
1379
- if (short.x === 0 && short.y === 0)
1380
- continue;
1381
- bundle.lanes.forEach((edge, index) => {
1382
- const place = index - (bundle.lanes.length - 1) / 2;
1383
- bows.set(edge, { x: short.x * place, y: short.y * place });
1384
- });
1385
- }
1386
- return bows;
1387
- }
1388
- /**
1389
- * How far apart adjacent lanes must sit for their texts to clear each other.
1390
- *
1391
- * The texts are knockout rectangles, so two of them clear when they are apart
1392
- * on *either* axis — hence the smaller of the two answers. `drift` is how far
1393
- * the midpoint travels per unit of step, and it is never zero: the two ends
1394
- * cancel only when both sides run the same way, and two such sides are always
1395
- * `aligned`, which adds rather than subtracts.
1396
- */
1397
- function laneStep(lanes, drift, measurer, fontSize) {
1398
- const withText = lanes.filter((edge) => edge.text !== undefined);
1399
- if (withText.length < 2)
1400
- return 0;
1401
- const need = (axis) => Math.max(...withText.map((edge) => textExtent(edge.lines, edge.textAttrs, axis, measurer, fontSize, edge.line)));
1402
- const along = (axis, reach) => reach === 0 ? Infinity : need(axis) / Math.abs(reach);
1403
- return Math.min(along('x', drift.x), along('y', drift.y));
1404
- }
1405
- /** Which lane of its bundle an edge's end at this side takes, if it is in one. */
1406
- function rankIn(bundle, edge, node, side) {
1407
- if (!bundle)
1408
- return undefined;
1409
- const lane = bundle.lanes.indexOf(edge);
1410
- const [first, second] = bundle.ends;
1411
- if (node === first.node && side === first.side)
1412
- return lane;
1413
- if (node === second.node && side === second.side)
1414
- return bundle.aligned ? lane : -lane;
1415
- return undefined;
1416
- }
1417
- /** The unit vector along a side, pointing the way that coordinate increases. */
1418
- function tangentOf(side) {
1419
- return side === 'top' || side === 'bottom' ? { x: 1, y: 0 } : { x: 0, y: 1 };
1420
- }
1421
- /** The midpoint of one side of a box. */
1422
- function sideCenter(end) {
1423
- const face = faceOf(end.node);
1424
- const along = end.side === 'top' || end.side === 'bottom' ? face.width : face.height;
1425
- const origin = end.side === 'top' || end.side === 'bottom' ? face.x : face.y;
1426
- return anchorOn(face, end.side, origin + along / 2);
1427
- }
1428
- function cross(a, b) {
1429
- return a.x * b.y - a.y * b.x;
1430
- }
1431
- function claim(claims, node, side, entry) {
1432
- let bySide = claims.get(node);
1433
- if (!bySide) {
1434
- bySide = new Map();
1435
- claims.set(node, bySide);
1436
- }
1437
- const group = bySide.get(side);
1438
- if (group)
1439
- group.push(entry);
1440
- else
1441
- bySide.set(side, [entry]);
1442
- }
1443
- /** The point `at` along one side of a box, with the outward normal for that side. */
1444
- function anchorOn(face, side, at) {
1445
- if (face.round)
1446
- return anchorOnCircle(face, side, at);
1447
- switch (side) {
1448
- case 'top':
1449
- return { x: at, y: face.y, tx: 0, ty: -1, side };
1450
- case 'bottom':
1451
- return { x: at, y: face.y + face.height, tx: 0, ty: 1, side };
1452
- case 'left':
1453
- return { x: face.x, y: at, tx: -1, ty: 0, side };
1454
- case 'right':
1455
- return { x: face.x + face.width, y: at, tx: 1, ty: 0, side };
1456
- }
1457
- }
1458
- /**
1459
- * The same, on a circle: a side is the quarter of the circle around its
1460
- * compass point, and `at` is walked round the arc rather than along a
1461
- * straight edge, so points spaced a step apart on a side are a step apart on
1462
- * the circle too. The line meets the circle square on, heading from its center.
1463
- */
1464
- function anchorOnCircle(face, side, at) {
1465
- const radius = face.width / 2;
1466
- const center = centerOf(face);
1467
- const across = side === 'top' || side === 'bottom' ? at - center.x : at - center.y;
1468
- const turn = Math.max(-Math.PI / 4, Math.min(Math.PI / 4, across / radius));
1469
- // SVG's y runs down, so the bottom is a quarter-turn clockwise from the right.
1470
- const angle = {
1471
- right: turn,
1472
- bottom: Math.PI / 2 - turn,
1473
- left: Math.PI - turn,
1474
- top: -Math.PI / 2 + turn,
1475
- }[side];
1476
- const tx = Math.cos(angle);
1477
- const ty = Math.sin(angle);
1478
- return { x: center.x + radius * tx, y: center.y + radius * ty, tx, ty, side };
1479
- }
1480
- /** An end with no side named: leave from the border, pointing at the far end. */
1481
- function free(face, toward) {
1482
- const point = sidePoint(face, toward);
1483
- const center = centerOf(face);
1484
- const dx = point.x - center.x;
1485
- const dy = point.y - center.y;
1486
- const length = Math.hypot(dx, dy) || 1;
1487
- return { x: point.x, y: point.y, tx: dx / length, ty: dy / length };
1488
- }
1489
- /**
1490
- * Walk from a point inside a box along a direction, stopping at the border.
1491
- *
1492
- * `sidePoint` walks from the center, which is the only place a single line
1493
- * passes through. A fanned-out group's lines are parallel to that one and
1494
- * beside it, so each needs the border crossing of its own line rather than of
1495
- * the center's — which is what keeps the group parallel instead of splayed.
1496
- */
1497
- function exitAlong(box, from, dir) {
1498
- // A shift wider than the box leaves the origin outside it; clamping back in
1499
- // is the graceful answer, and the crowding it signals is a diagnostic.
1500
- const x = Math.min(Math.max(from.x, box.x), box.x + box.width);
1501
- const y = Math.min(Math.max(from.y, box.y), box.y + box.height);
1502
- if (box.round) {
1503
- // Where the ray leaves the circle: the larger root of |p + t·dir − c| = r.
1504
- const center = centerOf(box);
1505
- const px = x - center.x;
1506
- const py = y - center.y;
1507
- const a = dir.x * dir.x + dir.y * dir.y;
1508
- const b = px * dir.x + py * dir.y;
1509
- const c = px * px + py * py - (box.width / 2) ** 2;
1510
- const reach = b * b - a * c;
1511
- if (a === 0 || reach < 0)
1512
- return { x, y };
1513
- const t = Math.max(0, (-b + Math.sqrt(reach)) / a);
1514
- return { x: x + dir.x * t, y: y + dir.y * t };
1515
- }
1516
- const tx = dir.x === 0 ? Infinity : ((dir.x > 0 ? box.x + box.width : box.x) - x) / dir.x;
1517
- const ty = dir.y === 0 ? Infinity : ((dir.y > 0 ? box.y + box.height : box.y) - y) / dir.y;
1518
- const t = Math.min(tx, ty);
1519
- if (!Number.isFinite(t))
1520
- return { x, y };
1521
- return { x: x + dir.x * Math.max(0, t), y: y + dir.y * Math.max(0, t) };
1522
- }
1523
- /**
1524
- * Both ends of an edge that named no side, moved `offset` sideways across its
1525
- * own run.
1526
- *
1527
- * The whole line is translated rather than each end being nudged along its
1528
- * border, so the result is genuinely parallel to the line the edge would have
1529
- * drawn alone, exactly `offset` away from it. Where each end lands then falls
1530
- * out of that: level boxes put both points further along the same two edges,
1531
- * and a diagonal pair whose line leaves through a corner puts one point on each
1532
- * of the two edges meeting there. Neither is a case in the code.
1533
- */
1534
- function parallelEnds(from, to, offset) {
1535
- const a = centerOf(from);
1536
- const b = centerOf(to);
1537
- const dx = b.x - a.x;
1538
- const dy = b.y - a.y;
1539
- const length = Math.hypot(dx, dy) || 1;
1540
- const dir = { x: dx / length, y: dy / length };
1541
- const across = { x: -dir.y * offset, y: dir.x * offset };
1542
- const startAt = exitAlong(from, { x: a.x + across.x, y: a.y + across.y }, dir);
1543
- const endAt = exitAlong(to, { x: b.x + across.x, y: b.y + across.y }, { x: -dir.x, y: -dir.y });
1544
- return {
1545
- start: { x: startAt.x, y: startAt.y, tx: dir.x, ty: dir.y },
1546
- end: { x: endAt.x, y: endAt.y, tx: -dir.x, ty: -dir.y },
1547
- };
1548
- }
1549
- /** How far the control points sit off the ends. Proportional, but bounded. */
1550
- function controlReach(start, end) {
1551
- const distance = Math.hypot(end.x - start.x, end.y - start.y);
1552
- return Math.max(24, Math.min(140, distance * 0.4));
1553
- }
1554
- /** The free interval between two boxes on one axis, or nothing if they overlap. */
1555
- function clearance(aStart, aSize, bStart, bSize) {
1556
- if (aStart + aSize < bStart)
1557
- return { lo: aStart + aSize, hi: bStart };
1558
- if (bStart + bSize < aStart)
1559
- return { lo: bStart + bSize, hi: aStart };
1560
- return undefined;
1561
- }
1562
- /**
1563
- * Which gap `between a and b` means, and how far along it reaches.
1564
- *
1565
- * The axis is derived wherever the pair leaves only one answer, the same way a
1566
- * separation direction is: one node is above the other, or one is left of the
1567
- * other, and whichever it is says which axis the gap binds. Most pairs are like
1568
- * that, and for them the file says nothing about axes at all.
1569
- *
1570
- * A pair sitting diagonally has two gaps and needs the author to pick, which is
1571
- * what `wanted` carries. That is a tie-break rather than part of the statement:
1572
- * where it is not needed it may still be written, and is then checked rather
1573
- * than ignored, because a word that silently does nothing looks like a bug in
1574
- * the tool.
1575
- */
1576
- function gapBetween(a, b, aName, bName, wanted, line) {
1577
- const pair = `"${aName}" and "${bName}"`;
1578
- const found = {
1579
- y: clearance(a.y, a.height, b.y, b.height),
1580
- x: clearance(a.x, a.width, b.x, b.width),
1581
- };
1582
- if (wanted !== undefined && found[wanted] === undefined) {
1583
- const other = wanted === 'y' ? 'x' : 'y';
1584
- throw new SourceError(found[other]
1585
- ? `${pair} have no gap between them ${describeAxis(wanted)} — they are apart ${describeAxis(other)}, so drop the word or say "${describeAxis(other)}"`
1586
- : `${pair} touch or overlap, so there is no gap between them to pass through`, line);
1587
- }
1588
- if (wanted === undefined && found.y && found.x) {
1589
- throw new SourceError(`${pair} are apart both vertically and horizontally, so I cannot tell which gap you mean — write "between ${aName} and ${bName} vertically" for the gap above and below them, or "horizontally" for the gap beside them`, line);
1590
- }
1591
- // Whichever was asked for, or whichever is the only one there is.
1592
- const axis = wanted ?? (found.y ? 'y' : 'x');
1593
- const gap = found[axis];
1594
- if (!gap) {
1595
- throw new SourceError(`${pair} touch or overlap, so there is no gap between them to pass through`, line);
1596
- }
1597
- // The corridor reaches as far as the pair does on the other axis: that is the
1598
- // stretch over which the line is actually passing them.
1599
- return {
1600
- axis,
1601
- ...gap,
1602
- across: axis === 'y'
1603
- ? [Math.min(a.x, b.x), Math.max(a.x + a.width, b.x + b.width)]
1604
- : [Math.min(a.y, b.y), Math.max(a.y + a.height, b.y + b.height)],
1605
- };
1606
- }
1607
- /**
1608
- * The gap each `between` edge named, and where along it the line must cross.
1609
- *
1610
- * Only the gap: which lane a line takes through it, and how far it keeps from
1611
- * the others there, is the search's, as it is for every line (see `planWays`).
1612
- */
1613
- function planCorridors(edges, ends) {
1614
- const plans = new Map();
1615
- for (const edge of edges) {
1616
- if (!edge.between)
1617
- continue;
1618
- const [first, second] = edge.between.nodes;
1619
- const a = faceOf(first);
1620
- const b = faceOf(second);
1621
- const gap = gapBetween(a, b, first.name, second.name, edge.between.axis, edge.line);
1622
- const run = gap.axis === 'y' ? 'x' : 'y';
1623
- // The line passes the pair only if its own ends reach past their extent.
1624
- const { start, end } = ends.get(edge);
1625
- if (Math.min(gap.across[1], Math.max(start[run], end[run])) <= Math.max(gap.across[0], Math.min(start[run], end[run]))) {
1626
- throw new SourceError(`this edge never passes between "${first.name}" and "${second.name}"`, edge.line);
1627
- }
1628
- // Where the two face each other, the middle of that; where they sit
1629
- // diagonally, the middle of the space between them.
1630
- const near = Math.max(lo(a, run), lo(b, run));
1631
- const far = Math.min(hi(a, run), hi(b, run));
1632
- plans.set(edge, { axis: gap.axis, lo: gap.lo, hi: gap.hi, at: (near + far) / 2 });
1633
- }
1634
- return plans;
1635
- }
1636
- /**
1637
- * Every line takes the shortest sensible way between its ends, and a way
1638
- * through a box is not sensible. Where the direct line — the fewest turns that
1639
- * leave and arrive head-on, or a plain straight line when no side is named — is
1640
- * clear of every box, that is the shortest way and it is kept as it is drawn
1641
- * today. Where it is not, the way is searched for through the open space (see
1642
- * `search.ts`), and the line is drawn along what was found.
1643
- *
1644
- * A line with clauses is the same line with rules added. `below m` fences off
1645
- * everything above M within M's width (see `fenceOf`), and the direct line is
1646
- * kept only if it keeps clear of the fences as well as the boxes; otherwise
1647
- * the search goes round them as it goes round a box. A `between` line must pass
1648
- * through the gap it names: the search carries the gap as a gate, and finds
1649
- * its lane there with the rest of its way. A clause line whose shortest way
1650
- * never reaches the side a clause names is sent through a point on that side,
1651
- * searched for in legs into and out of it (see `clauseGate`).
1652
- *
1653
- * Lines searched for are placed shortest first, and each later one keeps a lane
1654
- * clear of those already placed where they share a stretch, so a longer line
1655
- * nests outside a shorter one rather than drawing on top of it. Two lines that
1656
- * cross are then placed again the other way round, and kept so if they cross
1657
- * less.
1658
- */
1659
- function planWays(edges, nodes, ends, corridors, measurer, fontSize) {
1660
- const routes = new Map();
1661
- const wanted = [];
1662
- // Direct lines that turn twice, across a gap in its middle.
1663
- const turning = [];
1664
- for (const edge of edges) {
1665
- if (corridors.has(edge)) {
1666
- wanted.push(edge);
1667
- continue;
1668
- }
1669
- // A line from a container to something inside it lives inside the box it
1670
- // leaves, and has no way round anything.
1671
- if (!edge.passes && edge.from !== edge.to && (contains(edge.from, edge.to) || contains(edge.to, edge.from)))
1672
- continue;
1673
- const plan = ends.get(edge);
1674
- // A fence is checked at the box's own edge here, not grown: a line leaving
1675
- // M's bottom with `below m` starts on the fence's border, not inside it.
1676
- const boxes = [...obstaclesFor(edge, nodes), ...(edge.passes ?? []).map((pass) => fenceOf(pass, 0))];
1677
- const { start, end } = plan;
1678
- if (start.side !== undefined && end.side !== undefined) {
1679
- const direct = directWay(start, end);
1680
- if (direct && wayClear(direct, boxes) && passesAll(edge, direct)) {
1681
- // The curve is the direct way with its turns swept, and is kept — unless
1682
- // it bulges into a box the direct way clears, when the way is drawn.
1683
- if (edge.passes || (edge.look.path === 'curved' && curveHits(edge, plan, boxes))) {
1684
- routes.set(edge, { points: direct, mid: textOnWay(direct, edge, nodes, measurer, fontSize) });
1685
- }
1686
- if (direct.length === 4)
1687
- turning.push({ edge, points: direct });
1688
- continue;
1689
- }
1690
- }
1691
- else if (start.side === undefined && end.side === undefined) {
1692
- if (wayClear([start, end], boxes) && passesAll(edge, [start, end]))
1693
- continue;
1694
- }
1695
- else if (!curveHits(edge, plan, boxes) && passesAll(edge, curveTrace(edge, plan))) {
1696
- continue;
1697
- }
1698
- wanted.push(edge);
1699
- }
1700
- shareGaps(turning, routes, nodes, measurer, fontSize);
1701
- const reach = (edge) => {
1702
- const { start, end } = ends.get(edge);
1703
- return Math.abs(end.x - start.x) + Math.abs(end.y - start.y);
1704
- };
1705
- wanted.sort((p, q) => reach(p) - reach(q));
1706
- // Lines between the same two sides, told the same things, go the same way
1707
- // round, and the one placed first takes the inside lane. So the one whose end
1708
- // sits furthest the way the line turns goes first: out of a left side and
1709
- // down, the lowest end takes the inside, and none of them crosses another.
1710
- const sameWay = (edge) => {
1711
- const { start, end } = ends.get(edge);
1712
- return [
1713
- edge.from.name, edge.to.name, start.side, end.side, start.tx, start.ty, end.tx, end.ty,
1714
- corridors.has(edge) ? 'between' : '',
1715
- ...(edge.passes ?? []).map((pass) => pass.written),
1716
- ].join(' ');
1717
- };
1718
- // Each line of such a group after the first, and the line it is drawn as a
1719
- // copy of: the one a lane inside it.
1720
- const copies = new Map();
1721
- for (let index = 0; index < wanted.length; index += 1) {
1722
- const key = sameWay(wanted[index]);
1723
- const group = wanted.filter((edge) => sameWay(edge) === key);
1724
- if (group.length < 2 || group[0] !== wanted[index] || corridors.has(group[0]))
1725
- continue;
1726
- const trial = searchWay(group[0], nodes, ends.get(group[0]).start, group[0].from, ends.get(group[0]).end, group[0].to, [], ATTACH_STEP);
1727
- if (!trial || trial.points.length < 3)
1728
- continue;
1729
- const [, bend, after] = trial.points;
1730
- const turn = { x: Math.sign(after.x - bend.x), y: Math.sign(after.y - bend.y) };
1731
- const ordered = [...group].sort((p, q) => {
1732
- const a = ends.get(p).start;
1733
- const b = ends.get(q).start;
1734
- return (b.x - a.x) * turn.x + (b.y - a.y) * turn.y;
1735
- });
1736
- const rest = wanted.filter((edge) => !group.includes(edge));
1737
- rest.splice(index, 0, ...ordered);
1738
- wanted.splice(0, wanted.length, ...rest);
1739
- index += group.length - 1;
1740
- // A text widens the lane it rides in, which a copy cannot know; those are searched.
1741
- const named = (edge) => {
1742
- const { start, end } = ends.get(edge);
1743
- return start.side !== undefined && end.side !== undefined;
1744
- };
1745
- if (ordered.every((edge) => edge.lines === undefined && named(edge))) {
1746
- ordered.slice(1).forEach((edge, at) => copies.set(edge, ordered[at]));
1747
- }
1748
- }
1749
- // Where several lines leave one side, their ends may be squeezed closer than
1750
- // a lane; the lines then run out of them that close together, and the lanes
1751
- // beyond keep the same spacing, so they nest rather than finding no room.
1752
- const attached = [];
1753
- for (const [edge, { start, end }] of ends) {
1754
- attached.push({ node: edge.from, edge, anchor: start }, { node: edge.to, edge, anchor: end });
1755
- }
1756
- const laneFor = (edge) => {
1757
- const { start, end } = ends.get(edge);
1758
- let lane = ATTACH_STEP;
1759
- for (const [node, anchor] of [[edge.from, start], [edge.to, end]]) {
1760
- for (const other of attached) {
1761
- if (other.edge === edge || other.node !== node)
1762
- continue;
1763
- if (other.anchor.tx !== anchor.tx || other.anchor.ty !== anchor.ty)
1764
- continue;
1765
- const apart = Math.hypot(other.anchor.x - anchor.x, other.anchor.y - anchor.y);
1766
- if (apart > 0.5)
1767
- lane = Math.min(lane, apart);
1768
- }
1769
- }
1770
- return lane;
1771
- };
1772
- // The ends as planned, before a search chose a side for any end naming none,
1773
- // so a line placed again chooses afresh.
1774
- const planned = new Map(ends);
1775
- const found = new Map();
1776
- // For a placed line, per stretch, half the depth across it of its text where
1777
- // the text rides, so a line beside that stretch keeps clear of the words.
1778
- const halvesOf = (other) => {
1779
- const { points, mid } = found.get(other);
1780
- return points.slice(1).map((q, index) => {
1781
- const p = points[index];
1782
- if (other.lines === undefined)
1783
- return 0;
1784
- const level = Math.abs(p.y - q.y) < 0.5;
1785
- const on = level
1786
- ? Math.abs(mid.y - p.y) < 0.5 && mid.x >= Math.min(p.x, q.x) - 0.5 && mid.x <= Math.max(p.x, q.x) + 0.5
1787
- : Math.abs(mid.x - p.x) < 0.5 && mid.y >= Math.min(p.y, q.y) - 0.5 && mid.y <= Math.max(p.y, q.y) + 0.5;
1788
- return on ? laneExtent(other, level ? 'y' : 'x', measurer, fontSize) / 2 : 0;
1789
- });
1790
- };
1791
- const place = (edge, placed) => {
1792
- const plan = planned.get(edge);
1793
- const lane = laneFor(edge);
1794
- const taken = placed.map((other) => found.get(other).points);
1795
- const takenHalves = placed.map(halvesOf);
1796
- const used = [...ends].flatMap(([other, { start, end }]) => other === edge ? [] : [{ node: other.from, anchor: start }, { node: other.to, anchor: end }]);
1797
- // The way through a run of stops in order, each a straight stretch entered
1798
- // heading along it and left the same way; each leg between them is
1799
- // searched on its own, with a straight stretch into and out of every stop.
1800
- const through = (stops) => {
1801
- let from = plan.start;
1802
- let fromNode = edge.from;
1803
- let first;
1804
- const points = [];
1805
- for (const { enter, leave, run } of stops) {
1806
- const leg = searchWay(edge, nodes, from, fromNode, { x: enter.x, y: enter.y, tx: -run.x, ty: -run.y }, undefined, taken, lane, used, { takenHalves });
1807
- if (!leg)
1808
- return undefined;
1809
- first ??= leg.start;
1810
- points.push(...leg.points);
1811
- from = { x: leave.x, y: leave.y, tx: run.x, ty: run.y };
1812
- fromNode = undefined;
1813
- }
1814
- const last = searchWay(edge, nodes, from, fromNode, plan.end, edge.to, taken, lane, used, { takenHalves });
1815
- if (!last)
1816
- return undefined;
1817
- return { start: first ?? last.start, end: last.end, points: tidyRoute([...points, ...last.points]) };
1818
- };
1819
- const corridor = corridors.get(edge);
1820
- let way;
1821
- let mid;
1822
- if (corridor) {
1823
- // Somewhere along its way the line crosses the gap, travelling along it;
1824
- // which lane it takes there is found with the rest of the way, and its
1825
- // text, which rides in the gap, keeps the lines beside it clear.
1826
- const run = corridor.axis === 'y' ? 'x' : 'y';
1827
- const gate = { run, at: corridor.at, lo: corridor.lo, hi: corridor.hi };
1828
- const ownHalf = laneExtent(edge, corridor.axis, measurer, fontSize) / 2;
1829
- way = searchWay(edge, nodes, plan.start, edge.from, plan.end, edge.to, taken, lane, used, { gate, ownHalf, takenHalves });
1830
- if (!way)
1831
- throw new SourceError(gateMessage(edge, corridor, nodes), edge.line);
1832
- mid = gateCrossing(way.points, gate);
1833
- }
1834
- else {
1835
- way = searchWay(edge, nodes, plan.start, edge.from, plan.end, edge.to, taken, lane, used, { takenHalves });
1836
- if (!way)
1837
- throw new SourceError(edge.passes ? clauseMessage(edge, edge.passes) : blockedMessage(edge, plan, nodes), edge.line);
1838
- if (!passesAll(edge, way.points)) {
1839
- // The shortest way keeps clear of every fence without ever coming round
1840
- // to the side a clause names — a line leaving a node that sits over M,
1841
- // told to pass below M, can simply leave sideways. It is sent through a
1842
- // point on that side of each clause it missed, in the order they lie
1843
- // along its way.
1844
- const missed = edge.passes.filter((pass) => !alongside(way.points, pass));
1845
- const gates = missed.map((pass) => clauseGate(pass, plan.start, plan.end));
1846
- if (gates.some((gate) => gate === undefined)) {
1847
- throw new SourceError(neverPassesMessage(edge, way.points), edge.line);
1848
- }
1849
- const travel = { x: plan.end.x - plan.start.x, y: plan.end.y - plan.start.y };
1850
- const stops = gates
1851
- .map((gate) => gate)
1852
- .sort((p, q) => (p.at.x - q.at.x) * travel.x + (p.at.y - q.at.y) * travel.y)
1853
- .map(({ at, run }) => ({ enter: at, leave: at, run }));
1854
- way = through(stops);
1855
- if (!way)
1856
- throw new SourceError(clauseMessage(edge, edge.passes), edge.line);
1857
- if (!passesAll(edge, way.points))
1858
- throw new SourceError(neverPassesMessage(edge, way.points), edge.line);
1859
- }
1860
- }
1861
- return { ...way, mid: mid ?? textOnWay(way.points, edge, nodes, measurer, fontSize) };
1862
- };
1863
- const order = [];
1864
- const settle = (edge, way) => {
1865
- found.set(edge, way);
1866
- ends.set(edge, { start: way.start, end: way.end });
1867
- };
1868
- // A line identical to one already placed is that line moved out by a lane,
1869
- // not a search of its own: the same way, every stretch shifted to the same
1870
- // side by the distance between their ends. Searching each afresh, keeping
1871
- // clear of every copy before it, made fifteen identical lines take as long as
1872
- // a tangled diagram. Where the copy would not do — its ends do not line up
1873
- // with the shift, a stretch folds up, it meets a box, crowds or crosses a line
1874
- // the first does not — the line is searched for as any other.
1875
- const copyOf = (edge, inside, placed) => {
1876
- const { start, end } = planned.get(edge);
1877
- const base = found.get(inside).points;
1878
- const count = base.length - 1;
1879
- if (count < 1)
1880
- return undefined;
1881
- const heading = base.slice(1).map((q, index) => {
1882
- const p = base[index];
1883
- const length = Math.hypot(q.x - p.x, q.y - p.y);
1884
- return { x: (q.x - p.x) / length, y: (q.y - p.y) / length, length };
1885
- });
1886
- if (heading.some((one) => one.length < 0.5 || (Math.abs(one.x) > 1e-6 && Math.abs(one.y) > 1e-6)))
1887
- return undefined;
1888
- const normal = heading.map((one) => ({ x: -one.y, y: one.x }));
1889
- const along = (from, to, axis) => (to.x - from.x) * axis.x + (to.y - from.y) * axis.y;
1890
- const shift = along(base[0], start, normal[0]);
1891
- // Any shift will do, however small: where more lines leave a side than it
1892
- // has room for, their ends sit under half a pixel apart, and refusing those
1893
- // sent fifty-six copies to the search and took seconds.
1894
- if (Math.abs(shift) < 1e-6 || Math.abs(along(base[0], start, heading[0])) > 0.5)
1895
- return undefined;
1896
- if (Math.abs(along(base[count], end, normal[count - 1]) - shift) > 0.5 || Math.abs(along(base[count], end, heading[count - 1])) > 0.5) {
1897
- return undefined;
1898
- }
1899
- const points = [{ x: start.x, y: start.y }];
1900
- for (let index = 1; index < count; index += 1) {
1901
- const [p, q] = [normal[index - 1], normal[index]];
1902
- const turns = Math.abs(p.x * q.x + p.y * q.y) < 0.5;
1903
- const v = base[index];
1904
- points.push(turns ? { x: v.x + shift * (p.x + q.x), y: v.y + shift * (p.y + q.y) } : { x: v.x + shift * q.x, y: v.y + shift * q.y });
1905
- }
1906
- points.push({ x: end.x, y: end.y });
1907
- for (let index = 0; index < count; index += 1) {
1908
- const [p, q] = [points[index], points[index + 1]];
1909
- if (along(p, q, heading[index]) < 0.5)
1910
- return undefined;
1911
- }
1912
- const inner = points.slice(1, -1);
1913
- const walls = nodes
1914
- .filter((node) => !contains(node, edge.from) && !contains(node, edge.to))
1915
- .map((node) => grow(extentOfBox(faceOf(node)), ATTACH_MARGIN - 0.5));
1916
- const own = [edge.from, edge.to].map((node) => grow(extentOfBox(faceOf(node)), ATTACH_MARGIN - 0.5));
1917
- const fences = (edge.passes ?? []).map((pass) => fenceOf(pass, 0));
1918
- if (!wayClear(points, [...obstaclesFor(edge, nodes), ...walls]) || !wayClear(inner, [...own, ...fences]) || !passesAll(edge, points)) {
1919
- return undefined;
1920
- }
1921
- const lane = laneFor(edge);
1922
- for (const other of placed) {
1923
- const theirs = found.get(other).points;
1924
- if (linesCross(points, theirs) > linesCross(base, theirs))
1925
- return undefined;
1926
- if (other !== inside && crowds(points, theirs, lane))
1927
- return undefined;
1928
- }
1929
- return { start, end, points, mid: textOnWay(points, edge, nodes, measurer, fontSize) };
1930
- };
1931
- for (const edge of wanted) {
1932
- const inside = copies.get(edge);
1933
- const copy = inside && found.has(inside) ? copyOf(edge, inside, [...order]) : undefined;
1934
- settle(edge, copy ?? place(edge, [...order]));
1935
- order.push(edge);
1936
- }
1937
- // Which line takes the inside of a shared stretch depends on which was placed
1938
- // first, and the wrong order makes two lines cross where they need not. So a
1939
- // pair that crosses is placed again the other way round, and kept that way if
1940
- // it crosses less. This is tidying, not a promise: the pairs are tried a
1941
- // fixed number of times over, however tangled the diagram, since trying every
1942
- // pair that crossed made a diagram of forty lines take minutes. A count, not a
1943
- // clock, so a file draws the same on every machine. A writer who wants a line
1944
- // somewhere in particular says so.
1945
- const crossingsOf = (edge) => order.reduce((sum, other) => (other === edge ? sum : sum + linesCross(found.get(edge).points, found.get(other).points)), 0);
1946
- let tries = order.length * UNCROSS_TRIES;
1947
- for (let pass = 0; pass < 3 && tries > 0; pass += 1) {
1948
- let better = false;
1949
- for (let i = 0; i < order.length && tries > 0; i += 1) {
1950
- for (let j = i + 1; j < order.length && tries > 0; j += 1) {
1951
- const [first, second] = [order[i], order[j]];
1952
- if (linesCross(found.get(first).points, found.get(second).points) === 0)
1953
- continue;
1954
- tries -= 1;
1955
- const before = crossingsOf(first) + crossingsOf(second);
1956
- const kept = [found.get(first), found.get(second)];
1957
- const rest = order.filter((edge) => edge !== first && edge !== second);
1958
- try {
1959
- settle(second, place(second, rest));
1960
- settle(first, place(first, [...rest, second]));
1961
- }
1962
- catch (error) {
1963
- if (!(error instanceof SourceError))
1964
- throw error;
1965
- settle(first, kept[0]);
1966
- settle(second, kept[1]);
1967
- continue;
1968
- }
1969
- if (crossingsOf(first) + crossingsOf(second) < before) {
1970
- order.splice(j, 1);
1971
- order.splice(i, 0, second);
1972
- better = true;
1973
- }
1974
- else {
1975
- settle(first, kept[0]);
1976
- settle(second, kept[1]);
1977
- }
1978
- }
1979
- }
1980
- if (!better)
1981
- break;
1982
- }
1983
- // In the order placed, inside lanes first, which is the order sweeps are drawn in.
1984
- for (const edge of order) {
1985
- const { points, mid } = found.get(edge);
1986
- routes.set(edge, { points, mid });
1987
- }
1988
- return routes;
1989
- }
1990
- /**
1991
- * Whether two lines run side by side, along the same stretch, nearer than a
1992
- * lane apart — which a line found by search never does.
1993
- */
1994
- function crowds(a, b, lane) {
1995
- for (let i = 0; i + 1 < a.length; i += 1) {
1996
- const [p, q] = [a[i], a[i + 1]];
1997
- const level = Math.abs(p.y - q.y) < 0.5;
1998
- for (let j = 0; j + 1 < b.length; j += 1) {
1999
- const [r, s] = [b[j], b[j + 1]];
2000
- if (level !== Math.abs(r.y - s.y) < 0.5)
2001
- continue;
2002
- const [across, lo, hi, otherAcross, otherLo, otherHi] = level
2003
- ? [p.y, Math.min(p.x, q.x), Math.max(p.x, q.x), r.y, Math.min(r.x, s.x), Math.max(r.x, s.x)]
2004
- : [p.x, Math.min(p.y, q.y), Math.max(p.y, q.y), r.x, Math.min(r.y, s.y), Math.max(r.y, s.y)];
2005
- if (Math.min(hi, otherHi) - Math.max(lo, otherLo) > 0.5 && Math.abs(across - otherAcross) < lane - 0.5)
2006
- return true;
2007
- }
2008
- }
2009
- return false;
2010
- }
2011
- /** How many times two lines cross, each drawn as straight pieces through its points. */
2012
- function linesCross(a, b) {
2013
- let count = 0;
2014
- for (let i = 0; i + 1 < a.length; i += 1) {
2015
- for (let j = 0; j + 1 < b.length; j += 1) {
2016
- if (intersect(a[i], a[i + 1], b[j], b[j + 1]))
2017
- count += 1;
2018
- }
2019
- }
2020
- return count;
2021
- }
2022
- /** Far enough to stand for the edge of the page on any side. */
2023
- const FAR = 1e5;
2024
- /**
2025
- * The region a clause fences a line out of: everything from its nodes to the
2026
- * edge of the page on the far side, within their width or height. `below m` is
2027
- * M's whole column above M's bottom; "wherever the line is within M's width, it
2028
- * is below M" is exactly "the line never enters this". Grown by `by`, so the
2029
- * search keeps the margin a box gets.
2030
- */
2031
- function fenceOf(pass, by) {
2032
- const box = grow(extentOfBox(boundingBox(pass.nodes.map(faceOf))), by);
2033
- switch (pass.direction) {
2034
- case 'below':
2035
- return { ...box, minY: -FAR };
2036
- case 'above':
2037
- return { ...box, maxY: FAR };
2038
- case 'left':
2039
- return { ...box, maxX: FAR };
2040
- default:
2041
- return { ...box, minX: -FAR };
2042
- }
2043
- }
2044
- /** Whether a line along these points is alongside every clause's nodes somewhere. */
2045
- function passesAll(edge, points) {
2046
- return (edge.passes ?? []).every((pass) => alongside(points, pass));
2047
- }
2048
- /**
2049
- * Whether the line passes a clause's nodes on the side it names somewhere:
2050
- * some of it lies within their width (or height) and beyond their named side.
2051
- * A line that is within their width only on the wrong side — leaving a node
2052
- * that sits over M, say — has not passed M at all.
2053
- */
2054
- function alongside(points, pass) {
2055
- const box = extentOfBox(boundingBox(pass.nodes.map(faceOf)));
2056
- const across = sideAxis(pass) === 'y';
2057
- const [lo, hi] = across ? [box.minX, box.maxX] : [box.minY, box.maxY];
2058
- // The side named, as a test on the other coordinate.
2059
- const beyond = (level) => pass.direction === 'below' ? level >= box.maxY - 0.5
2060
- : pass.direction === 'above' ? level <= box.minY + 0.5
2061
- : pass.direction === 'left' ? level <= box.minX + 0.5
2062
- : level >= box.maxX - 0.5;
2063
- for (let index = 0; index + 1 < points.length; index += 1) {
2064
- const [p, q] = [points[index], points[index + 1]];
2065
- const [a, b] = across ? [p.x, q.x] : [p.y, q.y];
2066
- const [u, v] = across ? [p.y, q.y] : [p.x, q.x];
2067
- const overlap = Math.min(hi, Math.max(a, b)) - Math.max(lo, Math.min(a, b));
2068
- if (Math.abs(a - b) > 0.5) {
2069
- // Travelling along the run: within the range, at one level.
2070
- if (overlap > 0.5 && beyond(u))
2071
- return true;
2072
- }
2073
- else if (a > lo && a < hi && (beyond(u) || beyond(v))) {
2074
- // Travelling across the run, inside the range: reaching the named side.
2075
- return true;
2076
- }
2077
- }
2078
- return false;
2079
- }
2080
- /**
2081
- * A point the line must pass through to be on a clause's named side of its
2082
- * nodes: under the middle of them for `below`, a clear gap out, heading the way
2083
- * the line travels. Undefined when the line's ends do not reach past the
2084
- * nodes' width at all, so the clause has nothing to say about it.
2085
- */
2086
- function clauseGate(pass, start, end) {
2087
- const box = extentOfBox(boundingBox(pass.nodes.map(faceOf)));
2088
- const across = sideAxis(pass) === 'y';
2089
- const [lo, hi] = across ? [box.minX, box.maxX] : [box.minY, box.maxY];
2090
- const [a, b] = across ? [start.x, end.x] : [start.y, end.y];
2091
- const from = Math.max(lo, Math.min(a, b));
2092
- const to = Math.min(hi, Math.max(a, b));
2093
- if (to - from < -0.5)
2094
- return undefined;
2095
- // The middle of the nodes, or as near it as the line's own span reaches.
2096
- const middle = Math.max(Math.min(a, b), Math.min(Math.max(a, b), (lo + hi) / 2));
2097
- const travel = Math.sign(b - a) || 1;
2098
- const level = pass.direction === 'below' ? box.maxY + SEPARATION_GAP
2099
- : pass.direction === 'above' ? box.minY - SEPARATION_GAP
2100
- : pass.direction === 'left' ? box.minX - SEPARATION_GAP
2101
- : box.maxX + SEPARATION_GAP;
2102
- return across
2103
- ? { at: { x: middle, y: level }, run: { x: travel, y: 0 } }
2104
- : { at: { x: level, y: middle }, run: { x: 0, y: travel } };
2105
- }
2106
- function neverPassesMessage(edge, points) {
2107
- const pass = edge.passes.find((one) => !alongside(points, one));
2108
- return `edge ${edge.from.name} -> ${edge.to.name}: the line never passes ${quoteNames(pass.nodes)}, so ` +
2109
- `"${pass.written}" says nothing about it`;
2110
- }
2111
- /**
2112
- * Why no line keeps an edge's clauses. Two clauses that pull opposite ways over
2113
- * the same stretch cannot both hold; two that pull opposite ways one after the
2114
- * other need room between their nodes to cross over in. Otherwise the clauses
2115
- * and the boxes between them leave no way through.
2116
- */
2117
- function clauseMessage(edge, passes) {
2118
- const subject = `edge ${edge.from.name} -> ${edge.to.name}`;
2119
- const opposite = { below: 'above', above: 'below', left: 'right', right: 'left' };
2120
- for (const floor of passes) {
2121
- for (const ceiling of passes) {
2122
- if (opposite[floor.direction] !== ceiling.direction)
2123
- continue;
2124
- if (floor.direction !== 'below' && floor.direction !== 'right')
2125
- continue;
2126
- const f = extentOfBox(boundingBox(floor.nodes.map(faceOf)));
2127
- const c = extentOfBox(boundingBox(ceiling.nodes.map(faceOf)));
2128
- const across = sideAxis(floor) === 'y';
2129
- // The floor's far edge and the ceiling's near edge, on the axis the clauses bound.
2130
- const band = across ? c.minY - f.maxY : c.minX - f.maxX;
2131
- if (band >= CROSSING_ROOM)
2132
- continue;
2133
- const [flo, fhi, clo, chi] = across ? [f.minX, f.maxX, c.minX, c.maxX] : [f.minY, f.maxY, c.minY, c.maxY];
2134
- const apart = Math.max(clo - fhi, flo - chi);
2135
- if (apart < 0) {
2136
- return `${subject}: "${floor.written}" and "${ceiling.written}" cannot both hold — there is a stretch ` +
2137
- `where the line is alongside both, and it cannot be ${sideWord(floor)} ${quoteNames(floor.nodes)} and ` +
2138
- `${sideWord(ceiling)} ${quoteNames(ceiling.nodes)} at the same point. Drop one`;
2139
- }
2140
- if (apart < CROSSING_ROOM) {
2141
- return `${subject}: to pass "${floor.written}" and "${ceiling.written}" the line has to cross over ` +
2142
- `between ${quoteNames(floor.nodes)} and ${quoteNames(ceiling.nodes)}, and there is no room between ` +
2143
- 'them — give the placement between them a gap, or drop one of the two';
2144
- }
2145
- }
2146
- }
2147
- const written = passes.map((pass) => `"${pass.written}"`);
2148
- const listed = written.length === 1 ? written[0] : `${written.slice(0, -1).join(', ')} and ${written[written.length - 1]}`;
2149
- return `${subject}: no line from ${edge.from.name} to ${edge.to.name} keeps ${listed} without passing through ` +
2150
- 'another box. Give the nodes more room, or drop a clause';
2151
- }
2152
- /** Why a `between` line has no way into or out of its gap. */
2153
- function gateMessage(edge, corridor, nodes) {
2154
- const [a, b] = edge.between.nodes;
2155
- // Straight across the gap where the line has to cross it.
2156
- const [p1, p2] = corridor.axis === 'y'
2157
- ? [{ x: corridor.at, y: corridor.lo }, { x: corridor.at, y: corridor.hi }]
2158
- : [{ x: corridor.lo, y: corridor.at }, { x: corridor.hi, y: corridor.at }];
2159
- const blocker = nodes.find((node) => node !== a && node !== b && !contains(node, a) && !contains(node, b) &&
2160
- segmentHits(p1, p2, extentOfBox(faceOf(node))));
2161
- if (blocker) {
2162
- return `edge ${edge.from.name} -> ${edge.to.name}: the gap between "${a.name}" and "${b.name}" is blocked ` +
2163
- `by ${blocker.name}, so the line cannot pass through it`;
2164
- }
2165
- return `edge ${edge.from.name} -> ${edge.to.name}: no way from ${edge.from.name} through the gap between ` +
2166
- `"${a.name}" and "${b.name}" to ${edge.to.name} passes clear of the other boxes. Give them room, or name other sides`;
2167
- }
2168
- /**
2169
- * Direct lines that turn in the middle of the same gap and share some of it
2170
- * take a lane each, side by side, in the order that keeps them from crossing:
2171
- * of lines heading down a gap, the one whose ends are both higher goes further
2172
- * across, so it turns in above the other's arrival and the other turns down
2173
- * before reaching it. Each is then drawn along its lane, its turns swept.
2174
- */
2175
- function shareGaps(turning, routes, nodes, measurer, fontSize) {
2176
- // The middle piece runs down (x fixed) when the first piece runs across.
2177
- const upright = (points) => Math.abs(points[0].y - points[1].y) < 0.5;
2178
- const level = (points) => (upright(points) ? points[1].x : points[1].y);
2179
- const span = (points) => {
2180
- const [a, b] = upright(points) ? [points[1].y, points[2].y] : [points[1].x, points[2].x];
2181
- return [Math.min(a, b), Math.max(a, b)];
2182
- };
2183
- const groups = [];
2184
- for (const line of turning) {
2185
- const group = groups.find((members) => members.some((other) => {
2186
- if (upright(other.points) !== upright(line.points))
2187
- return false;
2188
- if (Math.abs(level(other.points) - level(line.points)) > 0.5)
2189
- return false;
2190
- const [a, b] = span(other.points);
2191
- const [c, d] = span(line.points);
2192
- return Math.min(b, d) - Math.max(a, c) > 0.5;
2193
- }));
2194
- if (group)
2195
- group.push(line);
2196
- else
2197
- groups.push([line]);
2198
- }
2199
- for (const group of groups) {
2200
- if (group.length < 2)
2201
- continue;
2202
- const first = group[0].points;
2203
- const down = upright(first);
2204
- // Which way the lines head across the gap, and how wide the gap is.
2205
- const heading = Math.sign(down ? first[1].x - first[0].x : first[1].y - first[0].y);
2206
- const width = Math.abs(down ? first[3].x - first[0].x : first[3].y - first[0].y);
2207
- const along = (p) => (down ? p.y : p.x);
2208
- const key = ({ points }) => {
2209
- const travel = Math.sign(along(points[3]) - along(points[0]));
2210
- return travel * (along(points[0]) + along(points[3]));
2211
- };
2212
- // Outermost — furthest the way the lines head — first.
2213
- const ordered = [...group].sort((p, q) => key(p) - key(q));
2214
- const step = Math.min(ATTACH_STEP, width / (group.length + 1));
2215
- const middle = level(first);
2216
- ordered.forEach(({ edge, points }, index) => {
2217
- const lane = middle + heading * step * ((group.length - 1) / 2 - index);
2218
- const [s, , , e] = points;
2219
- const laned = down
2220
- ? [s, { x: lane, y: s.y }, { x: lane, y: e.y }, e]
2221
- : [s, { x: s.x, y: lane }, { x: e.x, y: lane }, e];
2222
- routes.set(edge, { points: laned, mid: textOnWay(laned, edge, nodes, measurer, fontSize) });
2223
- });
2224
- }
2225
- }
2226
- /**
2227
- * The boxes a line must stay out of, as extents: every node that holds neither
2228
- * end, and the line's own two, shrunk a pixel so it may start and end on their
2229
- * sides. A container holding one end is not among them — the line crosses its
2230
- * border on the way out.
2231
- */
2232
- function obstaclesFor(edge, nodes) {
1084
+ function markMarker(mark, color, background, free) {
1085
+ const round = mark === 'dot' || mark === 'odot';
1086
+ const size = round ? ` markerUnits="userSpaceOnUse" markerWidth="${ARROW_MARKER_WIDTH * LINE_WIDTH}" markerHeight="${ARROW_MARKER_WIDTH * LINE_WIDTH}"` : ` markerWidth="${ARROW_MARKER_WIDTH}" markerHeight="${ARROW_MARKER_WIDTH}"`;
2233
1087
  return [
2234
- // A circle's ends sit inside its square extent, so the square cannot stand
2235
- // for it here; a line meeting a circle square on does not cross it anyway.
2236
- ...[edge.from, edge.to]
2237
- .filter((node) => !faceOf(node).round)
2238
- .map((node) => grow(extentOfBox(faceOf(node)), -1)),
2239
- ...nodes
2240
- .filter((node) => !contains(node, edge.from) && !contains(node, edge.to))
2241
- .map((node) => extentOfBox(faceOf(node))),
2242
- ];
2243
- }
2244
- /** Whether a line through these points passes through none of the boxes. */
2245
- function wayClear(points, boxes) {
2246
- for (let index = 0; index + 1 < points.length; index += 1) {
2247
- if (boxes.some((box) => segmentHits(points[index], points[index + 1], box)))
2248
- return false;
2249
- }
2250
- return true;
2251
- }
2252
- /**
2253
- * The direct way between two named sides: straight across when they face each
2254
- * other in line, two turns in the middle when they face each other offset, one
2255
- * turn when they are at right angles and the corner lies ahead of both.
2256
- * Undefined when there is no such way — the far end is behind a side, or both
2257
- * sides face the same way — and the line has to go round something.
2258
- */
2259
- function directWay(start, end) {
2260
- // Which way each side faces, square to the side even where a circle's point
2261
- // leans off its compass point.
2262
- const s = endOf(start);
2263
- const e = endOf(end);
2264
- const out = { x: s.dx, y: s.dy };
2265
- const back = { x: e.dx, y: e.dy };
2266
- const across = out.x !== 0;
2267
- const ahead = (p, q, d) => (q.x - p.x) * d.x + (q.y - p.y) * d.y;
2268
- if (across === (back.x !== 0)) {
2269
- const facing = out.x * back.x + out.y * back.y < 0;
2270
- if (!facing || ahead(start, end, out) <= 0)
2271
- return undefined;
2272
- if (Math.abs(across ? end.y - start.y : end.x - start.x) < 0.5)
2273
- return [start, end];
2274
- const m = across ? (start.x + end.x) / 2 : (start.y + end.y) / 2;
2275
- return across
2276
- ? [start, { x: m, y: start.y }, { x: m, y: end.y }, end]
2277
- : [start, { x: start.x, y: m }, { x: end.x, y: m }, end];
2278
- }
2279
- const corner = across ? { x: end.x, y: start.y } : { x: start.x, y: end.y };
2280
- if (ahead(start, corner, out) > 0 && ahead(end, corner, back) > 0)
2281
- return [start, corner, end];
2282
- return undefined;
2283
- }
2284
- /**
2285
- * Where a line's text rides: the middle of its longest piece on which the text
2286
- * touches no box, or of its longest piece if it touches one on every piece. The
2287
- * text never moves the line — it fits itself onto the line it is given.
2288
- */
2289
- function textOnWay(points, edge, nodes, measurer, fontSize) {
2290
- const pieces = [];
2291
- for (let index = 0; index + 1 < points.length; index += 1) {
2292
- const [p, q] = [points[index], points[index + 1]];
2293
- pieces.push({ mid: { x: (p.x + q.x) / 2, y: (p.y + q.y) / 2 }, length: Math.hypot(q.x - p.x, q.y - p.y) });
2294
- }
2295
- // Longest first, and of two the same length, the earlier.
2296
- pieces.sort((p, q) => q.length - p.length > 0.5 ? 1 : p.length - q.length > 0.5 ? -1 : 0);
2297
- if (edge.lines === undefined)
2298
- return pieces[0].mid;
2299
- const half = { x: laneExtent(edge, 'x', measurer, fontSize) / 2, y: laneExtent(edge, 'y', measurer, fontSize) / 2 };
2300
- const clear = pieces.find(({ mid }) => nodes.every((node) => {
2301
- const box = extentOfBox(faceOf(node));
2302
- // A container the text sits inside is not something it lands on.
2303
- const holds = box.minX < mid.x && mid.x < box.maxX && box.minY < mid.y && mid.y < box.maxY &&
2304
- node.children.length > 0;
2305
- return holds ||
2306
- mid.x + half.x <= box.minX || mid.x - half.x >= box.maxX ||
2307
- mid.y + half.y <= box.minY || mid.y - half.y >= box.maxY;
2308
- }));
2309
- return (clear ?? pieces[0]).mid;
2310
- }
2311
- /** How much a turn costs the search, in the same units as length. */
2312
- const SEARCH_TURN = SEPARATION_GAP * 2;
2313
- /** How many crossing pairs, per line placed, may be placed again the other way round. */
2314
- const UNCROSS_TRIES = 1;
2315
- /**
2316
- * The searched way for one edge, or one half of a `between` edge. A named side
2317
- * is fixed; an end with no side named may leave or arrive by any of its box's
2318
- * four, at that side's middle, and takes whichever makes the way cheapest. An
2319
- * end with no node is a point in a gap, heading along it, and is fixed too.
2320
- *
2321
- * The edge's clauses are walls like the boxes: each fences off the region
2322
- * `fenceOf` gives. An end starting inside a fence — a line leaving M's left
2323
- * side with `below m` — crosses its margin on the way out, as it crosses its
2324
- * own box's. An end heading into the fenced side — M's top with `below m` —
2325
- * gets a stub's room before the fence begins, so it leaves, steps round, and
2326
- * comes back below.
2327
- */
2328
- function searchWay(edge, nodes, start, startNode, end, endNode, taken, lane, used = [], more = {}) {
2329
- const holders = (node) => node ? nodes.filter((other) => contains(other, node)) : [];
2330
- const walls = new Map();
2331
- for (const node of nodes) {
2332
- // A container holding an end — one or both — is crossed, or lived inside,
2333
- // not gone round. The line's own boxes are walls, a loop back to one box
2334
- // included.
2335
- if (node !== edge.from && node !== edge.to && (contains(node, edge.from) || contains(node, edge.to)))
2336
- continue;
2337
- walls.set(node, wallOf(grow(extentOfBox(faceOf(node)), ATTACH_MARGIN)));
2338
- }
2339
- const ownOf = (node) => holders(node).map((holder) => walls.get(holder)).filter((wall) => wall !== undefined);
2340
- // An end with no side named takes a side's middle, or the nearest point
2341
- // along it a step clear of where another line already meets that side.
2342
- const choices = (anchor, node) => anchor.side !== undefined || !node
2343
- ? [anchor]
2344
- : ATTACH_SIDES.map((side) => {
2345
- const face = faceOf(node);
2346
- const center = centerOf(face);
2347
- const level = side === 'top' || side === 'bottom';
2348
- const middle = level ? center.x : center.y;
2349
- const out = endOf(anchorOn(face, side, middle));
2350
- const others = used
2351
- .filter((one) => one.node === node)
2352
- .map((one) => ({ at: one.anchor, heading: endOf(one.anchor) }))
2353
- .filter(({ heading }) => heading.dx === out.dx && heading.dy === out.dy)
2354
- .map(({ at }) => (level ? at.x : at.y));
2355
- const [lo, hi] = level
2356
- ? [face.x + ATTACH_MARGIN, face.x + face.width - ATTACH_MARGIN]
2357
- : [face.y + ATTACH_MARGIN, face.y + face.height - ATTACH_MARGIN];
2358
- const unused = (at) => others.every((other) => Math.abs(other - at) >= ATTACH_STEP - 0.5);
2359
- let at = middle;
2360
- for (let step = 1; !unused(at) && step <= 8; step += 1) {
2361
- const candidates = [middle + step * ATTACH_STEP, middle - step * ATTACH_STEP]
2362
- .filter((one) => one >= lo && one <= hi && unused(one));
2363
- if (candidates[0] !== undefined)
2364
- at = candidates[0];
2365
- }
2366
- return anchorOn(face, side, at);
2367
- });
2368
- const stub = ROUTE_STUB;
2369
- const clear = SEPARATION_GAP - ATTACH_MARGIN;
2370
- // Which way each fence runs off to the edge of the page.
2371
- const far = { below: { x: 0, y: -1 }, above: { x: 0, y: 1 }, left: { x: 1, y: 0 }, right: { x: -1, y: 0 } };
2372
- let best;
2373
- for (const from of choices(start, startNode)) {
2374
- for (const to of choices(end, endNode)) {
2375
- const s = endOf(from);
2376
- const e = endOf(to);
2377
- const ownStart = ownOf(startNode);
2378
- const ownEnd = ownOf(endNode);
2379
- const fences = [];
2380
- for (const pass of edge.passes ?? []) {
2381
- const fence = wallOf(fenceOf(pass, ATTACH_MARGIN));
2382
- const away = far[pass.direction];
2383
- let inStart = false;
2384
- let inEnd = false;
2385
- for (const [point, own] of [[s, ownStart], [e, ownEnd]]) {
2386
- if (!within(point, fence))
2387
- continue;
2388
- if (point.dx === away.x && point.dy === away.y) {
2389
- const room = stub + clear;
2390
- if (away.y < 0)
2391
- fence.maxY = Math.min(fence.maxY, point.y - room);
2392
- if (away.y > 0)
2393
- fence.minY = Math.max(fence.minY, point.y + room);
2394
- if (away.x > 0)
2395
- fence.minX = Math.max(fence.minX, point.x + room);
2396
- if (away.x < 0)
2397
- fence.maxX = Math.min(fence.maxX, point.x - room);
2398
- }
2399
- else if (own === ownStart) {
2400
- inStart = true;
2401
- }
2402
- else {
2403
- inEnd = true;
2404
- }
2405
- }
2406
- fences.push(fence);
2407
- if (inStart)
2408
- ownStart.push(fence);
2409
- if (inEnd)
2410
- ownEnd.push(fence);
2411
- }
2412
- const found = searchRoute(s, e, [...walls.values(), ...fences], {
2413
- stub,
2414
- clear,
2415
- turn: SEARCH_TURN,
2416
- ownStart,
2417
- ownEnd,
2418
- taken,
2419
- lane,
2420
- ...more,
2421
- // Only a way cheaper than the best so far is taken, so a search that
2422
- // cannot find one stops early.
2423
- limit: best ? best.cost - 1e-6 : Infinity,
2424
- bounds: wallOf(grow(nodes.map((node) => extentOfBox(faceOf(node))).reduce(union), ROUTE_STUB * 4)),
2425
- });
2426
- if (found && (!best || found.cost < best.cost - 1e-6))
2427
- best = { start: from, end: to, ...found };
2428
- }
2429
- }
2430
- return best;
2431
- }
2432
- /** An end for the search, heading straight out of its side: a circle's point off its compass point still leaves square to the side. */
2433
- function endOf(anchor) {
2434
- const outward = {
2435
- top: { x: 0, y: -1 },
2436
- bottom: { x: 0, y: 1 },
2437
- left: { x: -1, y: 0 },
2438
- right: { x: 1, y: 0 },
2439
- };
2440
- const t = anchor.side !== undefined ? outward[anchor.side] : { x: Math.sign(anchor.tx), y: Math.sign(anchor.ty) };
2441
- return { x: anchor.x, y: anchor.y, dx: t.x, dy: t.y };
2442
- }
2443
- function wallOf(extent) {
2444
- return { minX: extent.minX, minY: extent.minY, maxX: extent.maxX, maxY: extent.maxY };
2445
- }
2446
- /**
2447
- * Why an edge has no way through, naming the box in the way where one box
2448
- * explains it: a box against the side a line has to leave or arrive by.
2449
- */
2450
- function blockedMessage(edge, { start, end }, nodes) {
2451
- const subject = `edge ${edge.from.name} -> ${edge.to.name}`;
2452
- const against = (anchor, own) => {
2453
- if (anchor.side === undefined)
2454
- return undefined;
2455
- // Room to get out is room for both boxes' margins; anything closer is against it.
2456
- const room = ATTACH_MARGIN * 2;
2457
- const tip = { x: anchor.x + anchor.tx * room, y: anchor.y + anchor.ty * room };
2458
- return nodes.find((node) => !contains(node, own) && segmentHits(anchor, tip, extentOfBox(faceOf(node))));
2459
- };
2460
- const first = against(start, edge.from);
2461
- if (first)
2462
- return `${subject}: cannot leave ${edge.from.name}'s ${start.side} side, ${first.name} is against it`;
2463
- const last = against(end, edge.to);
2464
- if (last)
2465
- return `${subject}: cannot arrive at ${edge.to.name}'s ${end.side} side, ${last.name} is against it`;
2466
- return `${subject}: every way from ${edge.from.name} to ${edge.to.name} passes through another box. Give them room, or name other sides`;
2467
- }
2468
- /**
2469
- * Whether the single curve an edge would otherwise be drawn as passes through
2470
- * a box: either of its own, or any other that holds neither end. Measured on
2471
- * the curve itself, so the answer is the picture's and not a rule about which
2472
- * sides were named.
2473
- *
2474
- * Only passing through counts. Also counting a curve that merely comes close,
2475
- * or whose text touches a node, was tried (2026-09-27) and sent nearly every
2476
- * such edge the long way round with square corners, which the user rejected
2477
- * outright.
2478
- */
2479
- function curveHits(edge, ends, boxes) {
2480
- return !wayClear(curveTrace(edge, ends), boxes);
2481
- }
2482
- /** The curve an edge with a named side is drawn as, as points close enough together to test against boxes. */
2483
- function curveTrace(edge, ends) {
2484
- const curve = sideCurve(edge, ends);
2485
- const { p0, c1, c2, p3 } = curve;
2486
- const at = (t) => {
2487
- const u = 1 - t;
2488
- return {
2489
- x: u * u * u * p0.x + 3 * u * u * t * c1.x + 3 * u * t * t * c2.x + t * t * t * p3.x,
2490
- y: u * u * u * p0.y + 3 * u * u * t * c1.y + 3 * u * t * t * c2.y + t * t * t * p3.y,
2491
- };
2492
- };
2493
- const steps = 32;
2494
- return [curve.start, ...Array.from({ length: steps + 1 }, (_, step) => at(step / steps)), curve.end];
2495
- }
2496
- /** Whether a point lies strictly inside a wall. */
2497
- function within(point, wall) {
2498
- return point.x > wall.minX && point.x < wall.maxX && point.y > wall.minY && point.y < wall.maxY;
2499
- }
2500
- /**
2501
- * The curve a line with a named side is drawn as. Its handles leave along each
2502
- * side's outward normal. At an end carrying an arrowhead the curve stops an
2503
- * arrowhead's length out and the line finishes straight, so the head sits on a
2504
- * straight piece pointing square into the side rather than on a line still
2505
- * bending.
2506
- */
2507
- function sideCurve(edge, { start, end, bow }) {
2508
- const head = arrowLength(edge.look.thickness);
2509
- const tip = (anchor, arrowed) => arrowed && anchor.side !== undefined
2510
- ? { x: anchor.x + anchor.tx * head, y: anchor.y + anchor.ty * head }
2511
- : { x: anchor.x, y: anchor.y };
2512
- const p0 = tip(start, edge.marks.from !== 'none');
2513
- const p3 = tip(end, edge.marks.to !== 'none');
2514
- const reach = controlReach(start, end);
2515
- // A bundle whose sides were too short to spread it takes the rest of the
2516
- // room in the middle — see `bowBundles`. Displacing both control points
2517
- // equally moves the curve's middle by three quarters as much, so the bow is
2518
- // scaled up by the inverse of that.
2519
- const lift = 4 / 3;
2520
- const bx = (bow?.x ?? 0) * lift;
2521
- const by = (bow?.y ?? 0) * lift;
2522
- return {
2523
- start,
2524
- p0,
2525
- c1: { x: start.x + start.tx * reach + bx, y: start.y + start.ty * reach + by },
2526
- c2: { x: end.x + end.tx * reach + bx, y: end.y + end.ty * reach + by },
2527
- p3,
2528
- end,
2529
- };
2530
- }
2531
- /** The most a route's corner is rounded by. */
2532
- const ROUTE_RADIUS = 20;
2533
- /**
2534
- * How far a route leaves its side before its first turn: a full corner, and the
2535
- * arrowhead's length on top so the head lands on a straight piece of line.
2536
- */
2537
- const ROUTE_STUB = ROUTE_RADIUS + ARROW_LENGTH;
2538
- /** The narrowest gap a route will cross over in, between two nodes it passes on opposite sides. */
2539
- const CROSSING_ROOM = ATTACH_MARGIN * 2;
2540
- /** Which axis a clause's side sits on: above and below are a matter of y. */
2541
- function sideAxis(pass) {
2542
- return pass.direction === 'above' || pass.direction === 'below' ? 'y' : 'x';
2543
- }
2544
- /** "below", "left of" — the side as the author would say it. */
2545
- function sideWord(pass) {
2546
- return pass.direction === 'above' || pass.direction === 'below'
2547
- ? pass.direction
2548
- : `${pass.direction} of`;
2549
- }
2550
- /** The box that just bounds several. */
2551
- function boundingBox(boxes) {
2552
- const x = Math.min(...boxes.map((box) => box.x));
2553
- const y = Math.min(...boxes.map((box) => box.y));
2554
- return {
2555
- x,
2556
- y,
2557
- width: Math.max(...boxes.map((box) => box.x + box.width)) - x,
2558
- height: Math.max(...boxes.map((box) => box.y + box.height)) - y,
2559
- };
2560
- }
2561
- /** `"a"`, `"a" and "b"` — for error messages. */
2562
- function quoteNames(nodes) {
2563
- const quoted = nodes.map((node) => `"${node.name}"`);
2564
- return quoted.length <= 1
2565
- ? (quoted[0] ?? '')
2566
- : `${quoted.slice(0, -1).join(', ')} and ${quoted[quoted.length - 1]}`;
2567
- }
2568
- /** Drop repeated points and ones partway along a straight piece, which are not corners. */
2569
- function tidyRoute(points) {
2570
- const kept = [];
2571
- for (const point of points) {
2572
- const previous = kept[kept.length - 1];
2573
- if (previous && Math.hypot(point.x - previous.x, point.y - previous.y) < 0.5)
2574
- continue;
2575
- const before = kept[kept.length - 2];
2576
- if (before && previous &&
2577
- Math.abs((previous.x - before.x) * (point.y - previous.y) - (previous.y - before.y) * (point.x - previous.x)) < 1e-6 &&
2578
- (previous.x - before.x) * (point.x - previous.x) + (previous.y - before.y) * (point.y - previous.y) >= 0) {
2579
- kept[kept.length - 1] = point;
2580
- continue;
2581
- }
2582
- kept.push(point);
2583
- }
2584
- return kept;
2585
- }
2586
- /**
2587
- * A line through `points` with every corner rounded. A corner takes at most
2588
- * half of each piece it shares with a neighboring corner, and the whole of a
2589
- * piece at either end short of the arrowhead, so two corners never overlap and
2590
- * the head always lands on a straight piece.
2591
- */
2592
- function roundedPath(points, arrow = ARROW_LENGTH) {
2593
- const parts = [`M ${round(points[0].x)} ${round(points[0].y)}`];
2594
- const kappa = 0.5523; // a cubic's handle, as a share of the radius, for a quarter circle
2595
- for (let index = 1; index + 1 < points.length; index += 1) {
2596
- const [before, corner, after] = [points[index - 1], points[index], points[index + 1]];
2597
- const inward = Math.hypot(corner.x - before.x, corner.y - before.y);
2598
- const outward = Math.hypot(after.x - corner.x, after.y - corner.y);
2599
- const radius = Math.max(0, Math.min(ROUTE_RADIUS, index === 1 ? inward - arrow : inward / 2, index + 2 === points.length ? outward - arrow : outward / 2));
2600
- const din = { x: (corner.x - before.x) / inward, y: (corner.y - before.y) / inward };
2601
- const dout = { x: (after.x - corner.x) / outward, y: (after.y - corner.y) / outward };
2602
- const enter = { x: corner.x - din.x * radius, y: corner.y - din.y * radius };
2603
- const leave = { x: corner.x + dout.x * radius, y: corner.y + dout.y * radius };
2604
- parts.push(`L ${round(enter.x)} ${round(enter.y)}`, `C ${round(enter.x + din.x * radius * kappa)} ${round(enter.y + din.y * radius * kappa)}, ` +
2605
- `${round(leave.x - dout.x * radius * kappa)} ${round(leave.y - dout.y * radius * kappa)}, ` +
2606
- `${round(leave.x)} ${round(leave.y)}`);
2607
- }
2608
- const last = points[points.length - 1];
2609
- parts.push(`L ${round(last.x)} ${round(last.y)}`);
2610
- return parts.join(' ');
2611
- }
2612
- /** The furthest a sweep reaches along a piece from the turn it rounds. */
2613
- const SWEEP_REACH = 120;
2614
- /** The nearest a sweep comes to another line, except where the two cross. */
2615
- const SWEEP_AIR = 5;
2616
- /**
2617
- * A line through `points` that sweeps through its turns rather than cornering.
2618
- *
2619
- * A jog — a turn one way and straight back the other, as a line stepping into
2620
- * a gap does — is one S from well back along the piece before it to well along
2621
- * the piece after, the middle piece taken up entirely. Any other turn is a
2622
- * quarter sweep, starting as far back along the piece coming in and ending as
2623
- * far along the piece going out as the room allows. A turn may take all of an
2624
- * end piece short of the arrowhead, and half of a piece it shares with the next
2625
- * turn, so a U under a row is two sweeps meeting in its middle.
2626
- *
2627
- * Neither may cut into a box or come near another line; each is tried at its
2628
- * widest and shrunk until clear. A jog that cannot clear as one S is swept as
2629
- * two turns, and a turn at its smallest is the rounded corner a square line
2630
- * has. So where the room is tight the line looks nearly square, and where there
2631
- * is room it flows.
2632
- */
2633
- function sweptPath(points, boxes, neighbors, arrow, marks) {
2634
- const kappa = 0.5523;
2635
- const count = points.length;
2636
- const legLength = (leg) => Math.hypot(points[leg + 1].x - points[leg].x, points[leg + 1].y - points[leg].y);
2637
- const heading = (leg) => {
2638
- const length = legLength(leg);
2639
- return { x: (points[leg + 1].x - points[leg].x) / length, y: (points[leg + 1].y - points[leg].y) / length };
2640
- };
2641
- // How much of a piece one of its turns may take.
2642
- const room = (leg) => {
2643
- const length = legLength(leg);
2644
- if (leg === 0)
2645
- return Math.max(0, length - (marks.from !== 'none' ? arrow : 0));
2646
- if (leg === count - 2)
2647
- return Math.max(0, length - (marks.to !== 'none' ? arrow : 0));
2648
- return length / 2;
2649
- };
2650
- const cubicAt = (p0, c1, c2, p3, t) => {
2651
- const u = 1 - t;
2652
- return {
2653
- x: u * u * u * p0.x + 3 * u * u * t * c1.x + 3 * u * t * t * c2.x + t * t * t * p3.x,
2654
- y: u * u * u * p0.y + 3 * u * u * t * c1.y + 3 * u * t * t * c2.y + t * t * t * p3.y,
2655
- };
2656
- };
2657
- const distance = (p, a, b) => {
2658
- const dx = b.x - a.x;
2659
- const dy = b.y - a.y;
2660
- const squared = dx * dx + dy * dy;
2661
- const t = squared === 0 ? 0 : Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / squared));
2662
- return Math.hypot(p.x - (a.x + dx * t), p.y - (a.y + dy * t));
2663
- };
2664
- // Whether a curve keeps out of every box and clear of every other line.
2665
- const clear = (p0, c1, c2, p3) => {
2666
- const steps = 24;
2667
- const trace = Array.from({ length: steps + 1 }, (_, step) => cubicAt(p0, c1, c2, p3, step / steps));
2668
- for (const point of trace) {
2669
- if (boxes.some((box) => point.x > box.minX && point.x < box.maxX && point.y > box.minY && point.y < box.maxY)) {
2670
- return false;
2671
- }
2672
- }
2673
- // A piece of another line wholly beyond the curve's reach, and its air,
2674
- // can neither cross it nor come near it.
2675
- const reach = {
2676
- minX: Math.min(...trace.map((point) => point.x)) - SWEEP_AIR,
2677
- maxX: Math.max(...trace.map((point) => point.x)) + SWEEP_AIR,
2678
- minY: Math.min(...trace.map((point) => point.y)) - SWEEP_AIR,
2679
- maxY: Math.max(...trace.map((point) => point.y)) + SWEEP_AIR,
2680
- };
2681
- for (const line of neighbors) {
2682
- for (let index = 0; index + 1 < line.length; index += 1) {
2683
- const [p, q] = [line[index], line[index + 1]];
2684
- if (Math.max(p.x, q.x) < reach.minX || Math.min(p.x, q.x) > reach.maxX)
2685
- continue;
2686
- if (Math.max(p.y, q.y) < reach.minY || Math.min(p.y, q.y) > reach.maxY)
2687
- continue;
2688
- for (let step = 0; step < steps; step += 1) {
2689
- if (intersect(trace[step], trace[step + 1], p, q))
2690
- return false;
2691
- }
2692
- // The ends of the sweep sit on the line's own way, which already keeps
2693
- // its lane; only the part cut inside the turn is held off.
2694
- if (trace.slice(1, -1).some((point) => distance(point, p, q) < SWEEP_AIR))
2695
- return false;
2696
- }
2697
- }
2698
- return true;
2699
- };
2700
- const along = (from, direction, by) => ({
2701
- x: from.x + direction.x * by,
2702
- y: from.y + direction.y * by,
2703
- });
2704
- const parts = [`M ${round(points[0].x)} ${round(points[0].y)}`];
2705
- const curve = (p0, c1, c2, p3) => {
2706
- parts.push(`L ${round(p0.x)} ${round(p0.y)}`, `C ${round(c1.x)} ${round(c1.y)}, ${round(c2.x)} ${round(c2.y)}, ${round(p3.x)} ${round(p3.y)}`);
2707
- };
2708
- for (let index = 1; index + 1 < count; index += 1) {
2709
- const din = heading(index - 1);
2710
- const dout = heading(index);
2711
- const wantIn = Math.min(SWEEP_REACH, room(index - 1));
2712
- // A jog: this turn and the next go opposite ways, so the line comes out
2713
- // heading the way it went in.
2714
- if (index + 2 < count) {
2715
- const dafter = heading(index + 1);
2716
- if (Math.abs(dafter.x - din.x) < 1e-6 && Math.abs(dafter.y - din.y) < 1e-6) {
2717
- const wantOut = Math.min(SWEEP_REACH, room(index + 1));
2718
- // The two ends shrink separately, since a neighbor often crowds only
2719
- // one of them, and the widest S that clears wins.
2720
- const sizes = (want) => {
2721
- const found = [];
2722
- for (let size = want; size >= ROUTE_RADIUS; size *= 0.8)
2723
- found.push(size);
2724
- return found;
2725
- };
2726
- const tries = sizes(wantIn)
2727
- .flatMap((a) => sizes(wantOut).map((b) => [a, b]))
2728
- .sort((p, q) => q[0] + q[1] - (p[0] + p[1]));
2729
- const jog = tries
2730
- .map(([a, b]) => {
2731
- const p0 = along(points[index], din, -a);
2732
- const p3 = along(points[index + 1], din, b);
2733
- const run = (p3.x - p0.x) * din.x + (p3.y - p0.y) * din.y;
2734
- return [p0, along(p0, din, run / 2), along(p3, din, -run / 2), p3];
2735
- })
2736
- .find((piece) => clear(...piece));
2737
- if (jog) {
2738
- curve(...jog);
2739
- index += 1;
2740
- continue;
2741
- }
2742
- }
2743
- }
2744
- const wantOut = Math.min(SWEEP_REACH, room(index));
2745
- // The smallest a sweep gets: a square line's rounded corner.
2746
- const least = Math.max(0, Math.min(ROUTE_RADIUS, wantIn, wantOut));
2747
- let a = Math.max(0, wantIn);
2748
- let b = Math.max(0, wantOut);
2749
- const corner = points[index];
2750
- const quarter = (a, b) => {
2751
- const p0 = along(corner, din, -a);
2752
- const p3 = along(corner, dout, b);
2753
- return [p0, along(p0, din, a * kappa), along(p3, dout, -b * kappa), p3];
2754
- };
2755
- // Within half a pixel of the smallest is the smallest: where an end piece
2756
- // has no room left, the smallest is zero, which shrinking never reaches,
2757
- // and twenty-two lines into one side took the page down.
2758
- const shrink = (size) => (size * 0.85 < least + 0.5 ? least : size * 0.85);
2759
- while ((a > least || b > least) && !clear(...quarter(a, b))) {
2760
- a = shrink(a);
2761
- b = shrink(b);
2762
- }
2763
- curve(...quarter(a, b));
2764
- }
2765
- const last = points[count - 1];
2766
- parts.push(`L ${round(last.x)} ${round(last.y)}`);
2767
- return parts.join(' ');
2768
- }
2769
- /**
2770
- * What a sweep may not cut into: every box that holds neither end, with the
2771
- * margin a line keeps from it, and the line's own two shrunk a pixel, since it
2772
- * starts and ends on their sides.
2773
- */
2774
- function sweepObstacles(edge, nodes) {
2775
- return [
2776
- ...[edge.from, edge.to]
2777
- .filter((node) => !faceOf(node).round)
2778
- .map((node) => grow(extentOfBox(faceOf(node)), -1)),
2779
- ...nodes
2780
- .filter((node) => !contains(node, edge.from) && !contains(node, edge.to))
2781
- .map((node) => grow(extentOfBox(faceOf(node)), ATTACH_MARGIN)),
2782
- ];
2783
- }
2784
- function lo(box, axis) {
2785
- return axis === 'x' ? box.x : box.y;
2786
- }
2787
- function hi(box, axis) {
2788
- return axis === 'x' ? box.x + box.width : box.y + box.height;
2789
- }
2790
- /** Whether `inner` is `outer` or sits somewhere inside it. */
2791
- function contains(outer, inner) {
2792
- for (let node = inner; node; node = node.parent) {
2793
- if (node === outer)
2794
- return true;
2795
- }
2796
- return false;
2797
- }
2798
- /**
2799
- * How much room an edge's text takes across the corridor — its depth in a
2800
- * horizontal channel, its width in a vertical one. Zero for an edge with no text,
2801
- * which needs no more than the arrow spacing.
2802
- *
2803
- * `textExtent` measures the knockout along whichever axis it is handed, and the
2804
- * axis wanted here is the one the channel is measured on rather than the one the
2805
- * edge runs along — a channel measured vertically carries edges running
2806
- * horizontally, and what has to fit between two lanes of it is a text's depth.
2807
- */
2808
- function laneExtent(edge, axis, measurer, fontSize) {
2809
- if (edge.lines === undefined)
2810
- return 0;
2811
- return textExtent(edge.lines, edge.textAttrs, axis, measurer, fontSize, edge.line);
2812
- }
2813
- /** Where a line crosses the line across its gate, inside the gap: where its text rides. */
2814
- function gateCrossing(points, gate) {
2815
- for (let index = 0; index + 1 < points.length; index += 1) {
2816
- const [p, q] = [points[index], points[index + 1]];
2817
- const [a, b, level] = gate.run === 'x' ? [p.x, q.x, p.y] : [p.y, q.y, p.x];
2818
- const along = gate.run === 'x' ? Math.abs(p.y - q.y) < 0.5 : Math.abs(p.x - q.x) < 0.5;
2819
- if (along && level > gate.lo && level < gate.hi && Math.min(a, b) <= gate.at + 0.01 && Math.max(a, b) >= gate.at - 0.01) {
2820
- return gate.run === 'x' ? { x: gate.at, y: level } : { x: level, y: gate.at };
2821
- }
2822
- }
2823
- return points[Math.floor(points.length / 2)];
2824
- }
2825
- function sideAttr(edge, key) {
2826
- const value = edge.attrs[key];
2827
- if (value === undefined)
2828
- return impliedSide(edge, key);
2829
- if (!ATTACH_SIDES.includes(value)) {
2830
- throw new SourceError(`"${key}: ${value}" is not a side — use ${ATTACH_SIDES.join(', ')}`, edge.line);
2831
- }
2832
- return value;
2833
- }
2834
- /**
2835
- * The side a square line leaves or arrives on when its author named none.
2836
- *
2837
- * A curved or straight line with no sides aims straight at the other box, but a
2838
- * square one can only leave a side head-on, so it has to pick one. Boxes that
2839
- * share a column join top to bottom and boxes that share a row join side to
2840
- * side; otherwise the line goes across first and then down, the tie the mixed
2841
- * passes settled. An end whose partner did name a side keeps the line to one
2842
- * turn where it can. Everything after this treats the side as though it had
2843
- * been written, so the ends are spread and bundled like any named side.
2844
- *
2845
- * An edge through a `between` gap or past a named node is routed by its clauses,
2846
- * and one from a node to itself or to its own container has no outside to pick
2847
- * from, so none of those is given a side here.
2848
- */
2849
- function impliedSide(edge, key) {
2850
- if (edge.look.path !== 'square' || edge.between || edge.passes)
2851
- return undefined;
2852
- if (contains(edge.from, edge.to) || contains(edge.to, edge.from))
2853
- return undefined;
2854
- const own = faceOf(key === 'from' ? edge.from : edge.to);
2855
- const other = faceOf(key === 'from' ? edge.to : edge.from);
2856
- const partnerKey = key === 'from' ? 'to' : 'from';
2857
- const partner = edge.attrs[partnerKey];
2858
- const overlapX = own.x < other.x + other.width && other.x < own.x + own.width;
2859
- const overlapY = own.y < other.y + other.height && other.y < own.y + own.height;
2860
- const across = centerOf(other).x >= centerOf(own).x ? 'right' : 'left';
2861
- const upDown = centerOf(other).y >= centerOf(own).y ? 'bottom' : 'top';
2862
- if (partner !== undefined && ATTACH_SIDES.includes(partner)) {
2863
- // The partner's side is fixed, so take whichever side of this box joins it
2864
- // in the fewest turns. The order breaks ties the way the rest of this does.
2865
- const theirs = anchorOn(other, partner, partner === 'top' || partner === 'bottom' ? centerOf(other).x : centerOf(other).y);
2866
- const order = overlapX && !overlapY ? [upDown, across] : [across, upDown];
2867
- const sides = [...order, ...ATTACH_SIDES.filter((side) => !order.includes(side))];
2868
- let best = sides[0];
2869
- let fewest = Infinity;
2870
- for (const side of sides) {
2871
- const mine = anchorOn(own, side, side === 'top' || side === 'bottom' ? centerOf(own).x : centerOf(own).y);
2872
- const [from, to] = key === 'from' ? [mine, theirs] : [theirs, mine];
2873
- const turns = tidyRoute(rightAngles(from, { x: from.tx, y: from.ty }, to, { x: to.tx, y: to.ty }, ROUTE_STUB)).length;
2874
- if (turns < fewest) {
2875
- fewest = turns;
2876
- best = side;
2877
- }
2878
- }
2879
- return best;
2880
- }
2881
- if (overlapX && !overlapY)
2882
- return upDown;
2883
- if (overlapY && !overlapX)
2884
- return across;
2885
- // Across first, then down: the start leaves across and the end is reached down.
2886
- return key === 'from' ? across : upDown;
2887
- }
2888
- /**
2889
- * The shape of each mark, in a 10 by 10 box whose right edge is the end of the
2890
- * line, pointing along it. The marker scales with the stroke, so a unit is 0.7
2891
- * of the line's width and an outline of 1.4 units is as thick as the line.
2892
- *
2893
- * An outlined mark is filled with the page's background so the line running
2894
- * under it does not show through. Over a filled container that is the wrong
2895
- * color inside the outline; nothing has hit it yet.
2896
- */
2897
- function markShape(mark, color, background) {
2898
- const outline = `fill="${background}" stroke="${color}" stroke-width="1.4"`;
2899
- switch (mark) {
2900
- case 'arrow':
2901
- return `<path d="M 0 0 L 10 5 L 0 10 z" fill="${color}"/>`;
2902
- case 'oarrow':
2903
- return `<path d="M 0.7 1.2 L 9.3 5 L 0.7 8.8 z" ${outline}/>`;
2904
- case 'dot':
2905
- return `<circle cx="5" cy="5" r="4" fill="${color}"/>`;
2906
- case 'odot':
2907
- return `<circle cx="5.3" cy="5" r="3.4" ${outline}/>`;
2908
- case 'diamond':
2909
- return `<path d="M 0 5 L 5 1.2 L 10 5 L 5 8.8 z" fill="${color}"/>`;
2910
- case 'odiamond':
2911
- return `<path d="M 0.9 5 L 5 1.9 L 9.1 5 L 5 8.1 z" ${outline}/>`;
2912
- case 'bar':
2913
- return `<rect x="5.5" y="0.5" width="1.4" height="9" fill="${color}"/>`;
2914
- case 'none':
2915
- return '';
2916
- }
2917
- }
2918
- /** One marker, drawn at either end: `auto-start-reverse` turns it round at the start. */
2919
- function markMarker(mark, color, background) {
2920
- return [
2921
- ` <marker id="${markerId(mark, color)}" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="${ARROW_MARKER_WIDTH}" markerHeight="${ARROW_MARKER_WIDTH}" orient="auto-start-reverse">`,
1088
+ ` <marker id="${markerId(mark, color, free)}" viewBox="0 0 10 10" refX="${round && free ? 5 : 9}" refY="5"${size} orient="auto-start-reverse">`,
2922
1089
  ` ${markShape(mark, color, background)}`,
2923
1090
  ' </marker>',
2924
1091
  ].join('\n');
2925
1092
  }
2926
- function markerId(mark, color) {
2927
- return `${mark}-${color.replace(/[^a-zA-Z0-9]/g, '')}`;
1093
+ function markerId(mark, color, free) {
1094
+ return `${mark}-${color.replace(/[^a-zA-Z0-9]/g, '')}${free && (mark === 'dot' || mark === 'odot') ? '-free' : ''}`;
2928
1095
  }
2929
- /** The rectangle actually drawn. Differs from the node box only for a deck. */
1096
+ /** The rectangle drawn, and whether it is round. */
2930
1097
  function faceOf(node) {
2931
1098
  return {
2932
- x: node.x + node.inset,
2933
- y: node.y + node.inset,
2934
- width: node.width - node.inset,
2935
- height: node.height - node.inset,
1099
+ x: node.x,
1100
+ y: node.y,
1101
+ width: node.width,
1102
+ height: node.height,
2936
1103
  round: node.body.kind === 'shape' && node.body.outline === 'circle',
2937
1104
  };
2938
1105
  }
2939
- function centerOf(box) {
2940
- return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
2941
- }
2942
1106
  /**
2943
1107
  * Wrap a block of text in its own size, but only when that differs from the
2944
1108
  * document's — everything at the default size inherits it from the <svg>
@@ -2963,6 +1127,14 @@ function sized(block, size, fontSize) {
2963
1127
  * nothing.
2964
1128
  */
2965
1129
  function textBlock(lines, x, y, lineHeight, fontSize, box, style) {
1130
+ if (style.reads === 'up' || style.reads === 'down') {
1131
+ // `box` is the turned ink, so the level block is the same box on its side.
1132
+ const cx = x + box.x + box.width / 2;
1133
+ const cy = y + box.y + box.height / 2;
1134
+ const level = { x: cx - box.height / 2, y: cy - box.width / 2, width: box.height, height: box.width };
1135
+ const inner = textBlock(lines, 0, 0, lineHeight, fontSize, level, { ...style, reads: undefined });
1136
+ return ` <g transform="rotate(${style.reads === 'up' ? -90 : 90} ${round(cx)} ${round(cy)})">\n${inner}\n </g>`;
1137
+ }
2966
1138
  // `box` is the ink the text occupies, worked out by the resolver — the one
2967
1139
  // place that decides where a text sits, because `hub text` is a placement
2968
1140
  // target and the answer has to be a number before anything is solved.
@@ -3035,6 +1207,32 @@ function lineOf(appearance, theme, fallback) {
3035
1207
  const value = appearance['line'];
3036
1208
  return value === undefined ? fallback : themeColor(value, theme);
3037
1209
  }
1210
+ /**
1211
+ * A container's fill when it states none. A container inside another one is a
1212
+ * step further from the page than the fill it sits on, the same step the theme
1213
+ * takes from the page to a container, so engine inside host reads as a second
1214
+ * backdrop rather than as host showing through. An invisible container is
1215
+ * passed over, since nothing of it is drawn to sit on.
1216
+ */
1217
+ function containerFillOf(node, theme) {
1218
+ let below = node.parent;
1219
+ // A container that draws no fill is not a layer: an invisible one, or a deck that states none.
1220
+ const unfilled = (one) => one.body.kind === 'none' || !holdsContents(one) || (one.appearance['layout'] === 'deck' && one.appearance['fill'] === undefined);
1221
+ while (below !== undefined && unfilled(below))
1222
+ below = below.parent;
1223
+ if (below === undefined)
1224
+ return theme.containerFill;
1225
+ const base = fillOf(below.appearance, theme, containerFillOf(below, theme));
1226
+ const channel = (hex, at) => parseInt(hex.slice(at, at + 2), 16);
1227
+ if (!/^#[0-9a-f]{6}$/i.test(base))
1228
+ return theme.containerFill;
1229
+ let out = '#';
1230
+ for (const at of [1, 3, 5]) {
1231
+ const step = channel(theme.containerFill, at) - channel(theme.background, at);
1232
+ out += Math.max(0, Math.min(255, channel(base, at) + step)).toString(16).padStart(2, '0');
1233
+ }
1234
+ return out;
1235
+ }
3038
1236
  function fillOf(appearance, theme, fallback) {
3039
1237
  const value = appearance['fill'];
3040
1238
  return value === undefined ? fallback : themeColor(value, theme);