reladraw 0.4.0 → 0.6.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/NOTICE +37 -0
- package/README.md +2 -2
- package/SYNTAX.md +164 -7
- package/dist/ast.d.ts +40 -3
- package/dist/ast.js +24 -2
- package/dist/cli.js +18 -2
- package/dist/grammar.d.ts +1 -1
- package/dist/grammar.js +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/model.d.ts +14 -1
- package/dist/parser.js +93 -4
- package/dist/render.d.ts +1 -22
- package/dist/render.js +806 -34
- package/dist/resolve.js +123 -9
- package/dist/themes.d.ts +46 -0
- package/dist/themes.js +190 -0
- package/package.json +1 -1
package/dist/render.js
CHANGED
|
@@ -1,42 +1,27 @@
|
|
|
1
|
-
import { ARROW_MARKER_WIDTH, ATTACH_MARGIN, ATTACH_STEP, DECK_STEP, DEFAULT_FONT_SIZE, ICON_LINES, LINE_WIDTH, PAD, fontSizeFor, textExtent, textStyleFor, widestLine, } from './constants.js';
|
|
1
|
+
import { ARROW_LENGTH, ARROW_MARKER_WIDTH, ATTACH_MARGIN, ATTACH_STEP, DECK_STEP, DEFAULT_FONT_SIZE, ICON_LINES, LINE_WIDTH, PAD, SEPARATION_GAP, fontSizeFor, textExtent, textStyleFor, widestLine, } from './constants.js';
|
|
2
2
|
import { describeAxis } from './ast.js';
|
|
3
3
|
import { SourceError } from './errors.js';
|
|
4
4
|
import { ICON_STROKE } from './icons.js';
|
|
5
5
|
import { monospaceMeasurer } from './measure.js';
|
|
6
|
+
import { DARK_THEME, THEMES } from './themes.js';
|
|
6
7
|
import { plain } from './text.js';
|
|
7
|
-
/**
|
|
8
|
-
* Sampled out of `examples/reference/arch.png` rather than invented,
|
|
9
|
-
* so the benchmark render and the drawing it is measured against differ by
|
|
10
|
-
* geometry and typography alone. A container is a shade off the page and barely
|
|
11
|
-
* outlined; a leaf is the navy that carries the diagram's weight.
|
|
12
|
-
*/
|
|
13
|
-
export const DARK_THEME = {
|
|
14
|
-
background: '#111111',
|
|
15
|
-
boxFill: '#191728',
|
|
16
|
-
boxStroke: '#4f5367',
|
|
17
|
-
containerFill: '#191920',
|
|
18
|
-
containerStroke: '#25242f',
|
|
19
|
-
text: '#d9d9d9',
|
|
20
|
-
mutedText: '#8b8b8b',
|
|
21
|
-
edge: '#5c5c7c',
|
|
22
|
-
// Both sampled off the reference's machine glyphs. Note that the reference
|
|
23
|
-
// gives each icon its own hue — the drive is gray, the laptop periwinkle, the
|
|
24
|
-
// workstation violet — which is a drawing tool's per-shape default and not a
|
|
25
|
-
// system. One pair for the whole set is the deliberate difference: an icon
|
|
26
|
-
// should read as part of the diagram's palette, not as clip art dropped in.
|
|
27
|
-
iconInk: '#8d8d8e',
|
|
28
|
-
iconShade: '#3e3d58',
|
|
29
|
-
};
|
|
30
8
|
const CORNER = 8;
|
|
31
9
|
/** Turn solved geometry into a standalone SVG document. */
|
|
32
10
|
export function render(layout, options = {}) {
|
|
33
11
|
const measurer = options.measurer ?? monospaceMeasurer();
|
|
34
12
|
const fontSize = options.fontSize ?? DEFAULT_FONT_SIZE;
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
13
|
+
// A theme passed in — the command line's `--theme` — beats the one the file
|
|
14
|
+
// names, so one source renders in either. `diagram background:` and `text:`
|
|
15
|
+
// are the author overruling a color of whichever theme that is, and a color
|
|
16
|
+
// written by hand wins over any theme, so they are folded in afterwards and
|
|
17
|
+
// everything downstream sees one theme.
|
|
18
|
+
const named = layout.diagram['theme'];
|
|
19
|
+
const base = options.theme ?? (named === undefined ? undefined : THEMES[named]) ?? DARK_THEME;
|
|
20
|
+
const theme = {
|
|
21
|
+
...base,
|
|
22
|
+
...(layout.diagram['background'] !== undefined && { background: layout.diagram['background'] }),
|
|
23
|
+
...(layout.diagram['text.color'] !== undefined && { text: layout.diagram['text.color'] }),
|
|
24
|
+
};
|
|
40
25
|
const body = [];
|
|
41
26
|
for (const root of layout.roots) {
|
|
42
27
|
body.push(drawNode(root, theme, measurer, fontSize, layout.markup));
|
|
@@ -49,9 +34,11 @@ export function render(layout, options = {}) {
|
|
|
49
34
|
// is ordered by where its ends turned out to be.
|
|
50
35
|
const ends = planEndpoints(layout.edges, measurer, fontSize);
|
|
51
36
|
const corridors = planCorridors(layout.edges, ends, measurer, fontSize);
|
|
37
|
+
const routes = planRoutes(layout.edges, layout.nodes, ends, measurer, fontSize);
|
|
38
|
+
planLoops(layout.edges, layout.nodes, ends, corridors, routes, measurer, fontSize);
|
|
52
39
|
aimFreeEnds(layout.edges, ends, corridors);
|
|
53
40
|
for (const edge of layout.edges) {
|
|
54
|
-
const drawn = drawEdge(edge, ends.get(edge), corridors.get(edge), theme, measurer, fontSize, layout.markup);
|
|
41
|
+
const drawn = drawEdge(edge, ends.get(edge), corridors.get(edge), routes.get(edge), theme, measurer, fontSize, layout.markup);
|
|
55
42
|
body.push(drawn.svg);
|
|
56
43
|
ink = union(ink, grow(drawn.ink, layout.margin));
|
|
57
44
|
}
|
|
@@ -268,7 +255,7 @@ function drawIcon(icon, x, y, side, theme) {
|
|
|
268
255
|
].join('\n');
|
|
269
256
|
}
|
|
270
257
|
// --- edges -------------------------------------------------------------------
|
|
271
|
-
function drawEdge(edge, ends, corridor, theme, measurer, fontSize, markup) {
|
|
258
|
+
function drawEdge(edge, ends, corridor, route, theme, measurer, fontSize, markup) {
|
|
272
259
|
const { start, end } = ends;
|
|
273
260
|
const color = lineOf(edge.appearance, theme.edge);
|
|
274
261
|
const markerEnd = ` marker-end="url(#${markerId(color)})"`;
|
|
@@ -284,7 +271,13 @@ function drawEdge(edge, ends, corridor, theme, measurer, fontSize, markup) {
|
|
|
284
271
|
let ink = extentOfPoints([start, end]);
|
|
285
272
|
let midX;
|
|
286
273
|
let midY;
|
|
287
|
-
if (
|
|
274
|
+
if (route) {
|
|
275
|
+
parts.push(` <path d="${roundedPath(route.points)}" fill="none" stroke="${color}" stroke-width="${LINE_WIDTH}"${markerEnd}${markerStart}/>`);
|
|
276
|
+
ink = union(ink, extentOfPoints(route.points));
|
|
277
|
+
midX = route.mid.x;
|
|
278
|
+
midY = route.mid.y;
|
|
279
|
+
}
|
|
280
|
+
else if (corridor) {
|
|
288
281
|
const path = corridorPath(start, end, corridor);
|
|
289
282
|
ink = union(ink, path.ink);
|
|
290
283
|
parts.push(` <path d="${path.d}" fill="none" stroke="${color}" stroke-width="${LINE_WIDTH}"${markerEnd}${markerStart}/>`);
|
|
@@ -1018,6 +1011,775 @@ function planCorridors(edges, ends, measurer, fontSize) {
|
|
|
1018
1011
|
}
|
|
1019
1012
|
return plans;
|
|
1020
1013
|
}
|
|
1014
|
+
/**
|
|
1015
|
+
* Route every edge that has to go around its own two boxes.
|
|
1016
|
+
*
|
|
1017
|
+
* An edge leaving the left of one box for the right of another, with the second
|
|
1018
|
+
* box further right, has to turn back on itself, and a single curve can only
|
|
1019
|
+
* do that by crossing its own boxes. In a row it flattened into a straight line
|
|
1020
|
+
* through both; stepped down, even by a `normal` gap, it still doubled back
|
|
1021
|
+
* across the first box. So such an edge runs along a channel instead, turning
|
|
1022
|
+
* back at each end: in the gap between its two boxes if that holds the line
|
|
1023
|
+
* and its text, and otherwise over the top of everything between its ends,
|
|
1024
|
+
* with its text on the top, where it cannot land on a box.
|
|
1025
|
+
*
|
|
1026
|
+
* Over the top, always, and round the right for a column. Nothing here weighs
|
|
1027
|
+
* one way round against the other: the shorter way ties in the case that
|
|
1028
|
+
* seemed to argue for it, and a default that flips on one box's height is
|
|
1029
|
+
* harder to predict than one that never does. The run is placed the way a
|
|
1030
|
+
* `between` channel is, measured off where the boxes landed.
|
|
1031
|
+
*/
|
|
1032
|
+
function planLoops(edges, nodes, ends, corridors, routes, measurer, fontSize) {
|
|
1033
|
+
// Loops over the top, gathered so that two sharing a stretch can take a lane
|
|
1034
|
+
// each rather than drawing on top of one another.
|
|
1035
|
+
const tops = [];
|
|
1036
|
+
for (const edge of edges) {
|
|
1037
|
+
if (corridors.has(edge) || edge.passes)
|
|
1038
|
+
continue;
|
|
1039
|
+
const { start, end } = ends.get(edge);
|
|
1040
|
+
if (start.side === undefined || end.side === undefined)
|
|
1041
|
+
continue;
|
|
1042
|
+
// Sides at right angles, one facing away from the other end.
|
|
1043
|
+
if (start.tx * end.tx + start.ty * end.ty === 0) {
|
|
1044
|
+
const inWay = nodes.filter((node) => !(contains(node, edge.from) && contains(node, edge.to)));
|
|
1045
|
+
const plan = turnBack(edge, start, end, inWay, routes, measurer, fontSize);
|
|
1046
|
+
if (plan)
|
|
1047
|
+
tops.push(plan);
|
|
1048
|
+
continue;
|
|
1049
|
+
}
|
|
1050
|
+
// The two ends point opposite ways along one axis, each away from the other.
|
|
1051
|
+
if (start.tx !== -end.tx || start.ty !== -end.ty)
|
|
1052
|
+
continue;
|
|
1053
|
+
const run = start.tx !== 0 ? 'x' : 'y';
|
|
1054
|
+
const across = run === 'x' ? 'y' : 'x';
|
|
1055
|
+
const outward = run === 'x' ? start.tx : start.ty;
|
|
1056
|
+
if (outward * (end[run] - start[run]) >= 0)
|
|
1057
|
+
continue;
|
|
1058
|
+
const a = faceOf(edge.from);
|
|
1059
|
+
const b = faceOf(edge.to);
|
|
1060
|
+
// Far enough out that the line reads as passing, and that half the text,
|
|
1061
|
+
// centered on the line, still clears the box beside it.
|
|
1062
|
+
const clear = Math.max(SEPARATION_GAP, laneExtent(edge, across, measurer, fontSize) / 2 + ATTACH_MARGIN);
|
|
1063
|
+
const from = Math.min(lo(a, run), lo(b, run));
|
|
1064
|
+
const to = Math.max(hi(a, run), hi(b, run));
|
|
1065
|
+
const inWay = nodes.filter((node) => !(contains(node, edge.from) && contains(node, edge.to)));
|
|
1066
|
+
const blocked = (low, high) => inWay.some((node) => {
|
|
1067
|
+
const face = faceOf(node);
|
|
1068
|
+
return (lo(face, run) < to && hi(face, run) > from &&
|
|
1069
|
+
lo(face, across) < high && hi(face, across) > low);
|
|
1070
|
+
});
|
|
1071
|
+
// Boxes apart across the axis have a gap between them, and a line that
|
|
1072
|
+
// fits in it turns back through that — the shortest way, and the one a
|
|
1073
|
+
// single curve was reaching for.
|
|
1074
|
+
const [upper, lower] = lo(a, across) <= lo(b, across) ? [a, b] : [b, a];
|
|
1075
|
+
const gap = { lo: hi(upper, across), hi: lo(lower, across) };
|
|
1076
|
+
const middle = (gap.lo + gap.hi) / 2;
|
|
1077
|
+
if (gap.hi - gap.lo >= clear * 2 && !blocked(middle - clear, middle + clear)) {
|
|
1078
|
+
corridors.set(edge, {
|
|
1079
|
+
axis: across,
|
|
1080
|
+
lane: middle,
|
|
1081
|
+
enter: start[run],
|
|
1082
|
+
leave: end[run],
|
|
1083
|
+
loop: true,
|
|
1084
|
+
});
|
|
1085
|
+
continue;
|
|
1086
|
+
}
|
|
1087
|
+
// Otherwise over the top. Measured as distance outward — up for a row,
|
|
1088
|
+
// right for a column — so one loop serves both.
|
|
1089
|
+
const sign = across === 'y' ? -1 : 1;
|
|
1090
|
+
const out = (box) => {
|
|
1091
|
+
const [p, q] = [sign * lo(box, across), sign * hi(box, across)];
|
|
1092
|
+
return [Math.min(p, q), Math.max(p, q)];
|
|
1093
|
+
};
|
|
1094
|
+
const inner = Math.min(sign * start[across], sign * end[across]);
|
|
1095
|
+
// A box that shares the stretch and reaches into the band the line needs
|
|
1096
|
+
// pushes the line out past it, which can bring another into the band.
|
|
1097
|
+
const pushOut = (start) => {
|
|
1098
|
+
let lane = start;
|
|
1099
|
+
for (let moved = true; moved;) {
|
|
1100
|
+
moved = false;
|
|
1101
|
+
for (const node of inWay) {
|
|
1102
|
+
const face = faceOf(node);
|
|
1103
|
+
if (lo(face, run) >= to || hi(face, run) <= from)
|
|
1104
|
+
continue;
|
|
1105
|
+
const [near, far] = out(face);
|
|
1106
|
+
if (far + clear > lane && near < lane + clear && far > inner) {
|
|
1107
|
+
lane = far + clear;
|
|
1108
|
+
moved = true;
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
return lane;
|
|
1113
|
+
};
|
|
1114
|
+
tops.push({
|
|
1115
|
+
edge,
|
|
1116
|
+
across,
|
|
1117
|
+
sign,
|
|
1118
|
+
lane: pushOut(Math.max(out(a)[1], out(b)[1]) + clear),
|
|
1119
|
+
loop: true,
|
|
1120
|
+
from,
|
|
1121
|
+
to,
|
|
1122
|
+
height: sign * start[across] + sign * end[across],
|
|
1123
|
+
pushOut,
|
|
1124
|
+
place: (lane) => corridors.set(edge, { axis: across, lane: sign * lane, enter: start[run], leave: end[run], loop: true }),
|
|
1125
|
+
});
|
|
1126
|
+
}
|
|
1127
|
+
// Lines over one stretch nest so they do not cross. A turn-back goes inside
|
|
1128
|
+
// any loop, because it comes in to a box from the side the loops pass over;
|
|
1129
|
+
// otherwise the shorter goes inside, and of two the same length, the one
|
|
1130
|
+
// whose ends sit further out. Each lane is as far from the one inside it as
|
|
1131
|
+
// the lanes of a named gap are.
|
|
1132
|
+
tops.sort((p, q) => Number(p.loop) - Number(q.loop) || p.to - p.from - (q.to - q.from) || q.height - p.height);
|
|
1133
|
+
const placed = [];
|
|
1134
|
+
for (const top of tops) {
|
|
1135
|
+
for (let moved = true; moved;) {
|
|
1136
|
+
moved = false;
|
|
1137
|
+
for (const other of placed) {
|
|
1138
|
+
if (other.across !== top.across || other.sign !== top.sign)
|
|
1139
|
+
continue;
|
|
1140
|
+
if (other.from >= top.to || top.from >= other.to)
|
|
1141
|
+
continue;
|
|
1142
|
+
const step = Math.max(ATTACH_STEP, laneExtent(top.edge, top.across, measurer, fontSize), laneExtent(other.edge, other.across, measurer, fontSize));
|
|
1143
|
+
if (Math.abs(top.lane - other.lane) < step - 0.5) {
|
|
1144
|
+
top.lane = top.pushOut(other.lane + step);
|
|
1145
|
+
moved = true;
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
placed.push(top);
|
|
1150
|
+
top.place(top.lane);
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
/**
|
|
1154
|
+
* A route for an edge whose named sides are at right angles and one of which
|
|
1155
|
+
* faces away from the other end: `from: left to: top` with the far node to
|
|
1156
|
+
* the right. A single curve leaving that side can only turn back across its
|
|
1157
|
+
* own box. So the line steps out of the side facing away, goes out past both
|
|
1158
|
+
* boxes the way the other side faces, runs along there, and comes straight in
|
|
1159
|
+
* to the other side. Undefined when neither side faces away, where the curve
|
|
1160
|
+
* already reads right. The route is recorded in `routes` once its lane is
|
|
1161
|
+
* settled against any other line running outside the same boxes.
|
|
1162
|
+
*/
|
|
1163
|
+
function turnBack(edge, start, end, inWay, routes, measurer, fontSize) {
|
|
1164
|
+
const facesAway = (from, to) => from.tx * (to.x - from.x) + from.ty * (to.y - from.y) < 0;
|
|
1165
|
+
const startAway = facesAway(start, end);
|
|
1166
|
+
if (!startAway && !facesAway(end, start))
|
|
1167
|
+
return undefined;
|
|
1168
|
+
// Planned from the end facing away, and turned round if that is the far end.
|
|
1169
|
+
const [away, other] = startAway ? [start, end] : [end, start];
|
|
1170
|
+
const [awayBox, otherBox] = (startAway ? [edge.from, edge.to] : [edge.to, edge.from]).map(faceOf);
|
|
1171
|
+
const run = away.tx !== 0 ? 'x' : 'y';
|
|
1172
|
+
const across = run === 'x' ? 'y' : 'x';
|
|
1173
|
+
const make = axesAcross(across).make;
|
|
1174
|
+
// The way the other side faces, which is the way the line goes out.
|
|
1175
|
+
const sign = across === 'y' ? other.ty : other.tx;
|
|
1176
|
+
const clear = Math.max(SEPARATION_GAP, laneExtent(edge, across, measurer, fontSize) / 2 + ATTACH_MARGIN);
|
|
1177
|
+
const stub = away[run] + (run === 'x' ? away.tx : away.ty) * ROUTE_STUB;
|
|
1178
|
+
const from = Math.min(stub, other[run], lo(awayBox, run), lo(otherBox, run));
|
|
1179
|
+
const to = Math.max(stub, other[run], hi(awayBox, run), hi(otherBox, run));
|
|
1180
|
+
// Measured as distance outward, the way the line goes.
|
|
1181
|
+
const out = (box) => {
|
|
1182
|
+
const [p, q] = [sign * lo(box, across), sign * hi(box, across)];
|
|
1183
|
+
return [Math.min(p, q), Math.max(p, q)];
|
|
1184
|
+
};
|
|
1185
|
+
const inner = Math.min(sign * away[across], sign * other[across]);
|
|
1186
|
+
const pushOut = (start) => {
|
|
1187
|
+
let level = start;
|
|
1188
|
+
for (let moved = true; moved;) {
|
|
1189
|
+
moved = false;
|
|
1190
|
+
for (const node of inWay) {
|
|
1191
|
+
const face = faceOf(node);
|
|
1192
|
+
if (lo(face, run) >= to || hi(face, run) <= from)
|
|
1193
|
+
continue;
|
|
1194
|
+
const [near, far] = out(face);
|
|
1195
|
+
if (far + clear > level && near < level + clear && far > inner) {
|
|
1196
|
+
level = far + clear;
|
|
1197
|
+
moved = true;
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
return level;
|
|
1202
|
+
};
|
|
1203
|
+
return {
|
|
1204
|
+
edge,
|
|
1205
|
+
across,
|
|
1206
|
+
sign,
|
|
1207
|
+
lane: pushOut(Math.max(out(awayBox)[1], out(otherBox)[1]) + clear),
|
|
1208
|
+
loop: false,
|
|
1209
|
+
from,
|
|
1210
|
+
to,
|
|
1211
|
+
height: sign * away[across] + sign * other[across],
|
|
1212
|
+
pushOut,
|
|
1213
|
+
place: (lane) => {
|
|
1214
|
+
const at = sign * lane;
|
|
1215
|
+
// A shallow turn steps out no further than half its depth, the half
|
|
1216
|
+
// circle a loop's turn makes, so it stays inside any loop turning
|
|
1217
|
+
// round the same box.
|
|
1218
|
+
const outward = run === 'x' ? away.tx : away.ty;
|
|
1219
|
+
const step = away[run] + outward * Math.min(ROUTE_STUB, Math.abs(at - away[across]) / 2);
|
|
1220
|
+
const points = tidyRoute([away, make(step, away[across]), make(step, at), make(other[run], at), other]);
|
|
1221
|
+
// The text rides on the longest piece, which is the run outside the
|
|
1222
|
+
// boxes unless the two ends are nearly level.
|
|
1223
|
+
let mid = make((stub + other[run]) / 2, at);
|
|
1224
|
+
let best = -1;
|
|
1225
|
+
for (let index = 0; index + 1 < points.length; index += 1) {
|
|
1226
|
+
const [p, q] = [points[index], points[index + 1]];
|
|
1227
|
+
const length = Math.hypot(q.x - p.x, q.y - p.y);
|
|
1228
|
+
if (length > best) {
|
|
1229
|
+
best = length;
|
|
1230
|
+
mid = { x: (p.x + q.x) / 2, y: (p.y + q.y) / 2 };
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
routes.set(edge, { points: startAway ? points : points.reverse(), mid });
|
|
1234
|
+
},
|
|
1235
|
+
};
|
|
1236
|
+
}
|
|
1237
|
+
/** The most a route's corner is rounded by. */
|
|
1238
|
+
const ROUTE_RADIUS = 20;
|
|
1239
|
+
/**
|
|
1240
|
+
* How far a route leaves its side before its first turn: a full corner, and the
|
|
1241
|
+
* arrowhead's length on top so the head lands on a straight piece of line.
|
|
1242
|
+
*/
|
|
1243
|
+
const ROUTE_STUB = ROUTE_RADIUS + ARROW_LENGTH;
|
|
1244
|
+
/** The narrowest gap a route will cross over in, between two nodes it passes on opposite sides. */
|
|
1245
|
+
const CROSSING_ROOM = ATTACH_MARGIN * 2;
|
|
1246
|
+
function axesAcross(across) {
|
|
1247
|
+
return across === 'y'
|
|
1248
|
+
? { across, run: 'x', make: (along, level) => ({ x: along, y: level }) }
|
|
1249
|
+
: { across, run: 'y', make: (along, level) => ({ x: level, y: along }) };
|
|
1250
|
+
}
|
|
1251
|
+
/**
|
|
1252
|
+
* Route every edge that says which side of something it passes.
|
|
1253
|
+
*
|
|
1254
|
+
* `below resolver` means that where the line passes Resolver it is below it —
|
|
1255
|
+
* not that the whole line is. So the line is a run of straight stretches along
|
|
1256
|
+
* the way it travels, and each clause binds only the stretch lying alongside
|
|
1257
|
+
* its nodes. A clause naming several nodes binds the stretch alongside all of
|
|
1258
|
+
* them, which is how `below a and b` says "with no rising in between" and two
|
|
1259
|
+
* separate clauses do not. Consecutive stretches that can share one level do;
|
|
1260
|
+
* where they cannot, the line crosses over in the gap between the two sets of
|
|
1261
|
+
* nodes, and if there is no gap, the file is refused rather than drawn through
|
|
1262
|
+
* a box. No clause says an order: the line meets the nodes in the order they
|
|
1263
|
+
* sit along its way.
|
|
1264
|
+
*
|
|
1265
|
+
* Above and below are passed travelling across the page, left and right
|
|
1266
|
+
* travelling up or down it, so an edge naming both kinds turns between such
|
|
1267
|
+
* runs, as few times as keeps every clause. Which comes first is the named
|
|
1268
|
+
* side's to say, and across when no end names one.
|
|
1269
|
+
*
|
|
1270
|
+
* Nothing here moves a node or looks for a path. Every level is read off where
|
|
1271
|
+
* the named nodes landed, as a `between` channel is, and the only other nodes
|
|
1272
|
+
* consulted are ones sitting on a stretch, which push it further the way its
|
|
1273
|
+
* clause already points.
|
|
1274
|
+
*/
|
|
1275
|
+
function planRoutes(edges, nodes, ends, measurer, fontSize) {
|
|
1276
|
+
const routes = new Map();
|
|
1277
|
+
for (const edge of edges) {
|
|
1278
|
+
if (edge.passes)
|
|
1279
|
+
routes.set(edge, planRoute(edge, edge.passes, nodes, ends.get(edge), measurer, fontSize));
|
|
1280
|
+
}
|
|
1281
|
+
return routes;
|
|
1282
|
+
}
|
|
1283
|
+
function planRoute(edge, passes, nodes, { start, end }, measurer, fontSize) {
|
|
1284
|
+
const subject = `edge ${edge.from.name} -> ${edge.to.name}`;
|
|
1285
|
+
const a = faceOf(edge.from);
|
|
1286
|
+
const b = faceOf(edge.to);
|
|
1287
|
+
const named = (anchor, face) => anchor.side === undefined ? centerOf(face) : anchor;
|
|
1288
|
+
// Far enough out that the line reads as passing. The one stretch carrying
|
|
1289
|
+
// the text is held further out, so that half the text, centered on the line,
|
|
1290
|
+
// still clears the box beside it.
|
|
1291
|
+
const clear = SEPARATION_GAP;
|
|
1292
|
+
const inWay = nodes.filter((node) => !(contains(node, edge.from) && contains(node, edge.to)));
|
|
1293
|
+
// Above and below are passed travelling across the page, left and right
|
|
1294
|
+
// travelling up or down it. An edge naming only one kind is one section.
|
|
1295
|
+
const acrossPasses = passes.filter((pass) => sideAxis(pass) === 'y');
|
|
1296
|
+
const downPasses = passes.filter((pass) => sideAxis(pass) === 'x');
|
|
1297
|
+
if (acrossPasses.length === 0 || downPasses.length === 0) {
|
|
1298
|
+
const axes = axesAcross(sideAxis(passes[0]));
|
|
1299
|
+
const travel = Math.sign(named(end, b)[axes.run] - named(start, a)[axes.run]) || 1;
|
|
1300
|
+
const section = cut(axes, passes, reach(start, a, axes), reach(end, b, axes), travel);
|
|
1301
|
+
const carrier = longest(section.stretches);
|
|
1302
|
+
settle(section, (centerOf(a)[axes.across] + centerOf(b)[axes.across]) / 2, carrier);
|
|
1303
|
+
return finish([
|
|
1304
|
+
...approach(start, a, axes, section.stretches[0].level, travel),
|
|
1305
|
+
...crossings(section),
|
|
1306
|
+
...approach(end, b, axes, section.stretches[section.stretches.length - 1].level, -travel).reverse(),
|
|
1307
|
+
], carrier, axes);
|
|
1308
|
+
}
|
|
1309
|
+
// Both kinds: the line turns between travelling across the page and
|
|
1310
|
+
// travelling down it, as often as its clauses need. A side named at an end
|
|
1311
|
+
// says which it does first; with none named, it goes across first. The
|
|
1312
|
+
// fewest turns that keep every clause win, and at each count the other
|
|
1313
|
+
// order is tried before adding a turn. If every attempt fails the same way,
|
|
1314
|
+
// that is the error; if they fail differently, no one of them is the reason
|
|
1315
|
+
// and the error names every clause.
|
|
1316
|
+
const sideways = (anchor) => anchor.side === undefined ? undefined : anchor.side === 'left' || anchor.side === 'right';
|
|
1317
|
+
const acrossFirst = sideways(start) ?? !(sideways(end) ?? false);
|
|
1318
|
+
const refusals = [];
|
|
1319
|
+
for (const count of [2, 3, 4]) {
|
|
1320
|
+
for (const order of [acrossFirst, !acrossFirst]) {
|
|
1321
|
+
try {
|
|
1322
|
+
return turned(order, count);
|
|
1323
|
+
}
|
|
1324
|
+
catch (error) {
|
|
1325
|
+
if (!(error instanceof SourceError))
|
|
1326
|
+
throw error;
|
|
1327
|
+
refusals.push(error);
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
if (refusals.every((refusal) => refusal.message === refusals[0].message))
|
|
1332
|
+
throw refusals[0];
|
|
1333
|
+
const written = passes.map((pass) => `"${pass.written}"`);
|
|
1334
|
+
throw new SourceError(`${subject}: no line keeps ${written.slice(0, -1).join(', ')} and ${written[written.length - 1]} ` +
|
|
1335
|
+
'all at once, whichever way it turns — each way misses one of those nodes or runs into one. ' +
|
|
1336
|
+
'Give the nodes more room, or drop a clause', edge.line);
|
|
1337
|
+
/**
|
|
1338
|
+
* A route in `count` sections, alternating across and down, joined at
|
|
1339
|
+
* corners. Each section is planned as an edge naming one kind is, running
|
|
1340
|
+
* from the level of the section before it to the level of the one after,
|
|
1341
|
+
* and each clause binds every section of its kind that passes its node.
|
|
1342
|
+
* The first and last keep as near their own ends as their clauses allow,
|
|
1343
|
+
* so with nothing in the way two sections make an L through the corner
|
|
1344
|
+
* level with both ends; a section between hugs its own clauses. Where the
|
|
1345
|
+
* corners land depends on every section, so they are planned in turn
|
|
1346
|
+
* until none moves.
|
|
1347
|
+
*/
|
|
1348
|
+
function turned(acrossFirst, count) {
|
|
1349
|
+
const axes = Array.from({ length: count }, (_, index) => axesAcross((index % 2 === 0) === acrossFirst ? 'y' : 'x'));
|
|
1350
|
+
const last = count - 1;
|
|
1351
|
+
const targets = axes.map(({ across }, index) => index === 0
|
|
1352
|
+
? centerOf(a)[across]
|
|
1353
|
+
: index === last
|
|
1354
|
+
? centerOf(b)[across]
|
|
1355
|
+
: (centerOf(a)[across] + centerOf(b)[across]) / 2);
|
|
1356
|
+
// Each section's first and last level, which bound its neighbours' runs.
|
|
1357
|
+
const firstLevels = [...targets];
|
|
1358
|
+
const lastLevels = [...targets];
|
|
1359
|
+
const startAt = reach(start, a, axes[0]);
|
|
1360
|
+
const endAt = reach(end, b, axes[last]);
|
|
1361
|
+
// A section starts or ends at the middle of its end's own node, which
|
|
1362
|
+
// must not push it: the line leaves that node from the side facing it.
|
|
1363
|
+
const obstacles = inWay.filter((node) => !contains(node, edge.from) && !contains(node, edge.to));
|
|
1364
|
+
const near = new Set(passes.flatMap((pass) => pass.nodes));
|
|
1365
|
+
// Which stretch carries the text: its section and index, from the last round.
|
|
1366
|
+
let carries;
|
|
1367
|
+
let sections = [];
|
|
1368
|
+
let travels = [];
|
|
1369
|
+
let bound = new Set();
|
|
1370
|
+
for (let round = 0; round < 8; round += 1) {
|
|
1371
|
+
let moved = false;
|
|
1372
|
+
sections = [];
|
|
1373
|
+
travels = [];
|
|
1374
|
+
bound = new Set();
|
|
1375
|
+
axes.forEach((section, index) => {
|
|
1376
|
+
const from = index === 0 ? startAt : lastLevels[index - 1];
|
|
1377
|
+
const to = index === last ? endAt : firstLevels[index + 1];
|
|
1378
|
+
const travel = Math.sign((index === last ? named(end, b)[section.run] : to) -
|
|
1379
|
+
(index === 0 ? named(start, a)[section.run] : from)) || 1;
|
|
1380
|
+
const low = Math.min(from, to);
|
|
1381
|
+
const high = Math.max(from, to);
|
|
1382
|
+
const mine = passes.filter((pass) => {
|
|
1383
|
+
const box = boundingBox(pass.nodes.map(faceOf));
|
|
1384
|
+
return sideAxis(pass) === section.across && hi(box, section.run) > low && lo(box, section.run) < high;
|
|
1385
|
+
});
|
|
1386
|
+
mine.forEach((pass) => bound.add(pass));
|
|
1387
|
+
const planned = cut(section, mine, from, to, travel);
|
|
1388
|
+
const carrier = carries?.[0] === index ? planned.stretches[carries[1]] : undefined;
|
|
1389
|
+
settle(planned, targets[index], carrier, obstacles, index > 0 && index < last, near);
|
|
1390
|
+
const first = planned.stretches[0].level;
|
|
1391
|
+
const final = planned.stretches[planned.stretches.length - 1].level;
|
|
1392
|
+
if (Math.abs(first - firstLevels[index]) >= 0.5 || Math.abs(final - lastLevels[index]) >= 0.5) {
|
|
1393
|
+
moved = true;
|
|
1394
|
+
}
|
|
1395
|
+
firstLevels[index] = first;
|
|
1396
|
+
lastLevels[index] = final;
|
|
1397
|
+
sections.push(planned);
|
|
1398
|
+
travels.push(travel);
|
|
1399
|
+
});
|
|
1400
|
+
let wanted;
|
|
1401
|
+
if (edge.lines !== undefined) {
|
|
1402
|
+
sections.forEach((section, index) => {
|
|
1403
|
+
const best = longest(section.stretches);
|
|
1404
|
+
if (!wanted || length(best) > length(sections[wanted[0]].stretches[wanted[1]])) {
|
|
1405
|
+
wanted = [index, section.stretches.indexOf(best)];
|
|
1406
|
+
}
|
|
1407
|
+
});
|
|
1408
|
+
}
|
|
1409
|
+
const settled = round > 0 && !moved && wanted?.[0] === carries?.[0] && wanted?.[1] === carries?.[1];
|
|
1410
|
+
carries = wanted;
|
|
1411
|
+
if (settled)
|
|
1412
|
+
break;
|
|
1413
|
+
}
|
|
1414
|
+
const unpassed = passes.find((pass) => !bound.has(pass));
|
|
1415
|
+
if (unpassed) {
|
|
1416
|
+
throw new SourceError(`${subject}: the line never passes ${quoteNames(unpassed.nodes)}, so "${unpassed.written}" says ` +
|
|
1417
|
+
'nothing about it', edge.line);
|
|
1418
|
+
}
|
|
1419
|
+
const points = [...approach(start, a, axes[0], firstLevels[0], travels[0])];
|
|
1420
|
+
sections.forEach((section, index) => {
|
|
1421
|
+
points.push(...crossings(section));
|
|
1422
|
+
if (index < last)
|
|
1423
|
+
points.push(section.axes.make(firstLevels[index + 1], lastLevels[index]));
|
|
1424
|
+
});
|
|
1425
|
+
points.push(...approach(end, b, axes[last], lastLevels[last], -travels[last]).reverse());
|
|
1426
|
+
const broken = breaks(points);
|
|
1427
|
+
if (broken) {
|
|
1428
|
+
throw new SourceError(`${subject}: a line passing things both above or below and left or right has to turn, and ` +
|
|
1429
|
+
`no way it can turn keeps "${broken.written}" — drop that clause, or one of the others`, edge.line);
|
|
1430
|
+
}
|
|
1431
|
+
const [which, index] = carries ?? [0, 0];
|
|
1432
|
+
const carrier = edge.lines === undefined ? undefined : sections[which].stretches[index];
|
|
1433
|
+
return finish(points, carrier, axes[which]);
|
|
1434
|
+
}
|
|
1435
|
+
/** The first clause the drawn line breaks, wherever it lies alongside that clause's nodes. */
|
|
1436
|
+
function breaks(points) {
|
|
1437
|
+
for (const pass of passes) {
|
|
1438
|
+
const box = boundingBox(pass.nodes.map(faceOf));
|
|
1439
|
+
const across = sideAxis(pass);
|
|
1440
|
+
const run = across === 'y' ? 'x' : 'y';
|
|
1441
|
+
const further = pass.direction === 'below' || pass.direction === 'right';
|
|
1442
|
+
for (let index = 0; index + 1 < points.length; index += 1) {
|
|
1443
|
+
const [p, q] = [points[index], points[index + 1]];
|
|
1444
|
+
const alongside = Math.abs(p[run] - q[run]) < 0.5
|
|
1445
|
+
? p[run] > lo(box, run) + 0.5 && p[run] < hi(box, run) - 0.5
|
|
1446
|
+
: Math.min(Math.max(p[run], q[run]), hi(box, run)) -
|
|
1447
|
+
Math.max(Math.min(p[run], q[run]), lo(box, run)) > 0.5;
|
|
1448
|
+
if (!alongside)
|
|
1449
|
+
continue;
|
|
1450
|
+
const kept = further
|
|
1451
|
+
? Math.min(p[across], q[across]) >= hi(box, across) - 0.5
|
|
1452
|
+
: Math.max(p[across], q[across]) <= lo(box, across) + 0.5;
|
|
1453
|
+
if (!kept)
|
|
1454
|
+
return pass;
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
return undefined;
|
|
1458
|
+
}
|
|
1459
|
+
/**
|
|
1460
|
+
* Where the line reaches along a run at one end, before it turns onto a
|
|
1461
|
+
* stretch. Enough to tell which nodes it passes.
|
|
1462
|
+
*/
|
|
1463
|
+
function reach(anchor, face, { run }) {
|
|
1464
|
+
const along = run === 'x' ? anchor.tx : anchor.ty;
|
|
1465
|
+
return anchor.side !== undefined && along !== 0
|
|
1466
|
+
? anchor[run] + along * ROUTE_STUB
|
|
1467
|
+
: named(anchor, face)[run];
|
|
1468
|
+
}
|
|
1469
|
+
function length(stretch) {
|
|
1470
|
+
return Math.abs(stretch.to - stretch.from);
|
|
1471
|
+
}
|
|
1472
|
+
function longest(stretches) {
|
|
1473
|
+
return stretches.reduce((best, stretch) => (length(stretch) > length(best) ? stretch : best));
|
|
1474
|
+
}
|
|
1475
|
+
/**
|
|
1476
|
+
* The way from `first` to `last` along a run, cut wherever a clause starts
|
|
1477
|
+
* or stops binding, with each piece given the band its clauses leave the
|
|
1478
|
+
* line, and consecutive pieces grouped into stretches that can share one
|
|
1479
|
+
* level. Levels are left for `settle`.
|
|
1480
|
+
*/
|
|
1481
|
+
function cut(axes, passes, first, last, travel) {
|
|
1482
|
+
const { across, run } = axes;
|
|
1483
|
+
const low = Math.min(first, last);
|
|
1484
|
+
const high = Math.max(first, last);
|
|
1485
|
+
const clauses = passes.map((pass) => {
|
|
1486
|
+
const box = boundingBox(pass.nodes.map(faceOf));
|
|
1487
|
+
if (hi(box, run) <= low || lo(box, run) >= high) {
|
|
1488
|
+
throw new SourceError(`${subject}: the line never passes ${quoteNames(pass.nodes)}, so "${pass.written}" says nothing ` +
|
|
1489
|
+
'about it', edge.line);
|
|
1490
|
+
}
|
|
1491
|
+
const further = pass.direction === 'below' || pass.direction === 'right';
|
|
1492
|
+
return {
|
|
1493
|
+
pass,
|
|
1494
|
+
from: lo(box, run),
|
|
1495
|
+
to: hi(box, run),
|
|
1496
|
+
bound: further ? hi(box, across) + clear : lo(box, across) - clear,
|
|
1497
|
+
further,
|
|
1498
|
+
};
|
|
1499
|
+
});
|
|
1500
|
+
// Cut the way into pieces at every place a clause starts or stops binding,
|
|
1501
|
+
// and give each piece the band its clauses leave the line.
|
|
1502
|
+
const cuts = [...new Set([low, high, ...clauses.flatMap((c) => [c.from, c.to])])]
|
|
1503
|
+
.filter((at) => at >= low && at <= high)
|
|
1504
|
+
.sort((p, q) => (p - q) * travel);
|
|
1505
|
+
const pieces = cuts.slice(0, -1).map((from, index) => {
|
|
1506
|
+
const to = cuts[index + 1];
|
|
1507
|
+
const middle = (from + to) / 2;
|
|
1508
|
+
const binding = clauses.filter((c) => c.from < middle && c.to > middle);
|
|
1509
|
+
const floor = binding.filter((c) => c.further).sort((p, q) => q.bound - p.bound)[0];
|
|
1510
|
+
const ceiling = binding.filter((c) => !c.further).sort((p, q) => p.bound - q.bound)[0];
|
|
1511
|
+
if (floor && ceiling && floor.bound > ceiling.bound) {
|
|
1512
|
+
const grouped = [floor, ceiling].some((c) => c.pass.nodes.length > 1);
|
|
1513
|
+
throw new SourceError(`${subject}: "${floor.pass.written}" and "${ceiling.pass.written}" cannot both hold — there ` +
|
|
1514
|
+
'is a stretch where the line is alongside both, and it cannot be ' +
|
|
1515
|
+
`${sideWord(floor.pass)} ${quoteNames(floor.pass.nodes)} and ${sideWord(ceiling.pass)} ` +
|
|
1516
|
+
`${quoteNames(ceiling.pass.nodes)} at the same point. Drop one` +
|
|
1517
|
+
(grouped
|
|
1518
|
+
? ', or name the nodes in separate clauses so the line may cross over between them'
|
|
1519
|
+
: ''), edge.line);
|
|
1520
|
+
}
|
|
1521
|
+
return {
|
|
1522
|
+
from,
|
|
1523
|
+
to,
|
|
1524
|
+
lo: floor?.bound ?? -Infinity,
|
|
1525
|
+
hi: ceiling?.bound ?? Infinity,
|
|
1526
|
+
floor,
|
|
1527
|
+
ceiling,
|
|
1528
|
+
};
|
|
1529
|
+
});
|
|
1530
|
+
// A way of no length, as the first section of a turned route can be when
|
|
1531
|
+
// the corner is level with its start, is one piece binding nothing.
|
|
1532
|
+
if (pieces.length === 0) {
|
|
1533
|
+
pieces.push({ from: low, to: high, lo: -Infinity, hi: Infinity, floor: undefined, ceiling: undefined });
|
|
1534
|
+
}
|
|
1535
|
+
// Consecutive pieces share one level for as long as their bands overlap.
|
|
1536
|
+
const stretches = [];
|
|
1537
|
+
let current = { lo: -Infinity, hi: Infinity, first: 0, last: 0, from: 0, to: 0, level: 0 };
|
|
1538
|
+
pieces.forEach((piece, index) => {
|
|
1539
|
+
const lower = Math.max(current.lo, piece.lo);
|
|
1540
|
+
const upper = Math.min(current.hi, piece.hi);
|
|
1541
|
+
if (lower <= upper) {
|
|
1542
|
+
Object.assign(current, { lo: lower, hi: upper, last: index });
|
|
1543
|
+
}
|
|
1544
|
+
else {
|
|
1545
|
+
stretches.push(current);
|
|
1546
|
+
current = { lo: piece.lo, hi: piece.hi, first: index, last: index, from: 0, to: 0, level: 0 };
|
|
1547
|
+
}
|
|
1548
|
+
});
|
|
1549
|
+
stretches.push(current);
|
|
1550
|
+
for (const stretch of stretches) {
|
|
1551
|
+
stretch.from = pieces[stretch.first].from;
|
|
1552
|
+
stretch.to = pieces[stretch.last].to;
|
|
1553
|
+
}
|
|
1554
|
+
return { axes, pieces, stretches };
|
|
1555
|
+
}
|
|
1556
|
+
/**
|
|
1557
|
+
* Each stretch sits as near `target` as its band allows, and a node lying
|
|
1558
|
+
* on it pushes it on the way its clause already points. `carrier` is the
|
|
1559
|
+
* stretch held clear for the text, if this section has it.
|
|
1560
|
+
*/
|
|
1561
|
+
function settle({ axes: { across, run }, stretches }, target, carrier, obstacles = inWay, hug = false, near = new Set()) {
|
|
1562
|
+
const textClear = Math.max(clear, laneExtent(edge, across, measurer, fontSize) / 2 + ATTACH_MARGIN);
|
|
1563
|
+
for (const stretch of stretches) {
|
|
1564
|
+
const room = stretch === carrier && edge.lines !== undefined ? textClear : clear;
|
|
1565
|
+
const extra = room - clear;
|
|
1566
|
+
const lower = stretch.lo + extra;
|
|
1567
|
+
const upper = stretch.hi - extra;
|
|
1568
|
+
// A stretch hugging its clauses sits as close as its one bound allows.
|
|
1569
|
+
const wanted = !hug
|
|
1570
|
+
? target
|
|
1571
|
+
: stretch.hi === Infinity && stretch.lo !== -Infinity
|
|
1572
|
+
? lower
|
|
1573
|
+
: stretch.lo === -Infinity && stretch.hi !== Infinity
|
|
1574
|
+
? upper
|
|
1575
|
+
: target;
|
|
1576
|
+
stretch.level = lower <= upper
|
|
1577
|
+
? Math.min(Math.max(wanted, lower), upper)
|
|
1578
|
+
: (stretch.lo + stretch.hi) / 2;
|
|
1579
|
+
const push = stretch.hi === Infinity ? 1 : stretch.lo === -Infinity ? -1 : 0;
|
|
1580
|
+
if (push === 0)
|
|
1581
|
+
continue;
|
|
1582
|
+
const from = Math.min(stretch.from, stretch.to);
|
|
1583
|
+
const to = Math.max(stretch.from, stretch.to);
|
|
1584
|
+
for (let moved = true; moved;) {
|
|
1585
|
+
moved = false;
|
|
1586
|
+
for (const node of obstacles) {
|
|
1587
|
+
const face = faceOf(node);
|
|
1588
|
+
if (lo(face, run) >= to || hi(face, run) <= from)
|
|
1589
|
+
continue;
|
|
1590
|
+
// A node the edge names is one it is meant to go close by, so it
|
|
1591
|
+
// keeps the line only as far off as a crossing does.
|
|
1592
|
+
const off = near.has(node) && room === clear ? ATTACH_MARGIN : room;
|
|
1593
|
+
if (lo(face, across) >= stretch.level + off || hi(face, across) <= stretch.level - off) {
|
|
1594
|
+
continue;
|
|
1595
|
+
}
|
|
1596
|
+
stretch.level = push > 0 ? hi(face, across) + off : lo(face, across) - off;
|
|
1597
|
+
moved = true;
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
/**
|
|
1603
|
+
* Between two stretches the line crosses over, in whatever run of pieces at
|
|
1604
|
+
* the end of the first leaves room for both levels.
|
|
1605
|
+
*/
|
|
1606
|
+
function crossings({ axes: { make }, pieces, stretches }) {
|
|
1607
|
+
const points = [];
|
|
1608
|
+
for (let index = 0; index + 1 < stretches.length; index += 1) {
|
|
1609
|
+
const here = stretches[index];
|
|
1610
|
+
const next = stretches[index + 1];
|
|
1611
|
+
let open = here.last + 1;
|
|
1612
|
+
while (open > here.first &&
|
|
1613
|
+
pieces[open - 1].lo <= next.level &&
|
|
1614
|
+
pieces[open - 1].hi >= next.level) {
|
|
1615
|
+
open -= 1;
|
|
1616
|
+
}
|
|
1617
|
+
const from = open <= here.last ? pieces[open].from : pieces[here.last].to;
|
|
1618
|
+
const to = pieces[here.last].to;
|
|
1619
|
+
if (Math.abs(to - from) < CROSSING_ROOM) {
|
|
1620
|
+
const behind = [...pieces.slice(here.first, here.last + 1)]
|
|
1621
|
+
.reverse()
|
|
1622
|
+
.map((piece) => piece.floor ?? piece.ceiling)
|
|
1623
|
+
.find((clause) => clause !== undefined);
|
|
1624
|
+
const entering = pieces[next.first];
|
|
1625
|
+
const ahead = (next.level > here.level ? entering.floor : entering.ceiling) ??
|
|
1626
|
+
entering.floor ?? entering.ceiling;
|
|
1627
|
+
throw new SourceError(`${subject}: to pass "${behind.pass.written}" and "${ahead.pass.written}" the line has to ` +
|
|
1628
|
+
`cross over between ${quoteNames(behind.pass.nodes)} and ${quoteNames(ahead.pass.nodes)}, and there ` +
|
|
1629
|
+
'is no room between them — give the placement between them a gap, or drop one of the two', edge.line);
|
|
1630
|
+
}
|
|
1631
|
+
const at = (from + to) / 2;
|
|
1632
|
+
points.push(make(at, here.level), make(at, next.level));
|
|
1633
|
+
}
|
|
1634
|
+
return points;
|
|
1635
|
+
}
|
|
1636
|
+
/**
|
|
1637
|
+
* The route drawn through `points`, with its text at the middle of the
|
|
1638
|
+
* piece that lies on the stretch held clear for it.
|
|
1639
|
+
*/
|
|
1640
|
+
function finish(points, carrier, { across, run }) {
|
|
1641
|
+
const drawn = tidyRoute(points);
|
|
1642
|
+
let mid = centerOf(boundingBox([a, b]));
|
|
1643
|
+
let best = -1;
|
|
1644
|
+
for (let index = 0; index + 1 < drawn.length; index += 1) {
|
|
1645
|
+
const [p, q] = [drawn[index], drawn[index + 1]];
|
|
1646
|
+
if (!carrier)
|
|
1647
|
+
break;
|
|
1648
|
+
if (Math.abs(p[across] - carrier.level) > 0.5 || Math.abs(q[across] - carrier.level) > 0.5) {
|
|
1649
|
+
continue;
|
|
1650
|
+
}
|
|
1651
|
+
const length = Math.abs(q[run] - p[run]);
|
|
1652
|
+
if (length > best) {
|
|
1653
|
+
best = length;
|
|
1654
|
+
mid = { x: (p.x + q.x) / 2, y: (p.y + q.y) / 2 };
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
return { points: drawn, mid };
|
|
1658
|
+
}
|
|
1659
|
+
/**
|
|
1660
|
+
* The points from one end of the line to the level of its nearest stretch.
|
|
1661
|
+
* `toward` is the way the line heads along its run from this end.
|
|
1662
|
+
*/
|
|
1663
|
+
function approach(given, face, axes, level, toward) {
|
|
1664
|
+
const { across, run, make } = axes;
|
|
1665
|
+
const anchor = given.side !== undefined ? given : leaving(face, axes, level, toward);
|
|
1666
|
+
const along = run === 'x' ? anchor.tx : anchor.ty;
|
|
1667
|
+
const out = across === 'x' ? anchor.tx : anchor.ty;
|
|
1668
|
+
if (along !== 0) {
|
|
1669
|
+
// Out along the run, one way or the other, then across to the level.
|
|
1670
|
+
const turn = make(anchor[run] + along * ROUTE_STUB, anchor[across]);
|
|
1671
|
+
return [anchor, turn, make(turn[run], level)];
|
|
1672
|
+
}
|
|
1673
|
+
if ((level - anchor[across]) * out >= 0)
|
|
1674
|
+
return [anchor, make(anchor[run], level)];
|
|
1675
|
+
// The side faces away from the level, so the line steps round the back of
|
|
1676
|
+
// its own node — the end the run is heading away from — to get there.
|
|
1677
|
+
const stub = make(anchor[run], anchor[across] + out * ROUTE_STUB);
|
|
1678
|
+
const back = toward > 0 ? lo(face, run) - ROUTE_STUB : hi(face, run) + ROUTE_STUB;
|
|
1679
|
+
return [anchor, stub, make(back, stub[across]), make(back, level)];
|
|
1680
|
+
}
|
|
1681
|
+
/**
|
|
1682
|
+
* An end naming no side leaves from the one facing the level it is going
|
|
1683
|
+
* to, or, when the level is alongside the node, the one facing its way.
|
|
1684
|
+
*/
|
|
1685
|
+
function leaving(face, { across, run }, level, toward) {
|
|
1686
|
+
const center = centerOf(face);
|
|
1687
|
+
const side = level > hi(face, across)
|
|
1688
|
+
? across === 'y' ? 'bottom' : 'right'
|
|
1689
|
+
: level < lo(face, across)
|
|
1690
|
+
? across === 'y' ? 'top' : 'left'
|
|
1691
|
+
: run === 'x'
|
|
1692
|
+
? toward > 0 ? 'right' : 'left'
|
|
1693
|
+
: toward > 0 ? 'bottom' : 'top';
|
|
1694
|
+
return anchorOn(face, side, side === 'top' || side === 'bottom' ? center.x : center.y);
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1697
|
+
/** Which axis a clause's side sits on: above and below are a matter of y. */
|
|
1698
|
+
function sideAxis(pass) {
|
|
1699
|
+
return pass.direction === 'above' || pass.direction === 'below' ? 'y' : 'x';
|
|
1700
|
+
}
|
|
1701
|
+
/** "below", "left of" — the side as the author would say it. */
|
|
1702
|
+
function sideWord(pass) {
|
|
1703
|
+
return pass.direction === 'above' || pass.direction === 'below'
|
|
1704
|
+
? pass.direction
|
|
1705
|
+
: `${pass.direction} of`;
|
|
1706
|
+
}
|
|
1707
|
+
/** The box that just bounds several. */
|
|
1708
|
+
function boundingBox(boxes) {
|
|
1709
|
+
const x = Math.min(...boxes.map((box) => box.x));
|
|
1710
|
+
const y = Math.min(...boxes.map((box) => box.y));
|
|
1711
|
+
return {
|
|
1712
|
+
x,
|
|
1713
|
+
y,
|
|
1714
|
+
width: Math.max(...boxes.map((box) => box.x + box.width)) - x,
|
|
1715
|
+
height: Math.max(...boxes.map((box) => box.y + box.height)) - y,
|
|
1716
|
+
};
|
|
1717
|
+
}
|
|
1718
|
+
/** `"a"`, `"a" and "b"` — for error messages. */
|
|
1719
|
+
function quoteNames(nodes) {
|
|
1720
|
+
const quoted = nodes.map((node) => `"${node.name}"`);
|
|
1721
|
+
return quoted.length <= 1
|
|
1722
|
+
? (quoted[0] ?? '')
|
|
1723
|
+
: `${quoted.slice(0, -1).join(', ')} and ${quoted[quoted.length - 1]}`;
|
|
1724
|
+
}
|
|
1725
|
+
/** Drop repeated points and ones partway along a straight piece, which are not corners. */
|
|
1726
|
+
function tidyRoute(points) {
|
|
1727
|
+
const kept = [];
|
|
1728
|
+
for (const point of points) {
|
|
1729
|
+
const previous = kept[kept.length - 1];
|
|
1730
|
+
if (previous && Math.hypot(point.x - previous.x, point.y - previous.y) < 0.5)
|
|
1731
|
+
continue;
|
|
1732
|
+
const before = kept[kept.length - 2];
|
|
1733
|
+
if (before && previous &&
|
|
1734
|
+
Math.abs((previous.x - before.x) * (point.y - previous.y) - (previous.y - before.y) * (point.x - previous.x)) < 1e-6 &&
|
|
1735
|
+
(previous.x - before.x) * (point.x - previous.x) + (previous.y - before.y) * (point.y - previous.y) >= 0) {
|
|
1736
|
+
kept[kept.length - 1] = point;
|
|
1737
|
+
continue;
|
|
1738
|
+
}
|
|
1739
|
+
kept.push(point);
|
|
1740
|
+
}
|
|
1741
|
+
return kept;
|
|
1742
|
+
}
|
|
1743
|
+
/**
|
|
1744
|
+
* A line through `points` with every corner rounded. A corner takes at most
|
|
1745
|
+
* half of each piece it shares with a neighboring corner, and the whole of a
|
|
1746
|
+
* piece at either end short of the arrowhead, so two corners never overlap and
|
|
1747
|
+
* the head always lands on a straight piece.
|
|
1748
|
+
*/
|
|
1749
|
+
function roundedPath(points) {
|
|
1750
|
+
const parts = [`M ${round(points[0].x)} ${round(points[0].y)}`];
|
|
1751
|
+
const kappa = 0.5523; // a cubic's handle, as a share of the radius, for a quarter circle
|
|
1752
|
+
for (let index = 1; index + 1 < points.length; index += 1) {
|
|
1753
|
+
const [before, corner, after] = [points[index - 1], points[index], points[index + 1]];
|
|
1754
|
+
const inward = Math.hypot(corner.x - before.x, corner.y - before.y);
|
|
1755
|
+
const outward = Math.hypot(after.x - corner.x, after.y - corner.y);
|
|
1756
|
+
const radius = Math.max(0, Math.min(ROUTE_RADIUS, index === 1 ? inward - ARROW_LENGTH : inward / 2, index + 2 === points.length ? outward - ARROW_LENGTH : outward / 2));
|
|
1757
|
+
const din = { x: (corner.x - before.x) / inward, y: (corner.y - before.y) / inward };
|
|
1758
|
+
const dout = { x: (after.x - corner.x) / outward, y: (after.y - corner.y) / outward };
|
|
1759
|
+
const enter = { x: corner.x - din.x * radius, y: corner.y - din.y * radius };
|
|
1760
|
+
const leave = { x: corner.x + dout.x * radius, y: corner.y + dout.y * radius };
|
|
1761
|
+
parts.push(`L ${round(enter.x)} ${round(enter.y)}`, `C ${round(enter.x + din.x * radius * kappa)} ${round(enter.y + din.y * radius * kappa)}, ` +
|
|
1762
|
+
`${round(leave.x - dout.x * radius * kappa)} ${round(leave.y - dout.y * radius * kappa)}, ` +
|
|
1763
|
+
`${round(leave.x)} ${round(leave.y)}`);
|
|
1764
|
+
}
|
|
1765
|
+
const last = points[points.length - 1];
|
|
1766
|
+
parts.push(`L ${round(last.x)} ${round(last.y)}`);
|
|
1767
|
+
return parts.join(' ');
|
|
1768
|
+
}
|
|
1769
|
+
function lo(box, axis) {
|
|
1770
|
+
return axis === 'x' ? box.x : box.y;
|
|
1771
|
+
}
|
|
1772
|
+
function hi(box, axis) {
|
|
1773
|
+
return axis === 'x' ? box.x + box.width : box.y + box.height;
|
|
1774
|
+
}
|
|
1775
|
+
/** Whether `inner` is `outer` or sits somewhere inside it. */
|
|
1776
|
+
function contains(outer, inner) {
|
|
1777
|
+
for (let node = inner; node; node = node.parent) {
|
|
1778
|
+
if (node === outer)
|
|
1779
|
+
return true;
|
|
1780
|
+
}
|
|
1781
|
+
return false;
|
|
1782
|
+
}
|
|
1021
1783
|
/**
|
|
1022
1784
|
* An end whose side the author did not name aims at the far box's center, which
|
|
1023
1785
|
* is the wrong thing to aim at once the line has been told to go somewhere else
|
|
@@ -1070,10 +1832,11 @@ function corridorPath(start, end, plan) {
|
|
|
1070
1832
|
// Along the run the line travels one way, so that is its tangent at both ends
|
|
1071
1833
|
// of the straight stretch — it enters the gap already going where the gap goes.
|
|
1072
1834
|
const rt = plan.axis === 'y' ? { tx: forward, ty: 0 } : { tx: 0, ty: forward };
|
|
1073
|
-
const
|
|
1835
|
+
const reach = plan.loop ? loopReach : corridorReach;
|
|
1836
|
+
const r1 = reach(start, p1, plan.axis);
|
|
1074
1837
|
const c1 = { x: start.x + start.tx * r1, y: start.y + start.ty * r1 };
|
|
1075
1838
|
const c2 = { x: p1.x - rt.tx * r1, y: p1.y - rt.ty * r1 };
|
|
1076
|
-
const r2 =
|
|
1839
|
+
const r2 = reach(p2, end, plan.axis);
|
|
1077
1840
|
const c3 = { x: p2.x + rt.tx * r2, y: p2.y + rt.ty * r2 };
|
|
1078
1841
|
const c4 = { x: end.x + end.tx * r2, y: end.y + end.ty * r2 };
|
|
1079
1842
|
const d = [
|
|
@@ -1096,6 +1859,15 @@ function corridorReach(from, to, axis) {
|
|
|
1096
1859
|
const distance = Math.hypot(to.x - from.x, to.y - from.y);
|
|
1097
1860
|
return Math.min(140, Math.max(8, Math.min(distance * 0.4, run / 2)));
|
|
1098
1861
|
}
|
|
1862
|
+
/**
|
|
1863
|
+
* How far the handles reach on the turn at either end of a loop. The turn
|
|
1864
|
+
* leaves heading one way and joins the run heading the other, over the depth
|
|
1865
|
+
* between the side and the run, so it is a half circle on that depth — and a
|
|
1866
|
+
* cubic comes closest to a half circle with handles two thirds of its diameter.
|
|
1867
|
+
*/
|
|
1868
|
+
function loopReach(from, to, axis) {
|
|
1869
|
+
return (Math.abs(axis === 'y' ? to.y - from.y : to.x - from.x) * 2) / 3;
|
|
1870
|
+
}
|
|
1099
1871
|
function sideAttr(edge, key) {
|
|
1100
1872
|
const value = edge.attrs[key];
|
|
1101
1873
|
if (value === undefined)
|