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/README.md +11 -7
- package/SYNTAX.md +810 -680
- package/dist/ast.d.ts +342 -18
- package/dist/ast.js +138 -27
- package/dist/checks.d.ts +58 -0
- package/dist/checks.js +891 -0
- package/dist/cli.js +88 -12
- package/dist/constants.d.ts +15 -55
- package/dist/constants.js +30 -78
- package/dist/grammar.d.ts +2 -2
- package/dist/grammar.js +70 -10
- package/dist/index.d.ts +20 -0
- package/dist/index.js +18 -1
- package/dist/lines.d.ts +96 -0
- package/dist/lines.js +1968 -0
- package/dist/linetexts.d.ts +44 -0
- package/dist/linetexts.js +412 -0
- package/dist/model.d.ts +187 -36
- package/dist/model.js +23 -1
- package/dist/parser.js +1777 -107
- package/dist/place.d.ts +501 -0
- package/dist/place.js +2226 -0
- package/dist/render.d.ts +3 -1
- package/dist/render.js +691 -2493
- package/dist/resolve.d.ts +1 -1
- package/dist/resolve.js +1195 -1789
- package/dist/size.d.ts +64 -0
- package/dist/size.js +222 -0
- package/dist/themes.d.ts +17 -0
- package/dist/themes.js +28 -3
- package/package.json +2 -2
- package/dist/constrain.d.ts +0 -56
- package/dist/constrain.js +0 -95
package/dist/render.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { ARROW_LENGTH, ARROW_MARKER_WIDTH,
|
|
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 {
|
|
7
|
-
import {
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
//
|
|
35
|
-
//
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
|
|
45
|
-
const
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
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
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
|
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
|
-
//
|
|
238
|
-
//
|
|
239
|
-
|
|
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
|
|
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
|
-
//
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
const
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
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
|
-
|
|
273
|
-
|
|
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
|
|
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
|
-
/**
|
|
287
|
-
|
|
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,
|
|
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
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
-
//
|
|
378
|
-
// node bounds know nothing about it.
|
|
379
|
-
let ink = extentOfPoints(
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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 =
|
|
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
|
|
401
|
-
//
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
ink =
|
|
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 (
|
|
420
|
-
|
|
421
|
-
|
|
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
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
const
|
|
451
|
-
const
|
|
452
|
-
const width
|
|
453
|
-
const
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
/**
|
|
796
|
-
function
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
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
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
}
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
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
|
|
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
|
|
859
|
-
* the
|
|
860
|
-
*
|
|
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
|
|
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
|
-
|
|
867
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
906
|
-
*
|
|
907
|
-
*
|
|
908
|
-
*
|
|
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
|
|
918
|
-
const
|
|
919
|
-
const
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
const
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
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
|
-
/**
|
|
942
|
-
function
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
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
|
-
*
|
|
1004
|
-
*
|
|
1005
|
-
*
|
|
1006
|
-
*
|
|
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
|
|
1010
|
-
const
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
const
|
|
1014
|
-
const
|
|
1015
|
-
const
|
|
1016
|
-
const
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
}
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
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
|
|
1052
|
-
|
|
1053
|
-
|
|
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
|
-
*
|
|
1063
|
-
*
|
|
1064
|
-
*
|
|
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
|
-
*
|
|
1073
|
-
*
|
|
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
|
|
1076
|
-
const
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
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
|
-
*
|
|
1157
|
-
*
|
|
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
|
|
1164
|
-
|
|
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
|
-
*
|
|
1233
|
-
*
|
|
1234
|
-
*
|
|
1235
|
-
*
|
|
1236
|
-
*
|
|
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
|
|
1261
|
-
const
|
|
1262
|
-
const
|
|
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
|
-
|
|
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
|
|
1096
|
+
/** The rectangle drawn, and whether it is round. */
|
|
2930
1097
|
function faceOf(node) {
|
|
2931
1098
|
return {
|
|
2932
|
-
x: node.x
|
|
2933
|
-
y: node.y
|
|
2934
|
-
width: node.width
|
|
2935
|
-
height: node.height
|
|
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);
|