reladraw 0.15.1 → 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 +818 -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/lexer.d.ts +12 -0
- package/dist/lexer.js +34 -0
- 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 +1811 -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/lines.js
ADDED
|
@@ -0,0 +1,1968 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where every line goes (design.org section 6).
|
|
3
|
+
*
|
|
4
|
+
* A line is worked out from the things its statement names and nothing else:
|
|
5
|
+
* its two boxes, the sides or points it names, the places it passes, and its
|
|
6
|
+
* own bracket. Nothing is avoided and nothing is searched for, so the same
|
|
7
|
+
* statement draws the same line wherever it sits, and adding a box elsewhere
|
|
8
|
+
* never moves it. Two things are shared between lines: a side, where several
|
|
9
|
+
* ends spread (6.2), and a named line or leg, which others may end on or pass
|
|
10
|
+
* beside, so it is drawn first.
|
|
11
|
+
*
|
|
12
|
+
* The renderer draws what this returns and the findings measure it, so the two
|
|
13
|
+
* can't disagree about where a line is.
|
|
14
|
+
*/
|
|
15
|
+
import { GAPS } from './constants.js';
|
|
16
|
+
import { EDGE_ORDER_KEYS, PART_SIDES, WAYS, isPosition } from './ast.js';
|
|
17
|
+
import { SourceError } from './errors.js';
|
|
18
|
+
import { gapPixels } from './place.js';
|
|
19
|
+
/**
|
|
20
|
+
* How far apart copies of one line run, like lanes on a road (6.5). A fixed
|
|
21
|
+
* step, so adding a copy widens the set by one lane and moves no other lane.
|
|
22
|
+
*/
|
|
23
|
+
const FAN_STEP = 16;
|
|
24
|
+
/** Where every line in the layout goes. */
|
|
25
|
+
export function planLines(layout) {
|
|
26
|
+
return planSides(layout).routes;
|
|
27
|
+
}
|
|
28
|
+
/** Every line, and every side two or more ends share, for the findings to measure. */
|
|
29
|
+
export function planSides(layout) {
|
|
30
|
+
const legs = legOwners(layout);
|
|
31
|
+
const lines = new Map(layout.edges.filter((edge) => edge.name !== undefined).map((edge) => [edge.name, edge]));
|
|
32
|
+
const ends = new Map();
|
|
33
|
+
for (const edge of layout.edges)
|
|
34
|
+
ends.set(edge, [endOf(edge, 'from'), endOf(edge, 'to')]);
|
|
35
|
+
const copies = copySets(layout.edges, ends);
|
|
36
|
+
// Lines drawn straight from one box to the other: a bundle written `a -> b`,
|
|
37
|
+
// and plain copies whose sides were left to the default. Their ends land
|
|
38
|
+
// where their lanes meet the boxes, so they take no slot on a side.
|
|
39
|
+
const direct = directSets(layout.edges, ends, copies);
|
|
40
|
+
for (const edge of direct.keys()) {
|
|
41
|
+
for (const end of ends.get(edge)) {
|
|
42
|
+
end.spec = { kind: 'free' };
|
|
43
|
+
end.headings = [];
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const directDrawn = new Map();
|
|
47
|
+
// A bundle between two boxes that says how it goes is routed as one line
|
|
48
|
+
// between them would be (6.6); its members are laid either side of it.
|
|
49
|
+
const middles = new Map();
|
|
50
|
+
for (const edge of layout.edges) {
|
|
51
|
+
const between = edge.bundle?.between;
|
|
52
|
+
if (!between || !['from', 'to', 'line.path'].some((key) => edge.attrs[key] !== undefined))
|
|
53
|
+
continue;
|
|
54
|
+
const { bundle: _, ...rest } = edge;
|
|
55
|
+
const line = { ...rest, from: between[0], to: between[1] };
|
|
56
|
+
for (const which of ['from', 'to']) {
|
|
57
|
+
const written = edge.attrs[which];
|
|
58
|
+
if (written !== undefined && !['side', 'fraction', 'point', 'center'].includes(readEnd(written, line, which).kind)) {
|
|
59
|
+
throw new SourceError(`bundle ${edge.name}: "${which}: ${written}" — a bundle's ${which}: names a side of ${(which === 'from' ? line.from : line.to).name} or a point on it, such as ${which}: bottom or ${which}: bottom 1/4 across`, edge.line);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
middles.set(edge, { line, ends: [endOf(line, 'from'), endOf(line, 'to')] });
|
|
63
|
+
}
|
|
64
|
+
// Ends spread along a side first, since where an end lands on a shared side
|
|
65
|
+
// depends on the others there.
|
|
66
|
+
const middleLines = new Set([...middles.values()].map((middle) => middle.line));
|
|
67
|
+
const sides = spreadSides([...ends.values(), ...[...middles.values()].map((middle) => middle.ends)].flat(), layout.nodes)
|
|
68
|
+
.map((side) => ({ ...side, ends: side.ends.filter((end) => !middleLines.has(end.edge)) }))
|
|
69
|
+
.filter((side) => side.ends.length > 1);
|
|
70
|
+
const routes = new Map();
|
|
71
|
+
const context = { layout, legs, lines, ends, routes, shapes: new Map(), lanes: new Map() };
|
|
72
|
+
// A line others end on or pass beside is drawn before them (6.2). A loop of
|
|
73
|
+
// lines each needing the next has no first line, and is refused.
|
|
74
|
+
const done = new Set();
|
|
75
|
+
const working = [];
|
|
76
|
+
const draw = (edge) => {
|
|
77
|
+
if (done.has(edge))
|
|
78
|
+
return;
|
|
79
|
+
if (working.includes(edge)) {
|
|
80
|
+
const loop = [...working.slice(working.indexOf(edge)), edge].map((one) => one.name ?? describeEdge(one));
|
|
81
|
+
throw new SourceError(`line ${loop[0]} needs ${loop.slice(1).join(', which needs ')} to be drawn first — a loop of lines each ending on or running beside the next can't be drawn`, edge.line);
|
|
82
|
+
}
|
|
83
|
+
working.push(edge);
|
|
84
|
+
for (const other of needs(edge, context))
|
|
85
|
+
draw(other);
|
|
86
|
+
working.pop();
|
|
87
|
+
const middle = middles.get(edge);
|
|
88
|
+
if (middle) {
|
|
89
|
+
const [start, end] = middle.ends;
|
|
90
|
+
for (const one of [start, end])
|
|
91
|
+
placeEnd(one, context);
|
|
92
|
+
for (const [own, other] of [[start, end], [end, start]])
|
|
93
|
+
faceThePoint(own, other);
|
|
94
|
+
for (const [own, other] of [[start, end], [end, start]])
|
|
95
|
+
aimCenter(own, other);
|
|
96
|
+
routes.set(edge, { ...route(middle.line, start, end, context), edge });
|
|
97
|
+
done.add(edge);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (edge.bundle?.between) {
|
|
101
|
+
// A bundle between two boxes that says nothing of its way is the direct
|
|
102
|
+
// way: shown with the guides, the line between the two centers.
|
|
103
|
+
const [a, b] = edge.bundle.between.map(faceOfNode);
|
|
104
|
+
const from = outlineToward(a, centerOf(b));
|
|
105
|
+
const to = outlineToward(b, centerOf(a));
|
|
106
|
+
routes.set(edge, { edge, start: from, pieces: [{ to }], points: [from, to], legs: new Map(), stops: [] });
|
|
107
|
+
done.add(edge);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
const group = direct.get(edge);
|
|
111
|
+
if (group) {
|
|
112
|
+
let drawn = directDrawn.get(group);
|
|
113
|
+
if (!drawn) {
|
|
114
|
+
drawn = group.bundle && middles.has(group.bundle) ? laneRoutes(group, routes.get(group.bundle)) : directRoutes(group);
|
|
115
|
+
directDrawn.set(group, drawn);
|
|
116
|
+
}
|
|
117
|
+
routes.set(edge, drawn.get(edge));
|
|
118
|
+
done.add(edge);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const [start, end] = ends.get(edge);
|
|
122
|
+
for (const one of [start, end])
|
|
123
|
+
placeEnd(one, context);
|
|
124
|
+
for (const [own, other] of [[start, end], [end, start]])
|
|
125
|
+
faceThePoint(own, other);
|
|
126
|
+
for (const [own, other] of [[start, end], [end, start]])
|
|
127
|
+
aimCenter(own, other);
|
|
128
|
+
// A copy takes the first copy's shape, shifted over (6.5); the first is drawn as any line is.
|
|
129
|
+
const set = copies.get(edge);
|
|
130
|
+
const fan = set && set.lines[0] !== edge ? fanned(edge, start, end, set, context) : undefined;
|
|
131
|
+
routes.set(edge, fan ?? route(edge, start, end, context));
|
|
132
|
+
done.add(edge);
|
|
133
|
+
};
|
|
134
|
+
// The first copy of each set comes first, since the others are drawn from it.
|
|
135
|
+
for (const edge of layout.edges) {
|
|
136
|
+
const set = copies.get(edge);
|
|
137
|
+
if (set)
|
|
138
|
+
draw(set.lines[0]);
|
|
139
|
+
draw(edge);
|
|
140
|
+
}
|
|
141
|
+
// Back in the order written, which is the order lines are drawn and cross.
|
|
142
|
+
return { routes: new Map(layout.edges.map((edge) => [edge, routes.get(edge)])), sides };
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Copies are lines with the same two boxes on the same sides, in either
|
|
146
|
+
* direction, with nothing stated about their path (6.5): no waypoints, no
|
|
147
|
+
* landing point, no place among a side's ends. Found by filing each line under
|
|
148
|
+
* its boxes and sides, in one pass.
|
|
149
|
+
*/
|
|
150
|
+
function copySets(edges, ends) {
|
|
151
|
+
const filed = new Map();
|
|
152
|
+
const plain = (end) => end.spec.kind === 'side' && end.spec.after === undefined && !end.onLine && end.edge.onMark?.[end.which] !== 'point';
|
|
153
|
+
const keyOf = (end) => `${end.node.name}\n${end.spec.side}`;
|
|
154
|
+
for (const edge of edges) {
|
|
155
|
+
if (edge.via !== undefined || edge.straight !== undefined)
|
|
156
|
+
continue;
|
|
157
|
+
const [start, end] = ends.get(edge);
|
|
158
|
+
if (!plain(start) || !plain(end))
|
|
159
|
+
continue;
|
|
160
|
+
const [a, b] = [keyOf(start), keyOf(end)];
|
|
161
|
+
const key = a <= b ? `${a}\n${b}` : `${b}\n${a}`;
|
|
162
|
+
const set = filed.get(key) ?? { lines: [] };
|
|
163
|
+
set.lines.push(edge);
|
|
164
|
+
filed.set(key, set);
|
|
165
|
+
}
|
|
166
|
+
const found = new Map();
|
|
167
|
+
for (const set of filed.values()) {
|
|
168
|
+
if (set.lines.length < 2)
|
|
169
|
+
continue;
|
|
170
|
+
const [first] = ends.get(set.lines[0]);
|
|
171
|
+
set.lines.forEach((edge, k) => {
|
|
172
|
+
found.set(edge, set);
|
|
173
|
+
const [start, end] = ends.get(edge);
|
|
174
|
+
// Written the same way round as the first copy, or the other way.
|
|
175
|
+
const along = keyOf(start) === keyOf(first);
|
|
176
|
+
// Copy k is k steps to the left of the way the set travels: leaving a
|
|
177
|
+
// side, left of its outward heading; arriving, left of the inward one.
|
|
178
|
+
const leaving = along ? start : end;
|
|
179
|
+
const arriving = along ? end : start;
|
|
180
|
+
const n1 = SIDE_NORMAL[leaving.spec.side];
|
|
181
|
+
const n2 = SIDE_NORMAL[arriving.spec.side];
|
|
182
|
+
const line = set.lines[0].line;
|
|
183
|
+
leaving.copy = { set: line, k, left: { x: n1.y, y: -n1.x }, leading: true };
|
|
184
|
+
arriving.copy = { set: line, k, left: { x: -n2.y, y: n2.x }, leading: false };
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
return found;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* A copy drawn as the first copy's shape shifted k steps to its left (6.5).
|
|
191
|
+
* Each end keeps its own slot on its side, and every stretch between keeps the
|
|
192
|
+
* fixed step; on a square line the two never conflict, since a leg touching a
|
|
193
|
+
* side takes its place from the slot and the rest from the step. A straight
|
|
194
|
+
* shape has no stretch between, so the copy runs from slot to slot.
|
|
195
|
+
*/
|
|
196
|
+
function fanned(edge, start, end, set, context) {
|
|
197
|
+
const shape = context.shapes.get(set.lines[0]);
|
|
198
|
+
if (shape === undefined)
|
|
199
|
+
return undefined;
|
|
200
|
+
const k = set.lines.indexOf(edge);
|
|
201
|
+
// Worked out in the way the set travels, then turned round for a copy written the other way.
|
|
202
|
+
const along = start.copy.leading;
|
|
203
|
+
const [a, b] = along ? [start.at, end.at] : [end.at, start.at];
|
|
204
|
+
const moved = shiftShape(shape, a, b, k * FAN_STEP);
|
|
205
|
+
if (moved === undefined)
|
|
206
|
+
return undefined;
|
|
207
|
+
let { square, through, turned } = moved;
|
|
208
|
+
let out = shape.out;
|
|
209
|
+
let arrive = shape.arrive;
|
|
210
|
+
if (!along) {
|
|
211
|
+
square = [...square].reverse();
|
|
212
|
+
through = [...through].reverse();
|
|
213
|
+
[out, arrive] = [{ x: -arrive.x, y: -arrive.y }, { x: -out.x, y: -out.y }];
|
|
214
|
+
if (turned)
|
|
215
|
+
turned = { at: turned.at, dir: { x: -turned.dir.x, y: -turned.dir.y } };
|
|
216
|
+
}
|
|
217
|
+
const pieces = shape.curved
|
|
218
|
+
? curve(through, out, arrive, edge.look.curve, undefined, edge.look.stepOut, turned)
|
|
219
|
+
: square.slice(1).map((to) => ({ to }));
|
|
220
|
+
return { edge, start: square[0], pieces, points: shape.curved ? tracePieces(square[0], pieces) : tidyCorners(square), legs: new Map(), stops: [] };
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* A square shape moved to new ends, each leg between shifted `by` to the left
|
|
224
|
+
* of the way it travels; the points a curve passes through move with the legs
|
|
225
|
+
* they lie on, at the same share of the way along. Undefined for a shape that
|
|
226
|
+
* isn't made of legs at right angles, which is drawn as it is.
|
|
227
|
+
*/
|
|
228
|
+
function shiftShape(shape, a, b, by) {
|
|
229
|
+
const base = tidyCorners(shape.square);
|
|
230
|
+
const count = base.length - 1;
|
|
231
|
+
if (count < 1)
|
|
232
|
+
return undefined;
|
|
233
|
+
if (count === 1)
|
|
234
|
+
return { square: [a, b], through: [a, b] };
|
|
235
|
+
const level = [];
|
|
236
|
+
for (let index = 0; index < count; index += 1) {
|
|
237
|
+
const p = base[index];
|
|
238
|
+
const q = base[index + 1];
|
|
239
|
+
const flat = Math.abs(p.y - q.y) < 0.5;
|
|
240
|
+
const upright = Math.abs(p.x - q.x) < 0.5;
|
|
241
|
+
if (flat === upright)
|
|
242
|
+
return undefined;
|
|
243
|
+
level.push(flat);
|
|
244
|
+
if (index > 0 && level[index - 1] === flat)
|
|
245
|
+
return undefined;
|
|
246
|
+
}
|
|
247
|
+
// Each leg's coordinate across it: the first and last from the ends' slots.
|
|
248
|
+
const across = base.slice(0, count).map((p, index) => {
|
|
249
|
+
const q = base[index + 1];
|
|
250
|
+
if (index === 0)
|
|
251
|
+
return level[0] ? a.y : a.x;
|
|
252
|
+
if (index === count - 1)
|
|
253
|
+
return level[index] ? b.y : b.x;
|
|
254
|
+
const way = level[index] ? Math.sign(q.x - p.x) : Math.sign(q.y - p.y);
|
|
255
|
+
return level[index] ? p.y - way * by : p.x + way * by;
|
|
256
|
+
});
|
|
257
|
+
const corners = across.slice(0, -1).map((value, index) => level[index] ? { x: across[index + 1], y: value } : { x: value, y: across[index + 1] });
|
|
258
|
+
const square = [a, ...corners, b];
|
|
259
|
+
const move = (point) => {
|
|
260
|
+
for (let index = 0; index < count; index += 1) {
|
|
261
|
+
const p = base[index];
|
|
262
|
+
const q = base[index + 1];
|
|
263
|
+
const length = distance(p, q);
|
|
264
|
+
const t = length === 0 ? 0 : distance(p, point) / length;
|
|
265
|
+
const off = Math.abs((q.x - p.x) * (point.y - p.y) - (q.y - p.y) * (point.x - p.x)) / (length || 1);
|
|
266
|
+
if (off > 0.5 || t > 1 + 1e-6 || distance(q, point) > length + 0.5)
|
|
267
|
+
continue;
|
|
268
|
+
const p2 = square[index];
|
|
269
|
+
const q2 = square[index + 1];
|
|
270
|
+
return { x: p2.x + (q2.x - p2.x) * t, y: p2.y + (q2.y - p2.y) * t };
|
|
271
|
+
}
|
|
272
|
+
return point;
|
|
273
|
+
};
|
|
274
|
+
const through = shape.through.map(move);
|
|
275
|
+
return { square, through, ...(shape.turned ? { turned: { at: move(shape.turned.at), dir: shape.turned.dir } } : {}) };
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Each line drawn straight between its two boxes, and the set it shares that
|
|
279
|
+
* way with (design.org 6.5, 6.6). A bundle written `a -> b` holds the lines
|
|
280
|
+
* that name it in `via:`; plain straight copies whose sides were both left to
|
|
281
|
+
* the default are an unnamed parallel set, at a bundle's default gap.
|
|
282
|
+
*/
|
|
283
|
+
function directSets(edges, ends, copies) {
|
|
284
|
+
const found = new Map();
|
|
285
|
+
const bundles = new Map(edges.filter((edge) => edge.bundle?.between).map((edge) => [edge.name, edge]));
|
|
286
|
+
const named = new Map();
|
|
287
|
+
for (const edge of edges) {
|
|
288
|
+
if (edge.bundle)
|
|
289
|
+
continue;
|
|
290
|
+
const entries = edge.via ?? [];
|
|
291
|
+
const stop = entries.map((entry) => entry.stop).find((one) => one?.kind === 'bundle' && bundles.has(one.bundle));
|
|
292
|
+
if (!stop)
|
|
293
|
+
continue;
|
|
294
|
+
const bundle = bundles.get(stop.bundle);
|
|
295
|
+
const [a, b] = bundle.bundle.between;
|
|
296
|
+
const subject = `edge ${describeEdge(edge)}`;
|
|
297
|
+
if (!((edge.from === a && edge.to === b) || (edge.from === b && edge.to === a))) {
|
|
298
|
+
throw new SourceError(`${subject}: "via: ${stop.written}" — ${stop.bundle} runs directly between ${a.name} and ${b.name}, so only a line between those two can take it`, edge.line);
|
|
299
|
+
}
|
|
300
|
+
if (entries.length > 1) {
|
|
301
|
+
throw new SourceError(`${subject}: ${stop.bundle} runs directly between ${a.name} and ${b.name}, so the line passes nothing else — leave the other stops off`, edge.line);
|
|
302
|
+
}
|
|
303
|
+
for (const which of ['from', 'to']) {
|
|
304
|
+
if (edge.attrs[which] !== undefined) {
|
|
305
|
+
throw new SourceError(`${subject}: "${which}: ${edge.attrs[which]}" — ${stop.bundle} says where its lines land, each where its lane meets the box; leave ${which}: off, or take the line out of the bundle`, edge.line);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
if (edge.attrs['line.path'] !== undefined) {
|
|
309
|
+
throw new SourceError(`${subject}: "path: ${edge.attrs['line.path']}" — ${stop.bundle}'s layout: says the shape of its lines; leave path: off, or take the line out of the bundle`, edge.line);
|
|
310
|
+
}
|
|
311
|
+
if (stop.order !== undefined) {
|
|
312
|
+
throw new SourceError(`${subject}: "via: ${stop.written}" — ${stop.bundle} runs between two boxes, and its lines take their order from the bundle's order: or the order they are written in`, edge.line);
|
|
313
|
+
}
|
|
314
|
+
const set = named.get(bundle) ?? { a, b, members: [], arrangement: bundle.bundle.arrangement, gap: bundle.bundle.gap, bundle };
|
|
315
|
+
set.members.push(edge);
|
|
316
|
+
named.set(bundle, set);
|
|
317
|
+
}
|
|
318
|
+
for (const [bundle, set] of named) {
|
|
319
|
+
const order = bundle.bundle.order;
|
|
320
|
+
if (order) {
|
|
321
|
+
const subject = `bundle ${bundle.name}`;
|
|
322
|
+
for (const name of order) {
|
|
323
|
+
if (!set.members.some((edge) => edge.name === name)) {
|
|
324
|
+
throw new SourceError(`${subject}: order: lists ${name}, which is not a line through it — add \`via: ${bundle.name}\` to ${name}, or take it off the list`, bundle.line);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
for (const edge of set.members) {
|
|
328
|
+
if (edge.name === undefined || !order.includes(edge.name)) {
|
|
329
|
+
throw new SourceError(`edge ${describeEdge(edge)}: passes through ${bundle.name}, whose order: lists every line through it and leaves this one out`, edge.line);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
set.members.sort((p, q) => order.indexOf(p.name) - order.indexOf(q.name));
|
|
333
|
+
}
|
|
334
|
+
for (const edge of set.members)
|
|
335
|
+
found.set(edge, set);
|
|
336
|
+
}
|
|
337
|
+
// Plain copies: straight, and neither end's side written.
|
|
338
|
+
const seen = new Set();
|
|
339
|
+
for (const set of copies.values()) {
|
|
340
|
+
if (seen.has(set))
|
|
341
|
+
continue;
|
|
342
|
+
seen.add(set);
|
|
343
|
+
const plain = set.lines.every((edge) => edge.look.path === 'straight' && ends.get(edge).every((end) => end.spec.kind === 'side' && end.spec.implied));
|
|
344
|
+
if (!plain)
|
|
345
|
+
continue;
|
|
346
|
+
const first = set.lines[0];
|
|
347
|
+
const group = { a: first.from, b: first.to, members: set.lines, arrangement: 'parallel', gap: GAPS['tight'] };
|
|
348
|
+
for (const edge of set.lines)
|
|
349
|
+
found.set(edge, group);
|
|
350
|
+
}
|
|
351
|
+
return found;
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* The members of one direct set, drawn (6.6). Lanes run parallel to the line
|
|
355
|
+
* between the two centers, a gap apart, the first written uppermost (or
|
|
356
|
+
* leftmost, on an upright way), and each end lands where its lane meets the
|
|
357
|
+
* box. Where a box is too small across to take the lanes at their gap, they
|
|
358
|
+
* draw together at that box: parallel lines stay straight, closer together;
|
|
359
|
+
* fanned ones keep the gap at the middle and bow out to it, the middle line
|
|
360
|
+
* straight. Collapsed, they share the one line.
|
|
361
|
+
*/
|
|
362
|
+
function directRoutes(set) {
|
|
363
|
+
const fa = faceOfNode(set.a);
|
|
364
|
+
const fb = faceOfNode(set.b);
|
|
365
|
+
const ca = centerOf(fa);
|
|
366
|
+
const cb = centerOf(fb);
|
|
367
|
+
const d = unit(ca, cb);
|
|
368
|
+
// Across the way, pointing up, or left where the way is level across.
|
|
369
|
+
let p = { x: -d.y, y: d.x };
|
|
370
|
+
if (p.y > 1e-9 || (Math.abs(p.y) <= 1e-9 && p.x > 0))
|
|
371
|
+
p = { x: -p.x, y: -p.y };
|
|
372
|
+
const n = set.members.length;
|
|
373
|
+
const gap = set.arrangement === 'collapsed' ? 0 : set.gap;
|
|
374
|
+
const lane = (k) => ((n - 1) / 2 - k) * gap;
|
|
375
|
+
const widest = lane(0);
|
|
376
|
+
// How far across a lane may sit at a box: within its width across the way,
|
|
377
|
+
// kept a little off the corners.
|
|
378
|
+
const room = (face) => {
|
|
379
|
+
const half = face.round ? face.width / 2 : (Math.abs(p.x) * face.width + Math.abs(p.y) * face.height) / 2;
|
|
380
|
+
return Math.max(0, half - Math.min(8, half * 0.3));
|
|
381
|
+
};
|
|
382
|
+
const squeeze = (face) => (widest <= 0 ? 1 : Math.min(1, room(face) / widest));
|
|
383
|
+
const sa = squeeze(fa);
|
|
384
|
+
const sb = squeeze(fb);
|
|
385
|
+
const fanned = set.arrangement === 'fan';
|
|
386
|
+
// Parallel lines keep one spacing all the way, the tighter box's.
|
|
387
|
+
const parallelScale = Math.min(sa, sb);
|
|
388
|
+
const across = (point) => (point.x - ca.x) * p.x + (point.y - ca.y) * p.y;
|
|
389
|
+
const routes = new Map();
|
|
390
|
+
set.members.forEach((edge, k) => {
|
|
391
|
+
const ea = lane(k) * (fanned ? sa : parallelScale);
|
|
392
|
+
const eb = lane(k) * (fanned ? sb : parallelScale);
|
|
393
|
+
const s = leaving(fa, { x: ca.x + p.x * ea, y: ca.y + p.y * ea }, d);
|
|
394
|
+
const e = leaving(fb, { x: cb.x + p.x * eb, y: cb.y + p.y * eb }, { x: -d.x, y: -d.y });
|
|
395
|
+
let start = s;
|
|
396
|
+
let pieces = [{ to: e }];
|
|
397
|
+
const want = lane(k);
|
|
398
|
+
const shortfall = want - (across(s) + across(e)) / 2;
|
|
399
|
+
if (fanned && Math.abs(shortfall) > 0.5) {
|
|
400
|
+
// One curve, through the lane at its middle and level with the way there.
|
|
401
|
+
const length = (e.x - s.x) * d.x + (e.y - s.y) * d.y;
|
|
402
|
+
const h = (8 * want - 4 * (across(s) + across(e))) / 6;
|
|
403
|
+
const c1 = { x: s.x + (d.x * length) / 3 + p.x * h, y: s.y + (d.y * length) / 3 + p.y * h };
|
|
404
|
+
const c2 = { x: e.x - (d.x * length) / 3 + p.x * h, y: e.y - (d.y * length) / 3 + p.y * h };
|
|
405
|
+
pieces = [{ c1, c2, to: e }];
|
|
406
|
+
}
|
|
407
|
+
if (edge.from !== set.a) {
|
|
408
|
+
// Written the other way round: the same lane, travelled back.
|
|
409
|
+
const only = pieces[0];
|
|
410
|
+
pieces = 'c1' in only ? [{ c1: only.c2, c2: only.c1, to: s }] : [{ to: s }];
|
|
411
|
+
start = e;
|
|
412
|
+
}
|
|
413
|
+
routes.set(edge, { edge, start, pieces, points: 'c1' in pieces[0] ? tracePieces(start, pieces) : [start, pieces[0].to], legs: new Map(), stops: [] });
|
|
414
|
+
});
|
|
415
|
+
return routes;
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* The members of a bundle between two boxes that is routed as a line (6.6):
|
|
419
|
+
* each runs its lane, the bundle's way shifted across, in order across where
|
|
420
|
+
* it starts as `lanesOf` reads it, and ends where its lane meets the box.
|
|
421
|
+
*/
|
|
422
|
+
function laneRoutes(set, middle) {
|
|
423
|
+
const trace = tidyCorners(traceRoute(middle));
|
|
424
|
+
const fa = faceOfNode(set.a);
|
|
425
|
+
const fb = faceOfNode(set.b);
|
|
426
|
+
const first = unit(trace[0], trace[1]);
|
|
427
|
+
const reading = Math.abs(first.x) >= Math.abs(first.y) ? { x: 0, y: 1 } : { x: 1, y: 0 };
|
|
428
|
+
const left = { x: first.y, y: -first.x };
|
|
429
|
+
const sign = reading.x * left.x + reading.y * left.y;
|
|
430
|
+
const n = set.members.length;
|
|
431
|
+
// Where a box's side is too short to take the lanes at their gap, they draw
|
|
432
|
+
// closer all the way, as the direct way's parallel lanes do.
|
|
433
|
+
const widest = ((n - 1) / 2) * (set.arrangement === 'collapsed' ? 0 : set.gap);
|
|
434
|
+
const room = (face, way) => {
|
|
435
|
+
const half = face.round ? face.width / 2 : (Math.abs(way.y) * face.width + Math.abs(way.x) * face.height) / 2;
|
|
436
|
+
return Math.max(0, half - Math.min(8, half * 0.3));
|
|
437
|
+
};
|
|
438
|
+
const lastWay = unit(trace[trace.length - 2], trace[trace.length - 1]);
|
|
439
|
+
const scale = widest <= 0 ? 1 : Math.min(1, room(fa, first) / widest, room(fb, lastWay) / widest);
|
|
440
|
+
const gap = (set.arrangement === 'collapsed' ? 0 : set.gap) * scale;
|
|
441
|
+
const routes = new Map();
|
|
442
|
+
set.members.forEach((edge, k) => {
|
|
443
|
+
const lane = offsetLine(trace, (k - (n - 1) / 2) * gap * sign);
|
|
444
|
+
const last = lane.length - 1;
|
|
445
|
+
lane[0] = meetOutline(fa, lane[0], unit(lane[0], lane[1]));
|
|
446
|
+
lane[last] = meetOutline(fb, lane[last], unit(lane[last - 1], lane[last]));
|
|
447
|
+
// Meeting the box may shorten a first or last leg to nothing.
|
|
448
|
+
const kept = tidyCorners(lane);
|
|
449
|
+
const points = edge.from === set.a ? kept : [...kept].reverse();
|
|
450
|
+
routes.set(edge, { edge, start: points[0], pieces: points.slice(1).map((to) => ({ to })), points, legs: new Map(), stops: [] });
|
|
451
|
+
});
|
|
452
|
+
return routes;
|
|
453
|
+
}
|
|
454
|
+
/** A point moved along `way`, forward or back, the least distance that puts it on a face's outline; unmoved if that line misses the face. */
|
|
455
|
+
function meetOutline(face, at, way) {
|
|
456
|
+
const hits = [];
|
|
457
|
+
if (face.round) {
|
|
458
|
+
const c = centerOf(face);
|
|
459
|
+
const r = face.width / 2;
|
|
460
|
+
const fx = at.x - c.x;
|
|
461
|
+
const fy = at.y - c.y;
|
|
462
|
+
const b = fx * way.x + fy * way.y;
|
|
463
|
+
const disc = b * b - (fx * fx + fy * fy - r * r);
|
|
464
|
+
if (disc >= 0)
|
|
465
|
+
hits.push(-b - Math.sqrt(disc), -b + Math.sqrt(disc));
|
|
466
|
+
}
|
|
467
|
+
else {
|
|
468
|
+
const within = (value, low, size) => value >= low - 0.5 && value <= low + size + 0.5;
|
|
469
|
+
for (const x of [face.x, face.x + face.width]) {
|
|
470
|
+
if (Math.abs(way.x) < 1e-9)
|
|
471
|
+
continue;
|
|
472
|
+
const t = (x - at.x) / way.x;
|
|
473
|
+
if (within(at.y + way.y * t, face.y, face.height))
|
|
474
|
+
hits.push(t);
|
|
475
|
+
}
|
|
476
|
+
for (const y of [face.y, face.y + face.height]) {
|
|
477
|
+
if (Math.abs(way.y) < 1e-9)
|
|
478
|
+
continue;
|
|
479
|
+
const t = (y - at.y) / way.y;
|
|
480
|
+
if (within(at.x + way.x * t, face.x, face.width))
|
|
481
|
+
hits.push(t);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
if (hits.length === 0)
|
|
485
|
+
return at;
|
|
486
|
+
const t = hits.reduce((best, one) => (Math.abs(one) < Math.abs(best) ? one : best));
|
|
487
|
+
return { x: at.x + way.x * t, y: at.y + way.y * t };
|
|
488
|
+
}
|
|
489
|
+
/** Where a ray from a point inside a face, heading `way`, leaves its outline. */
|
|
490
|
+
function leaving(face, from, way) {
|
|
491
|
+
if (face.round) {
|
|
492
|
+
const c = centerOf(face);
|
|
493
|
+
const r = face.width / 2;
|
|
494
|
+
const fx = from.x - c.x;
|
|
495
|
+
const fy = from.y - c.y;
|
|
496
|
+
const b = fx * way.x + fy * way.y;
|
|
497
|
+
const t = -b + Math.sqrt(Math.max(0, b * b - (fx * fx + fy * fy - r * r)));
|
|
498
|
+
return { x: from.x + way.x * t, y: from.y + way.y * t };
|
|
499
|
+
}
|
|
500
|
+
const tx = way.x > 1e-9 ? (face.x + face.width - from.x) / way.x : way.x < -1e-9 ? (face.x - from.x) / way.x : Infinity;
|
|
501
|
+
const ty = way.y > 1e-9 ? (face.y + face.height - from.y) / way.y : way.y < -1e-9 ? (face.y - from.y) / way.y : Infinity;
|
|
502
|
+
const t = Math.max(0, Math.min(tx, ty));
|
|
503
|
+
return { x: from.x + way.x * t, y: from.y + way.y * t };
|
|
504
|
+
}
|
|
505
|
+
/** Every named leg, refusing a name used twice or already a box's or a line's. */
|
|
506
|
+
function legOwners(layout) {
|
|
507
|
+
const owners = new Map();
|
|
508
|
+
const boxes = new Set(layout.nodes.map((node) => node.name));
|
|
509
|
+
const lines = new Set(layout.edges.map((edge) => edge.name).filter((name) => name !== undefined));
|
|
510
|
+
for (const edge of layout.edges) {
|
|
511
|
+
for (const entry of edge.via ?? []) {
|
|
512
|
+
const name = entry.leg?.name;
|
|
513
|
+
if (name === undefined)
|
|
514
|
+
continue;
|
|
515
|
+
if (owners.has(name) || boxes.has(name) || lines.has(name)) {
|
|
516
|
+
const was = owners.has(name) ? `a leg of line ${describeEdge(owners.get(name))}` : boxes.has(name) ? "a node's name" : "a line's name";
|
|
517
|
+
throw new SourceError(`edge ${describeEdge(edge)}: leg ${name} is already ${was} — a name belongs to one thing`, edge.line);
|
|
518
|
+
}
|
|
519
|
+
owners.set(name, edge);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
return owners;
|
|
523
|
+
}
|
|
524
|
+
/** The lines a line needs drawn before it: those it ends on, passes a point of, or runs beside. */
|
|
525
|
+
function needs(edge, context) {
|
|
526
|
+
const named = (name) => name === undefined ? undefined : (context.lines.get(name) ?? context.legs.get(name));
|
|
527
|
+
const found = [];
|
|
528
|
+
for (const end of context.ends.get(edge)) {
|
|
529
|
+
if (end.onLine)
|
|
530
|
+
found.push(end.onLine);
|
|
531
|
+
if (end.spec.kind === 'online') {
|
|
532
|
+
const line = named(end.spec.line);
|
|
533
|
+
if (line)
|
|
534
|
+
found.push(line);
|
|
535
|
+
}
|
|
536
|
+
if (end.spec.kind === 'aligned' && context.lines.has(end.spec.name))
|
|
537
|
+
found.push(context.lines.get(end.spec.name));
|
|
538
|
+
}
|
|
539
|
+
for (const entry of edge.via ?? []) {
|
|
540
|
+
const stop = entry.stop;
|
|
541
|
+
if (stop?.kind === 'bundle')
|
|
542
|
+
found.push(context.lines.get(stop.bundle));
|
|
543
|
+
if (stop?.kind === 'online') {
|
|
544
|
+
const line = named(stop.line);
|
|
545
|
+
if (line)
|
|
546
|
+
found.push(line);
|
|
547
|
+
}
|
|
548
|
+
if (stop?.kind === 'alongside') {
|
|
549
|
+
const line = context.legs.get(stop.leg);
|
|
550
|
+
if (!line) {
|
|
551
|
+
throw new SourceError(`edge ${describeEdge(edge)}: "via: ${stop.written}" — there is no leg named ${stop.leg}; name one in a via: list with \`leg ${stop.leg}\``, edge.line);
|
|
552
|
+
}
|
|
553
|
+
found.push(line);
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
return found.filter((other) => other !== edge || refuseOwn(edge));
|
|
557
|
+
}
|
|
558
|
+
function refuseOwn(edge) {
|
|
559
|
+
throw new SourceError(`edge ${describeEdge(edge)}: a line can't end on, pass a point of, or run beside itself`, edge.line);
|
|
560
|
+
}
|
|
561
|
+
// --- reading an end -----------------------------------------------------------
|
|
562
|
+
const SIDE_NORMAL = {
|
|
563
|
+
top: { x: 0, y: -1 },
|
|
564
|
+
bottom: { x: 0, y: 1 },
|
|
565
|
+
left: { x: -1, y: 0 },
|
|
566
|
+
right: { x: 1, y: 0 },
|
|
567
|
+
};
|
|
568
|
+
/** The way a line goes, as a unit vector. */
|
|
569
|
+
const WAY_VECTOR = {
|
|
570
|
+
up: { x: 0, y: -1 },
|
|
571
|
+
down: { x: 0, y: 1 },
|
|
572
|
+
left: { x: -1, y: 0 },
|
|
573
|
+
right: { x: 1, y: 0 },
|
|
574
|
+
};
|
|
575
|
+
/** The side a line traveling one way meets first: going down, a box's top. */
|
|
576
|
+
const MET_SIDE = { down: 'top', up: 'bottom', left: 'right', right: 'left' };
|
|
577
|
+
/** The drawn rectangle of a node. */
|
|
578
|
+
export function faceOfNode(node) {
|
|
579
|
+
return {
|
|
580
|
+
x: node.x,
|
|
581
|
+
y: node.y,
|
|
582
|
+
width: node.width,
|
|
583
|
+
height: node.height,
|
|
584
|
+
round: node.body.kind === 'shape' && node.body.outline === 'circle',
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
function endOf(edge, which) {
|
|
588
|
+
const node = which === 'from' ? edge.from : edge.to;
|
|
589
|
+
const far = which === 'from' ? edge.to : edge.from;
|
|
590
|
+
const onLine = edge.onLine?.[which];
|
|
591
|
+
const face = faceOfNode(node);
|
|
592
|
+
const written = edge.attrs[which];
|
|
593
|
+
const subject = `edge ${describeEdge(edge)}`;
|
|
594
|
+
if (edge.bundle)
|
|
595
|
+
return { edge, which, node, face, far, spec: { kind: 'free' }, headings: [] };
|
|
596
|
+
// `straight right` leaves from the middle of the side facing right unless
|
|
597
|
+
// `from:` names a spot on that side, which it then leaves from (design.org 6.2).
|
|
598
|
+
const startWritten = edge.straight !== undefined ? edge.attrs['from'] : undefined;
|
|
599
|
+
if (edge.straight !== undefined && which === 'to' && written !== undefined) {
|
|
600
|
+
throw new SourceError(`${subject}: "straight ${edge.straight}" says where the line ends, so "to: ${written}" says it twice — keep one`, edge.line);
|
|
601
|
+
}
|
|
602
|
+
let spec;
|
|
603
|
+
if (edge.straight !== undefined) {
|
|
604
|
+
spec = which === 'from'
|
|
605
|
+
? straightStart(edge, edge.straight, subject)
|
|
606
|
+
: {
|
|
607
|
+
kind: 'aligned',
|
|
608
|
+
way: edge.straight,
|
|
609
|
+
name: edge.from.name,
|
|
610
|
+
written: `straight ${edge.straight}`,
|
|
611
|
+
...(startWritten !== undefined ? { fromStart: true } : {}),
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
else if (onLine) {
|
|
615
|
+
spec = written === undefined ? { kind: 'online', at: 1 / 2, written: 'its middle' } : readEnd(written, edge, which);
|
|
616
|
+
if (spec.kind !== 'online' && spec.kind !== 'aligned') {
|
|
617
|
+
throw new SourceError(`${subject}: "${which}: ${written}" names a part of a box, and ${node.name} is a line — say how far along it, such as ${which}: at 1/3`, edge.line);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
else if (edge.onMark?.[which] === 'point' && written !== undefined) {
|
|
621
|
+
throw new SourceError(`${subject}: "${which}: ${written}" — ${node.name} is a point, which has no sides or parts, so the line ${which === 'from' ? 'starts' : 'ends'} exactly on it; leave out ${which}:`, edge.line);
|
|
622
|
+
}
|
|
623
|
+
else {
|
|
624
|
+
const farOnLine = edge.onLine?.[which === 'from' ? 'to' : 'from'] !== undefined;
|
|
625
|
+
spec = written !== undefined
|
|
626
|
+
? readEnd(written, edge, which)
|
|
627
|
+
: farOnLine
|
|
628
|
+
? { kind: 'facing-point' }
|
|
629
|
+
: facingSpec(edge, which, node, far);
|
|
630
|
+
}
|
|
631
|
+
const end = { edge, which, node, face, far, spec, ...(onLine ? { onLine } : {}), headings: [] };
|
|
632
|
+
if (spec.kind === 'side' || spec.kind === 'fraction' || spec.kind === 'even')
|
|
633
|
+
end.headings = [SIDE_NORMAL[spec.side]];
|
|
634
|
+
if (spec.kind === 'fraction')
|
|
635
|
+
end.at = fractionPoint(face, spec.side, spec.value);
|
|
636
|
+
if (spec.kind === 'even') {
|
|
637
|
+
// Level with the thing's center on a left or right side, at its place
|
|
638
|
+
// across on a top or bottom one. A miss is refused, never bent (6.2).
|
|
639
|
+
const target = edge.evenAt[which];
|
|
640
|
+
const across = spec.side === 'top' || spec.side === 'bottom';
|
|
641
|
+
const coordinate = across ? target.x + target.width / 2 : target.y + target.height / 2;
|
|
642
|
+
const low = across ? face.x : face.y;
|
|
643
|
+
const high = across ? face.x + face.width : face.y + face.height;
|
|
644
|
+
if (coordinate < low - 0.5 || coordinate > high + 0.5) {
|
|
645
|
+
const thing = spec.written.replace(/^[^,]*,\s*even with\s+/, '');
|
|
646
|
+
const where = across ? (coordinate < low ? 'left of' : 'right of') : coordinate < low ? 'above' : 'below';
|
|
647
|
+
throw new SourceError(`${subject}: "${which}: ${spec.written}" misses ${node.name}'s ${spec.side} side — ${thing} is ${where} it, ` +
|
|
648
|
+
`so no point of that side is even with ${thing}; nothing is bent to reach it`, edge.line);
|
|
649
|
+
}
|
|
650
|
+
end.at = onSide(face, spec.side, coordinate);
|
|
651
|
+
}
|
|
652
|
+
if (spec.kind === 'point') {
|
|
653
|
+
end.at = positionPoint(face, spec.position);
|
|
654
|
+
const [vertical, horizontal] = cornerSides(spec.position);
|
|
655
|
+
if (vertical && horizontal)
|
|
656
|
+
end.headings = [SIDE_NORMAL[vertical], SIDE_NORMAL[horizontal]];
|
|
657
|
+
else if (vertical)
|
|
658
|
+
end.headings = [SIDE_NORMAL[vertical]];
|
|
659
|
+
else if (horizontal)
|
|
660
|
+
end.headings = [SIDE_NORMAL[horizontal]];
|
|
661
|
+
}
|
|
662
|
+
if (spec.kind === 'own-way') {
|
|
663
|
+
// Out of its own side that way, from the middle of it.
|
|
664
|
+
const side = MET_SIDE[opposite(spec.way)];
|
|
665
|
+
end.at = onSide(face, side, side === 'top' || side === 'bottom' ? face.x + face.width / 2 : face.y + face.height / 2);
|
|
666
|
+
end.headings = [SIDE_NORMAL[side]];
|
|
667
|
+
}
|
|
668
|
+
return end;
|
|
669
|
+
}
|
|
670
|
+
/**
|
|
671
|
+
* The start of a `straight` line. Bare, the middle of the side facing its
|
|
672
|
+
* way; a `from:` may name another spot on that side, and naming any other
|
|
673
|
+
* side is refused, since the line could not leave it that way.
|
|
674
|
+
*/
|
|
675
|
+
function straightStart(edge, way, subject) {
|
|
676
|
+
const written = edge.attrs['from'];
|
|
677
|
+
const facing = MET_SIDE[opposite(way)];
|
|
678
|
+
if (written === undefined)
|
|
679
|
+
return { kind: 'own-way', way };
|
|
680
|
+
const spec = readEnd(written, edge, 'from');
|
|
681
|
+
if (spec.kind === 'side' && spec.side === facing && spec.after === undefined)
|
|
682
|
+
return { kind: 'own-way', way };
|
|
683
|
+
const sides = spec.kind === 'fraction' || spec.kind === 'even'
|
|
684
|
+
? [spec.side]
|
|
685
|
+
: spec.kind === 'point'
|
|
686
|
+
? cornerSides(spec.position).filter((side) => side !== undefined)
|
|
687
|
+
: [];
|
|
688
|
+
if (!sides.includes(facing)) {
|
|
689
|
+
throw new SourceError(`${subject}: "straight ${way}" leaves from ${edge.from.name}'s ${facing} side, and "from: ${written}" is not on it — ` +
|
|
690
|
+
`name a spot on the ${facing} side, such as from: ${facing} 1/4 ${facing === 'left' || facing === 'right' ? 'down' : 'across'}, or drop one`, edge.line);
|
|
691
|
+
}
|
|
692
|
+
return spec;
|
|
693
|
+
}
|
|
694
|
+
function opposite(way) {
|
|
695
|
+
return { up: 'down', down: 'up', left: 'right', right: 'left' }[way];
|
|
696
|
+
}
|
|
697
|
+
/**
|
|
698
|
+
* `top`, `left 3/4 down`, `bottom 1/4 across from right`, `top, right of z`,
|
|
699
|
+
* `left, even with cat1`, `bottom-center`, `center`, `down from z3`, `at 1/3`, `climb at 1/3`,
|
|
700
|
+
* `req end`, as the parser stored them.
|
|
701
|
+
*/
|
|
702
|
+
function readEnd(written, edge, which) {
|
|
703
|
+
const subject = `edge ${describeEdge(edge)}`;
|
|
704
|
+
const [head, order] = splitOnce(written, ',');
|
|
705
|
+
const words = head.trim().split(/\s+/);
|
|
706
|
+
const first = words[0];
|
|
707
|
+
if (first === 'center' && words.length === 1 && order === undefined)
|
|
708
|
+
return { kind: 'center' };
|
|
709
|
+
if (isPosition(first) && words.length === 1 && order === undefined)
|
|
710
|
+
return { kind: 'point', position: first };
|
|
711
|
+
if (WAYS.includes(first) && words[1] === 'from' && words.length === 3) {
|
|
712
|
+
return { kind: 'aligned', way: first, name: words[2], written };
|
|
713
|
+
}
|
|
714
|
+
if (first === 'at' && words.length === 2) {
|
|
715
|
+
const value = fractionOf(words[1]);
|
|
716
|
+
if (value !== undefined)
|
|
717
|
+
return { kind: 'online', at: value, written };
|
|
718
|
+
}
|
|
719
|
+
if (words[1] === 'at' && words.length === 3) {
|
|
720
|
+
const value = fractionOf(words[2]);
|
|
721
|
+
if (value !== undefined)
|
|
722
|
+
return { kind: 'online', line: first, at: value, written };
|
|
723
|
+
}
|
|
724
|
+
if ((words[1] === 'start' || words[1] === 'end') && words.length === 2) {
|
|
725
|
+
return { kind: 'online', line: first, at: words[1], written };
|
|
726
|
+
}
|
|
727
|
+
if (!isSide(first)) {
|
|
728
|
+
throw new SourceError(`${subject}: "${which}: ${written}" is not a side or a point — write a side (${PART_SIDES.join(', ')}), ` +
|
|
729
|
+
'a point such as bottom-center, a side and how far along it such as left 3/4 down, or center', edge.line);
|
|
730
|
+
}
|
|
731
|
+
if (words.length > 1) {
|
|
732
|
+
const fraction = readFractionWords(words.slice(1));
|
|
733
|
+
if (fraction === undefined) {
|
|
734
|
+
throw new SourceError(`${subject}: "${which}: ${written}" — after the side comes how far along it, such as ${which}: ${first} ${first === 'left' || first === 'right' ? '3/4 down' : '1/4 across'}`, edge.line);
|
|
735
|
+
}
|
|
736
|
+
const runs = first === 'left' || first === 'right' ? ['down', 'up'] : ['across'];
|
|
737
|
+
if (!runs.includes(fraction.along)) {
|
|
738
|
+
throw new SourceError(`${subject}: "${which}: ${written}" names no point — ${fraction.along} doesn't run along a ${first} side; ` +
|
|
739
|
+
`it takes ${runs.join(' or ')}`, edge.line);
|
|
740
|
+
}
|
|
741
|
+
if (order !== undefined) {
|
|
742
|
+
throw new SourceError(`${subject}: "${which}: ${written}" — an end a fraction along a side is already one point, so it has no place among the others to state`, edge.line);
|
|
743
|
+
}
|
|
744
|
+
const value = fraction.along === 'up' || fraction.from === 'right' ? 1 - fraction.value : fraction.value;
|
|
745
|
+
return { kind: 'fraction', side: first, value, written: head.trim() };
|
|
746
|
+
}
|
|
747
|
+
if (order === undefined)
|
|
748
|
+
return { kind: 'side', side: first, implied: false };
|
|
749
|
+
// `left, even with cat1`: the parser read the targets, and the resolver located them.
|
|
750
|
+
if (/^\s*even with /.test(order))
|
|
751
|
+
return { kind: 'even', side: first, written };
|
|
752
|
+
// `top, right of z`: this end's place among the others on the side.
|
|
753
|
+
const ways = first === 'top' || first === 'bottom' ? ['left of', 'right of'] : ['above', 'below'];
|
|
754
|
+
const match = /^\s*(left of|right of|above|below)\s+(\S+)\s*$/.exec(order);
|
|
755
|
+
if (match === null || !ways.includes(match[1])) {
|
|
756
|
+
throw new SourceError(`${subject}: "${which}: ${written}" — after the comma comes this end's place among the others on the ${first} side: ` +
|
|
757
|
+
`${ways.map((way) => `${way} <line>`).join(' or ')}`, edge.line);
|
|
758
|
+
}
|
|
759
|
+
return { kind: 'side', side: first, implied: false, after: { way: match[1], name: match[2] } };
|
|
760
|
+
}
|
|
761
|
+
function fractionOf(amount) {
|
|
762
|
+
const ratio = /^(\d+)\/(\d+)$/.exec(amount);
|
|
763
|
+
const value = ratio
|
|
764
|
+
? Number(ratio[2]) === 0
|
|
765
|
+
? undefined
|
|
766
|
+
: Number(ratio[1]) / Number(ratio[2])
|
|
767
|
+
: /^\d*\.?\d+$/.test(amount)
|
|
768
|
+
? Number(amount)
|
|
769
|
+
: undefined;
|
|
770
|
+
return value !== undefined && value >= 0 && value <= 1 ? value : undefined;
|
|
771
|
+
}
|
|
772
|
+
function readFractionWords(words) {
|
|
773
|
+
const [amount, along, fromWord, side] = words;
|
|
774
|
+
if (amount === undefined || along === undefined)
|
|
775
|
+
return undefined;
|
|
776
|
+
const value = fractionOf(amount);
|
|
777
|
+
if (value === undefined)
|
|
778
|
+
return undefined;
|
|
779
|
+
if (along !== 'down' && along !== 'up' && along !== 'across')
|
|
780
|
+
return undefined;
|
|
781
|
+
if (fromWord === undefined)
|
|
782
|
+
return words.length === 2 ? { value, along } : undefined;
|
|
783
|
+
if (along !== 'across' || fromWord !== 'from' || (side !== 'left' && side !== 'right') || words.length !== 4)
|
|
784
|
+
return undefined;
|
|
785
|
+
return { value, along, from: side };
|
|
786
|
+
}
|
|
787
|
+
function splitOnce(text, separator) {
|
|
788
|
+
const at = text.indexOf(separator);
|
|
789
|
+
return at < 0 ? [text, undefined] : [text.slice(0, at), text.slice(at + 1)];
|
|
790
|
+
}
|
|
791
|
+
function isSide(word) {
|
|
792
|
+
return word === 'top' || word === 'bottom' || word === 'left' || word === 'right';
|
|
793
|
+
}
|
|
794
|
+
/**
|
|
795
|
+
* The side facing the other box, for an end nothing was said about.
|
|
796
|
+
*
|
|
797
|
+
* Boxes that share a stretch across face each other top to bottom, and boxes
|
|
798
|
+
* that share a stretch up and down face each other side to side. Boxes placed
|
|
799
|
+
* diagonally share neither; the facing side is the one the line between their
|
|
800
|
+
* centers crosses.
|
|
801
|
+
*/
|
|
802
|
+
function facingSpec(edge, which, node, far) {
|
|
803
|
+
if (holds(node, far) || holds(far, node)) {
|
|
804
|
+
throw new SourceError(`edge ${describeEdge(edge)}: ${far.name} is ${holds(node, far) ? 'inside' : 'around'} ${node.name}, so no side of ${node.name} faces it — ` +
|
|
805
|
+
`name the side, such as ${which}: ${holds(node, far) ? 'top' : 'bottom'}`, edge.line);
|
|
806
|
+
}
|
|
807
|
+
const own = faceOfNode(node);
|
|
808
|
+
const other = faceOfNode(far);
|
|
809
|
+
const overlapX = own.x < other.x + other.width && other.x < own.x + own.width;
|
|
810
|
+
const overlapY = own.y < other.y + other.height && other.y < own.y + own.height;
|
|
811
|
+
const from = centerOf(own);
|
|
812
|
+
const to = centerOf(other);
|
|
813
|
+
let side;
|
|
814
|
+
if (overlapX && !overlapY)
|
|
815
|
+
side = to.y >= from.y ? 'bottom' : 'top';
|
|
816
|
+
else if (overlapY && !overlapX)
|
|
817
|
+
side = to.x >= from.x ? 'right' : 'left';
|
|
818
|
+
else {
|
|
819
|
+
// Diagonal, or one on top of the other: the side the line between the centers crosses.
|
|
820
|
+
const dx = to.x - from.x;
|
|
821
|
+
const dy = to.y - from.y;
|
|
822
|
+
side =
|
|
823
|
+
Math.abs(dx) * own.height >= Math.abs(dy) * own.width
|
|
824
|
+
? dx >= 0
|
|
825
|
+
? 'right'
|
|
826
|
+
: 'left'
|
|
827
|
+
: dy >= 0
|
|
828
|
+
? 'bottom'
|
|
829
|
+
: 'top';
|
|
830
|
+
}
|
|
831
|
+
return { kind: 'side', side, implied: true };
|
|
832
|
+
}
|
|
833
|
+
/** Whether `outer` holds `inner`, at any depth. */
|
|
834
|
+
function holds(outer, inner) {
|
|
835
|
+
for (let at = inner.parent; at; at = at.parent)
|
|
836
|
+
if (at === outer)
|
|
837
|
+
return true;
|
|
838
|
+
return false;
|
|
839
|
+
}
|
|
840
|
+
// --- ends that need other lines, or the far end -----------------------------------
|
|
841
|
+
/**
|
|
842
|
+
* An end on a line, on a point of one, or lined up with another thing: worked
|
|
843
|
+
* out once the lines it needs are drawn.
|
|
844
|
+
*/
|
|
845
|
+
function placeEnd(end, context) {
|
|
846
|
+
const subject = `edge ${describeEdge(end.edge)}`;
|
|
847
|
+
const spec = end.spec;
|
|
848
|
+
if (spec.kind === 'online') {
|
|
849
|
+
if (end.onLine) {
|
|
850
|
+
// `edge log audit -> req to: at 1/3`, `to: climb at 1/3` naming a leg
|
|
851
|
+
// of req, or `edge x d -> climb`, ending on the leg itself.
|
|
852
|
+
const name = spec.line ?? end.node.name;
|
|
853
|
+
const owner = context.lines.get(name) ?? context.legs.get(name);
|
|
854
|
+
if (owner !== end.onLine || (spec.line !== undefined && context.legs.has(end.node.name))) {
|
|
855
|
+
throw new SourceError(`${subject}: "${end.which}: ${spec.written}" — ${name} is not ${end.node.name}${context.legs.has(end.node.name) ? '' : ' or one of its legs'}, and this end is on ${end.node.name}`, end.edge.line);
|
|
856
|
+
}
|
|
857
|
+
const trace = traceNamed(name, context, subject, end.edge.line);
|
|
858
|
+
end.at = alongLength(trace, spec.at);
|
|
859
|
+
standOnLine(end, trace);
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
862
|
+
// `from: req end` on a box: where req ends, which must be on this box.
|
|
863
|
+
const line = spec.line === undefined ? undefined : context.lines.get(spec.line);
|
|
864
|
+
if (line === undefined || typeof spec.at === 'number') {
|
|
865
|
+
throw new SourceError(`${subject}: "${end.which}: ${spec.written}" — a point along a line is not on ${end.node.name}; ` +
|
|
866
|
+
`to end on the line, name it in place of the box, such as \`edge ${end.which === 'from' ? `${spec.line ?? 'req'} -> ${end.far.name}` : `${end.far.name} -> ${spec.line ?? 'req'}`} ${end.which}: at 1/3\``, end.edge.line);
|
|
867
|
+
}
|
|
868
|
+
const [lineStart, lineEnd] = context.ends.get(line);
|
|
869
|
+
const target = spec.at === 'start' ? lineStart : lineEnd;
|
|
870
|
+
if (target.node !== end.node) {
|
|
871
|
+
throw new SourceError(`${subject}: the line ${end.which === 'from' ? 'starts' : 'ends'} at ${end.node.name}, but ${spec.line}'s ${spec.at} is on ${target.node.name}`, end.edge.line);
|
|
872
|
+
}
|
|
873
|
+
end.at = target.at ?? centerOf(end.face);
|
|
874
|
+
end.headings = [headingAt(end.face, end.at)];
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
877
|
+
if (spec.kind === 'aligned') {
|
|
878
|
+
// The line straight `way` from the named thing's center, and where it first meets this end's box or line.
|
|
879
|
+
const from = context.layout.nodes.find((node) => node.name === spec.name) ?? context.layout.marks.find((mark) => mark.name === spec.name);
|
|
880
|
+
const fromLine = context.lines.get(spec.name);
|
|
881
|
+
if (from === undefined && fromLine === undefined) {
|
|
882
|
+
throw new SourceError(`${subject}: "${end.which}: ${spec.written}" names ${spec.name}, which does not exist`, end.edge.line);
|
|
883
|
+
}
|
|
884
|
+
const start = spec.fromStart ? context.ends.get(end.edge)[0].at : undefined;
|
|
885
|
+
const origin = start
|
|
886
|
+
?? (from ? centerOf('body' in from ? faceOfNode(from) : { ...from, round: false }) : alongLength(traceRoute(context.routes.get(fromLine)), 1 / 2));
|
|
887
|
+
const way = WAY_VECTOR[spec.way];
|
|
888
|
+
if (end.onLine) {
|
|
889
|
+
const hit = rayHit(origin, way, traceRoute(context.routes.get(end.onLine)));
|
|
890
|
+
if (hit === undefined) {
|
|
891
|
+
throw new SourceError(`${subject}: the line straight ${spec.way} from ${spec.name} never meets ${end.onLine.name} — nothing is bent to reach it`, end.edge.line);
|
|
892
|
+
}
|
|
893
|
+
end.at = hit;
|
|
894
|
+
standOnLine(end, traceRoute(context.routes.get(end.onLine)));
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
const side = MET_SIDE[spec.way];
|
|
898
|
+
const across = side === 'top' || side === 'bottom';
|
|
899
|
+
const coordinate = across ? origin.x : origin.y;
|
|
900
|
+
const low = across ? end.face.x : end.face.y;
|
|
901
|
+
const high = across ? end.face.x + end.face.width : end.face.y + end.face.height;
|
|
902
|
+
const point = onSide(end.face, side, coordinate);
|
|
903
|
+
const ahead = (point.x - origin.x) * way.x + (point.y - origin.y) * way.y;
|
|
904
|
+
if (coordinate < low - 0.5 || coordinate > high + 0.5 || ahead < -0.5) {
|
|
905
|
+
throw new SourceError(`${subject}: the line straight ${spec.way} from ${spec.fromStart ? 'its start' : `${spec.name}'s center`} never meets ${end.node.name} — ` +
|
|
906
|
+
`${ahead < -0.5 ? `${end.node.name} is not ${spec.way} from it` : end.edge.onMark?.[end.which] === 'point' ? `it passes beside ${end.node.name}` : `it passes ${end.node.name}'s ${side} side`}; nothing is bent to reach it`, end.edge.line);
|
|
907
|
+
}
|
|
908
|
+
end.at = point;
|
|
909
|
+
end.headings = [SIDE_NORMAL[side]];
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
/** A line ending on another line heads square off it, toward its own far end. */
|
|
913
|
+
function standOnLine(end, trace) {
|
|
914
|
+
const at = end.at;
|
|
915
|
+
end.face = { x: at.x, y: at.y, width: 0, height: 0, round: false };
|
|
916
|
+
end.node.x = at.x;
|
|
917
|
+
end.node.y = at.y;
|
|
918
|
+
const along = directionAt(trace, at);
|
|
919
|
+
const toward = centerOf(faceOfNode(end.far));
|
|
920
|
+
// The axis across the line it stands on, or the nearer one where it runs aslant.
|
|
921
|
+
const normal = Math.abs(along.x) >= Math.abs(along.y) ? { x: 0, y: 1 } : { x: 1, y: 0 };
|
|
922
|
+
const sign = (toward.x - at.x) * normal.x + (toward.y - at.y) * normal.y >= 0 ? 1 : -1;
|
|
923
|
+
end.headings = [{ x: normal.x * sign, y: normal.y * sign }];
|
|
924
|
+
}
|
|
925
|
+
/**
|
|
926
|
+
* A box end with nothing said whose far end is on a line takes the side facing
|
|
927
|
+
* that point, by the same rule as facing a box, landing at its middle.
|
|
928
|
+
*/
|
|
929
|
+
function faceThePoint(own, other) {
|
|
930
|
+
if (own.spec.kind !== 'facing-point')
|
|
931
|
+
return;
|
|
932
|
+
const point = other.at;
|
|
933
|
+
const spec = facingSpec(own.edge, own.which, own.node, {
|
|
934
|
+
...own.far,
|
|
935
|
+
x: point.x,
|
|
936
|
+
y: point.y,
|
|
937
|
+
width: 0,
|
|
938
|
+
height: 0,
|
|
939
|
+
parent: undefined,
|
|
940
|
+
});
|
|
941
|
+
const side = spec.side;
|
|
942
|
+
const face = own.face;
|
|
943
|
+
own.at = onSide(face, side, side === 'top' || side === 'bottom' ? face.x + face.width / 2 : face.y + face.height / 2);
|
|
944
|
+
own.headings = [SIDE_NORMAL[side]];
|
|
945
|
+
}
|
|
946
|
+
/** A center end aims from the center at where the other end lands, or at its center if that is a center too. */
|
|
947
|
+
function aimCenter(own, other) {
|
|
948
|
+
if (own.spec.kind !== 'center')
|
|
949
|
+
return;
|
|
950
|
+
const target = other.spec.kind === 'center' ? centerOf(other.face) : (other.at ?? centerOf(other.face));
|
|
951
|
+
own.at = outlineToward(own.face, target);
|
|
952
|
+
own.headings = [headingAt(own.face, own.at)];
|
|
953
|
+
}
|
|
954
|
+
/** A named line's trace, or a named leg's. */
|
|
955
|
+
function traceNamed(name, context, subject, line) {
|
|
956
|
+
const edge = context.lines.get(name);
|
|
957
|
+
if (edge)
|
|
958
|
+
return traceRoute(context.routes.get(edge));
|
|
959
|
+
const owner = context.legs.get(name);
|
|
960
|
+
if (owner)
|
|
961
|
+
return context.routes.get(owner).legs.get(name);
|
|
962
|
+
throw new SourceError(`${subject}: there is no line or leg named ${name}`, line);
|
|
963
|
+
}
|
|
964
|
+
/** The point a share of the way along a polyline, bends included, or its start or end. */
|
|
965
|
+
export function alongLength(trace, at) {
|
|
966
|
+
if (at === 'start')
|
|
967
|
+
return trace[0];
|
|
968
|
+
if (at === 'end')
|
|
969
|
+
return trace[trace.length - 1];
|
|
970
|
+
let total = 0;
|
|
971
|
+
for (let index = 1; index < trace.length; index += 1)
|
|
972
|
+
total += distance(trace[index - 1], trace[index]);
|
|
973
|
+
let left = total * at;
|
|
974
|
+
for (let index = 1; index < trace.length; index += 1) {
|
|
975
|
+
const a = trace[index - 1];
|
|
976
|
+
const b = trace[index];
|
|
977
|
+
const piece = distance(a, b);
|
|
978
|
+
if (piece >= left && piece > 0)
|
|
979
|
+
return { x: a.x + ((b.x - a.x) * left) / piece, y: a.y + ((b.y - a.y) * left) / piece };
|
|
980
|
+
left -= piece;
|
|
981
|
+
}
|
|
982
|
+
return trace[trace.length - 1];
|
|
983
|
+
}
|
|
984
|
+
/** The direction a polyline runs at the point on it nearest `at`. */
|
|
985
|
+
function directionAt(trace, at) {
|
|
986
|
+
let best = { x: 1, y: 0 };
|
|
987
|
+
let nearest = Infinity;
|
|
988
|
+
for (let index = 1; index < trace.length; index += 1) {
|
|
989
|
+
const a = trace[index - 1];
|
|
990
|
+
const b = trace[index];
|
|
991
|
+
const length = distance(a, b);
|
|
992
|
+
if (length === 0)
|
|
993
|
+
continue;
|
|
994
|
+
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)));
|
|
995
|
+
const off = distance({ x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t }, at);
|
|
996
|
+
if (off < nearest) {
|
|
997
|
+
nearest = off;
|
|
998
|
+
best = { x: (b.x - a.x) / length, y: (b.y - a.y) / length };
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
return best;
|
|
1002
|
+
}
|
|
1003
|
+
/** Where a ray from `origin` along `way` first meets a polyline, if it does. */
|
|
1004
|
+
function rayHit(origin, way, trace) {
|
|
1005
|
+
let best;
|
|
1006
|
+
let nearest = Infinity;
|
|
1007
|
+
for (let index = 1; index < trace.length; index += 1) {
|
|
1008
|
+
const a = trace[index - 1];
|
|
1009
|
+
const b = trace[index];
|
|
1010
|
+
const ex = b.x - a.x;
|
|
1011
|
+
const ey = b.y - a.y;
|
|
1012
|
+
const denominator = way.x * ey - way.y * ex;
|
|
1013
|
+
if (Math.abs(denominator) < 1e-9)
|
|
1014
|
+
continue;
|
|
1015
|
+
const t = ((a.x - origin.x) * ey - (a.y - origin.y) * ex) / denominator;
|
|
1016
|
+
const u = ((a.x - origin.x) * way.y - (a.y - origin.y) * way.x) / denominator;
|
|
1017
|
+
if (t < 0 || u < -1e-9 || u > 1 + 1e-9)
|
|
1018
|
+
continue;
|
|
1019
|
+
if (t < nearest) {
|
|
1020
|
+
nearest = t;
|
|
1021
|
+
best = { x: origin.x + way.x * t, y: origin.y + way.y * t };
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
return best;
|
|
1025
|
+
}
|
|
1026
|
+
function distance(a, b) {
|
|
1027
|
+
return Math.hypot(b.x - a.x, b.y - a.y);
|
|
1028
|
+
}
|
|
1029
|
+
// --- points on a box ------------------------------------------------------------
|
|
1030
|
+
export function centerOf(face) {
|
|
1031
|
+
return { x: face.x + face.width / 2, y: face.y + face.height / 2 };
|
|
1032
|
+
}
|
|
1033
|
+
/** The sides a position sits on: `bottom-right` is on the bottom and the right, `top-center` on the top only. */
|
|
1034
|
+
function cornerSides(position) {
|
|
1035
|
+
const vertical = position.startsWith('top') ? 'top' : position.startsWith('bottom') ? 'bottom' : undefined;
|
|
1036
|
+
const horizontal = position.endsWith('left') || position === 'left-center'
|
|
1037
|
+
? 'left'
|
|
1038
|
+
: position.endsWith('right') || position === 'right-center'
|
|
1039
|
+
? 'right'
|
|
1040
|
+
: undefined;
|
|
1041
|
+
return [vertical, horizontal];
|
|
1042
|
+
}
|
|
1043
|
+
function positionPoint(face, position) {
|
|
1044
|
+
const [vertical, horizontal] = cornerSides(position);
|
|
1045
|
+
const point = {
|
|
1046
|
+
x: horizontal === 'left' ? face.x : horizontal === 'right' ? face.x + face.width : face.x + face.width / 2,
|
|
1047
|
+
y: vertical === 'top' ? face.y : vertical === 'bottom' ? face.y + face.height : face.y + face.height / 2,
|
|
1048
|
+
};
|
|
1049
|
+
// A circle has no corners; its corner is where the diagonal meets it.
|
|
1050
|
+
if (face.round && vertical && horizontal)
|
|
1051
|
+
return outlineToward(face, point);
|
|
1052
|
+
return point;
|
|
1053
|
+
}
|
|
1054
|
+
/** A point a fraction of the way along a side, from its left or top end. */
|
|
1055
|
+
function fractionPoint(face, side, value) {
|
|
1056
|
+
const along = side === 'top' || side === 'bottom' ? face.x + face.width * value : face.y + face.height * value;
|
|
1057
|
+
return onSide(face, side, along);
|
|
1058
|
+
}
|
|
1059
|
+
/** The point on a side at `along`, its coordinate along the side; on a circle, where the side's quarter is. */
|
|
1060
|
+
function onSide(face, side, along) {
|
|
1061
|
+
if (face.round) {
|
|
1062
|
+
const radius = face.width / 2;
|
|
1063
|
+
const center = centerOf(face);
|
|
1064
|
+
const across = side === 'top' || side === 'bottom' ? along - center.x : along - center.y;
|
|
1065
|
+
const turn = Math.max(-Math.PI / 4, Math.min(Math.PI / 4, across / radius));
|
|
1066
|
+
const angle = { right: turn, bottom: Math.PI / 2 - turn, left: Math.PI - turn, top: -Math.PI / 2 + turn }[side];
|
|
1067
|
+
return { x: center.x + radius * Math.cos(angle), y: center.y + radius * Math.sin(angle) };
|
|
1068
|
+
}
|
|
1069
|
+
switch (side) {
|
|
1070
|
+
case 'top':
|
|
1071
|
+
return { x: along, y: face.y };
|
|
1072
|
+
case 'bottom':
|
|
1073
|
+
return { x: along, y: face.y + face.height };
|
|
1074
|
+
case 'left':
|
|
1075
|
+
return { x: face.x, y: along };
|
|
1076
|
+
case 'right':
|
|
1077
|
+
return { x: face.x + face.width, y: along };
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
/** Where the line from the face's center toward `target` crosses its outline. */
|
|
1081
|
+
function outlineToward(face, target) {
|
|
1082
|
+
const center = centerOf(face);
|
|
1083
|
+
const dx = target.x - center.x;
|
|
1084
|
+
const dy = target.y - center.y;
|
|
1085
|
+
if (dx === 0 && dy === 0)
|
|
1086
|
+
return { x: center.x, y: face.y };
|
|
1087
|
+
if (face.round) {
|
|
1088
|
+
const radius = face.width / 2;
|
|
1089
|
+
const length = Math.hypot(dx, dy);
|
|
1090
|
+
return { x: center.x + (dx / length) * radius, y: center.y + (dy / length) * radius };
|
|
1091
|
+
}
|
|
1092
|
+
const scale = Math.min(dx === 0 ? Infinity : face.width / 2 / Math.abs(dx), dy === 0 ? Infinity : face.height / 2 / Math.abs(dy));
|
|
1093
|
+
return { x: center.x + dx * scale, y: center.y + dy * scale };
|
|
1094
|
+
}
|
|
1095
|
+
/** The outward heading of the side a point on the outline is on. */
|
|
1096
|
+
function headingAt(face, at) {
|
|
1097
|
+
const distances = [
|
|
1098
|
+
[Math.abs(at.y - face.y), 'top'],
|
|
1099
|
+
[Math.abs(at.y - (face.y + face.height)), 'bottom'],
|
|
1100
|
+
[Math.abs(at.x - face.x), 'left'],
|
|
1101
|
+
[Math.abs(at.x - (face.x + face.width)), 'right'],
|
|
1102
|
+
];
|
|
1103
|
+
if (face.round) {
|
|
1104
|
+
const center = centerOf(face);
|
|
1105
|
+
const dx = at.x - center.x;
|
|
1106
|
+
const dy = at.y - center.y;
|
|
1107
|
+
return Math.abs(dx) >= Math.abs(dy) ? SIDE_NORMAL[dx >= 0 ? 'right' : 'left'] : SIDE_NORMAL[dy >= 0 ? 'bottom' : 'top'];
|
|
1108
|
+
}
|
|
1109
|
+
distances.sort((a, b) => a[0] - b[0]);
|
|
1110
|
+
return SIDE_NORMAL[distances[0][1]];
|
|
1111
|
+
}
|
|
1112
|
+
// --- several ends on one side ----------------------------------------------------
|
|
1113
|
+
// --- several ends on one side ----------------------------------------------------
|
|
1114
|
+
/**
|
|
1115
|
+
* Every end that names a side, or was given the facing one, lands on it spread
|
|
1116
|
+
* evenly, ordered by where its far box sits along the side (6.2). An end pinned
|
|
1117
|
+
* to a point of that side splits it: the spreading ends before it in that order
|
|
1118
|
+
* share the part before the point, the rest the part after.
|
|
1119
|
+
*/
|
|
1120
|
+
function spreadSides(ends, nodes) {
|
|
1121
|
+
const shared = [];
|
|
1122
|
+
const listsUsed = new Set();
|
|
1123
|
+
const groups = new Map();
|
|
1124
|
+
for (const end of ends) {
|
|
1125
|
+
// At a named point every end lands on the point itself, heading out of
|
|
1126
|
+
// the side that faces its far end; nothing spreads, and nothing crowds.
|
|
1127
|
+
if (end.edge.onMark?.[end.which] === 'point' && end.spec.kind === 'side') {
|
|
1128
|
+
end.at = centerOf(end.face);
|
|
1129
|
+
continue;
|
|
1130
|
+
}
|
|
1131
|
+
const side = sideOf(end);
|
|
1132
|
+
if (side === undefined)
|
|
1133
|
+
continue;
|
|
1134
|
+
const key = `${end.node.name}\n${side}`;
|
|
1135
|
+
const group = groups.get(key) ?? [];
|
|
1136
|
+
group.push(end);
|
|
1137
|
+
groups.set(key, group);
|
|
1138
|
+
}
|
|
1139
|
+
for (const [key, group] of groups) {
|
|
1140
|
+
const side = key.split('\n')[1];
|
|
1141
|
+
if (!group.some((end) => end.spec.kind === 'side'))
|
|
1142
|
+
continue;
|
|
1143
|
+
const across = side === 'top' || side === 'bottom';
|
|
1144
|
+
const face = group[0].face;
|
|
1145
|
+
const farAt = (end) => {
|
|
1146
|
+
const center = centerOf(faceOfNode(end.far));
|
|
1147
|
+
return across ? center.x : center.y;
|
|
1148
|
+
};
|
|
1149
|
+
const along = (point) => (across ? point.x : point.y);
|
|
1150
|
+
// Written order breaks a tie. Copies of one line take adjacent slots, each
|
|
1151
|
+
// a step further to the left of the way the set travels (6.5), which keeps
|
|
1152
|
+
// them in step at both ends.
|
|
1153
|
+
const axis = across ? { x: 1, y: 0 } : { x: 0, y: 1 };
|
|
1154
|
+
const lane = (end) => (end.copy ? end.copy.k * (end.copy.left.x * axis.x + end.copy.left.y * axis.y) : 0);
|
|
1155
|
+
const ordered = [...group].sort((a, b) => farAt(a) - farAt(b) || (a.copy?.set ?? a.edge.line) - (b.copy?.set ?? b.edge.line) || lane(a) - lane(b) || a.edge.line - b.edge.line);
|
|
1156
|
+
// `right-edge-order: ae, ad, ac` on the box: every line spreading on that side, in reading order.
|
|
1157
|
+
const listKey = `${side}-edge-order`;
|
|
1158
|
+
const list = group[0].node.attrs[listKey];
|
|
1159
|
+
if (list !== undefined) {
|
|
1160
|
+
listsUsed.add(`${group[0].node.name}\n${listKey}`);
|
|
1161
|
+
const names = list.split(', ');
|
|
1162
|
+
const subject = `node "${group[0].node.name}"`;
|
|
1163
|
+
const spreading = ordered.filter((end) => end.spec.kind === 'side');
|
|
1164
|
+
const stated = spreading.find((end) => end.spec.kind === 'side' && end.spec.after !== undefined);
|
|
1165
|
+
if (stated !== undefined) {
|
|
1166
|
+
throw new SourceError(`edge ${describeEdge(stated.edge)}: states its own place on ${stated.node.name}'s ${side} side, and ${listKey}: already orders that side — keep one`, stated.edge.line);
|
|
1167
|
+
}
|
|
1168
|
+
const pinned = ordered.find((end) => end.spec.kind !== 'side');
|
|
1169
|
+
if (pinned !== undefined) {
|
|
1170
|
+
throw new SourceError(`edge ${describeEdge(pinned.edge)}: is pinned to a spot on ${pinned.node.name}'s ${side} side, and ${listKey}: orders lines that spread along it — take it off the side or drop the list`, pinned.edge.line);
|
|
1171
|
+
}
|
|
1172
|
+
if (new Set(names).size !== names.length)
|
|
1173
|
+
throw new SourceError(`${subject}: ${listKey}: lists a line twice — each line has one place`, group[0].node.line);
|
|
1174
|
+
const listed = names.map((name) => {
|
|
1175
|
+
const end = spreading.find((other) => other.edge.name === name);
|
|
1176
|
+
if (end === undefined) {
|
|
1177
|
+
throw new SourceError(`${subject}: ${listKey}: lists ${name}, and no line named ${name} meets ${group[0].node.name}'s ${side} side`, group[0].node.line);
|
|
1178
|
+
}
|
|
1179
|
+
return end;
|
|
1180
|
+
});
|
|
1181
|
+
const left = spreading.find((end) => !listed.includes(end));
|
|
1182
|
+
if (left !== undefined) {
|
|
1183
|
+
throw new SourceError(`edge ${describeEdge(left.edge)}: meets ${left.node.name}'s ${side} side, whose ${listKey}: lists every line there and leaves this one out` +
|
|
1184
|
+
(left.edge.name === undefined ? ' — name it, as in edge ab a -> b, and add it to the list' : ''), left.edge.line);
|
|
1185
|
+
}
|
|
1186
|
+
ordered.splice(0, ordered.length, ...listed);
|
|
1187
|
+
}
|
|
1188
|
+
// `to: top, right of z`: an end that states its place sits just past the line it names,
|
|
1189
|
+
// and takes along whatever is placed against it, so a chain (`ac below ad`, `ad below ae`)
|
|
1190
|
+
// holds whichever order it is written in.
|
|
1191
|
+
const before = new Map();
|
|
1192
|
+
const after = new Map();
|
|
1193
|
+
const attached = new Set();
|
|
1194
|
+
for (const end of ordered) {
|
|
1195
|
+
if (end.spec.kind !== 'side' || end.spec.after === undefined)
|
|
1196
|
+
continue;
|
|
1197
|
+
const { name, way } = end.spec.after;
|
|
1198
|
+
const anchor = ordered.find((other) => other !== end && other.edge.name === name);
|
|
1199
|
+
if (anchor === undefined) {
|
|
1200
|
+
throw new SourceError(`edge ${describeEdge(end.edge)}: "${end.which}: ${side}, ${way} ${name}" — no line named ${name} meets ${end.node.name}'s ${side} side`, end.edge.line);
|
|
1201
|
+
}
|
|
1202
|
+
const into = way === 'left of' || way === 'above' ? before : after;
|
|
1203
|
+
into.set(anchor, [...(into.get(anchor) ?? []), end]);
|
|
1204
|
+
attached.add(end);
|
|
1205
|
+
}
|
|
1206
|
+
const chained = [];
|
|
1207
|
+
const emit = (end) => {
|
|
1208
|
+
(before.get(end) ?? []).forEach(emit);
|
|
1209
|
+
chained.push(end);
|
|
1210
|
+
(after.get(end) ?? []).forEach(emit);
|
|
1211
|
+
};
|
|
1212
|
+
ordered.filter((end) => !attached.has(end)).forEach(emit);
|
|
1213
|
+
const looped = ordered.find((end) => !chained.includes(end));
|
|
1214
|
+
if (looped !== undefined && looped.spec.kind === 'side' && looped.spec.after !== undefined) {
|
|
1215
|
+
const { name, way } = looped.spec.after;
|
|
1216
|
+
throw new SourceError(`edge ${describeEdge(looped.edge)}: "${looped.which}: ${side}, ${way} ${name}" — the lines on ${looped.node.name}'s ${side} side are placed against each other in a loop, so none of them has a place to start from`, looped.edge.line);
|
|
1217
|
+
}
|
|
1218
|
+
ordered.splice(0, ordered.length, ...chained);
|
|
1219
|
+
// Pinned ends keep their slots in that order but take them in the order
|
|
1220
|
+
// they sit along the side, so the side is split from one end to the other
|
|
1221
|
+
// even where their far boxes tie (`to: left, even with cat1` from one box).
|
|
1222
|
+
const slots = ordered.flatMap((end, index) => (end.spec.kind === 'side' ? [] : [index]));
|
|
1223
|
+
const pins = slots.map((index) => ordered[index]).sort((a, b) => along(a.at) - along(b.at));
|
|
1224
|
+
slots.forEach((index, k) => (ordered[index] = pins[k]));
|
|
1225
|
+
const low = across ? face.x : face.y;
|
|
1226
|
+
const high = across ? face.x + face.width : face.y + face.height;
|
|
1227
|
+
let from = low;
|
|
1228
|
+
let run = [];
|
|
1229
|
+
const place = (to) => {
|
|
1230
|
+
run.forEach((end, index) => {
|
|
1231
|
+
end.at = onSide(face, side, from + ((to - from) * (index + 1)) / (run.length + 1));
|
|
1232
|
+
});
|
|
1233
|
+
run = [];
|
|
1234
|
+
};
|
|
1235
|
+
for (const end of ordered) {
|
|
1236
|
+
if (end.spec.kind === 'side') {
|
|
1237
|
+
run.push(end);
|
|
1238
|
+
continue;
|
|
1239
|
+
}
|
|
1240
|
+
const pinned = along(end.at);
|
|
1241
|
+
place(pinned);
|
|
1242
|
+
from = pinned;
|
|
1243
|
+
}
|
|
1244
|
+
place(high);
|
|
1245
|
+
if (ordered.length > 1) {
|
|
1246
|
+
shared.push({
|
|
1247
|
+
node: group[0].node,
|
|
1248
|
+
side,
|
|
1249
|
+
ends: ordered.map((end) => ({ at: end.at, edge: end.edge, which: end.which })).sort((p, q) => along(p.at) - along(q.at)),
|
|
1250
|
+
});
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
for (const node of nodes) {
|
|
1254
|
+
for (const key of EDGE_ORDER_KEYS) {
|
|
1255
|
+
if (node.attrs[key] === undefined || listsUsed.has(`${node.name}\n${key}`))
|
|
1256
|
+
continue;
|
|
1257
|
+
throw new SourceError(`node "${node.name}": ${key}: lists ${node.attrs[key]}, and no line spreads along ${node.name}'s ${key.split('-')[0]} side`, node.line);
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
return shared;
|
|
1261
|
+
}
|
|
1262
|
+
/** The side an end lands on, for grouping: a side, a fraction along one, or a point in the middle of one. */
|
|
1263
|
+
function sideOf(end) {
|
|
1264
|
+
const spec = end.spec;
|
|
1265
|
+
if (spec.kind === 'side' || spec.kind === 'fraction' || spec.kind === 'even')
|
|
1266
|
+
return spec.side;
|
|
1267
|
+
if (spec.kind === 'point') {
|
|
1268
|
+
const [vertical, horizontal] = cornerSides(spec.position);
|
|
1269
|
+
if (vertical && horizontal)
|
|
1270
|
+
return undefined;
|
|
1271
|
+
return vertical ?? horizontal;
|
|
1272
|
+
}
|
|
1273
|
+
return undefined;
|
|
1274
|
+
}
|
|
1275
|
+
function route(edge, start, end, context) {
|
|
1276
|
+
const subject = edge.bundle ? `bundle ${edge.name}` : `edge ${describeEdge(edge)}`;
|
|
1277
|
+
// A straight line from a box back to itself has nowhere to go but along
|
|
1278
|
+
// its own border; a loop needs a path that bends round something.
|
|
1279
|
+
const bends = edge.look.path !== 'straight' || (edge.via ?? []).some((entry) => entry.leg?.attrs['path'] !== undefined && entry.leg.attrs['path'] !== 'straight');
|
|
1280
|
+
if (!edge.bundle && edge.from === edge.to && !edge.onLine?.from && !edge.onLine?.to && !bends) {
|
|
1281
|
+
throw new SourceError(`${subject} goes from ${edge.from.name} back to itself, and a straight line can't loop — ` +
|
|
1282
|
+
`write it as a loop round a side, such as from: left to: right via: ${edge.from.name} top line: (path: square)`, edge.line);
|
|
1283
|
+
}
|
|
1284
|
+
// A bundle has no ends: its path is its stops alone (6.6).
|
|
1285
|
+
const ways = edge.bundle
|
|
1286
|
+
? []
|
|
1287
|
+
: [
|
|
1288
|
+
{
|
|
1289
|
+
at: start.at,
|
|
1290
|
+
face: start.face,
|
|
1291
|
+
...(start.headings.length === 1 ? { dir: start.headings[0] } : {}),
|
|
1292
|
+
...(start.headings.length > 1 ? { choices: start.headings } : {}),
|
|
1293
|
+
},
|
|
1294
|
+
];
|
|
1295
|
+
const segments = [];
|
|
1296
|
+
let pending;
|
|
1297
|
+
const pointLegs = new Map();
|
|
1298
|
+
const stops = [];
|
|
1299
|
+
const entries = edge.via ?? [];
|
|
1300
|
+
for (const [index, entry] of entries.entries()) {
|
|
1301
|
+
if (entry.leg && !entry.stop) {
|
|
1302
|
+
if (pending) {
|
|
1303
|
+
throw new SourceError(`${subject}: leg ${pending.name} and leg ${entry.leg.name} name the same stretch — keep one, or put a stop between them`, edge.line);
|
|
1304
|
+
}
|
|
1305
|
+
if (ways.length === 0) {
|
|
1306
|
+
throw new SourceError(`${subject}: leg ${entry.leg.name} comes before the first stop, and a bundle starts there — name a stretch between two stops`, edge.line);
|
|
1307
|
+
}
|
|
1308
|
+
pending = entry.leg;
|
|
1309
|
+
continue;
|
|
1310
|
+
}
|
|
1311
|
+
const stop = entry.stop;
|
|
1312
|
+
const prev = ways.length > 0 ? ways[ways.length - 1].at : firstPrev(edge, index);
|
|
1313
|
+
const stopWays = stop.kind === 'bundle'
|
|
1314
|
+
? alongBundle(edge, stop, prev, context, subject)
|
|
1315
|
+
: expand(stop, edge.viaAt?.[index], prev, context, subject, edge.line);
|
|
1316
|
+
// A bundle's first stop is where it starts, so nothing leads into it.
|
|
1317
|
+
if (ways.length > 0)
|
|
1318
|
+
segments.push({ kind: 'stretch', ...(pending ? { leg: pending } : {}) });
|
|
1319
|
+
pending = undefined;
|
|
1320
|
+
ways.push(stopWays[0]);
|
|
1321
|
+
const hidden = stop.kind === 'bundle' && context.lines.get(stop.bundle).bundle.arrangement === 'collapsed';
|
|
1322
|
+
for (const next of stopWays.slice(1)) {
|
|
1323
|
+
segments.push({ kind: 'run', ...(entry.leg ? { leg: entry.leg } : {}), ...(hidden ? { hidden } : {}) });
|
|
1324
|
+
ways.push(next);
|
|
1325
|
+
}
|
|
1326
|
+
if (entry.leg && stopWays.length === 1)
|
|
1327
|
+
pointLegs.set(entry.leg.name, stopWays[0].at);
|
|
1328
|
+
stops.push({ written: stop.written, points: stopWays.map((way) => way.at) });
|
|
1329
|
+
}
|
|
1330
|
+
const back = (heading) => ({ x: -heading.x, y: -heading.y });
|
|
1331
|
+
if (edge.bundle) {
|
|
1332
|
+
if (pending)
|
|
1333
|
+
throw new SourceError(`${subject}: leg ${pending.name} comes after the last stop, and a bundle ends there — name a stretch between two stops`, edge.line);
|
|
1334
|
+
if (ways.length < 2) {
|
|
1335
|
+
throw new SourceError(`${subject}: a bundle's path runs from one place to another, and via: names one point — add a stop, or pass a side or a region, such as \`via: through slot horizontal\``, edge.line);
|
|
1336
|
+
}
|
|
1337
|
+
}
|
|
1338
|
+
else {
|
|
1339
|
+
segments.push({ kind: 'stretch', ...(pending ? { leg: pending } : {}) });
|
|
1340
|
+
ways.push({
|
|
1341
|
+
at: end.at,
|
|
1342
|
+
face: end.face,
|
|
1343
|
+
...(end.headings.length === 1 ? { dir: back(end.headings[0]) } : {}),
|
|
1344
|
+
...(end.headings.length > 1 ? { choices: end.headings.map(back) } : {}),
|
|
1345
|
+
});
|
|
1346
|
+
}
|
|
1347
|
+
// A curved line passes a stop with no way of its own, such as a corner,
|
|
1348
|
+
// heading from the place before it toward the place after it (6.4), so a
|
|
1349
|
+
// set of stops that mirror each other draws a curve that does too.
|
|
1350
|
+
const smooth = ways.map((way, index) => index > 0 && index < ways.length - 1 && !way.dir && !way.choices ? unit(ways[index - 1].at, ways[index + 1].at) : undefined);
|
|
1351
|
+
// Each segment's points, in order, and whether it is drawn curved.
|
|
1352
|
+
const drawn = [];
|
|
1353
|
+
let arriving;
|
|
1354
|
+
// For each stretch the line's own turn: shapes, why it placed nothing there, or undefined where it did.
|
|
1355
|
+
const unturned = [];
|
|
1356
|
+
for (const [index, segment] of segments.entries()) {
|
|
1357
|
+
const from = ways[index];
|
|
1358
|
+
const to = ways[index + 1];
|
|
1359
|
+
const attrs = segment.leg?.attrs ?? {};
|
|
1360
|
+
const path = (attrs['path'] ?? edge.look.path);
|
|
1361
|
+
if (segment.kind === 'run' || path === 'straight') {
|
|
1362
|
+
drawn.push({ points: [from.at, to.at], curved: false, through: [from.at, to.at], segment, from, to, step: 0 });
|
|
1363
|
+
arriving = segment.kind === 'run' ? unit(from.at, to.at) : undefined;
|
|
1364
|
+
continue;
|
|
1365
|
+
}
|
|
1366
|
+
const turn = attrs['turn'] !== undefined ? fractionOf(attrs['turn']) ?? edge.look.turn : edge.look.turn;
|
|
1367
|
+
const step = attrs['step-out'] !== undefined ? gapPixels(attrs['step-out'], `${subject}: leg ${segment.leg.name}: step-out:`, edge.line) : edge.look.stepOut;
|
|
1368
|
+
const outs = from.dir ? [from.dir] : from.choices ?? [departure(from, to, arriving)];
|
|
1369
|
+
const ins = to.dir ? [to.dir] : to.choices ?? [];
|
|
1370
|
+
const options = [];
|
|
1371
|
+
for (const out of outs) {
|
|
1372
|
+
const arrivals = ins.length > 0 ? ins : [arrival(from.at, to.at, out)];
|
|
1373
|
+
for (const arrive of arrivals) {
|
|
1374
|
+
options.push({ out, arrive, built: squareRoute(from, to, out, back(arrive), turn ?? 1 / 2, step) });
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
const bends = (option) => option.built.points.length - 2;
|
|
1378
|
+
const fewest = Math.min(...options.map(bends));
|
|
1379
|
+
const best = options.filter((option) => bends(option) === fewest);
|
|
1380
|
+
if (best.length > 1) {
|
|
1381
|
+
refuseTie(edge, index === 0 ? start : end, best.map((option) => (index === 0 ? option.out : back(option.arrive))), fewest);
|
|
1382
|
+
}
|
|
1383
|
+
const chosen = best[0];
|
|
1384
|
+
// A point with no way of its own is passed in the way the line arrives,
|
|
1385
|
+
// along an axis, which is what a square line needs (6.4).
|
|
1386
|
+
from.dir ??= chosen.out;
|
|
1387
|
+
to.dir ??= chosen.arrive;
|
|
1388
|
+
arriving = chosen.arrive;
|
|
1389
|
+
// Only a stated turn is passed through; without one a curve is one free sweep.
|
|
1390
|
+
const turned = turn !== undefined ? chosen.built.turned : undefined;
|
|
1391
|
+
if (attrs['turn'] !== undefined && !chosen.built.turned) {
|
|
1392
|
+
throw new SourceError(`${subject}: leg ${segment.leg.name}: \`turn: ${attrs['turn']}\` places nothing — ${chosen.built.unturned ?? 'it has no leg between its stops'}. Remove it, or say where the line goes with \`via:\``, edge.line);
|
|
1393
|
+
}
|
|
1394
|
+
if (attrs['turn'] === undefined)
|
|
1395
|
+
unturned.push(chosen.built.turned ? undefined : chosen.built.unturned ?? '');
|
|
1396
|
+
drawn.push({ points: chosen.built.points, curved: path === 'curved', through: chosen.built.through, ...(turned ? { turned } : {}), segment, from, to, step });
|
|
1397
|
+
if (segments.length === 1) {
|
|
1398
|
+
context.shapes.set(edge, { square: chosen.built.points, through: chosen.built.through, curved: path === 'curved', out: chosen.out, arrive: chosen.arrive, ...(turned ? { turned } : {}) });
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
// A turn: written on the line itself must place a leg somewhere; from a style or a default it lands where it can.
|
|
1402
|
+
if (edge.look.turnWritten !== undefined && unturned.length > 0 && unturned.every((why) => why !== undefined)) {
|
|
1403
|
+
const why = unturned.length === 1 && unturned[0] ? unturned[0] : 'no stretch of it has a leg between its stops';
|
|
1404
|
+
throw new SourceError(`${subject}: \`turn: ${edge.look.turnWritten}\` places nothing — ${why}. Remove it, or say where the line goes with \`via:\``, edge.line);
|
|
1405
|
+
}
|
|
1406
|
+
// The pieces, and each named leg as drawn.
|
|
1407
|
+
const pieces = [];
|
|
1408
|
+
const legs = new Map();
|
|
1409
|
+
const points = [ways[0].at];
|
|
1410
|
+
const hidden = [];
|
|
1411
|
+
for (const [index, part] of drawn.entries()) {
|
|
1412
|
+
const own = part.curved
|
|
1413
|
+
? curve(part.through, smooth[index] ?? part.from.dir, smooth[index + 1] ?? part.to.dir, edge.look.curve, part.segment.leg?.attrs['curve'], part.step, part.turned)
|
|
1414
|
+
: part.points.slice(1).map((to) => ({ to }));
|
|
1415
|
+
if (part.segment.hidden) {
|
|
1416
|
+
const last = hidden[hidden.length - 1];
|
|
1417
|
+
if (last && last[1] === pieces.length)
|
|
1418
|
+
last[1] += own.length;
|
|
1419
|
+
else
|
|
1420
|
+
hidden.push([pieces.length, pieces.length + own.length]);
|
|
1421
|
+
}
|
|
1422
|
+
pieces.push(...own);
|
|
1423
|
+
points.push(...(part.curved ? tracePieces(part.from.at, own) : part.points).slice(1));
|
|
1424
|
+
const name = part.segment.leg?.name;
|
|
1425
|
+
if (name !== undefined) {
|
|
1426
|
+
const trace = tracePieces(part.points[0], own);
|
|
1427
|
+
const before = legs.get(name);
|
|
1428
|
+
// A run named once may be several segments, along a leg's corners.
|
|
1429
|
+
legs.set(name, before && drawn[index - 1]?.segment.leg?.name === name ? [...before, ...trace.slice(1)] : trace);
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
for (const [name, at] of pointLegs)
|
|
1433
|
+
legs.set(name, [at]);
|
|
1434
|
+
return { edge, start: ways[0].at, pieces, points: tidyCorners(points), legs, stops, ...(hidden.length > 0 ? { hidden } : {}) };
|
|
1435
|
+
}
|
|
1436
|
+
/**
|
|
1437
|
+
* What a bundle's first stop is measured from, having nothing before it: the
|
|
1438
|
+
* far side of it from the next stop, so a run there is taken away from where
|
|
1439
|
+
* the bundle goes next. With no next stop, the stop itself, which leaves a run
|
|
1440
|
+
* in reading order, left or top first.
|
|
1441
|
+
*/
|
|
1442
|
+
function firstPrev(edge, index) {
|
|
1443
|
+
const center = (rect) => ({ x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 });
|
|
1444
|
+
const own = edge.viaAt?.[index];
|
|
1445
|
+
const here = own ? center(own) : { x: 0, y: 0 };
|
|
1446
|
+
const next = edge.viaAt?.slice(index + 1).find((rect) => rect !== undefined);
|
|
1447
|
+
if (next === undefined)
|
|
1448
|
+
return here;
|
|
1449
|
+
const there = center(next);
|
|
1450
|
+
return { x: 2 * here.x - there.x, y: 2 * here.y - there.y };
|
|
1451
|
+
}
|
|
1452
|
+
/**
|
|
1453
|
+
* The lines through each bundle and the lane each runs in: its distance to the
|
|
1454
|
+
* left of the bundle's own way of travel (6.6). Members are spread evenly about
|
|
1455
|
+
* the path a gap apart, in one order read across the bundle where it starts —
|
|
1456
|
+
* top to bottom on a level start, left to right on an upright one — and held
|
|
1457
|
+
* round every turn. A collapsed bundle runs them all on the path itself.
|
|
1458
|
+
*/
|
|
1459
|
+
function lanesOf(bundle, context) {
|
|
1460
|
+
const cached = context.lanes.get(bundle);
|
|
1461
|
+
if (cached)
|
|
1462
|
+
return cached;
|
|
1463
|
+
const subject = `bundle ${bundle.name}`;
|
|
1464
|
+
const members = context.layout.edges.filter((edge) => (edge.via ?? []).some((entry) => entry.stop?.kind === 'bundle' && entry.stop.bundle === bundle.name));
|
|
1465
|
+
const placed = (edge) => (edge.via ?? []).map((entry) => entry.stop).find((stop) => stop?.kind === 'bundle' && stop.bundle === bundle.name);
|
|
1466
|
+
const named = new Map(members.filter((edge) => edge.name !== undefined).map((edge) => [edge.name, edge]));
|
|
1467
|
+
const info = bundle.bundle;
|
|
1468
|
+
let ordered;
|
|
1469
|
+
if (info.order) {
|
|
1470
|
+
// The bundle's list is its whole membership (design.org 6.6).
|
|
1471
|
+
for (const name of info.order) {
|
|
1472
|
+
if (!named.has(name)) {
|
|
1473
|
+
throw new SourceError(context.lines.has(name)
|
|
1474
|
+
? `${subject}: order: lists ${name}, which doesn't pass through it — add \`via: ${bundle.name}\` to ${name}, or take it off the list`
|
|
1475
|
+
: `${subject}: order: lists ${name}, which is not a line; order: names lines through the bundle`, bundle.line);
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
if (new Set(info.order).size !== info.order.length) {
|
|
1479
|
+
throw new SourceError(`${subject}: order: lists a line twice — each line has one place`, bundle.line);
|
|
1480
|
+
}
|
|
1481
|
+
for (const member of members) {
|
|
1482
|
+
if (member.name === undefined || !info.order.includes(member.name)) {
|
|
1483
|
+
throw new SourceError(`edge ${describeEdge(member)}: passes through ${bundle.name}, whose order: lists every line through it and leaves this one out` +
|
|
1484
|
+
(member.name === undefined ? ' — name the line, such as `edge x a -> b`, and add it to the list' : ` — add ${member.name} to the list`), member.line);
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
ordered = info.order.map((name) => named.get(name));
|
|
1488
|
+
}
|
|
1489
|
+
else {
|
|
1490
|
+
ordered = [...members];
|
|
1491
|
+
}
|
|
1492
|
+
// `via: trunk (after: z)`: one line placing itself, as the terms of order: say.
|
|
1493
|
+
for (const member of members) {
|
|
1494
|
+
const stop = placed(member);
|
|
1495
|
+
if (!stop.order)
|
|
1496
|
+
continue;
|
|
1497
|
+
const { way, name } = stop.order;
|
|
1498
|
+
const anchor = named.get(name);
|
|
1499
|
+
if (anchor === undefined || anchor === member) {
|
|
1500
|
+
throw new SourceError(`edge ${describeEdge(member)}: "via: ${stop.written}" — ${anchor === member ? 'a line has no place relative to itself' : `${name} is not a line through ${bundle.name}`}`, member.line);
|
|
1501
|
+
}
|
|
1502
|
+
if (info.arrangement === 'collapsed') {
|
|
1503
|
+
throw new SourceError(`edge ${describeEdge(member)}: "via: ${stop.written}" — ${bundle.name} is collapsed, so its lines share one path and have no order across`, member.line);
|
|
1504
|
+
}
|
|
1505
|
+
if (info.order) {
|
|
1506
|
+
const holds = way === 'after' ? ordered.indexOf(member) > ordered.indexOf(anchor) : ordered.indexOf(member) < ordered.indexOf(anchor);
|
|
1507
|
+
if (!holds) {
|
|
1508
|
+
throw new SourceError(`edge ${describeEdge(member)}: "via: ${stop.written}" puts it ${way} ${name}, and ${bundle.name}'s order: (${info.order.join(', ')}) puts it ${way === 'after' ? 'before' : 'after'} — keep one`, member.line);
|
|
1509
|
+
}
|
|
1510
|
+
continue;
|
|
1511
|
+
}
|
|
1512
|
+
ordered.splice(ordered.indexOf(member), 1);
|
|
1513
|
+
const at = ordered.indexOf(anchor);
|
|
1514
|
+
ordered.splice(way === 'after' ? at + 1 : at, 0, member);
|
|
1515
|
+
}
|
|
1516
|
+
const lanes = new Map();
|
|
1517
|
+
const trace = traceRoute(context.routes.get(bundle));
|
|
1518
|
+
const first = unit(trace[0], trace[1]);
|
|
1519
|
+
// Reading order across the start: down for a level start, rightward for an upright one.
|
|
1520
|
+
const reading = Math.abs(first.x) >= Math.abs(first.y) ? { x: 0, y: 1 } : { x: 1, y: 0 };
|
|
1521
|
+
const left = { x: first.y, y: -first.x };
|
|
1522
|
+
const sign = reading.x * left.x + reading.y * left.y;
|
|
1523
|
+
const gap = info.arrangement === 'collapsed' ? 0 : info.gap;
|
|
1524
|
+
ordered.forEach((member, index) => lanes.set(member, (index - (ordered.length - 1) / 2) * gap * sign));
|
|
1525
|
+
context.lanes.set(bundle, lanes);
|
|
1526
|
+
return lanes;
|
|
1527
|
+
}
|
|
1528
|
+
/**
|
|
1529
|
+
* A member's way along a bundle: the whole path, shifted into its lane, entered
|
|
1530
|
+
* at the end nearer where the line comes from (6.6).
|
|
1531
|
+
*/
|
|
1532
|
+
function alongBundle(edge, stop, prev, context, subject) {
|
|
1533
|
+
const bundle = context.lines.get(stop.bundle);
|
|
1534
|
+
if (edge.via.filter((entry) => entry.stop?.kind === 'bundle' && entry.stop.bundle === stop.bundle).length > 1) {
|
|
1535
|
+
throw new SourceError(`${subject}: passes through ${stop.bundle} twice, and a line has one lane in a bundle — use a second bundle`, edge.line);
|
|
1536
|
+
}
|
|
1537
|
+
const lane = lanesOf(bundle, context).get(edge);
|
|
1538
|
+
const path = tidyCorners(traceRoute(context.routes.get(bundle)));
|
|
1539
|
+
const shifted = offsetLine(path, lane);
|
|
1540
|
+
const forward = distance(prev, shifted[0]) <= distance(prev, shifted[shifted.length - 1]);
|
|
1541
|
+
const ordered = forward ? shifted : [...shifted].reverse();
|
|
1542
|
+
return ordered.map((p, index) => {
|
|
1543
|
+
const dir = unit(ordered[Math.max(0, index - 1)], ordered[Math.max(1, index)]);
|
|
1544
|
+
return { at: p, face: { x: p.x, y: p.y, width: 0, height: 0, round: false }, dir };
|
|
1545
|
+
});
|
|
1546
|
+
}
|
|
1547
|
+
/**
|
|
1548
|
+
* A polyline moved `by` to the left of the way it travels, each corner where
|
|
1549
|
+
* its two moved neighbors meet, so the moved line keeps its distance round
|
|
1550
|
+
* every turn.
|
|
1551
|
+
*/
|
|
1552
|
+
function offsetLine(points, by) {
|
|
1553
|
+
if (by === 0)
|
|
1554
|
+
return points;
|
|
1555
|
+
const normals = points.slice(1).map((point, index) => {
|
|
1556
|
+
const along = unit(points[index], point);
|
|
1557
|
+
return { x: along.y * by, y: -along.x * by };
|
|
1558
|
+
});
|
|
1559
|
+
return points.map((point, index) => {
|
|
1560
|
+
const before = normals[index - 1];
|
|
1561
|
+
const after = normals[index];
|
|
1562
|
+
if (!before)
|
|
1563
|
+
return { x: point.x + after.x, y: point.y + after.y };
|
|
1564
|
+
if (!after)
|
|
1565
|
+
return { x: point.x + before.x, y: point.y + before.y };
|
|
1566
|
+
// Where the two moved legs meet: the miter, as long as the turn needs.
|
|
1567
|
+
const sum = { x: before.x + after.x, y: before.y + after.y };
|
|
1568
|
+
const length = Math.hypot(sum.x, sum.y);
|
|
1569
|
+
if (length < 1e-9)
|
|
1570
|
+
return { x: point.x + after.x, y: point.y + after.y };
|
|
1571
|
+
const scale = (by * by * 2) / (length * length);
|
|
1572
|
+
return { x: point.x + sum.x * scale, y: point.y + sum.y * scale };
|
|
1573
|
+
});
|
|
1574
|
+
}
|
|
1575
|
+
/** The way out of a point with none of its own: along the axis the line was traveling, else the longer way toward the next point. */
|
|
1576
|
+
function departure(from, to, arriving) {
|
|
1577
|
+
if (arriving)
|
|
1578
|
+
return arriving;
|
|
1579
|
+
const dx = to.at.x - from.at.x;
|
|
1580
|
+
const dy = to.at.y - from.at.y;
|
|
1581
|
+
return Math.abs(dx) >= Math.abs(dy) ? { x: dx >= 0 ? 1 : -1, y: 0 } : { x: 0, y: dy >= 0 ? 1 : -1 };
|
|
1582
|
+
}
|
|
1583
|
+
/** The way into a point with none of its own: along the axis the stretch left on, toward it. */
|
|
1584
|
+
function arrival(from, to, out) {
|
|
1585
|
+
if (out.x !== 0) {
|
|
1586
|
+
const dx = to.x - from.x;
|
|
1587
|
+
if (Math.abs(dx) >= 0.5)
|
|
1588
|
+
return { x: dx > 0 ? 1 : -1, y: 0 };
|
|
1589
|
+
return { x: 0, y: to.y >= from.y ? 1 : -1 };
|
|
1590
|
+
}
|
|
1591
|
+
const dy = to.y - from.y;
|
|
1592
|
+
if (Math.abs(dy) >= 0.5)
|
|
1593
|
+
return { x: 0, y: dy > 0 ? 1 : -1 };
|
|
1594
|
+
return { x: to.x >= from.x ? 1 : -1, y: 0 };
|
|
1595
|
+
}
|
|
1596
|
+
function unit(from, to) {
|
|
1597
|
+
const length = distance(from, to) || 1;
|
|
1598
|
+
return { x: (to.x - from.x) / length, y: (to.y - from.y) / length };
|
|
1599
|
+
}
|
|
1600
|
+
/**
|
|
1601
|
+
* The points a stop puts on the line (6.4): one for a point, two or more for a
|
|
1602
|
+
* run, nearer end first.
|
|
1603
|
+
*/
|
|
1604
|
+
function expand(stop, at, prev, context, subject, line) {
|
|
1605
|
+
const point = (p, dir) => ({ at: p, face: { x: p.x, y: p.y, width: 0, height: 0, round: false }, ...(dir ? { dir } : {}) });
|
|
1606
|
+
const run = (a, b) => {
|
|
1607
|
+
// Nearer end first; an exact tie goes in reading order, left or top first.
|
|
1608
|
+
const first = distance(prev, a) < distance(prev, b) - 0.5 || (Math.abs(distance(prev, a) - distance(prev, b)) <= 0.5 && (a.x < b.x || (a.x === b.x && a.y <= b.y)));
|
|
1609
|
+
const [p, q] = first ? [a, b] : [b, a];
|
|
1610
|
+
const dir = unit(p, q);
|
|
1611
|
+
return [point(p, dir), point(q, dir)];
|
|
1612
|
+
};
|
|
1613
|
+
if (stop.kind === 'online') {
|
|
1614
|
+
if (stop.along === undefined)
|
|
1615
|
+
return [point(alongLength(traceNamed(stop.line, context, subject, line), stop.at))];
|
|
1616
|
+
// `req b top`: the middle of where req passes its own stop `b top`.
|
|
1617
|
+
const owner = context.lines.get(stop.line);
|
|
1618
|
+
if (owner === undefined) {
|
|
1619
|
+
throw new SourceError(`${subject}: "via: ${stop.written}" — ${stop.line} is not a line, so "${stop.along}" after it is a second stop, and stops are separated by commas`, line);
|
|
1620
|
+
}
|
|
1621
|
+
const found = context.routes.get(owner).stops.filter((one) => one.written === stop.along);
|
|
1622
|
+
if (found.length !== 1) {
|
|
1623
|
+
throw new SourceError(found.length === 0
|
|
1624
|
+
? `${subject}: "via: ${stop.written}" — ${stop.line} never passes ${stop.along}; its via: list doesn't name it`
|
|
1625
|
+
: `${subject}: "via: ${stop.written}" — ${stop.line} passes ${stop.along} ${found.length} times, so this could be any of them; name the one meant with a leg, \`leg <name> ${stop.along}\`, and write \`<name> at 1/2\``, line);
|
|
1626
|
+
}
|
|
1627
|
+
return [point(alongLength(found[0].points, 1 / 2))];
|
|
1628
|
+
}
|
|
1629
|
+
if (stop.kind === 'through') {
|
|
1630
|
+
const box = at;
|
|
1631
|
+
const middle = { x: box.x + box.width / 2, y: box.y + box.height / 2 };
|
|
1632
|
+
return stop.axis === 'horizontal'
|
|
1633
|
+
? run({ x: box.x, y: middle.y }, { x: box.x + box.width, y: middle.y })
|
|
1634
|
+
: run({ x: middle.x, y: box.y }, { x: middle.x, y: box.y + box.height });
|
|
1635
|
+
}
|
|
1636
|
+
if (stop.kind === 'alongside') {
|
|
1637
|
+
const trace = traceNamed(stop.leg, context, subject, line);
|
|
1638
|
+
const level = trace.every((p) => Math.abs(p.y - trace[0].y) < 0.5);
|
|
1639
|
+
const upright = trace.every((p) => Math.abs(p.x - trace[0].x) < 0.5);
|
|
1640
|
+
const across = stop.way === 'above' || stop.way === 'below';
|
|
1641
|
+
if ((across && upright) || (!across && level)) {
|
|
1642
|
+
throw new SourceError(`${subject}: "via: ${stop.written}" — ${stop.leg} runs ${upright ? 'up and down' : 'across'}, so it has no ${across ? 'above or below' : 'left or right'}; ` +
|
|
1643
|
+
`say ${across ? 'left of or right of' : 'above or below'}`, line);
|
|
1644
|
+
}
|
|
1645
|
+
const gap = stop.gap === undefined ? GAPS['normal'] : gapPixels(stop.gap, `${subject}: via:`, line);
|
|
1646
|
+
const shift = { above: { x: 0, y: -gap }, below: { x: 0, y: gap }, 'left of': { x: -gap, y: 0 }, 'right of': { x: gap, y: 0 } }[stop.way];
|
|
1647
|
+
const moved = trace.map((p) => ({ x: p.x + shift.x, y: p.y + shift.y }));
|
|
1648
|
+
const forward = distance(prev, moved[0]) <= distance(prev, moved[moved.length - 1]);
|
|
1649
|
+
const ordered = forward ? moved : [...moved].reverse();
|
|
1650
|
+
return ordered.map((p, index) => point(p, unit(ordered[Math.max(0, index - 1)], ordered[Math.max(1, index)])));
|
|
1651
|
+
}
|
|
1652
|
+
// A bundle is followed by `alongBundle`, never expanded here.
|
|
1653
|
+
if (stop.kind === 'bundle')
|
|
1654
|
+
throw new Error(`bundle stop ${stop.written} reached expand`);
|
|
1655
|
+
// A place: a side is a run just outside it, a point on a side is passed just
|
|
1656
|
+
// outside it going along the side, and anything else is passed through.
|
|
1657
|
+
// "Just outside" and "just inside" are a tight gap: a line passing a box
|
|
1658
|
+
// needs less room than two boxes need from each other.
|
|
1659
|
+
const box = at;
|
|
1660
|
+
const target = stop.target;
|
|
1661
|
+
const gap = stop.gap !== undefined
|
|
1662
|
+
? gapPixels(stop.gap, `${subject}: via:`, line)
|
|
1663
|
+
: stop.mode === 'on' ? 0 : GAPS['tight'];
|
|
1664
|
+
const out = stop.mode === 'inside' ? -gap : stop.mode === 'on' ? 0 : gap;
|
|
1665
|
+
const part = target.part;
|
|
1666
|
+
if (stop.gap !== undefined && stop.mode === 'on') {
|
|
1667
|
+
throw new SourceError(`${subject}: "via: ${stop.written}" — on is exactly on the side, so it takes no gap`, line);
|
|
1668
|
+
}
|
|
1669
|
+
if (part !== undefined && isSide(part) && target.fraction === undefined) {
|
|
1670
|
+
const normal = SIDE_NORMAL[part];
|
|
1671
|
+
const across = part === 'top' || part === 'bottom';
|
|
1672
|
+
const a = across ? { x: box.x, y: box.y } : { x: box.x, y: box.y };
|
|
1673
|
+
const b = across ? { x: box.x + box.width, y: box.y } : { x: box.x, y: box.y + box.height };
|
|
1674
|
+
return run({ x: a.x + normal.x * out, y: a.y + normal.y * out }, { x: b.x + normal.x * out, y: b.y + normal.y * out });
|
|
1675
|
+
}
|
|
1676
|
+
if (part !== undefined && part !== 'text' && part !== 'center') {
|
|
1677
|
+
// A point on a side, or a corner: just outside it, on each side it is on.
|
|
1678
|
+
const position = isSide(part) ? undefined : part;
|
|
1679
|
+
const [vertical, horizontal] = position ? cornerSides(position) : isSide(part) && (part === 'top' || part === 'bottom') ? [part, undefined] : [undefined, part];
|
|
1680
|
+
const shift = {
|
|
1681
|
+
x: (horizontal === 'left' ? -1 : horizontal === 'right' ? 1 : 0) * out,
|
|
1682
|
+
y: (vertical === 'top' ? -1 : vertical === 'bottom' ? 1 : 0) * out,
|
|
1683
|
+
};
|
|
1684
|
+
const spot = { x: box.x + shift.x, y: box.y + shift.y };
|
|
1685
|
+
// On one side only, the line travels along that side there.
|
|
1686
|
+
const along = vertical && !horizontal ? { x: spot.x >= prev.x ? 1 : -1, y: 0 } : horizontal && !vertical ? { x: 0, y: spot.y >= prev.y ? 1 : -1 } : undefined;
|
|
1687
|
+
return [point(spot, along)];
|
|
1688
|
+
}
|
|
1689
|
+
// A box, a region or a point: through its middle.
|
|
1690
|
+
return [point({ x: box.x + box.width / 2, y: box.y + box.height / 2 })];
|
|
1691
|
+
}
|
|
1692
|
+
function refuseTie(edge, which, headings, bends) {
|
|
1693
|
+
const position = which.spec.position;
|
|
1694
|
+
const [vertical, horizontal] = cornerSides(position);
|
|
1695
|
+
const ways = [...new Set(headings.map(wordOf))];
|
|
1696
|
+
const key = which.which;
|
|
1697
|
+
const across = horizontal === 'right' ? '1/1' : '0';
|
|
1698
|
+
const down = vertical === 'bottom' ? '1/1' : '0';
|
|
1699
|
+
throw new SourceError(`edge ${describeEdge(edge)}: ${which.node.name}'s ${position} heads ${ways.join(' or ')} with ${bends === 1 ? 'one bend' : `${bends} bends`} either way, ` +
|
|
1700
|
+
`and nothing says which — name it as a point on a side: ${key}: ${vertical} ${across} across, or ${key}: ${horizontal} ${down} down`, edge.line);
|
|
1701
|
+
}
|
|
1702
|
+
function wordOf(heading) {
|
|
1703
|
+
return heading.x > 0 ? 'right' : heading.x < 0 ? 'left' : heading.y > 0 ? 'down' : 'up';
|
|
1704
|
+
}
|
|
1705
|
+
/**
|
|
1706
|
+
* The square route between two points (design.org 6.3), leaving the first
|
|
1707
|
+
* along `out` and arriving at the second against `back`, which points the way
|
|
1708
|
+
* a box's side would face. One range, read off the two boxes (or the points
|
|
1709
|
+
* themselves, at a stop), decides where the free leg goes; everything else is
|
|
1710
|
+
* a step-out.
|
|
1711
|
+
*/
|
|
1712
|
+
function squareRoute(start, end, out, back, turn, step) {
|
|
1713
|
+
const a = start.at;
|
|
1714
|
+
const b = end.at;
|
|
1715
|
+
const fromFace = start.face;
|
|
1716
|
+
const toFace = end.face;
|
|
1717
|
+
const across = out.x !== 0;
|
|
1718
|
+
// u runs along the start's heading, v across it.
|
|
1719
|
+
const u = (p) => (across ? p.x : p.y);
|
|
1720
|
+
const v = (p) => (across ? p.y : p.x);
|
|
1721
|
+
const make = (uu, vv) => (across ? { x: uu, y: vv } : { x: vv, y: uu });
|
|
1722
|
+
const du = across ? out.x : out.y;
|
|
1723
|
+
const sa = { x: a.x + out.x * step, y: a.y + out.y * step };
|
|
1724
|
+
const sb = { x: b.x + back.x * step, y: b.y + back.y * step };
|
|
1725
|
+
const boxSpan = (face, onU) => onU === across ? [face.x, face.x + face.width] : [face.y, face.y + face.height];
|
|
1726
|
+
// The gap between the two boxes on an axis, as [near, far] in travel order, or undefined where they overlap.
|
|
1727
|
+
const gapOn = (onU) => {
|
|
1728
|
+
const [a0, a1] = boxSpan(fromFace, onU);
|
|
1729
|
+
const [b0, b1] = boxSpan(toFace, onU);
|
|
1730
|
+
if (a1 <= b0)
|
|
1731
|
+
return [a1, b0];
|
|
1732
|
+
if (b1 <= a0)
|
|
1733
|
+
return [a0, b1];
|
|
1734
|
+
return undefined;
|
|
1735
|
+
};
|
|
1736
|
+
const between = (from, to) => from + (to - from) * turn;
|
|
1737
|
+
// The free leg from p1 to p2, placed by `between`, as a curve passes it.
|
|
1738
|
+
const turnedOn = (p1, p2) => distance(p1, p2) < 0.5 ? {} : { turned: { at: midpoint(p1, p2), dir: unit(p1, p2) } };
|
|
1739
|
+
const parallel = (back.x !== 0) === across;
|
|
1740
|
+
if (parallel) {
|
|
1741
|
+
const facing = out.x * back.x + out.y * back.y < 0;
|
|
1742
|
+
if (facing && (u(b) - u(a)) * du >= 0) {
|
|
1743
|
+
// The range has two ends: the free leg runs between them.
|
|
1744
|
+
if (Math.abs(v(a) - v(b)) < 0.5)
|
|
1745
|
+
return { points: [a, b], through: [a, b], unturned: 'its ends are level, so it runs straight across' };
|
|
1746
|
+
const m = between(u(a), u(b));
|
|
1747
|
+
const p1 = make(m, v(a));
|
|
1748
|
+
const p2 = make(m, v(b));
|
|
1749
|
+
return { points: [a, p1, p2, b], through: [a, midpoint(p1, p2), b], ...turnedOn(p1, p2) };
|
|
1750
|
+
}
|
|
1751
|
+
if (!facing) {
|
|
1752
|
+
// Both sides face the same way: the leg runs a step-out beyond the farther side.
|
|
1753
|
+
const far = du > 0 ? Math.max(u(a), u(b)) + step : Math.min(u(a), u(b)) - step;
|
|
1754
|
+
const p1 = make(far, v(a));
|
|
1755
|
+
const p2 = make(far, v(b));
|
|
1756
|
+
return {
|
|
1757
|
+
points: tidyCorners([a, p1, p2, b]),
|
|
1758
|
+
through: [a, midpoint(p1, p2), b],
|
|
1759
|
+
unturned: 'both its ends face the same way, so its middle leg runs a step-out beyond the farther one, not between them',
|
|
1760
|
+
};
|
|
1761
|
+
}
|
|
1762
|
+
// The range is empty: each end steps out, and the line crosses halfway
|
|
1763
|
+
// between the boxes in the other direction, through the gap if there is one.
|
|
1764
|
+
const gap = gapOn(false);
|
|
1765
|
+
const m = gap ? between(gap[0], gap[1]) : between(v(a), v(b));
|
|
1766
|
+
const p1 = make(u(sa), m);
|
|
1767
|
+
const p2 = make(u(sb), m);
|
|
1768
|
+
return { points: tidyCorners([a, sa, p1, p2, sb, b]), through: [a, sa, midpoint(p1, p2), sb, b], ...turnedOn(p1, p2) };
|
|
1769
|
+
}
|
|
1770
|
+
// At right angles: one corner where it sits behind neither end's side.
|
|
1771
|
+
const corner = make(u(b), v(a));
|
|
1772
|
+
const dv = across ? back.y : back.x;
|
|
1773
|
+
const aheadOfStart = (u(corner) - u(a)) * du > 0;
|
|
1774
|
+
const aheadOfEnd = (v(corner) - v(b)) * dv > 0;
|
|
1775
|
+
const once = 'it turns once, at right angles, and that corner has only one place';
|
|
1776
|
+
if (aheadOfStart && aheadOfEnd)
|
|
1777
|
+
return { points: [a, corner, b], through: [a, b], unturned: once };
|
|
1778
|
+
// A waypoint in line with the other end: the corner is the waypoint itself,
|
|
1779
|
+
// so the line turns there rather than going round it.
|
|
1780
|
+
const point = (face) => face.width === 0 && face.height === 0;
|
|
1781
|
+
const inLine = 'it runs straight, in line with the waypoint';
|
|
1782
|
+
if (aheadOfStart && point(toFace) && Math.abs(v(corner) - v(b)) < 0.5)
|
|
1783
|
+
return { points: [a, b], through: [a, b], unturned: inLine };
|
|
1784
|
+
if (aheadOfEnd && point(fromFace) && Math.abs(u(corner) - u(a)) < 0.5)
|
|
1785
|
+
return { points: [a, b], through: [a, b], unturned: inLine };
|
|
1786
|
+
const round = 'it goes round the end a step-out off it, with no gap between the two to cross';
|
|
1787
|
+
if (!aheadOfStart && aheadOfEnd) {
|
|
1788
|
+
// The far end is behind the start's side: out, across the gap on the end's
|
|
1789
|
+
// axis (or round, a step-out off the end where there is none), and in.
|
|
1790
|
+
const gap = gapOn(false);
|
|
1791
|
+
const m = gap ? between(gap[0], gap[1]) : v(sb);
|
|
1792
|
+
const p1 = make(u(sa), m);
|
|
1793
|
+
const p2 = make(u(b), m);
|
|
1794
|
+
return { points: tidyCorners([a, sa, p1, p2, b]), through: [a, sa, midpoint(p1, p2), b], ...(gap ? turnedOn(p1, p2) : { unturned: round }) };
|
|
1795
|
+
}
|
|
1796
|
+
if (aheadOfStart && !aheadOfEnd) {
|
|
1797
|
+
// The start is behind the end's side: along to the gap on the start's axis
|
|
1798
|
+
// (or a step-out where there is none), round, and into the end.
|
|
1799
|
+
const gap = gapOn(true);
|
|
1800
|
+
const m = gap ? between(gap[0], gap[1]) : u(sa);
|
|
1801
|
+
const p1 = make(m, v(a));
|
|
1802
|
+
const p2 = make(m, v(sb));
|
|
1803
|
+
const p3 = make(u(b), v(sb));
|
|
1804
|
+
return { points: tidyCorners([a, p1, p2, p3, b]), through: [a, midpoint(p1, p2), sb, b], ...(gap ? turnedOn(p1, p2) : { unturned: round }) };
|
|
1805
|
+
}
|
|
1806
|
+
// Behind both: out, round to a step-out off the end, and in.
|
|
1807
|
+
const p1 = make(u(sa), v(sb));
|
|
1808
|
+
return { points: tidyCorners([a, sa, p1, sb, b]), through: [a, sa, sb, b], unturned: 'it goes round behind both ends, a step-out off each' };
|
|
1809
|
+
}
|
|
1810
|
+
function midpoint(p, q) {
|
|
1811
|
+
return { x: (p.x + q.x) / 2, y: (p.y + q.y) / 2 };
|
|
1812
|
+
}
|
|
1813
|
+
/** The points with repeats and points in line with both neighbors dropped. */
|
|
1814
|
+
export function tidyCorners(points) {
|
|
1815
|
+
const kept = [];
|
|
1816
|
+
for (const point of points) {
|
|
1817
|
+
const last = kept[kept.length - 1];
|
|
1818
|
+
if (last && Math.abs(last.x - point.x) < 0.5 && Math.abs(last.y - point.y) < 0.5)
|
|
1819
|
+
continue;
|
|
1820
|
+
kept.push(point);
|
|
1821
|
+
}
|
|
1822
|
+
for (let index = kept.length - 2; index >= 1; index -= 1) {
|
|
1823
|
+
const [p, q, r] = [kept[index - 1], kept[index], kept[index + 1]];
|
|
1824
|
+
const inLine = (Math.abs(p.x - q.x) < 0.5 && Math.abs(q.x - r.x) < 0.5) || (Math.abs(p.y - q.y) < 0.5 && Math.abs(q.y - r.y) < 0.5);
|
|
1825
|
+
// A leg that folds back on itself is kept: it is drawn, and reported.
|
|
1826
|
+
const folds = (q.x - p.x) * (r.x - q.x) + (q.y - p.y) * (r.y - q.y) < 0;
|
|
1827
|
+
if (inLine && !folds)
|
|
1828
|
+
kept.splice(index, 1);
|
|
1829
|
+
}
|
|
1830
|
+
return kept;
|
|
1831
|
+
}
|
|
1832
|
+
/** How far a curve's handles reach, as a share of the piece they shape, for each `curve:`. */
|
|
1833
|
+
const CURVE_REACH = { subtle: 0.22, normal: 0.36, strong: 0.5 };
|
|
1834
|
+
/**
|
|
1835
|
+
* One sweep from a stretch's first point to its last (design.org 6.3),
|
|
1836
|
+
* leaving and arriving in the way the line travels there. The square route's
|
|
1837
|
+
* corners are not passed through: a curve forced through points a step-out
|
|
1838
|
+
* apart bends sharply between them. An end that heads away from the other end
|
|
1839
|
+
* reaches at least a step-out, so a line that turns back loops wide enough to
|
|
1840
|
+
* read as a turn. A stated turn is stated, so it splits the sweep in two,
|
|
1841
|
+
* passing the middle of the free leg heading along it. Nothing is checked or
|
|
1842
|
+
* retried.
|
|
1843
|
+
*/
|
|
1844
|
+
function curve(points, first, last, look, leg, step, turned) {
|
|
1845
|
+
if (leg !== undefined && !(leg in CURVE_REACH))
|
|
1846
|
+
leg = undefined;
|
|
1847
|
+
const reach = CURVE_REACH[leg ?? look];
|
|
1848
|
+
const a = points[0];
|
|
1849
|
+
const b = points[points.length - 1];
|
|
1850
|
+
if (turned)
|
|
1851
|
+
return [...sweep(a, turned.at, first, turned.dir, reach, step), ...sweep(turned.at, b, turned.dir, last, reach, step)];
|
|
1852
|
+
return sweep(a, b, first, last, reach, step);
|
|
1853
|
+
}
|
|
1854
|
+
/** One cubic from a to b, leaving along `first` and arriving along `last`. */
|
|
1855
|
+
function sweep(a, b, first, last, reach, step) {
|
|
1856
|
+
const length = distance(a, b);
|
|
1857
|
+
const chord = unit(a, b);
|
|
1858
|
+
const ta = first ?? chord;
|
|
1859
|
+
const tb = last ?? chord;
|
|
1860
|
+
// How far each handle reaches: a share of the chord, more as the end turns away from it.
|
|
1861
|
+
const handle = (t) => {
|
|
1862
|
+
const ahead = t.x * chord.x + t.y * chord.y;
|
|
1863
|
+
return Math.max(length * reach, step * Math.max(0, 1 - ahead));
|
|
1864
|
+
};
|
|
1865
|
+
const ha = handle(ta);
|
|
1866
|
+
const hb = handle(tb);
|
|
1867
|
+
return [
|
|
1868
|
+
{
|
|
1869
|
+
c1: { x: a.x + ta.x * ha, y: a.y + ta.y * ha },
|
|
1870
|
+
c2: { x: b.x - tb.x * hb, y: b.y - tb.y * hb },
|
|
1871
|
+
to: b,
|
|
1872
|
+
},
|
|
1873
|
+
];
|
|
1874
|
+
}
|
|
1875
|
+
/** Pieces from a starting point, as a polyline: a cubic flattened, a straight piece as it is. */
|
|
1876
|
+
function tracePieces(start, pieces) {
|
|
1877
|
+
const trace = [start];
|
|
1878
|
+
let from = start;
|
|
1879
|
+
for (const piece of pieces) {
|
|
1880
|
+
if ('c1' in piece) {
|
|
1881
|
+
const { c1, c2, to } = piece;
|
|
1882
|
+
for (let step = 1; step <= 16; step += 1) {
|
|
1883
|
+
const t = step / 16;
|
|
1884
|
+
const u = 1 - t;
|
|
1885
|
+
trace.push({
|
|
1886
|
+
x: u * u * u * from.x + 3 * u * u * t * c1.x + 3 * u * t * t * c2.x + t * t * t * to.x,
|
|
1887
|
+
y: u * u * u * from.y + 3 * u * u * t * c1.y + 3 * u * t * t * c2.y + t * t * t * to.y,
|
|
1888
|
+
});
|
|
1889
|
+
}
|
|
1890
|
+
}
|
|
1891
|
+
else {
|
|
1892
|
+
trace.push(piece.to);
|
|
1893
|
+
}
|
|
1894
|
+
from = piece.to;
|
|
1895
|
+
}
|
|
1896
|
+
return trace;
|
|
1897
|
+
}
|
|
1898
|
+
/** The line as drawn, as a polyline: curves flattened, everything else its own points. */
|
|
1899
|
+
export function traceRoute(route) {
|
|
1900
|
+
return tracePieces(route.start, route.pieces);
|
|
1901
|
+
}
|
|
1902
|
+
/** A line's pieces with those along a collapsed bundle left out, as the runs either side of each. */
|
|
1903
|
+
export function visibleRuns(route) {
|
|
1904
|
+
const runs = [];
|
|
1905
|
+
let run = { start: route.start, pieces: [] };
|
|
1906
|
+
route.pieces.forEach((piece, index) => {
|
|
1907
|
+
const hidden = route.hidden?.some(([first, past]) => index >= first && index < past) ?? false;
|
|
1908
|
+
if (hidden) {
|
|
1909
|
+
if (run.pieces.length > 0)
|
|
1910
|
+
runs.push(run);
|
|
1911
|
+
run = { start: piece.to, pieces: [] };
|
|
1912
|
+
}
|
|
1913
|
+
else {
|
|
1914
|
+
run.pieces.push(piece);
|
|
1915
|
+
}
|
|
1916
|
+
});
|
|
1917
|
+
if (run.pieces.length > 0)
|
|
1918
|
+
runs.push(run);
|
|
1919
|
+
return runs;
|
|
1920
|
+
}
|
|
1921
|
+
/** The line as drawn, as polylines: its trace with any stretch along a collapsed bundle left out. */
|
|
1922
|
+
export function traceVisible(route) {
|
|
1923
|
+
return visibleRuns(route).map((run) => tracePieces(run.start, run.pieces));
|
|
1924
|
+
}
|
|
1925
|
+
/**
|
|
1926
|
+
* Where two drawn lines cross, with the way each runs there. Lines that only
|
|
1927
|
+
* touch or run together don't cross, and nor do two lines nearer than `clear`
|
|
1928
|
+
* to an end of either: that is two lines meeting at a box, not passing each
|
|
1929
|
+
* other. The renderer jumps these and the `edge-crossings` finding counts
|
|
1930
|
+
* them, so the two agree.
|
|
1931
|
+
*/
|
|
1932
|
+
export function crossingPoints(a, b, clear) {
|
|
1933
|
+
const found = [];
|
|
1934
|
+
const ends = [a[0], a[a.length - 1], b[0], b[b.length - 1]];
|
|
1935
|
+
for (let i = 1; i < a.length; i += 1) {
|
|
1936
|
+
const p = a[i - 1];
|
|
1937
|
+
const q = a[i];
|
|
1938
|
+
for (let j = 1; j < b.length; j += 1) {
|
|
1939
|
+
const r = b[j - 1];
|
|
1940
|
+
const s = b[j];
|
|
1941
|
+
const at = intersect(p, q, r, s);
|
|
1942
|
+
if (!at || ends.some((end) => distance(end, at) < clear))
|
|
1943
|
+
continue;
|
|
1944
|
+
if (found.some((one) => distance(one.at, at) < 1))
|
|
1945
|
+
continue;
|
|
1946
|
+
found.push({ at, along: unit(p, q), other: unit(r, s) });
|
|
1947
|
+
}
|
|
1948
|
+
}
|
|
1949
|
+
return found;
|
|
1950
|
+
}
|
|
1951
|
+
/** Where two segments cross, if they do. Segments that only touch or run together do not. */
|
|
1952
|
+
function intersect(a, b, c, d) {
|
|
1953
|
+
const r = { x: b.x - a.x, y: b.y - a.y };
|
|
1954
|
+
const s = { x: d.x - c.x, y: d.y - c.y };
|
|
1955
|
+
const denominator = r.x * s.y - r.y * s.x;
|
|
1956
|
+
if (Math.abs(denominator) < 1e-9)
|
|
1957
|
+
return undefined;
|
|
1958
|
+
const t = ((c.x - a.x) * s.y - (c.y - a.y) * s.x) / denominator;
|
|
1959
|
+
const u = ((c.x - a.x) * r.y - (c.y - a.y) * r.x) / denominator;
|
|
1960
|
+
if (t <= 0 || t >= 1 || u <= 0 || u >= 1)
|
|
1961
|
+
return undefined;
|
|
1962
|
+
return { x: a.x + r.x * t, y: a.y + r.y * t };
|
|
1963
|
+
}
|
|
1964
|
+
export function describeEdge(edge) {
|
|
1965
|
+
if (edge.bundle)
|
|
1966
|
+
return `bundle ${edge.name}`;
|
|
1967
|
+
return `${edge.from.name} ${edge.arrow} ${edge.to.name}`;
|
|
1968
|
+
}
|