reladraw 0.16.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -7
- package/SYNTAX.md +810 -680
- package/dist/ast.d.ts +342 -18
- package/dist/ast.js +138 -27
- package/dist/checks.d.ts +58 -0
- package/dist/checks.js +891 -0
- package/dist/cli.js +88 -12
- package/dist/constants.d.ts +15 -55
- package/dist/constants.js +30 -78
- package/dist/grammar.d.ts +2 -2
- package/dist/grammar.js +70 -10
- package/dist/index.d.ts +20 -0
- package/dist/index.js +18 -1
- package/dist/lines.d.ts +96 -0
- package/dist/lines.js +1968 -0
- package/dist/linetexts.d.ts +44 -0
- package/dist/linetexts.js +412 -0
- package/dist/model.d.ts +187 -36
- package/dist/model.js +23 -1
- package/dist/parser.js +1777 -107
- package/dist/place.d.ts +501 -0
- package/dist/place.js +2226 -0
- package/dist/render.d.ts +3 -1
- package/dist/render.js +691 -2493
- package/dist/resolve.d.ts +1 -1
- package/dist/resolve.js +1195 -1789
- package/dist/size.d.ts +64 -0
- package/dist/size.js +222 -0
- package/dist/themes.d.ts +17 -0
- package/dist/themes.js +28 -3
- package/package.json +2 -2
- package/dist/constrain.d.ts +0 -56
- package/dist/constrain.js +0 -95
package/dist/checks.js
ADDED
|
@@ -0,0 +1,891 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Findings: things about a drawn picture that may look wrong. An error means
|
|
3
|
+
* the file can't be drawn; a finding means "look at this". A render with
|
|
4
|
+
* findings succeeds.
|
|
5
|
+
*
|
|
6
|
+
* Every finding is measured off the solved layout, never fixed by moving
|
|
7
|
+
* anything: the picture is drawn as written, and this says where it may not
|
|
8
|
+
* read as the writer meant.
|
|
9
|
+
*/
|
|
10
|
+
import { isWithin, joinedToText } from './model.js';
|
|
11
|
+
import { ARROW_MARKER_WIDTH, LINE_WIDTH, UNIT, arrowLength } from './constants.js';
|
|
12
|
+
import { crossingPoints, describeEdge, planSides, traceRoute, traceVisible } from './lines.js';
|
|
13
|
+
import { placeLineTexts } from './linetexts.js';
|
|
14
|
+
/**
|
|
15
|
+
* The kinds of finding, each with whether it is on when nothing says. Named in
|
|
16
|
+
* the language's own nouns, since the name is what a writer types to turn one
|
|
17
|
+
* off. Crossings are often fine, so they are off; the rest are almost always a
|
|
18
|
+
* fault, so an agent that never thinks to ask still hears about them.
|
|
19
|
+
*/
|
|
20
|
+
export const CHECK_DEFAULTS = {
|
|
21
|
+
'text-overlap': true,
|
|
22
|
+
'text-overflow': true,
|
|
23
|
+
'edge-node-overlap': true,
|
|
24
|
+
'edge-overlap': true,
|
|
25
|
+
'crowded-edge-ends': true,
|
|
26
|
+
'node-overlap': true,
|
|
27
|
+
'edge-no-length': true,
|
|
28
|
+
'edge-crossings': false,
|
|
29
|
+
};
|
|
30
|
+
export const CHECK_KINDS = Object.keys(CHECK_DEFAULTS);
|
|
31
|
+
export function isCheckKind(word) {
|
|
32
|
+
return Object.hasOwn(CHECK_DEFAULTS, word);
|
|
33
|
+
}
|
|
34
|
+
/** One finding: the statement at fault, the kind, and what happened. */
|
|
35
|
+
export class Finding {
|
|
36
|
+
kind;
|
|
37
|
+
message;
|
|
38
|
+
line;
|
|
39
|
+
constructor(kind, message, line) {
|
|
40
|
+
this.kind = kind;
|
|
41
|
+
this.message = message;
|
|
42
|
+
this.line = line;
|
|
43
|
+
}
|
|
44
|
+
/** `diagram.reladraw:12: warning(node-overlap): …` — the form compilers and editors already read. */
|
|
45
|
+
format(file) {
|
|
46
|
+
const where = file ? `${file}:${this.line}` : `line ${this.line}`;
|
|
47
|
+
return `${where}: warning(${this.kind}): ${this.message}`;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* `edge-crossings: on, crowded-edge-ends: off` — the inside of a `checks:`
|
|
52
|
+
* bracket, as `--checks` takes it, laid over `base`. A kind not mentioned
|
|
53
|
+
* keeps what `base` says.
|
|
54
|
+
*/
|
|
55
|
+
export function readChecks(text, base) {
|
|
56
|
+
const checks = { ...base };
|
|
57
|
+
for (const entry of text.split(',')) {
|
|
58
|
+
const match = /^\s*([^:\s]+)\s*:\s*(\S+)\s*$/.exec(entry);
|
|
59
|
+
if (match === null) {
|
|
60
|
+
throw new Error(`checks are written "kind: on" or "kind: off", found "${entry.trim()}"`);
|
|
61
|
+
}
|
|
62
|
+
const [, kind, value] = match;
|
|
63
|
+
setCheck(checks, kind, value, (message) => new Error(message));
|
|
64
|
+
}
|
|
65
|
+
return checks;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* The checks a layout asks for: the defaults, then the file's
|
|
69
|
+
* `diagram checks: (…)`, which arrives flattened as `checks.<kind>` and was
|
|
70
|
+
* refused by the parser if it named no kind.
|
|
71
|
+
*/
|
|
72
|
+
export function fileChecks(layout) {
|
|
73
|
+
const checks = { ...CHECK_DEFAULTS };
|
|
74
|
+
for (const [key, value] of Object.entries(layout.diagram)) {
|
|
75
|
+
const kind = key.slice('checks.'.length);
|
|
76
|
+
if (key.startsWith('checks.') && isCheckKind(kind))
|
|
77
|
+
checks[kind] = value === 'on';
|
|
78
|
+
}
|
|
79
|
+
return checks;
|
|
80
|
+
}
|
|
81
|
+
/** One `kind: on|off`, refused by name if either word is wrong. */
|
|
82
|
+
export function setCheck(checks, kind, value, fail) {
|
|
83
|
+
if (!isCheckKind(kind)) {
|
|
84
|
+
const near = nearMatch(kind, CHECK_KINDS);
|
|
85
|
+
throw fail(`there is no check called "${kind}"` +
|
|
86
|
+
(near ? ` — did you mean ${near}?` : ` — the checks are ${CHECK_KINDS.join(', ')}`));
|
|
87
|
+
}
|
|
88
|
+
if (value !== 'on' && value !== 'off') {
|
|
89
|
+
throw fail(`${kind}: takes on or off, not "${value}"`);
|
|
90
|
+
}
|
|
91
|
+
checks[kind] = value === 'on';
|
|
92
|
+
}
|
|
93
|
+
/** Every finding the layout has, for the kinds that are on, in line order. */
|
|
94
|
+
export function findings(layout, checks) {
|
|
95
|
+
const found = [];
|
|
96
|
+
if (checks['node-overlap'])
|
|
97
|
+
found.push(...nodeOverlaps(layout));
|
|
98
|
+
if (checks['text-overflow'])
|
|
99
|
+
found.push(...textOverflows(layout));
|
|
100
|
+
if (checks['edge-node-overlap'] || checks['edge-overlap'] || checks['edge-no-length'] || checks['crowded-edge-ends'] || checks['edge-crossings'] || checks['text-overlap']) {
|
|
101
|
+
const plan = planSides(layout);
|
|
102
|
+
if (checks['edge-node-overlap'])
|
|
103
|
+
found.push(...edgeNodeOverlaps(layout, plan.routes));
|
|
104
|
+
if (checks['edge-overlap'])
|
|
105
|
+
found.push(...edgeOverlaps(layout, plan.routes));
|
|
106
|
+
if (checks['edge-no-length'])
|
|
107
|
+
found.push(...edgesWithNoLength(layout, plan.routes));
|
|
108
|
+
if (checks['crowded-edge-ends'])
|
|
109
|
+
found.push(...crowdedEnds(plan.sides));
|
|
110
|
+
if (checks['edge-crossings'])
|
|
111
|
+
found.push(...edgeCrossings(layout, plan.routes));
|
|
112
|
+
if (checks['text-overlap'])
|
|
113
|
+
found.push(...textOverlaps(layout, plan.routes));
|
|
114
|
+
}
|
|
115
|
+
return found.sort((a, b) => a.line - b.line);
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* A line across a node that isn't one of its ends: drawn, since nothing is
|
|
119
|
+
* avoided, and reported (design.org 6.3). A line through one of its own boxes
|
|
120
|
+
* is reported too, which is what a straight line between side-by-side boxes
|
|
121
|
+
* named on their far sides does. A box holding either end is the line's own
|
|
122
|
+
* surroundings, not something it crosses.
|
|
123
|
+
*
|
|
124
|
+
* Nodes are filed in a grid first, so each line looks only at the boxes near
|
|
125
|
+
* it rather than at every box.
|
|
126
|
+
*/
|
|
127
|
+
function edgeNodeOverlaps(layout, routes) {
|
|
128
|
+
const found = [];
|
|
129
|
+
const grid = new Grid(layout.nodes.filter((node) => !node.guide));
|
|
130
|
+
for (const edge of layout.edges) {
|
|
131
|
+
if (edge.guide)
|
|
132
|
+
continue;
|
|
133
|
+
const trace = traceRoute(routes.get(edge));
|
|
134
|
+
for (const node of grid.near(trace)) {
|
|
135
|
+
if (holdsEnd(node, edge))
|
|
136
|
+
continue;
|
|
137
|
+
const inner = { x: node.x + 1, y: node.y + 1, width: node.width - 2, height: node.height - 2 };
|
|
138
|
+
const round = node.body.kind === 'shape' && node.body.outline === 'circle';
|
|
139
|
+
const through = round ? crossingCircle(trace, node) : crossing(trace, inner);
|
|
140
|
+
if (through === undefined)
|
|
141
|
+
continue;
|
|
142
|
+
const own = node === edge.from || node === edge.to;
|
|
143
|
+
const words = `line ${describeEdge(edge)} ${own ? 'runs through its own box' : 'crosses node'} ${node.name}, ` +
|
|
144
|
+
`${through.enters === through.leaves ? `in and out of its ${through.enters}` : `entering its ${through.enters} and leaving its ${through.leaves}`}`;
|
|
145
|
+
const hint = own && edge.look.path !== 'straight'
|
|
146
|
+
? `; say which way round, such as \`via: ${node.name} top\` or \`via: ${node.name} bottom\``
|
|
147
|
+
: '';
|
|
148
|
+
found.push(new Finding('edge-node-overlap', words + hint, edge.line));
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return dropInner(found, layout);
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Only the outermost of several nested boxes one line crosses is reported: a
|
|
155
|
+
* line across a container crosses whatever of its children it reaches too, and
|
|
156
|
+
* saying so for each child repeats the one fact.
|
|
157
|
+
*/
|
|
158
|
+
function dropInner(found, layout) {
|
|
159
|
+
const byName = new Map(layout.nodes.map((node) => [node.name, node]));
|
|
160
|
+
const key = (finding) => {
|
|
161
|
+
const match = /^(line .*? (?:crosses node|runs through its own box)) (\S+),/.exec(finding.message);
|
|
162
|
+
return { prefix: `${finding.line}\n${match?.[1] ?? ''}`, node: byName.get(match?.[2] ?? '') };
|
|
163
|
+
};
|
|
164
|
+
const reported = new Set(found.map((finding) => {
|
|
165
|
+
const { prefix, node } = key(finding);
|
|
166
|
+
return `${prefix}\n${node?.name}`;
|
|
167
|
+
}));
|
|
168
|
+
return found.filter((finding) => {
|
|
169
|
+
const { prefix, node } = key(finding);
|
|
170
|
+
for (let at = node?.parent; at; at = at.parent) {
|
|
171
|
+
if (reported.has(`${prefix}\n${at.name}`))
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
return true;
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Whether a node is one of the line's ends, or holds one: the line starts or
|
|
179
|
+
* ends inside it. A venn's set holds what is in the overlap too.
|
|
180
|
+
*/
|
|
181
|
+
function holdsEnd(node, edge) {
|
|
182
|
+
for (const end of [edge.from, edge.to]) {
|
|
183
|
+
for (let at = end.parent; at; at = at.parent)
|
|
184
|
+
if (at === node)
|
|
185
|
+
return true;
|
|
186
|
+
if (inVennOverlapOf(end, node))
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
/** Which part of a venn a node is: 0 the left set, 1 the overlap, 2 the right set. */
|
|
192
|
+
function vennPart(node) {
|
|
193
|
+
const venn = node.parent;
|
|
194
|
+
if (!venn || venn.appearance['layout'] !== 'venn')
|
|
195
|
+
return undefined;
|
|
196
|
+
const index = venn.children.filter((child) => child !== venn.heading && !joinedToText(child)).indexOf(node);
|
|
197
|
+
return index === -1 ? undefined : index;
|
|
198
|
+
}
|
|
199
|
+
/** Whether `node` is inside the overlap of the venn that `set` is a set of. */
|
|
200
|
+
function inVennOverlapOf(node, set) {
|
|
201
|
+
const part = vennPart(set);
|
|
202
|
+
if (part !== 0 && part !== 2)
|
|
203
|
+
return false;
|
|
204
|
+
for (let at = node.parent; at; at = at.parent)
|
|
205
|
+
if (at.parent === set.parent && vennPart(at) === 1)
|
|
206
|
+
return true;
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Overlap a venn states (design.org 5.x): its parts crossing each other, and
|
|
211
|
+
* what is in the overlap sitting inside both sets.
|
|
212
|
+
*/
|
|
213
|
+
function vennStates(a, b) {
|
|
214
|
+
if (a.parent === b.parent && vennPart(a) !== undefined && vennPart(b) !== undefined)
|
|
215
|
+
return true;
|
|
216
|
+
return inVennOverlapOf(a, b) || inVennOverlapOf(b, a);
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Where a polyline first enters a box and last leaves it, as the sides it
|
|
220
|
+
* crosses; undefined when it never goes inside. A point on the border is not
|
|
221
|
+
* inside, so a line ending on a box's side doesn't cross it.
|
|
222
|
+
*/
|
|
223
|
+
function crossing(trace, box) {
|
|
224
|
+
const inside = (p) => p.x > box.x && p.x < box.x + box.width && p.y > box.y && p.y < box.y + box.height;
|
|
225
|
+
const sideAt = (p) => {
|
|
226
|
+
const distances = [
|
|
227
|
+
[Math.abs(p.y - box.y), 'top'],
|
|
228
|
+
[Math.abs(p.y - (box.y + box.height)), 'bottom'],
|
|
229
|
+
[Math.abs(p.x - box.x), 'left'],
|
|
230
|
+
[Math.abs(p.x - (box.x + box.width)), 'right'],
|
|
231
|
+
];
|
|
232
|
+
distances.sort((a, b) => a[0] - b[0]);
|
|
233
|
+
return distances[0][1];
|
|
234
|
+
};
|
|
235
|
+
let enters;
|
|
236
|
+
let leaves;
|
|
237
|
+
for (let index = 1; index < trace.length; index += 1) {
|
|
238
|
+
const a = trace[index - 1];
|
|
239
|
+
const b = trace[index];
|
|
240
|
+
const clip = clipSegment(a, b, box);
|
|
241
|
+
if (clip === undefined)
|
|
242
|
+
continue;
|
|
243
|
+
if (enters === undefined)
|
|
244
|
+
enters = inside(a) ? 'middle' : sideAt(clip[0]);
|
|
245
|
+
leaves = inside(b) ? 'middle' : sideAt(clip[1]);
|
|
246
|
+
}
|
|
247
|
+
if (enters === undefined || leaves === undefined)
|
|
248
|
+
return undefined;
|
|
249
|
+
return { enters, leaves };
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* The same for a circle, which is drawn in its node's square: walked a couple
|
|
253
|
+
* of pixels at a time, a point counting as inside only past a pixel within the
|
|
254
|
+
* outline, so a line ending on the circle doesn't cross it.
|
|
255
|
+
*/
|
|
256
|
+
function crossingCircle(trace, node) {
|
|
257
|
+
const radius = node.width / 2 - 1;
|
|
258
|
+
const center = { x: node.x + node.width / 2, y: node.y + node.height / 2 };
|
|
259
|
+
const inside = (p) => Math.hypot(p.x - center.x, p.y - center.y) < radius;
|
|
260
|
+
const sideAt = (p) => {
|
|
261
|
+
const dx = p.x - center.x;
|
|
262
|
+
const dy = p.y - center.y;
|
|
263
|
+
return Math.abs(dx) >= Math.abs(dy) ? (dx >= 0 ? 'right' : 'left') : dy >= 0 ? 'bottom' : 'top';
|
|
264
|
+
};
|
|
265
|
+
let enters;
|
|
266
|
+
let leaves;
|
|
267
|
+
let was = inside(trace[0]);
|
|
268
|
+
if (was)
|
|
269
|
+
enters = 'middle';
|
|
270
|
+
for (let index = 1; index < trace.length; index += 1) {
|
|
271
|
+
const a = trace[index - 1];
|
|
272
|
+
const b = trace[index];
|
|
273
|
+
const steps = Math.max(1, Math.ceil(Math.hypot(b.x - a.x, b.y - a.y) / 2));
|
|
274
|
+
for (let step = 1; step <= steps; step += 1) {
|
|
275
|
+
const p = { x: a.x + ((b.x - a.x) * step) / steps, y: a.y + ((b.y - a.y) * step) / steps };
|
|
276
|
+
const is = inside(p);
|
|
277
|
+
if (is && !was)
|
|
278
|
+
enters ??= sideAt(p);
|
|
279
|
+
if (!is && was)
|
|
280
|
+
leaves = sideAt(p);
|
|
281
|
+
was = is;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
if (enters === undefined)
|
|
285
|
+
return undefined;
|
|
286
|
+
return { enters, leaves: was ? 'middle' : (leaves ?? enters) };
|
|
287
|
+
}
|
|
288
|
+
/** The part of a segment inside a box, strictly, as its two ends; undefined when none is. */
|
|
289
|
+
function clipSegment(a, b, box) {
|
|
290
|
+
if (box.width <= 0 || box.height <= 0)
|
|
291
|
+
return undefined;
|
|
292
|
+
let t0 = 0;
|
|
293
|
+
let t1 = 1;
|
|
294
|
+
const dx = b.x - a.x;
|
|
295
|
+
const dy = b.y - a.y;
|
|
296
|
+
const edges = [
|
|
297
|
+
[-dx, a.x - box.x],
|
|
298
|
+
[dx, box.x + box.width - a.x],
|
|
299
|
+
[-dy, a.y - box.y],
|
|
300
|
+
[dy, box.y + box.height - a.y],
|
|
301
|
+
];
|
|
302
|
+
for (const [p, q] of edges) {
|
|
303
|
+
if (p === 0) {
|
|
304
|
+
if (q <= 0)
|
|
305
|
+
return undefined;
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
const t = q / p;
|
|
309
|
+
if (p < 0)
|
|
310
|
+
t0 = Math.max(t0, t);
|
|
311
|
+
else
|
|
312
|
+
t1 = Math.min(t1, t);
|
|
313
|
+
if (t0 >= t1)
|
|
314
|
+
return undefined;
|
|
315
|
+
}
|
|
316
|
+
return [
|
|
317
|
+
{ x: a.x + dx * t0, y: a.y + dy * t0 },
|
|
318
|
+
{ x: a.x + dx * t1, y: a.y + dy * t1 },
|
|
319
|
+
];
|
|
320
|
+
}
|
|
321
|
+
/** Boxes filed by the cells of a coarse grid, so a line finds the boxes near it without trying every one. */
|
|
322
|
+
class Grid {
|
|
323
|
+
cells = new Map();
|
|
324
|
+
static SIZE = 160;
|
|
325
|
+
constructor(nodes) {
|
|
326
|
+
for (const node of nodes) {
|
|
327
|
+
for (const key of this.keys(node.x, node.y, node.x + node.width, node.y + node.height)) {
|
|
328
|
+
const cell = this.cells.get(key) ?? [];
|
|
329
|
+
cell.push(node);
|
|
330
|
+
this.cells.set(key, cell);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
near(trace) {
|
|
335
|
+
const found = new Set();
|
|
336
|
+
for (let index = 1; index < trace.length; index += 1) {
|
|
337
|
+
const a = trace[index - 1];
|
|
338
|
+
const b = trace[index];
|
|
339
|
+
for (const key of this.keys(Math.min(a.x, b.x), Math.min(a.y, b.y), Math.max(a.x, b.x), Math.max(a.y, b.y))) {
|
|
340
|
+
for (const node of this.cells.get(key) ?? [])
|
|
341
|
+
found.add(node);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return [...found];
|
|
345
|
+
}
|
|
346
|
+
keys(minX, minY, maxX, maxY) {
|
|
347
|
+
const keys = [];
|
|
348
|
+
for (let i = Math.floor(minX / Grid.SIZE); i <= Math.floor(maxX / Grid.SIZE); i += 1) {
|
|
349
|
+
for (let j = Math.floor(minY / Grid.SIZE); j <= Math.floor(maxY / Grid.SIZE); j += 1)
|
|
350
|
+
keys.push(`${i},${j}`);
|
|
351
|
+
}
|
|
352
|
+
return keys;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* A line between two boxes that touch has no length: it is drawn as its marks
|
|
357
|
+
* at the seam, and reported (design.org 6.3).
|
|
358
|
+
*/
|
|
359
|
+
function edgesWithNoLength(layout, routes) {
|
|
360
|
+
const found = [];
|
|
361
|
+
for (const edge of layout.edges) {
|
|
362
|
+
if (edge.guide)
|
|
363
|
+
continue;
|
|
364
|
+
const points = routes.get(edge).points;
|
|
365
|
+
let length = 0;
|
|
366
|
+
for (let index = 1; index < points.length; index += 1) {
|
|
367
|
+
length += Math.hypot(points[index].x - points[index - 1].x, points[index].y - points[index - 1].y);
|
|
368
|
+
}
|
|
369
|
+
if (length >= 1)
|
|
370
|
+
continue;
|
|
371
|
+
found.push(new Finding('edge-no-length', `line ${describeEdge(edge)} has no length; ${edge.from.name} and ${edge.to.name} touch`, edge.line));
|
|
372
|
+
}
|
|
373
|
+
return found;
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Ends spread on a side too short to space them: closer than an arrowhead's
|
|
377
|
+
* width apart, their marks run together. Reported against the last line
|
|
378
|
+
* written to land there, with the room they need beside the room there is.
|
|
379
|
+
*/
|
|
380
|
+
function crowdedEnds(sides) {
|
|
381
|
+
const found = [];
|
|
382
|
+
for (const { node, side, ends } of sides) {
|
|
383
|
+
if (node.guide)
|
|
384
|
+
continue;
|
|
385
|
+
const drawn = ends.filter((end) => !end.edge.guide);
|
|
386
|
+
if (drawn.length < 2)
|
|
387
|
+
continue;
|
|
388
|
+
const across = side === 'top' || side === 'bottom';
|
|
389
|
+
// Two neighbors are crowded when their marks would touch, or, where
|
|
390
|
+
// neither has a mark, when the lines come within a third of a unit.
|
|
391
|
+
let crowded = false;
|
|
392
|
+
let want = 0;
|
|
393
|
+
const anyMark = drawn.some((end) => end.edge.marks[end.which] !== 'none');
|
|
394
|
+
for (let index = 1; index < drawn.length; index += 1) {
|
|
395
|
+
const a = drawn[index - 1];
|
|
396
|
+
const b = drawn[index];
|
|
397
|
+
const apart = Math.abs(across ? b.at.x - a.at.x : b.at.y - a.at.y);
|
|
398
|
+
const marked = a.edge.marks[a.which] !== 'none' || b.edge.marks[b.which] !== 'none';
|
|
399
|
+
const needed = marked
|
|
400
|
+
? (markWidth(a.edge, a.which) + markWidth(b.edge, b.which)) / 2
|
|
401
|
+
: (a.edge.look.thickness + b.edge.look.thickness) / 2 + UNIT / 3;
|
|
402
|
+
want = Math.max(want, needed);
|
|
403
|
+
if (apart < needed - EPSILON)
|
|
404
|
+
crowded = true;
|
|
405
|
+
}
|
|
406
|
+
if (!crowded)
|
|
407
|
+
continue;
|
|
408
|
+
const length = across ? node.width : node.height;
|
|
409
|
+
// Rounded up: rounded to the nearest, a side just short of room read as
|
|
410
|
+
// needing exactly its own length.
|
|
411
|
+
const needs = Math.ceil((want * (drawn.length + 1)) / UNIT) * UNIT;
|
|
412
|
+
found.push(new Finding('crowded-edge-ends', `${drawn.length} line ends share node ${node.name}'s ${side} side, which is ${units(length)} long; ` +
|
|
413
|
+
`spaced so their ${anyMark ? 'marks' : 'lines'} don't run together they need ${units(needs)}`, Math.max(...drawn.map((end) => end.edge.line))));
|
|
414
|
+
}
|
|
415
|
+
return found;
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* How wide a line's end is drawn across the line: its mark, or the line
|
|
419
|
+
* alone. The fractions are how much of its 10-unit marker box each of the
|
|
420
|
+
* renderer's mark shapes spans across the line; a dot keeps an ordinary
|
|
421
|
+
* line's size, and every other mark scales with the stroke.
|
|
422
|
+
*/
|
|
423
|
+
function markWidth(edge, which) {
|
|
424
|
+
const thickness = edge.look.thickness;
|
|
425
|
+
const mark = edge.marks[which];
|
|
426
|
+
if (mark === 'none')
|
|
427
|
+
return thickness;
|
|
428
|
+
if (mark === 'dot' || mark === 'odot')
|
|
429
|
+
return (mark === 'dot' ? 0.8 : 0.82) * ARROW_MARKER_WIDTH * LINE_WIDTH;
|
|
430
|
+
const across = { arrow: 1, oarrow: 0.76, diamond: 0.76, odiamond: 0.62, bar: 0.9 }[mark];
|
|
431
|
+
return across * ARROW_MARKER_WIDTH * thickness;
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* Two lines crossing (design.org 6.6). Often fine, which is why it is off
|
|
435
|
+
* unless asked for. Found where the renderer finds the crossings it jumps, so
|
|
436
|
+
* a crossing drawn is a crossing reported; reported once per pair, against the
|
|
437
|
+
* later line, with how many times they cross.
|
|
438
|
+
*/
|
|
439
|
+
function edgeCrossings(layout, routes) {
|
|
440
|
+
const found = [];
|
|
441
|
+
const drawn = layout.edges.filter((edge) => !edge.guide).map((edge) => ({ edge, trace: traceRoute(routes.get(edge)) }));
|
|
442
|
+
drawn.forEach((later, index) => {
|
|
443
|
+
for (const earlier of drawn.slice(0, index)) {
|
|
444
|
+
const thickness = Math.max(later.edge.look.thickness, earlier.edge.look.thickness);
|
|
445
|
+
const count = crossingPoints(later.trace, earlier.trace, 3 + thickness * 2 + arrowLength(thickness)).length;
|
|
446
|
+
if (count === 0)
|
|
447
|
+
continue;
|
|
448
|
+
const times = count === 1 ? '' : count === 2 ? ' twice' : ` ${count} times`;
|
|
449
|
+
found.push(new Finding('edge-crossings', `line ${describeEdge(later.edge)} crosses line ${describeEdge(earlier.edge)}${times}`, later.edge.line));
|
|
450
|
+
}
|
|
451
|
+
});
|
|
452
|
+
return found;
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* Two lines on one run for a stretch, of any length (design.org 10.1). Where
|
|
456
|
+
* they part, nothing shows which went which way, so unlike a crossing it is
|
|
457
|
+
* never readable. Lines that only touch or cross share nothing. A collapsed
|
|
458
|
+
* bundle is the one shared run that is meant: the bus is drawn once, and its
|
|
459
|
+
* members' way along it is left out of their traces here, so only what they
|
|
460
|
+
* share off the bus is reported. Reported once per pair, against the later
|
|
461
|
+
* line, with the length shared.
|
|
462
|
+
*
|
|
463
|
+
* Segments are swept by left edge, as boxes are for node-overlap, so each is
|
|
464
|
+
* compared only with those beside it.
|
|
465
|
+
*/
|
|
466
|
+
function edgeOverlaps(layout, routes) {
|
|
467
|
+
const segments = [];
|
|
468
|
+
for (const edge of layout.edges) {
|
|
469
|
+
if (edge.guide)
|
|
470
|
+
continue;
|
|
471
|
+
for (const trace of traceVisible(routes.get(edge))) {
|
|
472
|
+
for (let index = 1; index < trace.length; index += 1) {
|
|
473
|
+
const a = trace[index - 1];
|
|
474
|
+
const b = trace[index];
|
|
475
|
+
if (Math.hypot(b.x - a.x, b.y - a.y) < EPSILON)
|
|
476
|
+
continue;
|
|
477
|
+
segments.push({ edge, a, b, minX: Math.min(a.x, b.x), maxX: Math.max(a.x, b.x) });
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
segments.sort((p, q) => p.minX - q.minX);
|
|
482
|
+
// A collapsed bundle between two boxes is all bus: its members share the
|
|
483
|
+
// whole way, box to box, with nothing off it to report.
|
|
484
|
+
const buses = new Set(layout.edges.filter((edge) => edge.bundle?.between && edge.bundle.arrangement === 'collapsed').map((edge) => edge.name));
|
|
485
|
+
const busOf = (edge) => (edge.via ?? []).map((entry) => entry.stop).find((stop) => stop?.kind === 'bundle' && buses.has(stop.bundle))?.written;
|
|
486
|
+
const reach = SAME_RUN + Math.max(0, ...segments.map((segment) => segment.edge.look.thickness));
|
|
487
|
+
const shared = new Map();
|
|
488
|
+
let active = [];
|
|
489
|
+
for (const segment of segments) {
|
|
490
|
+
active = active.filter((other) => other.maxX + reach >= segment.minX);
|
|
491
|
+
for (const other of active) {
|
|
492
|
+
// Lines one statement draws, `edge a -> b, c` or `each child of`, share
|
|
493
|
+
// their way on purpose, as a collapsed bundle's members do.
|
|
494
|
+
if (other.edge === segment.edge || other.edge.line === segment.edge.line)
|
|
495
|
+
continue;
|
|
496
|
+
const bus = busOf(segment.edge);
|
|
497
|
+
if (bus !== undefined && bus === busOf(other.edge))
|
|
498
|
+
continue;
|
|
499
|
+
const length = runShared(segment, other, SAME_RUN + Math.max(segment.edge.look.thickness, other.edge.look.thickness));
|
|
500
|
+
if (length < EPSILON)
|
|
501
|
+
continue;
|
|
502
|
+
const [earlier, later] = other.edge.line <= segment.edge.line ? [other.edge, segment.edge] : [segment.edge, other.edge];
|
|
503
|
+
const key = `${layout.edges.indexOf(earlier)} ${layout.edges.indexOf(later)}`;
|
|
504
|
+
const entry = shared.get(key) ?? { earlier, later, length: 0, longest: { length: 0, a: segment.a, b: segment.b } };
|
|
505
|
+
entry.length += length;
|
|
506
|
+
if (length > entry.longest.length)
|
|
507
|
+
entry.longest = { length, a: segment.a, b: segment.b };
|
|
508
|
+
shared.set(key, entry);
|
|
509
|
+
}
|
|
510
|
+
active.push(segment);
|
|
511
|
+
}
|
|
512
|
+
return [...shared.values()].map(({ earlier, later, length, longest }) => {
|
|
513
|
+
const across = Math.abs(longest.b.x - longest.a.x);
|
|
514
|
+
const down = Math.abs(longest.b.y - longest.a.y);
|
|
515
|
+
const run = down < EPSILON ? 'a level run' : across < EPSILON ? 'an upright run' : 'one run';
|
|
516
|
+
return new Finding('edge-overlap', `line ${describeEdge(later)} runs on line ${describeEdge(earlier)} for ${units(length)}, on ${run}; ` +
|
|
517
|
+
`say where one of them goes, or put both in a collapsed bundle if they share the way on purpose`, later.line);
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* Two lines whose centers are no further apart than this plus the thicker
|
|
522
|
+
* one's width leave less than a pixel between them, and read as one.
|
|
523
|
+
*/
|
|
524
|
+
const SAME_RUN = 1;
|
|
525
|
+
/**
|
|
526
|
+
* How much of segment `p` runs on segment `q`: zero unless they head the same
|
|
527
|
+
* way, or opposite ways, and then the length of the stretch where both are
|
|
528
|
+
* and `q` is within `near` of `p` at each end of it.
|
|
529
|
+
*/
|
|
530
|
+
function runShared(p, q, near) {
|
|
531
|
+
const length = Math.hypot(p.b.x - p.a.x, p.b.y - p.a.y);
|
|
532
|
+
const along = { x: (p.b.x - p.a.x) / length, y: (p.b.y - p.a.y) / length };
|
|
533
|
+
const qLength = Math.hypot(q.b.x - q.a.x, q.b.y - q.a.y);
|
|
534
|
+
const sine = Math.abs(along.x * (q.b.y - q.a.y) - along.y * (q.b.x - q.a.x)) / qLength;
|
|
535
|
+
if (sine > 0.05)
|
|
536
|
+
return 0;
|
|
537
|
+
const position = (point) => along.x * (point.x - p.a.x) + along.y * (point.y - p.a.y);
|
|
538
|
+
const offset = (point) => along.x * (point.y - p.a.y) - along.y * (point.x - p.a.x);
|
|
539
|
+
const [first, last] = position(q.a) <= position(q.b) ? [q.a, q.b] : [q.b, q.a];
|
|
540
|
+
const start = Math.max(0, position(first));
|
|
541
|
+
const end = Math.min(length, position(last));
|
|
542
|
+
if (end - start < EPSILON)
|
|
543
|
+
return 0;
|
|
544
|
+
// q's distance from p's line, at a place along p, read off q itself.
|
|
545
|
+
const span = position(last) - position(first);
|
|
546
|
+
const offsetAt = (at) => offset(first) + ((offset(last) - offset(first)) * (at - position(first))) / span;
|
|
547
|
+
if (Math.abs(offsetAt(start)) > near || Math.abs(offsetAt(end)) > near)
|
|
548
|
+
return 0;
|
|
549
|
+
return end - start;
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* A text that runs out of the box it belongs inside: one placed inside the
|
|
553
|
+
* border or in the middle, a leaf's or a title. A stated size is kept even
|
|
554
|
+
* when the text needs more, so the text is drawn spilling and this says so.
|
|
555
|
+
*/
|
|
556
|
+
function textOverflows(layout) {
|
|
557
|
+
const found = [];
|
|
558
|
+
for (const node of layout.nodes) {
|
|
559
|
+
if (node.body.kind !== 'shape' || node.guide)
|
|
560
|
+
continue;
|
|
561
|
+
const text = node.textBox;
|
|
562
|
+
if (text.width <= 0 || text.height <= 0)
|
|
563
|
+
continue;
|
|
564
|
+
const place = node.textAttrs['place'];
|
|
565
|
+
if (place !== undefined && place !== 'center' && !place.startsWith('inside'))
|
|
566
|
+
continue;
|
|
567
|
+
const spill = {
|
|
568
|
+
left: Math.max(0, -text.x),
|
|
569
|
+
right: Math.max(0, text.x + text.width - node.width),
|
|
570
|
+
top: Math.max(0, -text.y),
|
|
571
|
+
bottom: Math.max(0, text.y + text.height - node.height),
|
|
572
|
+
};
|
|
573
|
+
const sides = Object.keys(spill).filter((side) => spill[side] > EPSILON);
|
|
574
|
+
if (sides.length === 0)
|
|
575
|
+
continue;
|
|
576
|
+
const wider = spill.left + spill.right;
|
|
577
|
+
const taller = spill.top + spill.bottom;
|
|
578
|
+
const needs = [
|
|
579
|
+
wider > EPSILON ? `${units(wider)} more width` : '',
|
|
580
|
+
taller > EPSILON ? `${units(taller)} more height` : '',
|
|
581
|
+
].filter((part) => part.length > 0);
|
|
582
|
+
const named = sides.length === 4
|
|
583
|
+
? 'every side'
|
|
584
|
+
: sides.length === 1
|
|
585
|
+
? `the ${sides[0]} side`
|
|
586
|
+
: `the ${sides.slice(0, -1).join(', ')} and ${sides[sides.length - 1]} sides`;
|
|
587
|
+
found.push(new Finding('text-overflow', `the text of node ${node.name} runs past ${named} of its box; it needs ${needs.join(' and ')}`, node.line));
|
|
588
|
+
}
|
|
589
|
+
return found;
|
|
590
|
+
}
|
|
591
|
+
/**
|
|
592
|
+
* A text on a line that lands on another such text, or on a node it doesn't
|
|
593
|
+
* belong to, or one placed beside its line that the line still runs through
|
|
594
|
+
* (design.org 7.3, 10.1). A text never moves to get clear, so this is how the
|
|
595
|
+
* writer hears of it. A text belongs to no node, but riding across a
|
|
596
|
+
* container that holds it is what a line inside a container does, so that
|
|
597
|
+
* container doesn't count; what is inside it does.
|
|
598
|
+
*
|
|
599
|
+
* Found with a sweep across the page, as node overlaps are.
|
|
600
|
+
*/
|
|
601
|
+
function textOverlaps(layout, routes) {
|
|
602
|
+
const items = [];
|
|
603
|
+
const found = [];
|
|
604
|
+
for (const edge of layout.edges) {
|
|
605
|
+
if (edge.guide)
|
|
606
|
+
continue;
|
|
607
|
+
const route = routes.get(edge);
|
|
608
|
+
for (const placed of placeLineTexts(edge, route)) {
|
|
609
|
+
const box = boxOf(placed.box);
|
|
610
|
+
items.push({ box, text: placed, words: placed.text.text, line: edge.line });
|
|
611
|
+
if (placed.beside && acrossLine(traceRoute(route), placed.box)) {
|
|
612
|
+
found.push(new Finding('text-overlap', `text "${placed.text.text}" is placed ${placed.text.place.beside} edge, and its line still runs through it — ` +
|
|
613
|
+
'the part it landed on has no room beside it; try the other side, or place it along the line with at:', edge.line));
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
for (const mark of layout.marks) {
|
|
618
|
+
if (mark.guide || !mark.text)
|
|
619
|
+
continue;
|
|
620
|
+
items.push({ box: mark.text.box, words: mark.text.lines.map((line) => line.map((run) => run.text).join('')).join(' '), line: mark.line });
|
|
621
|
+
}
|
|
622
|
+
// Another line drawn through a text: the text knocks a hole only in its own
|
|
623
|
+
// line, and a point's or a region's text has none.
|
|
624
|
+
const traces = layout.edges.filter((edge) => !edge.guide).map((edge) => ({ edge, trace: traceRoute(routes.get(edge)) }));
|
|
625
|
+
for (const item of items) {
|
|
626
|
+
const own = item.text?.text;
|
|
627
|
+
const extent = { minX: item.box.x, minY: item.box.y, maxX: item.box.x + item.box.width, maxY: item.box.y + item.box.height };
|
|
628
|
+
for (const { edge, trace } of traces) {
|
|
629
|
+
if ((own && edge.texts.includes(own)) || !acrossLine(trace, extent))
|
|
630
|
+
continue;
|
|
631
|
+
found.push(new Finding('text-overlap', `text "${item.words}" has the line ${edge.from.name} ${edge.arrow} ${edge.to.name} drawn through it — ` +
|
|
632
|
+
(own
|
|
633
|
+
? 'a text breaks only its own line; place it clear of the other with at: or a side such as above edge'
|
|
634
|
+
: 'place it clear of the line, such as (below) or (above-left)'), item.line));
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
// A box's own title, against boxes only. A box covering the title of a box
|
|
638
|
+
// it overlaps from outside is node-overlap's finding; this one is a child
|
|
639
|
+
// over its container's title, or anything over a title drawn outside its box.
|
|
640
|
+
for (const node of layout.nodes) {
|
|
641
|
+
if (node.guide || node.lines.length === 0 || node.textBox.width <= EPSILON)
|
|
642
|
+
continue;
|
|
643
|
+
const box = { x: node.x + node.textBox.x, y: node.y + node.textBox.y, width: node.textBox.width, height: node.textBox.height };
|
|
644
|
+
items.push({ box, words: node.text, title: node, line: node.line });
|
|
645
|
+
}
|
|
646
|
+
if (items.length === 0)
|
|
647
|
+
return found;
|
|
648
|
+
for (const node of layout.nodes) {
|
|
649
|
+
if (node.guide || node.body.kind === 'none')
|
|
650
|
+
continue;
|
|
651
|
+
items.push({ box: { x: node.x, y: node.y, width: node.width, height: node.height }, node, line: node.line });
|
|
652
|
+
}
|
|
653
|
+
const holds = (node, box) => {
|
|
654
|
+
const cx = box.x + box.width / 2;
|
|
655
|
+
const cy = box.y + box.height / 2;
|
|
656
|
+
return (node.children.some((child) => child !== node.heading && !joinedToText(child)) &&
|
|
657
|
+
node.x < cx && cx < node.x + node.width && node.y < cy && cy < node.y + node.height);
|
|
658
|
+
};
|
|
659
|
+
const sorted = [...items].sort((a, b) => a.box.x - b.box.x);
|
|
660
|
+
let active = [];
|
|
661
|
+
for (const item of sorted) {
|
|
662
|
+
active = active.filter((other) => other.box.x + other.box.width > item.box.x + EPSILON);
|
|
663
|
+
for (const other of active) {
|
|
664
|
+
if (item.words === undefined && other.words === undefined)
|
|
665
|
+
continue;
|
|
666
|
+
const width = Math.min(item.box.x + item.box.width, other.box.x + other.box.width) - Math.max(item.box.x, other.box.x);
|
|
667
|
+
const height = Math.min(item.box.y + item.box.height, other.box.y + other.box.height) - Math.max(item.box.y, other.box.y);
|
|
668
|
+
if (width <= EPSILON || height <= EPSILON)
|
|
669
|
+
continue;
|
|
670
|
+
if (item.words !== undefined && other.words !== undefined) {
|
|
671
|
+
if (item.title || other.title)
|
|
672
|
+
continue;
|
|
673
|
+
const [earlier, later] = other.line <= item.line ? [other, item] : [item, other];
|
|
674
|
+
found.push(new Finding('text-overlap', `text "${later.words}" covers ${coveredPart(earlier.box, later.box)}text "${earlier.words}"; ${size(width, height)}`, later.line));
|
|
675
|
+
continue;
|
|
676
|
+
}
|
|
677
|
+
const [text, node] = item.words !== undefined ? [item, other] : [other, item];
|
|
678
|
+
const owner = text.title;
|
|
679
|
+
if (owner) {
|
|
680
|
+
if (node.node === owner || isWithin(owner, node.node))
|
|
681
|
+
continue;
|
|
682
|
+
const outside = !within(text.box, { x: owner.x, y: owner.y, width: owner.width, height: owner.height });
|
|
683
|
+
if (!outside && !isWithin(node.node, owner))
|
|
684
|
+
continue;
|
|
685
|
+
found.push(new Finding('text-overlap', `node ${node.node.name} covers ${coveredPart(text.box, node.box)}${owner.name}'s title "${text.words}"; ${size(width, height)}`, node.line));
|
|
686
|
+
continue;
|
|
687
|
+
}
|
|
688
|
+
if (holds(node.node, text.box)) {
|
|
689
|
+
// Held, but a text never moves a border, so one that runs out past it
|
|
690
|
+
// is the writer's to make room for.
|
|
691
|
+
const out = pokesOut(text.box, node.box);
|
|
692
|
+
if (out !== undefined) {
|
|
693
|
+
found.push(new Finding('text-overlap', `text "${text.words}" runs out of node ${node.node.name} past its ${out.sides}, by ${out.by} — ` +
|
|
694
|
+
`give ${node.node.name} more padding:, or move the text`, text.line));
|
|
695
|
+
}
|
|
696
|
+
continue;
|
|
697
|
+
}
|
|
698
|
+
found.push(new Finding('text-overlap', `text "${text.words}" covers ${coveredPart(node.box, text.box)}node ${node.node.name}; ${size(width, height)}`, text.line));
|
|
699
|
+
}
|
|
700
|
+
active.push(item);
|
|
701
|
+
}
|
|
702
|
+
return found;
|
|
703
|
+
}
|
|
704
|
+
function boxOf(extent) {
|
|
705
|
+
return { x: extent.minX, y: extent.minY, width: extent.maxX - extent.minX, height: extent.maxY - extent.minY };
|
|
706
|
+
}
|
|
707
|
+
/** Whether a polyline passes through the inside of a box, not merely along its edge. */
|
|
708
|
+
function acrossLine(trace, extent) {
|
|
709
|
+
const inner = { x: extent.minX + 1, y: extent.minY + 1, width: extent.maxX - extent.minX - 2, height: extent.maxY - extent.minY - 2 };
|
|
710
|
+
if (inner.width <= 0 || inner.height <= 0)
|
|
711
|
+
return false;
|
|
712
|
+
for (let index = 1; index < trace.length; index += 1) {
|
|
713
|
+
if (clipSegment(trace[index - 1], trace[index], inner) !== undefined)
|
|
714
|
+
return true;
|
|
715
|
+
}
|
|
716
|
+
return false;
|
|
717
|
+
}
|
|
718
|
+
/**
|
|
719
|
+
* Two nodes on one spot that nothing accounts for. A node and what it holds
|
|
720
|
+
* overlap by being nested; one placed `on` or `inside` another overlaps it
|
|
721
|
+
* because the file said so; `overlap: allow` says so for every pair a node is
|
|
722
|
+
* in. Anything else sharing space is reported, against the later of the two,
|
|
723
|
+
* since that is the statement that put it there.
|
|
724
|
+
*
|
|
725
|
+
* Found with a sweep across the page rather than by trying every pair, so a
|
|
726
|
+
* large diagram costs little more than the overlaps it actually has.
|
|
727
|
+
*/
|
|
728
|
+
function nodeOverlaps(layout) {
|
|
729
|
+
const byName = new Map(layout.nodes.map((node) => [node.name, node]));
|
|
730
|
+
const stated = new Map();
|
|
731
|
+
for (const node of layout.nodes) {
|
|
732
|
+
for (const placement of node.placements) {
|
|
733
|
+
if (placement.kind !== 'on' && !(placement.kind === 'offset' && placement.written === 'inside'))
|
|
734
|
+
continue;
|
|
735
|
+
for (const target of placement.targets) {
|
|
736
|
+
const other = byName.get(target.name);
|
|
737
|
+
if (other === undefined)
|
|
738
|
+
continue;
|
|
739
|
+
const set = stated.get(node) ?? new Set();
|
|
740
|
+
set.add(other);
|
|
741
|
+
stated.set(node, set);
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
const lineage = (node) => {
|
|
746
|
+
const chain = [];
|
|
747
|
+
for (let at = node; at; at = at.parent)
|
|
748
|
+
chain.push(at);
|
|
749
|
+
return chain;
|
|
750
|
+
};
|
|
751
|
+
const accounted = (a, b) => {
|
|
752
|
+
const upA = lineage(a);
|
|
753
|
+
const upB = lineage(b);
|
|
754
|
+
if (upA.includes(b) || upB.includes(a))
|
|
755
|
+
return true;
|
|
756
|
+
if (vennStates(a, b))
|
|
757
|
+
return true;
|
|
758
|
+
// A deck's cards are stacked on each other by the deck, and so is what they hold.
|
|
759
|
+
if (upA.some((one) => one.parent?.appearance['layout'] === 'deck' && upB.some((other) => other !== one && other.parent === one.parent)))
|
|
760
|
+
return true;
|
|
761
|
+
if (upA.some(saysOverlapAllowed) || upB.some(saysOverlapAllowed))
|
|
762
|
+
return true;
|
|
763
|
+
const placedOn = (over, under) => over.some((node) => under.some((target) => stated.get(node)?.has(target)));
|
|
764
|
+
return placedOn(upA, upB) || placedOn(upB, upA);
|
|
765
|
+
};
|
|
766
|
+
const pairs = [];
|
|
767
|
+
// A guide box draws nothing, so nothing drawn can cover it or be covered by it.
|
|
768
|
+
const sorted = layout.nodes.filter((node) => !node.guide).sort((a, b) => a.x - b.x);
|
|
769
|
+
let active = [];
|
|
770
|
+
for (const node of sorted) {
|
|
771
|
+
active = active.filter((other) => other.x + other.width > node.x + EPSILON);
|
|
772
|
+
for (const other of active) {
|
|
773
|
+
const width = Math.min(node.x + node.width, other.x + other.width) - Math.max(node.x, other.x);
|
|
774
|
+
const height = Math.min(node.y + node.height, other.y + other.height) - Math.max(node.y, other.y);
|
|
775
|
+
if (width <= EPSILON || height <= EPSILON)
|
|
776
|
+
continue;
|
|
777
|
+
if (accounted(node, other))
|
|
778
|
+
continue;
|
|
779
|
+
const [earlier, later] = other.line <= node.line ? [other, node] : [node, other];
|
|
780
|
+
pairs.push({ earlier, later, width, height });
|
|
781
|
+
}
|
|
782
|
+
active.push(node);
|
|
783
|
+
}
|
|
784
|
+
// A box over a container is over whatever of its children it reaches too,
|
|
785
|
+
// and saying so for each child repeats the one fact. Only the outermost
|
|
786
|
+
// pair is reported.
|
|
787
|
+
const reported = new Set(pairs.map(({ earlier, later }) => namePair(earlier, later)));
|
|
788
|
+
const inner = ({ earlier, later }) => lineage(earlier).some((a) => lineage(later).some((b) => (a !== earlier || b !== later) && reported.has(namePair(a, b))));
|
|
789
|
+
return pairs
|
|
790
|
+
.filter((pair) => !inner(pair))
|
|
791
|
+
.map(({ earlier, later, width, height }) => new Finding('node-overlap', `node ${later.name} covers ${coveredPart(earlier, later)}node ${earlier.name}; ${size(width, height)}`, later.line));
|
|
792
|
+
}
|
|
793
|
+
function namePair(a, b) {
|
|
794
|
+
return a.name < b.name ? `${a.name}\n${b.name}` : `${b.name}\n${a.name}`;
|
|
795
|
+
}
|
|
796
|
+
/** Shared space smaller than this is two edges meeting, not an overlap. */
|
|
797
|
+
const EPSILON = 0.5;
|
|
798
|
+
function saysOverlapAllowed(node) {
|
|
799
|
+
return node.attrs['overlap'] === 'allow';
|
|
800
|
+
}
|
|
801
|
+
/**
|
|
802
|
+
* Which of the nine positions of `under` that `over` covers, worded to go
|
|
803
|
+
* before the node: "the top-left corner of ", "the left side of ", "all of ".
|
|
804
|
+
* Along each axis the covered stretch is at the near end if it reaches that
|
|
805
|
+
* edge and not the far one, the far end the other way round, and the middle
|
|
806
|
+
* otherwise.
|
|
807
|
+
*/
|
|
808
|
+
/** Whether `inner` lies wholly inside `outer`. */
|
|
809
|
+
function within(inner, outer) {
|
|
810
|
+
return (inner.x >= outer.x - EPSILON && inner.y >= outer.y - EPSILON &&
|
|
811
|
+
inner.x + inner.width <= outer.x + outer.width + EPSILON && inner.y + inner.height <= outer.y + outer.height + EPSILON);
|
|
812
|
+
}
|
|
813
|
+
/** The sides of `outer` that `inner` crosses, and by how far at most, or undefined when it is inside. */
|
|
814
|
+
function pokesOut(inner, outer) {
|
|
815
|
+
const past = [
|
|
816
|
+
['top', outer.y - inner.y],
|
|
817
|
+
['bottom', inner.y + inner.height - (outer.y + outer.height)],
|
|
818
|
+
['left', outer.x - inner.x],
|
|
819
|
+
['right', inner.x + inner.width - (outer.x + outer.width)],
|
|
820
|
+
];
|
|
821
|
+
const crossed = past.filter(([, by]) => by > EPSILON);
|
|
822
|
+
if (crossed.length === 0)
|
|
823
|
+
return undefined;
|
|
824
|
+
const names = crossed.map(([side]) => side);
|
|
825
|
+
const sides = names.length === 1 ? `${names[0]} side` : `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]} sides`;
|
|
826
|
+
return { sides, by: units(Math.max(...crossed.map(([, by]) => by))) };
|
|
827
|
+
}
|
|
828
|
+
function coveredPart(under, over) {
|
|
829
|
+
const along = (start, extent, overStart, overExtent) => {
|
|
830
|
+
const near = overStart <= start + EPSILON;
|
|
831
|
+
const far = overStart + overExtent >= start + extent - EPSILON;
|
|
832
|
+
if (near && far)
|
|
833
|
+
return 'all';
|
|
834
|
+
if (near)
|
|
835
|
+
return 'near';
|
|
836
|
+
if (far)
|
|
837
|
+
return 'far';
|
|
838
|
+
return 'middle';
|
|
839
|
+
};
|
|
840
|
+
const x = along(under.x, under.width, over.x, over.width);
|
|
841
|
+
const y = along(under.y, under.height, over.y, over.height);
|
|
842
|
+
if (x === 'all' && y === 'all')
|
|
843
|
+
return 'all of ';
|
|
844
|
+
const across = { near: 'left', far: 'right', middle: '', all: '' }[x];
|
|
845
|
+
const down = { near: 'top', far: 'bottom', middle: '', all: '' }[y];
|
|
846
|
+
if (across && down)
|
|
847
|
+
return `the ${down}-${across} corner of `;
|
|
848
|
+
if (down)
|
|
849
|
+
return `the ${down} of `;
|
|
850
|
+
if (across)
|
|
851
|
+
return `the ${across} side of `;
|
|
852
|
+
if (x === 'all')
|
|
853
|
+
return 'a band across the middle of ';
|
|
854
|
+
if (y === 'all')
|
|
855
|
+
return 'a band down the middle of ';
|
|
856
|
+
return 'the center of ';
|
|
857
|
+
}
|
|
858
|
+
/** "the overlap is 3 units wide and 2 units tall", in the file's unit. */
|
|
859
|
+
function size(width, height) {
|
|
860
|
+
return `the overlap is ${units(width)} wide and ${units(height)} tall`;
|
|
861
|
+
}
|
|
862
|
+
function units(pixels) {
|
|
863
|
+
const count = Math.round(pixels / UNIT);
|
|
864
|
+
if (count === 0)
|
|
865
|
+
return 'less than a unit';
|
|
866
|
+
return count === 1 ? '1 unit' : `${count} units`;
|
|
867
|
+
}
|
|
868
|
+
/** The one word in `words` a slip of a letter or two away from `word`, if there is one. */
|
|
869
|
+
export function nearMatch(word, words) {
|
|
870
|
+
let best;
|
|
871
|
+
let bestDistance = Infinity;
|
|
872
|
+
for (const candidate of words) {
|
|
873
|
+
const distance = editDistance(word, candidate);
|
|
874
|
+
if (distance < bestDistance) {
|
|
875
|
+
best = candidate;
|
|
876
|
+
bestDistance = distance;
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
return bestDistance <= Math.max(1, Math.floor(word.length / 3)) ? best : undefined;
|
|
880
|
+
}
|
|
881
|
+
function editDistance(a, b) {
|
|
882
|
+
let row = Array.from({ length: b.length + 1 }, (_, j) => j);
|
|
883
|
+
for (let i = 1; i <= a.length; i += 1) {
|
|
884
|
+
const next = [i];
|
|
885
|
+
for (let j = 1; j <= b.length; j += 1) {
|
|
886
|
+
next[j] = Math.min(row[j] + 1, next[j - 1] + 1, row[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
887
|
+
}
|
|
888
|
+
row = next;
|
|
889
|
+
}
|
|
890
|
+
return row[b.length];
|
|
891
|
+
}
|