reladraw 0.0.1 → 0.2.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/LICENSE +202 -0
- package/NOTICE +14 -0
- package/README.md +135 -2
- package/SYNTAX.md +617 -0
- package/dist/ast.d.ts +228 -0
- package/dist/ast.js +171 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +72 -0
- package/dist/constants.d.ts +145 -0
- package/dist/constants.js +189 -0
- package/dist/constrain.d.ts +56 -0
- package/dist/constrain.js +95 -0
- package/dist/errors.d.ts +7 -0
- package/dist/errors.js +14 -0
- package/dist/grammar.d.ts +103 -0
- package/dist/grammar.js +215 -0
- package/dist/icons.d.ts +86 -0
- package/dist/icons.js +166 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +16 -0
- package/dist/lexer.d.ts +25 -0
- package/dist/lexer.js +91 -0
- package/dist/measure.d.ts +38 -0
- package/dist/measure.js +66 -0
- package/dist/model.d.ts +78 -0
- package/dist/model.js +1 -0
- package/dist/parser.d.ts +3 -0
- package/dist/parser.js +459 -0
- package/dist/render.d.ts +31 -0
- package/dist/render.js +1180 -0
- package/dist/resolve.d.ts +22 -0
- package/dist/resolve.js +1130 -0
- package/package.json +42 -4
package/dist/resolve.js
ADDED
|
@@ -0,0 +1,1130 @@
|
|
|
1
|
+
import { ALL_ATTR_KEYS, ATTR_KEYS, COLOR_KEYS, COLOR_PARTS, describePlacement, } from './ast.js';
|
|
2
|
+
import { ARROW_LENGTH, CHILD_GAP, DECK_STEP, DEFAULT_FONT_SIZE, DEFAULT_MARGIN, GAPS, HEADER_GAP, ICON_GAP, ICON_LINES, LABEL_CLEARANCE, PAD, SEPARATION_GAP, fontSizeFor, labelExtent, labelStyleFor, } from './constants.js';
|
|
3
|
+
import { fix, reachability, tightest } from './constrain.js';
|
|
4
|
+
import { SourceError } from './errors.js';
|
|
5
|
+
import { iconFor, shapeFor } from './icons.js';
|
|
6
|
+
import { monospaceMeasurer, splitLines } from './measure.js';
|
|
7
|
+
/**
|
|
8
|
+
* Turn a parsed document into solved geometry.
|
|
9
|
+
*
|
|
10
|
+
* Three passes: build the containment tree, size every node bottom-up, then
|
|
11
|
+
* turn each node's placements into minimum distances and solve for the tightest
|
|
12
|
+
* arrangement that satisfies them. That last pass also adds the separations
|
|
13
|
+
* that keep boxes off each other, and solves again until none is left to add.
|
|
14
|
+
*
|
|
15
|
+
* It is a constraint solve, of the kind that computes rather than searches. It
|
|
16
|
+
* works out how far apart things are; nothing about which side of what a node
|
|
17
|
+
* sits on is ever decided here, because the author wrote it down.
|
|
18
|
+
*/
|
|
19
|
+
export function resolve(doc, options = {}) {
|
|
20
|
+
const measurer = options.measurer ?? monospaceMeasurer();
|
|
21
|
+
const fontSize = options.fontSize ?? DEFAULT_FONT_SIZE;
|
|
22
|
+
const margin = options.margin ?? DEFAULT_MARGIN;
|
|
23
|
+
const styles = collectStyles(doc.statements);
|
|
24
|
+
checkStyleKeys(doc.statements);
|
|
25
|
+
const { nodes, byName, roots } = buildTree(doc.statements, styles);
|
|
26
|
+
applyDecks(doc.statements, byName);
|
|
27
|
+
// Links are resolved to nodes before anything is sized, because a labeled
|
|
28
|
+
// link claims room in the gap it crosses and so has to be in hand while the
|
|
29
|
+
// gaps are being worked out. Nothing here reads geometry.
|
|
30
|
+
const links = buildLinks(doc.statements, byName, styles);
|
|
31
|
+
const local = new Map();
|
|
32
|
+
for (const root of roots)
|
|
33
|
+
sizeNode(root, links, measurer, fontSize, local);
|
|
34
|
+
placeRoots(roots, byName, links, measurer, fontSize, local);
|
|
35
|
+
normalize(nodes, margin);
|
|
36
|
+
const extent = bounds(nodes);
|
|
37
|
+
return {
|
|
38
|
+
nodes,
|
|
39
|
+
roots,
|
|
40
|
+
links,
|
|
41
|
+
diagram: collectDiagram(doc.statements),
|
|
42
|
+
width: Math.ceil(extent.maxX + margin),
|
|
43
|
+
height: Math.ceil(extent.maxY + margin),
|
|
44
|
+
margin,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
// --- pass one: the containment tree -----------------------------------------
|
|
48
|
+
function collectStyles(statements) {
|
|
49
|
+
const styles = new Map();
|
|
50
|
+
for (const stmt of statements) {
|
|
51
|
+
if (stmt.kind !== 'style')
|
|
52
|
+
continue;
|
|
53
|
+
if (styles.has(stmt.name)) {
|
|
54
|
+
throw new SourceError(`style "${stmt.name}" is declared twice`, stmt.line);
|
|
55
|
+
}
|
|
56
|
+
styles.set(stmt.name, stmt.attrs);
|
|
57
|
+
}
|
|
58
|
+
return styles;
|
|
59
|
+
}
|
|
60
|
+
/** A file holds one diagram, so a second `diagram` statement is a mistake. */
|
|
61
|
+
function collectDiagram(statements) {
|
|
62
|
+
let found;
|
|
63
|
+
for (const stmt of statements) {
|
|
64
|
+
if (stmt.kind !== 'diagram')
|
|
65
|
+
continue;
|
|
66
|
+
if (found)
|
|
67
|
+
throw new SourceError('the diagram is described twice', stmt.line);
|
|
68
|
+
found = stmt.attrs;
|
|
69
|
+
}
|
|
70
|
+
return found ?? {};
|
|
71
|
+
}
|
|
72
|
+
function buildTree(statements, styles) {
|
|
73
|
+
const nodes = [];
|
|
74
|
+
const byName = new Map();
|
|
75
|
+
const roots = [];
|
|
76
|
+
for (const stmt of statements) {
|
|
77
|
+
if (stmt.kind !== 'box' && stmt.kind !== 'note')
|
|
78
|
+
continue;
|
|
79
|
+
if (byName.has(stmt.name)) {
|
|
80
|
+
throw new SourceError(`"${stmt.name}" is declared twice`, stmt.line);
|
|
81
|
+
}
|
|
82
|
+
const node = {
|
|
83
|
+
name: stmt.name,
|
|
84
|
+
kind: stmt.kind,
|
|
85
|
+
text: stmt.text,
|
|
86
|
+
lines: linesFor(stmt.text, stmt.attrs, stmt.line),
|
|
87
|
+
children: [],
|
|
88
|
+
x: 0,
|
|
89
|
+
y: 0,
|
|
90
|
+
width: 0,
|
|
91
|
+
height: 0,
|
|
92
|
+
inset: 0,
|
|
93
|
+
deckLabels: [],
|
|
94
|
+
headerHeight: 0,
|
|
95
|
+
label: stmt.kind === 'box' ? stmt.label : {},
|
|
96
|
+
attrs: stmt.attrs,
|
|
97
|
+
appearance: appearanceOf(stmt.attrs, styles, stmt.line),
|
|
98
|
+
placements: stmt.placements,
|
|
99
|
+
line: stmt.line,
|
|
100
|
+
};
|
|
101
|
+
const kind = kindOf(node);
|
|
102
|
+
checkAttrs(kind, node.name, stmt.attrs, stmt.line);
|
|
103
|
+
checkStyleUse(kind, node.name, stmt.attrs, styles, stmt.line);
|
|
104
|
+
const cut = stmt.name.lastIndexOf('.');
|
|
105
|
+
if (cut === -1) {
|
|
106
|
+
roots.push(node);
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
const parentName = stmt.name.slice(0, cut);
|
|
110
|
+
const parent = byName.get(parentName);
|
|
111
|
+
if (!parent) {
|
|
112
|
+
throw new SourceError(`"${stmt.name}" is inside "${parentName}", which is not declared yet`, stmt.line);
|
|
113
|
+
}
|
|
114
|
+
node.parent = parent;
|
|
115
|
+
parent.children.push(node);
|
|
116
|
+
}
|
|
117
|
+
nodes.push(node);
|
|
118
|
+
byName.set(stmt.name, node);
|
|
119
|
+
}
|
|
120
|
+
return { nodes, byName, roots };
|
|
121
|
+
}
|
|
122
|
+
/** How each kind reads in an error, and what it is actually made of. */
|
|
123
|
+
const KIND_WORD = { box: 'box', note: 'note', glyph: 'glyph body', link: 'link' };
|
|
124
|
+
const KIND_PARTS = {
|
|
125
|
+
box: 'a fill, a border and text',
|
|
126
|
+
note: 'bare text and nothing else',
|
|
127
|
+
glyph: 'a picture and the text under it',
|
|
128
|
+
link: 'a line and its label',
|
|
129
|
+
};
|
|
130
|
+
/**
|
|
131
|
+
* An attribute is refused on a kind that has no use for it — the same rule as
|
|
132
|
+
* an unknown `diagram` key, and for the same reason: a key that silently does
|
|
133
|
+
* nothing looks like the tool being broken. A color names a part and so is a
|
|
134
|
+
* special case of this, which is why the two checks are one.
|
|
135
|
+
*
|
|
136
|
+
* What is checked is what the author wrote *on this statement*, not what a
|
|
137
|
+
* style contributed. A style is a bundle meant to be shared across kinds — the
|
|
138
|
+
* benchmark's `synced` carries a fill and a border for the green boxes and a
|
|
139
|
+
* line for the four links joining them — so a key it carries that this kind has
|
|
140
|
+
* no part for is simply unused, and is not a mistake anybody made here. That is
|
|
141
|
+
* forced rather than chosen: checking the merged appearance would refuse the
|
|
142
|
+
* benchmark's own central idiom four times over. `checkStyleUse` is what keeps
|
|
143
|
+
* the permissiveness honest.
|
|
144
|
+
*/
|
|
145
|
+
function checkAttrs(kind, name, attrs, line) {
|
|
146
|
+
const allowed = ATTR_KEYS[kind];
|
|
147
|
+
// In the author's own order, so the error names the first offending word as
|
|
148
|
+
// it is read rather than the first in some list of ours.
|
|
149
|
+
for (const [key, value] of Object.entries(attrs)) {
|
|
150
|
+
if (allowed.includes(key))
|
|
151
|
+
continue;
|
|
152
|
+
if (!ALL_ATTR_KEYS.includes(key)) {
|
|
153
|
+
// Nothing anywhere in the language answers to this word, so the only
|
|
154
|
+
// remedy is the vocabulary itself.
|
|
155
|
+
throw new SourceError(`"${name}" has ${key}: ${value}, which is not an attribute. A ${KIND_WORD[kind]} takes ${allowed.join(', ')}`, line);
|
|
156
|
+
}
|
|
157
|
+
// A real word in the wrong place, and the two sorts of word want different
|
|
158
|
+
// explanations. A color names a *part*, so saying what the kind is made of
|
|
159
|
+
// is the whole reason it has no such color, and the remedy is the narrower
|
|
160
|
+
// list of parts it does have: `line:` on a box is a different mistake from
|
|
161
|
+
// `border:` on a note, and one hint cannot serve both.
|
|
162
|
+
if (COLOR_KEYS.includes(key)) {
|
|
163
|
+
const parts = COLOR_PARTS[kind].map((part) => `\`${part}:\``).join(', ');
|
|
164
|
+
throw new SourceError(`"${name}" is a ${KIND_WORD[kind]} and has ${key}: ${value}. A ${KIND_WORD[kind]} is ${KIND_PARTS[kind]}, so it has no ${key} — it takes ${parts}`, line);
|
|
165
|
+
}
|
|
166
|
+
// Anything else names no part, so what the kind is made of explains
|
|
167
|
+
// nothing. What does explain it is where the word *does* belong, which is
|
|
168
|
+
// also the more useful thing to be told: the author has usually written a
|
|
169
|
+
// real statement about the wrong half of the diagram.
|
|
170
|
+
throw new SourceError(`"${name}" is a ${KIND_WORD[kind]} and has ${key}: ${value}. \`${key}:\` belongs to ` +
|
|
171
|
+
`${listKinds(belongTo(key))} — a ${KIND_WORD[kind]} takes ${allowed.join(', ')}`, line);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* A style may carry keys this kind has no use for, but it may not carry *only*
|
|
176
|
+
* those. Partial overlap is the normal case and the reason styles exist; zero
|
|
177
|
+
* overlap is a style name written on the wrong sort of thing, and nothing else.
|
|
178
|
+
*
|
|
179
|
+
* This is the check that lets `checkAttrs` ignore style-contributed keys
|
|
180
|
+
* without the silence coming back. It cannot catch a style that names every key
|
|
181
|
+
* in the language, since such a style contributes to everything by
|
|
182
|
+
* construction — that hole is left open, because nobody writes one by accident.
|
|
183
|
+
*/
|
|
184
|
+
function checkStyleUse(kind, name, attrs, styles, line) {
|
|
185
|
+
const named = attrs['style'];
|
|
186
|
+
if (named === undefined)
|
|
187
|
+
return;
|
|
188
|
+
const base = styles.get(named);
|
|
189
|
+
if (base === undefined)
|
|
190
|
+
return; // `appearanceOf` reports the missing style.
|
|
191
|
+
// A style naming another style is the one way to carry nothing at all: the
|
|
192
|
+
// parser already refuses one with no attributes, and `appearanceOf` does not
|
|
193
|
+
// recurse, so the name would sit there doing nothing.
|
|
194
|
+
const carried = Object.keys(base).filter((key) => key !== 'style');
|
|
195
|
+
if (carried.length === 0) {
|
|
196
|
+
throw new SourceError(`style "${named}" carries nothing but a style name`, line);
|
|
197
|
+
}
|
|
198
|
+
if (carried.some((key) => ATTR_KEYS[kind].includes(key)))
|
|
199
|
+
return;
|
|
200
|
+
throw new SourceError(`style "${named}" gives "${name}" nothing. It carries ${carried.join(' and ')}; ` +
|
|
201
|
+
`a ${KIND_WORD[kind]} is ${KIND_PARTS[kind]}`, line);
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* A style is a bundle spanning kinds, so its keys cannot be checked against any
|
|
205
|
+
* one of them — but a word that is an attribute of *nothing* is a misspelling
|
|
206
|
+
* wherever it sits, and a style was the last place in the language where one
|
|
207
|
+
* could hide.
|
|
208
|
+
*/
|
|
209
|
+
function checkStyleKeys(statements) {
|
|
210
|
+
for (const stmt of statements) {
|
|
211
|
+
if (stmt.kind !== 'style')
|
|
212
|
+
continue;
|
|
213
|
+
for (const [key, value] of Object.entries(stmt.attrs)) {
|
|
214
|
+
if (ALL_ATTR_KEYS.includes(key))
|
|
215
|
+
continue;
|
|
216
|
+
throw new SourceError(`style "${stmt.name}" has ${key}: ${value}, which is not an attribute. ` +
|
|
217
|
+
`The attributes are ${ALL_ATTR_KEYS.join(', ')}`, stmt.line);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
/** Which kinds understand an attribute, in the order the table declares them. */
|
|
222
|
+
function belongTo(key) {
|
|
223
|
+
return Object.keys(ATTR_KEYS).filter((kind) => ATTR_KEYS[kind].includes(key));
|
|
224
|
+
}
|
|
225
|
+
/** "a link", "a box or a glyph body", "a box, a note or a glyph body". */
|
|
226
|
+
function listKinds(kinds) {
|
|
227
|
+
const words = kinds.map((kind) => `a ${KIND_WORD[kind]}`);
|
|
228
|
+
if (words.length <= 1)
|
|
229
|
+
return words[0] ?? 'nothing';
|
|
230
|
+
return `${words.slice(0, -1).join(', ')} or ${words[words.length - 1]}`;
|
|
231
|
+
}
|
|
232
|
+
/** Which of the four kinds a node is, which its `shape:` may decide. */
|
|
233
|
+
function kindOf(node) {
|
|
234
|
+
if (node.kind === 'note')
|
|
235
|
+
return 'note';
|
|
236
|
+
return shapeFor(node.appearance, node.line).body !== undefined ? 'glyph' : 'box';
|
|
237
|
+
}
|
|
238
|
+
function appearanceOf(attrs, styles, line) {
|
|
239
|
+
const named = attrs['style'];
|
|
240
|
+
if (named === undefined)
|
|
241
|
+
return { ...attrs };
|
|
242
|
+
const base = styles.get(named);
|
|
243
|
+
if (!base)
|
|
244
|
+
throw new SourceError(`no style named "${named}"`, line);
|
|
245
|
+
return { ...base, ...attrs };
|
|
246
|
+
}
|
|
247
|
+
function applyDecks(statements, byName) {
|
|
248
|
+
for (const stmt of statements) {
|
|
249
|
+
if (stmt.kind !== 'deck')
|
|
250
|
+
continue;
|
|
251
|
+
const node = byName.get(stmt.name);
|
|
252
|
+
if (!node)
|
|
253
|
+
throw new SourceError(`deck names "${stmt.name}", which does not exist`, stmt.line);
|
|
254
|
+
node.deckLabels = stmt.labels;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
function buildLinks(statements, byName, styles) {
|
|
258
|
+
const links = [];
|
|
259
|
+
for (const stmt of statements) {
|
|
260
|
+
if (stmt.kind !== 'link')
|
|
261
|
+
continue;
|
|
262
|
+
const from = byName.get(stmt.from);
|
|
263
|
+
const to = byName.get(stmt.to);
|
|
264
|
+
if (!from)
|
|
265
|
+
throw new SourceError(`link from "${stmt.from}", which does not exist`, stmt.line);
|
|
266
|
+
if (!to)
|
|
267
|
+
throw new SourceError(`link to "${stmt.to}", which does not exist`, stmt.line);
|
|
268
|
+
const between = stmt.between && {
|
|
269
|
+
nodes: stmt.between.targets.map((name) => {
|
|
270
|
+
const node = byName.get(name);
|
|
271
|
+
if (!node) {
|
|
272
|
+
throw new SourceError(`link passes between "${name}", which does not exist`, stmt.line);
|
|
273
|
+
}
|
|
274
|
+
return node;
|
|
275
|
+
}),
|
|
276
|
+
...(stmt.between.axis !== undefined ? { axis: stmt.between.axis } : {}),
|
|
277
|
+
};
|
|
278
|
+
const appearance = appearanceOf(stmt.attrs, styles, stmt.line);
|
|
279
|
+
const what = `${stmt.from} -> ${stmt.to}`;
|
|
280
|
+
checkAttrs('link', what, stmt.attrs, stmt.line);
|
|
281
|
+
checkStyleUse('link', what, stmt.attrs, styles, stmt.line);
|
|
282
|
+
links.push({
|
|
283
|
+
from,
|
|
284
|
+
to,
|
|
285
|
+
both: stmt.both,
|
|
286
|
+
...(stmt.label !== undefined ? { label: stmt.label } : {}),
|
|
287
|
+
...(between ? { between } : {}),
|
|
288
|
+
attrs: stmt.attrs,
|
|
289
|
+
appearance,
|
|
290
|
+
line: stmt.line,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
return links;
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Give a node a width and height, sizing its children first. Also records each
|
|
297
|
+
* child's offset within this node, which pass three turns into absolute
|
|
298
|
+
* coordinates once this node itself is placed.
|
|
299
|
+
*/
|
|
300
|
+
function sizeNode(node, links, measurer, fontSize, local) {
|
|
301
|
+
for (const child of node.children)
|
|
302
|
+
sizeNode(child, links, measurer, fontSize, local);
|
|
303
|
+
// Text is measured at the size it will be drawn at — the size lives in
|
|
304
|
+
// `constants.ts` precisely so the resolver reserving the room and the
|
|
305
|
+
// renderer filling it cannot disagree about how much room there is.
|
|
306
|
+
const textSize = fontSizeFor(node.kind, node.appearance, fontSize, node.line);
|
|
307
|
+
const lineHeight = measurer.lineHeight(textSize);
|
|
308
|
+
// A node with empty text takes no room for it. This is what makes an
|
|
309
|
+
// invisible grouping container size to exactly its contents.
|
|
310
|
+
const hasLabel = node.lines.some((line) => line.length > 0);
|
|
311
|
+
const labelWidth = hasLabel ? widestLine(node.lines, measurer, textSize) : 0;
|
|
312
|
+
const labelHeight = hasLabel ? node.lines.length * lineHeight : 0;
|
|
313
|
+
if (node.kind === 'note') {
|
|
314
|
+
// A note is bare text, so it gets no padding and takes no children.
|
|
315
|
+
node.width = labelWidth;
|
|
316
|
+
node.height = labelHeight;
|
|
317
|
+
return;
|
|
318
|
+
}
|
|
319
|
+
const shape = shapeFor(node.appearance, node.line);
|
|
320
|
+
const glyphSide = ICON_LINES * lineHeight;
|
|
321
|
+
if (shape.body !== undefined) {
|
|
322
|
+
// Drawn as a glyph, so there is no box to pad and the node's size is the
|
|
323
|
+
// picture's. A label goes under it rather than inside it, which is the
|
|
324
|
+
// arrangement that makes a row of these read as captioned things.
|
|
325
|
+
if (node.children.length > 0) {
|
|
326
|
+
throw new SourceError(`"${node.name}" is drawn as a glyph and has children. A glyph is not a box, so nothing can go inside it`, node.line);
|
|
327
|
+
}
|
|
328
|
+
node.width = Math.max(glyphSide, labelWidth);
|
|
329
|
+
node.height = glyphSide + (hasLabel ? ICON_GAP + labelHeight : 0);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
// An icon takes a column of its own on the right of whatever the box holds,
|
|
333
|
+
// so the label never runs underneath it and the box grows to fit both. That
|
|
334
|
+
// is why an icon is not a renderer-only concern: it is content taking room,
|
|
335
|
+
// like a label, and not appearance like `fill:`.
|
|
336
|
+
const icon = iconFor(node.appearance, node.line);
|
|
337
|
+
const iconSide = icon === undefined ? 0 : glyphSide;
|
|
338
|
+
const iconRoom = icon === undefined ? 0 : iconSide + (hasLabel ? ICON_GAP : 0);
|
|
339
|
+
if (node.children.length === 0) {
|
|
340
|
+
// A band only exists because contents have to sit clear of it, and a leaf
|
|
341
|
+
// has none, so `at` has no end to name. `align` is a different question and
|
|
342
|
+
// is allowed: a label of several lines has lines of unequal length in any
|
|
343
|
+
// box, and ranging them left rather than centering them is a real thing to
|
|
344
|
+
// want. It was refused here too until 2026-09-09, purely because the two
|
|
345
|
+
// words arrive in the same brackets.
|
|
346
|
+
if (node.label['at'] !== undefined) {
|
|
347
|
+
throw new SourceError(`"${node.name}" holds nothing and its label carries at: ${node.label['at']}. ` +
|
|
348
|
+
`A label sits at one end of a box so its contents can have the other; with no contents there is no band for it to sit at either end of`, node.line);
|
|
349
|
+
}
|
|
350
|
+
node.width = labelWidth + iconRoom + PAD * 2;
|
|
351
|
+
node.height = Math.max(labelHeight, iconSide) + PAD * 2;
|
|
352
|
+
}
|
|
353
|
+
else {
|
|
354
|
+
applyAlign(node);
|
|
355
|
+
const content = layoutChildren(node, links, measurer, fontSize, local);
|
|
356
|
+
const band = Math.max(labelHeight, iconSide);
|
|
357
|
+
// `headerHeight` is the band the label and icon take, whichever end of the
|
|
358
|
+
// box that band is at. Only the contents' offset depends on the side.
|
|
359
|
+
node.headerHeight = hasLabel || icon !== undefined ? band + HEADER_GAP : 0;
|
|
360
|
+
node.width = Math.max(labelWidth + iconRoom, content.width) + PAD * 2;
|
|
361
|
+
node.height = node.headerHeight + content.height + PAD * 2;
|
|
362
|
+
const above = labelStyleFor(node.label, node.line).at === 'top' ? node.headerHeight : 0;
|
|
363
|
+
for (const child of node.children) {
|
|
364
|
+
const offset = local.get(child);
|
|
365
|
+
offset.x += PAD;
|
|
366
|
+
offset.y += PAD + above;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
if (node.deckLabels.length > 0) {
|
|
370
|
+
// The copies sit behind and above-left, so the whole node grows by the
|
|
371
|
+
// depth of the stack and its own face moves down and right by the same.
|
|
372
|
+
node.inset = node.deckLabels.length * DECK_STEP;
|
|
373
|
+
node.width += node.inset;
|
|
374
|
+
node.height += node.inset;
|
|
375
|
+
for (const child of node.children) {
|
|
376
|
+
const offset = local.get(child);
|
|
377
|
+
offset.x += node.inset;
|
|
378
|
+
offset.y += node.inset;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
/**
|
|
383
|
+
* `align: widths` widens every direct child to the widest one's natural
|
|
384
|
+
* width, before layoutChildren sizes and positions anything from those
|
|
385
|
+
* widths. A container's own children are already sized by this point.
|
|
386
|
+
*/
|
|
387
|
+
function applyAlign(node) {
|
|
388
|
+
const value = node.attrs['align'];
|
|
389
|
+
if (value === undefined)
|
|
390
|
+
return;
|
|
391
|
+
if (value !== 'widths') {
|
|
392
|
+
throw new SourceError(`"${node.name}" has align: ${value}, which is not one of widths`, node.line);
|
|
393
|
+
}
|
|
394
|
+
const maxWidth = Math.max(...node.children.map((child) => child.width));
|
|
395
|
+
for (const child of node.children)
|
|
396
|
+
child.width = maxWidth;
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* Position a container's children relative to each other. Children that make
|
|
400
|
+
* no placement stack vertically in written order; the rest are solved against the
|
|
401
|
+
* siblings they name, by the same constraint pass that positions top-level
|
|
402
|
+
* nodes. A placement may only name a sibling — containment scopes the group.
|
|
403
|
+
*/
|
|
404
|
+
function layoutChildren(parent, links, measurer, fontSize, local) {
|
|
405
|
+
const siblings = new Map(parent.children.map((child) => [child.name, child]));
|
|
406
|
+
// Children that say nothing keep the written order, down the page and flush
|
|
407
|
+
// left. Written as constraints rather than a cursor so a placed sibling can
|
|
408
|
+
// push them along like anything else.
|
|
409
|
+
const stack = [];
|
|
410
|
+
const alignment = [];
|
|
411
|
+
const quiet = parent.children.filter((child) => child.placements.length === 0);
|
|
412
|
+
const indexOf = new Map(parent.children.map((child, index) => [child, index]));
|
|
413
|
+
quiet.forEach((child, position) => {
|
|
414
|
+
const previous = quiet[position - 1];
|
|
415
|
+
if (!previous)
|
|
416
|
+
return;
|
|
417
|
+
const before = indexOf.get(previous);
|
|
418
|
+
const after = indexOf.get(child);
|
|
419
|
+
stack.push({ from: before, to: after, weight: previous.height + CHILD_GAP });
|
|
420
|
+
alignment.push(...fix(before, after, 0));
|
|
421
|
+
});
|
|
422
|
+
const positions = positionGroup(parent.children, (placement, owner) => placement.targets.map((name) => {
|
|
423
|
+
const target = siblings.get(name);
|
|
424
|
+
if (!target) {
|
|
425
|
+
throw new SourceError(`"${owner.name}" is placed against "${name}", which is not one of its siblings`, placement.line);
|
|
426
|
+
}
|
|
427
|
+
return {
|
|
428
|
+
name,
|
|
429
|
+
node: target,
|
|
430
|
+
index: indexOf.get(target),
|
|
431
|
+
offset: { x: 0, y: 0 },
|
|
432
|
+
width: target.width,
|
|
433
|
+
height: target.height,
|
|
434
|
+
};
|
|
435
|
+
}), { x: alignment, y: stack }, corridorsIn(links, (node) => liftTo(node, indexOf, local), measurer, fontSize));
|
|
436
|
+
for (const child of parent.children)
|
|
437
|
+
local.set(child, positions.get(child));
|
|
438
|
+
return extentOf(parent.children, local);
|
|
439
|
+
}
|
|
440
|
+
function extentOf(children, local) {
|
|
441
|
+
let minX = Infinity;
|
|
442
|
+
let minY = Infinity;
|
|
443
|
+
let maxX = -Infinity;
|
|
444
|
+
let maxY = -Infinity;
|
|
445
|
+
for (const child of children) {
|
|
446
|
+
const offset = local.get(child);
|
|
447
|
+
minX = Math.min(minX, offset.x);
|
|
448
|
+
minY = Math.min(minY, offset.y);
|
|
449
|
+
maxX = Math.max(maxX, offset.x + child.width);
|
|
450
|
+
maxY = Math.max(maxY, offset.y + child.height);
|
|
451
|
+
}
|
|
452
|
+
for (const child of children) {
|
|
453
|
+
const offset = local.get(child);
|
|
454
|
+
offset.x -= minX;
|
|
455
|
+
offset.y -= minY;
|
|
456
|
+
}
|
|
457
|
+
return { width: maxX - minX, height: maxY - minY };
|
|
458
|
+
}
|
|
459
|
+
// --- pass three: solve for positions -----------------------------------------
|
|
460
|
+
function placeRoots(roots, byName, links, measurer, fontSize, local) {
|
|
461
|
+
const anchors = roots.filter((root) => root.placements.length === 0);
|
|
462
|
+
if (anchors.length === 0) {
|
|
463
|
+
throw new SourceError('every node is placed relative to another, so nothing anchors the diagram', 1);
|
|
464
|
+
}
|
|
465
|
+
if (anchors.length > 1) {
|
|
466
|
+
const names = anchors.map((node) => `"${node.name}"`).join(', ');
|
|
467
|
+
throw new SourceError(`exactly one node may say nothing about where it goes, but ${anchors.length} do: ${names}`, anchors[1].line);
|
|
468
|
+
}
|
|
469
|
+
const indexOf = new Map(roots.map((root, index) => [root, index]));
|
|
470
|
+
// A placement may name something nested — `right of server.docker` places a top-level
|
|
471
|
+
// node against a box inside another. Sizes and offsets within a container are
|
|
472
|
+
// already settled, so a nested target is its root's position plus a constant.
|
|
473
|
+
const positions = positionGroup(roots, (placement, owner) => placement.targets.map((name) => {
|
|
474
|
+
const target = byName.get(name);
|
|
475
|
+
if (!target) {
|
|
476
|
+
throw new SourceError(`"${owner.name}" is placed against "${name}", which does not exist`, placement.line);
|
|
477
|
+
}
|
|
478
|
+
let root = target;
|
|
479
|
+
const offset = { x: 0, y: 0 };
|
|
480
|
+
while (root.parent) {
|
|
481
|
+
const step = local.get(root);
|
|
482
|
+
offset.x += step.x;
|
|
483
|
+
offset.y += step.y;
|
|
484
|
+
root = root.parent;
|
|
485
|
+
}
|
|
486
|
+
return {
|
|
487
|
+
name,
|
|
488
|
+
node: target,
|
|
489
|
+
index: indexOf.get(root),
|
|
490
|
+
offset,
|
|
491
|
+
width: target.width,
|
|
492
|
+
height: target.height,
|
|
493
|
+
};
|
|
494
|
+
}), { x: [], y: [] }, corridorsIn(links, (node) => liftTo(node, indexOf, local), measurer, fontSize));
|
|
495
|
+
for (const root of roots) {
|
|
496
|
+
const position = positions.get(root);
|
|
497
|
+
root.x = position.x;
|
|
498
|
+
root.y = position.y;
|
|
499
|
+
spreadToChildren(root, local);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
/** Once a node has an absolute position, its whole subtree follows from the offsets. */
|
|
503
|
+
function spreadToChildren(node, local) {
|
|
504
|
+
for (const child of node.children) {
|
|
505
|
+
const offset = local.get(child);
|
|
506
|
+
child.x = node.x + offset.x;
|
|
507
|
+
child.y = node.y + offset.y;
|
|
508
|
+
spreadToChildren(child, local);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
const AXES = ['x', 'y'];
|
|
512
|
+
const AXIS_WORD = { x: 'horizontally', y: 'vertically' };
|
|
513
|
+
/**
|
|
514
|
+
* Where a node sits within the group being solved: which member holds it, and
|
|
515
|
+
* where inside that member. A link may name anything at any depth, so its ends
|
|
516
|
+
* are lifted to the members of whichever group is being solved — and a node
|
|
517
|
+
* outside that group has no answer, which is how a link is sorted into the one
|
|
518
|
+
* group where its two ends are different members.
|
|
519
|
+
*/
|
|
520
|
+
function liftTo(node, indexOf, local) {
|
|
521
|
+
let member = node;
|
|
522
|
+
const offset = { x: 0, y: 0 };
|
|
523
|
+
while (!indexOf.has(member)) {
|
|
524
|
+
const step = local.get(member);
|
|
525
|
+
if (!step || !member.parent)
|
|
526
|
+
return undefined;
|
|
527
|
+
offset.x += step.x;
|
|
528
|
+
offset.y += step.y;
|
|
529
|
+
member = member.parent;
|
|
530
|
+
}
|
|
531
|
+
return {
|
|
532
|
+
name: node.name,
|
|
533
|
+
node,
|
|
534
|
+
index: indexOf.get(member),
|
|
535
|
+
offset,
|
|
536
|
+
width: node.width,
|
|
537
|
+
height: node.height,
|
|
538
|
+
};
|
|
539
|
+
}
|
|
540
|
+
/** The labeled links whose two ends are different members of this group. */
|
|
541
|
+
function corridorsIn(links, locate, measurer, fontSize) {
|
|
542
|
+
const corridors = [];
|
|
543
|
+
for (const link of links) {
|
|
544
|
+
// An unlabeled link asks for nothing: every gap holds an arrowhead. And a
|
|
545
|
+
// link told to pass between two named things carries its label in *that*
|
|
546
|
+
// corridor rather than in the gap between its own ends, so widening this one
|
|
547
|
+
// would make room where the label never goes.
|
|
548
|
+
if (link.label === undefined || link.between)
|
|
549
|
+
continue;
|
|
550
|
+
const from = locate(link.from);
|
|
551
|
+
const to = locate(link.to);
|
|
552
|
+
if (!from || !to || from.index === to.index)
|
|
553
|
+
continue;
|
|
554
|
+
// The clearance is doubled because the label is drawn at the *midpoint* of
|
|
555
|
+
// the line, so the room it needs is symmetric about that point whatever sits
|
|
556
|
+
// at either end. The arrowhead is charged on both sides for the same reason:
|
|
557
|
+
// it covers `ARROW_LENGTH` of the line it arrives on, and reserving that at
|
|
558
|
+
// one end only would move the midpoint rather than lengthen the run.
|
|
559
|
+
const extent = (axis) => labelExtent(link.label, link.appearance, axis, measurer, fontSize, link.line) +
|
|
560
|
+
(LABEL_CLEARANCE + ARROW_LENGTH) * 2;
|
|
561
|
+
corridors.push({ link, from, to, need: { x: extent('x'), y: extent('y') } });
|
|
562
|
+
}
|
|
563
|
+
return corridors;
|
|
564
|
+
}
|
|
565
|
+
/**
|
|
566
|
+
* Position a set of nodes against each other from their placements.
|
|
567
|
+
*
|
|
568
|
+
* Every placement becomes a minimum distance, and the answer is the arrangement
|
|
569
|
+
* where nothing is further apart than its placements require — which is what an
|
|
570
|
+
* author does by hand when they push two things apart to fit something between
|
|
571
|
+
* them and then pull the slack back out.
|
|
572
|
+
*
|
|
573
|
+
* Because a gap is a floor rather than a fixed distance, a corridor widens to
|
|
574
|
+
* hold whatever is put in it and closes again when that is removed. No number
|
|
575
|
+
* anywhere has to be guessed, and nothing is ever tried and rejected.
|
|
576
|
+
*/
|
|
577
|
+
function positionGroup(members, locate, extra, corridors = []) {
|
|
578
|
+
const constraints = { x: [...extra.x], y: [...extra.y] };
|
|
579
|
+
const indexOf = new Map(members.map((member, index) => [member, index]));
|
|
580
|
+
const pending = [];
|
|
581
|
+
for (const node of members) {
|
|
582
|
+
// Checked here as well as in `gapFor`, so a misspelt node-wide gap is caught
|
|
583
|
+
// on a node whose placements all name their own or are alignments — and on
|
|
584
|
+
// one that carries no placements at all, where it now still has an effect.
|
|
585
|
+
namedGap(node, node.attrs['gap'], node.line);
|
|
586
|
+
if (node.placements.length === 0)
|
|
587
|
+
continue;
|
|
588
|
+
const me = indexOf.get(node);
|
|
589
|
+
const size = { x: node.width, y: node.height };
|
|
590
|
+
const located = node.placements.map((placement) => ({ placement, targets: locate(placement, node) }));
|
|
591
|
+
const spokenFor = { x: false, y: false };
|
|
592
|
+
for (const { placement } of located) {
|
|
593
|
+
if (placement.kind === 'align') {
|
|
594
|
+
spokenFor[placement.axis] = true;
|
|
595
|
+
}
|
|
596
|
+
else {
|
|
597
|
+
if (/left|right/.test(placement.direction))
|
|
598
|
+
spokenFor.x = true;
|
|
599
|
+
if (/above|below/.test(placement.direction))
|
|
600
|
+
spokenFor.y = true;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
// Aligning to several targets means aligning to the box that just bounds
|
|
604
|
+
// them. That box is a constant only while its members hold still relative
|
|
605
|
+
// to one another; otherwise the alignment waits for the first solution.
|
|
606
|
+
const alignOn = (axis, edge, targets, placement) => {
|
|
607
|
+
const anchor = sharedMember(targets);
|
|
608
|
+
if (anchor === undefined) {
|
|
609
|
+
pending.push({ node, me, axis, edge, targets, placement });
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
const span = spanOf(targets, axis, () => 0);
|
|
613
|
+
constraints[axis].push(...fix(anchor, me, alignedAt(edge, span, size[axis]), placement));
|
|
614
|
+
};
|
|
615
|
+
for (const { placement, targets } of located) {
|
|
616
|
+
if (placement.kind === 'align') {
|
|
617
|
+
alignOn(placement.axis, placement.edge, targets, placement);
|
|
618
|
+
continue;
|
|
619
|
+
}
|
|
620
|
+
// One constraint per target, so the node clears the furthest of them.
|
|
621
|
+
// Taking that maximum is what longest paths already does, which is why a
|
|
622
|
+
// direction against a whole region needs nothing added to the solver.
|
|
623
|
+
const { direction } = placement;
|
|
624
|
+
// A gap belongs to the relationship rather than to either box in it, so
|
|
625
|
+
// each placement may name its own and the node's `gap:` is only the
|
|
626
|
+
// default. That is what lets a node wedged between two things sit tight
|
|
627
|
+
// against one of them and wide of the other.
|
|
628
|
+
for (const target of targets) {
|
|
629
|
+
const gap = gapFor(node, placement, target.node);
|
|
630
|
+
if (direction.includes('right')) {
|
|
631
|
+
constraints.x.push({
|
|
632
|
+
from: target.index,
|
|
633
|
+
to: me,
|
|
634
|
+
weight: target.offset.x + target.width + gap,
|
|
635
|
+
placement,
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
if (direction.includes('left')) {
|
|
639
|
+
constraints.x.push({
|
|
640
|
+
from: me,
|
|
641
|
+
to: target.index,
|
|
642
|
+
weight: node.width + gap - target.offset.x,
|
|
643
|
+
placement,
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
if (direction.includes('below')) {
|
|
647
|
+
constraints.y.push({
|
|
648
|
+
from: target.index,
|
|
649
|
+
to: me,
|
|
650
|
+
weight: target.offset.y + target.height + gap,
|
|
651
|
+
placement,
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
if (direction.includes('above')) {
|
|
655
|
+
constraints.y.push({
|
|
656
|
+
from: me,
|
|
657
|
+
to: target.index,
|
|
658
|
+
weight: node.height + gap - target.offset.y,
|
|
659
|
+
placement,
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
// An axis nobody spoke to falls back to the center line of whatever the
|
|
665
|
+
// node was placed against, which is why "right of docker" alone is a whole
|
|
666
|
+
// position. Two different targets would decide which row the node shares,
|
|
667
|
+
// so that is refused rather than guessed — but two targets named by one
|
|
668
|
+
// placement are a single region, and centering on it is unambiguous.
|
|
669
|
+
for (const axis of AXES) {
|
|
670
|
+
if (spokenFor[axis])
|
|
671
|
+
continue;
|
|
672
|
+
const offers = located.filter((entry) => entry.placement.kind === 'offset');
|
|
673
|
+
const first = offers[0];
|
|
674
|
+
if (!first) {
|
|
675
|
+
throw new SourceError(`"${node.name}" says nothing about where it sits ${AXIS_WORD[axis]}`, node.placements[0].line);
|
|
676
|
+
}
|
|
677
|
+
const named = (entry) => entry.placement.targets.join('\u0000');
|
|
678
|
+
const other = offers.find((entry) => named(entry) !== named(first));
|
|
679
|
+
if (other) {
|
|
680
|
+
throw new SourceError(`"${node.name}" does not say where it sits ${AXIS_WORD[axis]}: ` +
|
|
681
|
+
`"${describePlacement(first.placement)}" and "${describePlacement(other.placement)}" ` +
|
|
682
|
+
`would put it in different places`, other.placement.line);
|
|
683
|
+
}
|
|
684
|
+
alignOn(axis, 'center', first.targets, first.placement);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
const solved = { x: [], y: [] };
|
|
688
|
+
const solveAll = () => {
|
|
689
|
+
for (const axis of AXES) {
|
|
690
|
+
const outcome = tightest(members.length, constraints[axis]);
|
|
691
|
+
if ('contradiction' in outcome)
|
|
692
|
+
throw noRoom(outcome.contradiction, axis, members);
|
|
693
|
+
solved[axis] = outcome.positions;
|
|
694
|
+
}
|
|
695
|
+
};
|
|
696
|
+
solveAll();
|
|
697
|
+
room(corridors, constraints, solved, solveAll);
|
|
698
|
+
settle(pending, members, constraints, solved, solveAll);
|
|
699
|
+
snug(members, constraints, solved, solveAll);
|
|
700
|
+
separate(members, constraints, solved, solveAll);
|
|
701
|
+
confirm(pending, solved);
|
|
702
|
+
return new Map(members.map((member, index) => [
|
|
703
|
+
member,
|
|
704
|
+
{ x: solved.x[index], y: solved.y[index] },
|
|
705
|
+
]));
|
|
706
|
+
}
|
|
707
|
+
/** The member every target belongs to, or nothing if they are spread across several. */
|
|
708
|
+
function sharedMember(targets) {
|
|
709
|
+
const first = targets[0].index;
|
|
710
|
+
return targets.every((target) => target.index === first) ? first : undefined;
|
|
711
|
+
}
|
|
712
|
+
/** The stretch of one axis that just covers every target. */
|
|
713
|
+
function spanOf(targets, axis, base) {
|
|
714
|
+
let start = Infinity;
|
|
715
|
+
let end = -Infinity;
|
|
716
|
+
for (const target of targets) {
|
|
717
|
+
const at = base(target) + target.offset[axis];
|
|
718
|
+
start = Math.min(start, at);
|
|
719
|
+
end = Math.max(end, at + (axis === 'x' ? target.width : target.height));
|
|
720
|
+
}
|
|
721
|
+
return { start, size: end - start };
|
|
722
|
+
}
|
|
723
|
+
/** Where a node of this size sits so that the named edge of it meets the span's. */
|
|
724
|
+
function alignedAt(edge, span, own) {
|
|
725
|
+
if (edge === 'center')
|
|
726
|
+
return span.start + (span.size - own) / 2;
|
|
727
|
+
if (edge === 'top' || edge === 'left')
|
|
728
|
+
return span.start;
|
|
729
|
+
return span.start + span.size - own;
|
|
730
|
+
}
|
|
731
|
+
/**
|
|
732
|
+
* Widen a corridor to hold the label of the link crossing it.
|
|
733
|
+
*
|
|
734
|
+
* This is the one place a link reaches the constraint system, and it is the same
|
|
735
|
+
* measure-then-constrain move `settle` makes rather than links joining the graph
|
|
736
|
+
* outright: the first solution says which gap each label actually falls in, and
|
|
737
|
+
* from there the room it needs is an ordinary minimum distance like any other.
|
|
738
|
+
* Nothing is nudged and no layout is repaired — a constraint the file already
|
|
739
|
+
* implied is derived and the whole system is solved again.
|
|
740
|
+
*
|
|
741
|
+
* Which gap that is, is derived and never chosen. A pair clear of each other on
|
|
742
|
+
* exactly one axis has exactly one corridor between them, and the label is in
|
|
743
|
+
* it. A pair clear on *both* axes sits corner to corner, so the line runs
|
|
744
|
+
* diagonally through open space and there is no corridor to widen; a pair clear
|
|
745
|
+
* on neither overlaps, which is the separation pass's business and not this
|
|
746
|
+
* one's. Both are left alone, which is why this only ever moves boxes that a
|
|
747
|
+
* label is genuinely wedged between.
|
|
748
|
+
*
|
|
749
|
+
* A gap being a minimum does the rest. Where the corridor is already wide enough
|
|
750
|
+
* — because the author said `gap: wide`, or because something else is in there
|
|
751
|
+
* — the constraint is slack and nothing moves; delete the label and the corridor
|
|
752
|
+
* closes back to whatever the file asked for.
|
|
753
|
+
*/
|
|
754
|
+
function room(corridors, constraints, solved, solveAll) {
|
|
755
|
+
let added = false;
|
|
756
|
+
for (const { from, to, need } of corridors) {
|
|
757
|
+
const clear = (axis) => {
|
|
758
|
+
const at = (end) => solved[axis][end.index] + end.offset[axis];
|
|
759
|
+
const size = (end) => (axis === 'x' ? end.width : end.height);
|
|
760
|
+
if (at(to) - (at(from) + size(from)) > 1e-9)
|
|
761
|
+
return { before: from, after: to };
|
|
762
|
+
if (at(from) - (at(to) + size(to)) > 1e-9)
|
|
763
|
+
return { before: to, after: from };
|
|
764
|
+
return undefined;
|
|
765
|
+
};
|
|
766
|
+
const open = AXES.filter((axis) => clear(axis));
|
|
767
|
+
if (open.length !== 1)
|
|
768
|
+
continue;
|
|
769
|
+
const axis = open[0];
|
|
770
|
+
const { before, after } = clear(axis);
|
|
771
|
+
constraints[axis].push({
|
|
772
|
+
from: before.index,
|
|
773
|
+
to: after.index,
|
|
774
|
+
weight: before.offset[axis] +
|
|
775
|
+
(axis === 'x' ? before.width : before.height) +
|
|
776
|
+
need[axis] -
|
|
777
|
+
after.offset[axis],
|
|
778
|
+
});
|
|
779
|
+
added = true;
|
|
780
|
+
}
|
|
781
|
+
if (added)
|
|
782
|
+
solveAll();
|
|
783
|
+
}
|
|
784
|
+
/**
|
|
785
|
+
* Fix the alignments that had to wait, by measuring what they align to.
|
|
786
|
+
*
|
|
787
|
+
* The first solution says where every target actually landed, so the region
|
|
788
|
+
* they bound is now a number rather than an expression, and the alignment
|
|
789
|
+
* becomes an ordinary constraint at a fixed distance. Nothing already solved is
|
|
790
|
+
* moved by hand; a distance is read off and added, which is the same shape as
|
|
791
|
+
* the separation pass and not the repair pass this design refuses.
|
|
792
|
+
*
|
|
793
|
+
* That is exact so long as the region does not depend on the node being aligned
|
|
794
|
+
* to it. Where it does, measuring changes the thing measured and there is no
|
|
795
|
+
* order that settles, so the file is refused rather than iterated at. The test
|
|
796
|
+
* is the same reachability the separation pass uses.
|
|
797
|
+
*/
|
|
798
|
+
function settle(pending, members, constraints, solved, solveAll) {
|
|
799
|
+
if (pending.length === 0)
|
|
800
|
+
return;
|
|
801
|
+
for (const axis of AXES) {
|
|
802
|
+
const here = pending.filter((entry) => entry.axis === axis);
|
|
803
|
+
if (here.length === 0)
|
|
804
|
+
continue;
|
|
805
|
+
const reach = reachability(members.length, constraints[axis]);
|
|
806
|
+
for (const entry of here) {
|
|
807
|
+
for (const target of entry.targets) {
|
|
808
|
+
if (target.index !== entry.me && !reach[entry.me][target.index])
|
|
809
|
+
continue;
|
|
810
|
+
throw new SourceError(`"${entry.node.name}" is ${describePlacement(entry.placement)}, but "${target.name}" ` +
|
|
811
|
+
`is placed ${AXIS_WORD[axis]} against "${entry.node.name}" in turn, so there is no ` +
|
|
812
|
+
`arrangement where each waits for the other`, entry.placement.line);
|
|
813
|
+
}
|
|
814
|
+
const span = spanOf(entry.targets, axis, (target) => solved[axis][target.index]);
|
|
815
|
+
const own = axis === 'x' ? entry.node.width : entry.node.height;
|
|
816
|
+
const anchor = entry.targets[0].index;
|
|
817
|
+
constraints[axis].push(...fix(anchor, entry.me, alignedAt(entry.edge, span, own) - solved[axis][anchor], entry.placement));
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
solveAll();
|
|
821
|
+
}
|
|
822
|
+
/**
|
|
823
|
+
* Check the measured alignments still hold.
|
|
824
|
+
*
|
|
825
|
+
* Separation runs afterwards and only ever adds, so it can push two targets
|
|
826
|
+
* apart and leave a region wider than it was when it was measured. Nothing here
|
|
827
|
+
* repairs that — the picture is reported as unbuildable, because silently
|
|
828
|
+
* drawing a node that is no longer level with what it names is the failure the
|
|
829
|
+
* diagnostics work exists to prevent.
|
|
830
|
+
*/
|
|
831
|
+
function confirm(pending, solved) {
|
|
832
|
+
for (const entry of pending) {
|
|
833
|
+
const { axis } = entry;
|
|
834
|
+
const span = spanOf(entry.targets, axis, (target) => solved[axis][target.index]);
|
|
835
|
+
const own = axis === 'x' ? entry.node.width : entry.node.height;
|
|
836
|
+
if (Math.abs(alignedAt(entry.edge, span, own) - solved[axis][entry.me]) <= 0.5)
|
|
837
|
+
continue;
|
|
838
|
+
throw new SourceError(`"${entry.node.name}" cannot be ${describePlacement(entry.placement)}: keeping boxes off ` +
|
|
839
|
+
`each other moved them apart after that region was measured`, entry.placement.line);
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
/**
|
|
843
|
+
* Pull in a node that nothing pushes back the other way.
|
|
844
|
+
*
|
|
845
|
+
* Every constraint reads "this one is at least so far along the axis from that
|
|
846
|
+
* one", and the solve puts each member at the smallest position its constraints
|
|
847
|
+
* allow. That is the tightest arrangement for anything with something behind
|
|
848
|
+
* it — but `left of X` and `above X` bound *X*, not the node that wrote them,
|
|
849
|
+
* so a node carrying only those has nothing behind it at all. It settles at the
|
|
850
|
+
* far edge of the drawing while the thing it names is carried away by the rest
|
|
851
|
+
* of the diagram, which is neither what the file says nor what the language
|
|
852
|
+
* promises: as close together as the placements allow.
|
|
853
|
+
*
|
|
854
|
+
* The remedy is to look the other way for exactly those members. A member with
|
|
855
|
+
* no incoming edge cannot be pushed by anything, so moving it along the axis
|
|
856
|
+
* disturbs nothing; the furthest it may travel is set by whichever of its own
|
|
857
|
+
* placements binds first, and pinning that one placement to an exact distance
|
|
858
|
+
* puts it there. Every other placement it wrote had more room to spare and is
|
|
859
|
+
* still satisfied.
|
|
860
|
+
*
|
|
861
|
+
* The pins are worked out against the first solution and applied together,
|
|
862
|
+
* which keeps them independent: a member with no incoming edge is never the
|
|
863
|
+
* target of another member's pin, because being a target is what an incoming
|
|
864
|
+
* edge is.
|
|
865
|
+
*/
|
|
866
|
+
function snug(members, constraints, solved, solveAll) {
|
|
867
|
+
const pins = [];
|
|
868
|
+
for (const axis of AXES) {
|
|
869
|
+
const pushed = new Array(members.length).fill(false);
|
|
870
|
+
for (const constraint of constraints[axis])
|
|
871
|
+
pushed[constraint.to] = true;
|
|
872
|
+
for (let index = 0; index < members.length; index += 1) {
|
|
873
|
+
if (pushed[index])
|
|
874
|
+
continue;
|
|
875
|
+
let binding;
|
|
876
|
+
let slack = Infinity;
|
|
877
|
+
for (const constraint of constraints[axis]) {
|
|
878
|
+
if (constraint.from !== index)
|
|
879
|
+
continue;
|
|
880
|
+
const room = solved[axis][constraint.to] - solved[axis][index] - constraint.weight;
|
|
881
|
+
if (room < slack) {
|
|
882
|
+
slack = room;
|
|
883
|
+
binding = constraint;
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
// Nothing to travel toward, or already against it.
|
|
887
|
+
if (!binding || slack <= 1e-9)
|
|
888
|
+
continue;
|
|
889
|
+
pins.push({
|
|
890
|
+
axis,
|
|
891
|
+
constraint: {
|
|
892
|
+
from: binding.to,
|
|
893
|
+
to: index,
|
|
894
|
+
weight: -binding.weight,
|
|
895
|
+
...(binding.placement ? { placement: binding.placement } : {}),
|
|
896
|
+
},
|
|
897
|
+
});
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
if (pins.length === 0)
|
|
901
|
+
return;
|
|
902
|
+
for (const pin of pins)
|
|
903
|
+
constraints[pin.axis].push(pin.constraint);
|
|
904
|
+
solveAll();
|
|
905
|
+
}
|
|
906
|
+
/**
|
|
907
|
+
* Push apart any two boxes that landed on top of each other.
|
|
908
|
+
*
|
|
909
|
+
* Two boxes not overlapping is a placement the author never has to write, but on
|
|
910
|
+
* its own it says nothing about *which way* to separate them — left, right,
|
|
911
|
+
* above and below all satisfy it, and choosing among four is the search this
|
|
912
|
+
* whole design refuses. So the direction is never chosen. It is read off the
|
|
913
|
+
* constraints already built from the file: if the file lets one box travel away
|
|
914
|
+
* from the other along an axis and offers no way back, that is the only
|
|
915
|
+
* separation consistent with what was written. Where nothing in the file orders
|
|
916
|
+
* a pair on either axis, this reports the pair instead of guessing.
|
|
917
|
+
*
|
|
918
|
+
* One case is genuinely free. When both axes already imply an order, either
|
|
919
|
+
* would do, and the tie is broken by separating along the axis where the two
|
|
920
|
+
* overlap least — the smallest movement, and the one a person makes by hand.
|
|
921
|
+
* That is the single place the tool decides something nobody wrote.
|
|
922
|
+
*
|
|
923
|
+
* The loop only ever adds constraints and re-solves the whole system, so no
|
|
924
|
+
* arrangement is ever tried and rejected and it settles without backtracking.
|
|
925
|
+
* A solved layout is never nudged in place; that is a different thing and it is
|
|
926
|
+
* the thing the design rules out.
|
|
927
|
+
*
|
|
928
|
+
* Members here are always siblings, or the roots of the diagram, so no member
|
|
929
|
+
* ever contains another and containment needs no exemption of its own.
|
|
930
|
+
*/
|
|
931
|
+
function separate(members, constraints, solved, solveAll) {
|
|
932
|
+
const eligible = members.map(allowsOverlap).map((allowed) => !allowed);
|
|
933
|
+
if (eligible.filter(Boolean).length < 2)
|
|
934
|
+
return;
|
|
935
|
+
// Adding only, so the number of separations is bounded; the cap is a
|
|
936
|
+
// backstop against a bug rather than an expected outcome.
|
|
937
|
+
for (let round = 0; round < members.length * members.length + 1; round += 1) {
|
|
938
|
+
let reach;
|
|
939
|
+
let added = false;
|
|
940
|
+
for (let i = 0; i < members.length; i += 1) {
|
|
941
|
+
if (!eligible[i])
|
|
942
|
+
continue;
|
|
943
|
+
for (let j = i + 1; j < members.length; j += 1) {
|
|
944
|
+
if (!eligible[j])
|
|
945
|
+
continue;
|
|
946
|
+
const over = overlapOf(members, solved, i, j);
|
|
947
|
+
if (!over)
|
|
948
|
+
continue;
|
|
949
|
+
reach ??= { x: reachability(members.length, constraints.x), y: reachability(members.length, constraints.y) };
|
|
950
|
+
const orders = {};
|
|
951
|
+
for (const axis of AXES) {
|
|
952
|
+
const order = impliedOrder(reach[axis], i, j);
|
|
953
|
+
if (order)
|
|
954
|
+
orders[axis] = order;
|
|
955
|
+
}
|
|
956
|
+
const axis = pickAxis(orders, over);
|
|
957
|
+
if (!axis)
|
|
958
|
+
throw unordered(members[i], members[j]);
|
|
959
|
+
const { before, after } = orders[axis];
|
|
960
|
+
const span = axis === 'x' ? members[before].width : members[before].height;
|
|
961
|
+
constraints[axis].push({ from: before, to: after, weight: span + SEPARATION_GAP });
|
|
962
|
+
reach = undefined;
|
|
963
|
+
added = true;
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
if (!added)
|
|
967
|
+
return;
|
|
968
|
+
solveAll();
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
/** How far two members share space on each axis, or nothing if they are clear of each other. */
|
|
972
|
+
function overlapOf(members, solved, i, j) {
|
|
973
|
+
const shared = (axis) => {
|
|
974
|
+
const size = (index) => (axis === 'x' ? members[index].width : members[index].height);
|
|
975
|
+
const startI = solved[axis][i];
|
|
976
|
+
const startJ = solved[axis][j];
|
|
977
|
+
return Math.min(startI + size(i), startJ + size(j)) - Math.max(startI, startJ);
|
|
978
|
+
};
|
|
979
|
+
const x = shared('x');
|
|
980
|
+
const y = shared('y');
|
|
981
|
+
return x > 1e-9 && y > 1e-9 ? { x, y } : undefined;
|
|
982
|
+
}
|
|
983
|
+
/** Which of two members the file lets the other move past, if either. */
|
|
984
|
+
function impliedOrder(reach, i, j) {
|
|
985
|
+
const forward = reach[i][j];
|
|
986
|
+
const backward = reach[j][i];
|
|
987
|
+
if (forward === backward)
|
|
988
|
+
return undefined;
|
|
989
|
+
return forward ? { before: i, after: j } : { before: j, after: i };
|
|
990
|
+
}
|
|
991
|
+
/** Of the axes that can separate a pair, the one where they overlap least. */
|
|
992
|
+
function pickAxis(orders, over) {
|
|
993
|
+
const available = AXES.filter((axis) => orders[axis]);
|
|
994
|
+
if (available.length < 2)
|
|
995
|
+
return available[0];
|
|
996
|
+
return over.x <= over.y ? 'x' : 'y';
|
|
997
|
+
}
|
|
998
|
+
function allowsOverlap(node) {
|
|
999
|
+
const value = node.attrs['overlap'];
|
|
1000
|
+
if (value === undefined)
|
|
1001
|
+
return false;
|
|
1002
|
+
if (value !== 'allow') {
|
|
1003
|
+
throw new SourceError(`"${node.name}" has overlap: ${value}, which is not one of allow`, node.line);
|
|
1004
|
+
}
|
|
1005
|
+
return true;
|
|
1006
|
+
}
|
|
1007
|
+
function unordered(first, second) {
|
|
1008
|
+
const [earlier, later] = first.line <= second.line ? [first, second] : [second, first];
|
|
1009
|
+
return new SourceError(`"${earlier.name}" and "${later.name}" overlap, and nothing says which side of the ` +
|
|
1010
|
+
`other either one sits on — place one against the other, or say overlap: allow`, later.line);
|
|
1011
|
+
}
|
|
1012
|
+
/** Report a set of placements that cannot all hold, in the words they were written in. */
|
|
1013
|
+
function noRoom(contradiction, axis, members) {
|
|
1014
|
+
const { placements } = contradiction;
|
|
1015
|
+
if (placements.length === 0) {
|
|
1016
|
+
return new SourceError(`these placements run in a circle ${AXIS_WORD[axis]} and cannot all hold`, members[0]?.line ?? 1);
|
|
1017
|
+
}
|
|
1018
|
+
const quoted = placements.map((placement) => `"${describePlacement(placement)}"`).join(' and ');
|
|
1019
|
+
return new SourceError(`${quoted} cannot all hold — they leave no room ${AXIS_WORD[axis]}`, placements[placements.length - 1].line);
|
|
1020
|
+
}
|
|
1021
|
+
/**
|
|
1022
|
+
* How far this placement holds the node off its target.
|
|
1023
|
+
*
|
|
1024
|
+
* A gap is a property of the relationship, not of either box in it, so the
|
|
1025
|
+
* placement's own bracketed gap is the specific statement about this pair and
|
|
1026
|
+
* wins outright. Where it says nothing, `gap:` on a node is a default — and both
|
|
1027
|
+
* ends of the relationship may offer one. The node doing the placing wrote its
|
|
1028
|
+
* gap down; the target had someone else's placement written against it. Neither
|
|
1029
|
+
* is more entitled than the other, so the larger applies, which is the only
|
|
1030
|
+
* answer consistent with a gap being a minimum in the first place.
|
|
1031
|
+
*
|
|
1032
|
+
* That is what makes `gap: wide` on a node that carries no placements of its own
|
|
1033
|
+
* do the obvious thing rather than nothing at all: an author looking at two boxes
|
|
1034
|
+
* pushed too close together has no reason to know which of the two happened to
|
|
1035
|
+
* name the other.
|
|
1036
|
+
*/
|
|
1037
|
+
function gapFor(node, placement, target) {
|
|
1038
|
+
if (placement.gap !== undefined)
|
|
1039
|
+
return namedGap(node, placement.gap, placement.line);
|
|
1040
|
+
const mine = node.attrs['gap'];
|
|
1041
|
+
const theirs = target.attrs['gap'];
|
|
1042
|
+
// Only a gap somebody actually wrote down counts. Reading an absent one as the
|
|
1043
|
+
// default would make it a floor rather than a fallback, and every `gap: tight`
|
|
1044
|
+
// placed against a silent node would quietly widen back to normal.
|
|
1045
|
+
const stated = [];
|
|
1046
|
+
if (mine !== undefined)
|
|
1047
|
+
stated.push(namedGap(node, mine, node.line));
|
|
1048
|
+
if (theirs !== undefined)
|
|
1049
|
+
stated.push(namedGap(target, theirs, target.line));
|
|
1050
|
+
if (stated.length === 0)
|
|
1051
|
+
return namedGap(node, undefined, node.line);
|
|
1052
|
+
return Math.max(...stated);
|
|
1053
|
+
}
|
|
1054
|
+
function namedGap(node, named, line) {
|
|
1055
|
+
const gap = GAPS[named ?? 'normal'];
|
|
1056
|
+
if (gap === undefined) {
|
|
1057
|
+
const known = Object.keys(GAPS).join(', ');
|
|
1058
|
+
throw new SourceError(`"${node.name}" asks for gap: ${named}, which is not one of ${known}`, line);
|
|
1059
|
+
}
|
|
1060
|
+
return gap;
|
|
1061
|
+
}
|
|
1062
|
+
// --- shared helpers ----------------------------------------------------------
|
|
1063
|
+
function widestLine(lines, measurer, fontSize) {
|
|
1064
|
+
return lines.reduce((widest, line) => {
|
|
1065
|
+
const { width } = measurer.measure(line, fontSize);
|
|
1066
|
+
return Math.max(widest, width);
|
|
1067
|
+
}, 0);
|
|
1068
|
+
}
|
|
1069
|
+
/**
|
|
1070
|
+
* Split a label into the lines that get drawn. `/` always breaks a line. A
|
|
1071
|
+
* `width` attribute additionally folds each of those at word boundaries, which
|
|
1072
|
+
* is what stops a long note running across the whole diagram.
|
|
1073
|
+
*
|
|
1074
|
+
* The width is a character count rather than a distance. It says how much text
|
|
1075
|
+
* fits on a line, not where anything sits, so it stays a property of the text
|
|
1076
|
+
* and never becomes a coordinate in disguise.
|
|
1077
|
+
*/
|
|
1078
|
+
function linesFor(text, attrs, line) {
|
|
1079
|
+
const stated = attrs['wrap'];
|
|
1080
|
+
if (stated === undefined)
|
|
1081
|
+
return splitLines(text);
|
|
1082
|
+
const columns = Number(stated);
|
|
1083
|
+
if (!Number.isInteger(columns) || columns < 1) {
|
|
1084
|
+
throw new SourceError(`wrap must be a whole number of characters, not "${stated}"`, line);
|
|
1085
|
+
}
|
|
1086
|
+
return splitLines(text).flatMap((part) => wrap(part, columns));
|
|
1087
|
+
}
|
|
1088
|
+
/** Fold one line onto several at word boundaries, never exceeding `columns`. */
|
|
1089
|
+
function wrap(text, columns) {
|
|
1090
|
+
const lines = [];
|
|
1091
|
+
let current = '';
|
|
1092
|
+
for (const word of text.split(/\s+/).filter(Boolean)) {
|
|
1093
|
+
if (current.length === 0) {
|
|
1094
|
+
current = word;
|
|
1095
|
+
}
|
|
1096
|
+
else if (current.length + 1 + word.length <= columns) {
|
|
1097
|
+
current += ` ${word}`;
|
|
1098
|
+
}
|
|
1099
|
+
else {
|
|
1100
|
+
lines.push(current);
|
|
1101
|
+
current = word;
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
if (current.length > 0)
|
|
1105
|
+
lines.push(current);
|
|
1106
|
+
return lines.length > 0 ? lines : [''];
|
|
1107
|
+
}
|
|
1108
|
+
function bounds(nodes) {
|
|
1109
|
+
let minX = Infinity;
|
|
1110
|
+
let minY = Infinity;
|
|
1111
|
+
let maxX = -Infinity;
|
|
1112
|
+
let maxY = -Infinity;
|
|
1113
|
+
for (const node of nodes) {
|
|
1114
|
+
minX = Math.min(minX, node.x);
|
|
1115
|
+
minY = Math.min(minY, node.y);
|
|
1116
|
+
maxX = Math.max(maxX, node.x + node.width);
|
|
1117
|
+
maxY = Math.max(maxY, node.y + node.height);
|
|
1118
|
+
}
|
|
1119
|
+
return { minX, minY, maxX, maxY };
|
|
1120
|
+
}
|
|
1121
|
+
/** Shift everything so the diagram starts at the margin rather than wherever the anchor fell. */
|
|
1122
|
+
function normalize(nodes, margin) {
|
|
1123
|
+
const { minX, minY } = bounds(nodes);
|
|
1124
|
+
const dx = margin - minX;
|
|
1125
|
+
const dy = margin - minY;
|
|
1126
|
+
for (const node of nodes) {
|
|
1127
|
+
node.x += dx;
|
|
1128
|
+
node.y += dy;
|
|
1129
|
+
}
|
|
1130
|
+
}
|