reladraw 0.1.0 → 0.3.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 +45 -35
- package/SYNTAX.md +498 -193
- package/dist/ast.d.ts +257 -38
- package/dist/ast.js +250 -18
- package/dist/constants.d.ts +60 -42
- package/dist/constants.js +78 -63
- package/dist/grammar.d.ts +24 -11
- package/dist/grammar.js +61 -15
- package/dist/icons.d.ts +55 -37
- package/dist/icons.js +83 -45
- package/dist/lexer.d.ts +1 -1
- package/dist/lexer.js +7 -6
- package/dist/measure.d.ts +3 -3
- package/dist/measure.js +3 -3
- package/dist/model.d.ts +86 -16
- package/dist/parser.js +529 -179
- package/dist/render.d.ts +1 -1
- package/dist/render.js +401 -320
- package/dist/resolve.js +1287 -249
- package/dist/text.d.ts +48 -0
- package/dist/text.js +196 -0
- package/package.json +2 -2
package/dist/resolve.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { describePlacement } from './ast.js';
|
|
2
|
-
import { ARROW_LENGTH, CHILD_GAP, DECK_STEP, DEFAULT_FONT_SIZE, DEFAULT_MARGIN, GAPS, HEADER_GAP, ICON_GAP, ICON_LINES,
|
|
1
|
+
import { ALL_ATTR_KEYS, ATTR_KEYS, PART_SIDES, COLOR_KEYS, COLOR_PARTS, CONTENT_ALIGNMENTS, CONTENT_WIDTHS, describePlacement, nameTarget, } from './ast.js';
|
|
2
|
+
import { ARROW_LENGTH, CHILD_GAP, DECK_STEP, DEFAULT_FONT_SIZE, DEFAULT_MARGIN, GAPS, HEADER_GAP, ICON_GAP, ICON_LINES, TEXT_CLEARANCE, PAD, SEPARATION_GAP, fontSizeFor, leafTop, textExtent, textStyleFor, widestLine, } from './constants.js';
|
|
3
3
|
import { fix, reachability, tightest } from './constrain.js';
|
|
4
4
|
import { SourceError } from './errors.js';
|
|
5
|
-
import {
|
|
6
|
-
import { monospaceMeasurer
|
|
5
|
+
import { bodyFor } from './icons.js';
|
|
6
|
+
import { monospaceMeasurer } from './measure.js';
|
|
7
|
+
import { markupStyles, parseMarkup, plain, splitRuns, wrapLine } from './text.js';
|
|
7
8
|
/**
|
|
8
9
|
* Turn a parsed document into solved geometry.
|
|
9
10
|
*
|
|
@@ -21,22 +22,24 @@ export function resolve(doc, options = {}) {
|
|
|
21
22
|
const fontSize = options.fontSize ?? DEFAULT_FONT_SIZE;
|
|
22
23
|
const margin = options.margin ?? DEFAULT_MARGIN;
|
|
23
24
|
const styles = collectStyles(doc.statements);
|
|
25
|
+
checkStyleKeys(doc.statements);
|
|
24
26
|
const { nodes, byName, roots } = buildTree(doc.statements, styles);
|
|
25
27
|
applyDecks(doc.statements, byName);
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
+
// Edges are resolved to nodes before anything is sized, because a labeled
|
|
29
|
+
// edge claims room in the gap it crosses and so has to be in hand while the
|
|
28
30
|
// gaps are being worked out. Nothing here reads geometry.
|
|
29
|
-
const
|
|
31
|
+
const edges = buildEdges(doc.statements, byName, styles);
|
|
30
32
|
const local = new Map();
|
|
31
33
|
for (const root of roots)
|
|
32
|
-
sizeNode(root,
|
|
33
|
-
placeRoots(roots, byName,
|
|
34
|
+
sizeNode(root, edges, measurer, fontSize, local);
|
|
35
|
+
placeRoots(roots, byName, edges, measurer, fontSize, local);
|
|
34
36
|
normalize(nodes, margin);
|
|
35
37
|
const extent = bounds(nodes);
|
|
36
38
|
return {
|
|
37
39
|
nodes,
|
|
38
40
|
roots,
|
|
39
|
-
|
|
41
|
+
edges,
|
|
42
|
+
markup: markupColors(nodes, edges, styles),
|
|
40
43
|
diagram: collectDiagram(doc.statements),
|
|
41
44
|
width: Math.ceil(extent.maxX + margin),
|
|
42
45
|
height: Math.ceil(extent.maxY + margin),
|
|
@@ -72,31 +75,60 @@ function buildTree(statements, styles) {
|
|
|
72
75
|
const nodes = [];
|
|
73
76
|
const byName = new Map();
|
|
74
77
|
const roots = [];
|
|
78
|
+
/** Each badge child's name, and the node whose `badge:` it was written out from. */
|
|
79
|
+
const badges = new Map();
|
|
75
80
|
for (const stmt of statements) {
|
|
76
|
-
if (stmt.kind !== '
|
|
81
|
+
if (stmt.kind !== 'node')
|
|
77
82
|
continue;
|
|
78
83
|
if (byName.has(stmt.name)) {
|
|
84
|
+
const owner = badges.get(stmt.name);
|
|
85
|
+
if (owner !== undefined) {
|
|
86
|
+
throw new SourceError(`"${stmt.name}" is the child that "${owner}"'s badge: is written out as. Drop badge: from ` +
|
|
87
|
+
`"${owner}" and write the icon here yourself, or give this node another name`, stmt.line);
|
|
88
|
+
}
|
|
79
89
|
throw new SourceError(`"${stmt.name}" is declared twice`, stmt.line);
|
|
80
90
|
}
|
|
91
|
+
const appearance = appearanceOf(stmt.attrs, styles, stmt.line);
|
|
92
|
+
const body = bodyFor(stmt.attrs, appearance, stmt.line);
|
|
93
|
+
const kind = KIND_OF_BODY[body.kind];
|
|
94
|
+
// A node with no text of its own is labelled with its name, because the
|
|
95
|
+
// first lines anybody types are `node a` and `node b right of a` and they
|
|
96
|
+
// mean the boxes to read "a" and "b". A node drawn as a *picture* is the
|
|
97
|
+
// exception: a picture usually is the statement, and the default would
|
|
98
|
+
// caption a row of cubes a, b, db1, c, db2. `""` then says what writing
|
|
99
|
+
// nothing says, which is only true here — on a box it carries information.
|
|
100
|
+
const text = body.kind === 'icon' && !stmt.statedText ? '' : stmt.text;
|
|
101
|
+
// A style has no string of its own, so it says what it has to say about a
|
|
102
|
+
// text through `text: (…)`, which arrives here under dotted keys. What the
|
|
103
|
+
// node wrote in its own brackets wins, key by key, exactly as its own
|
|
104
|
+
// attributes win over the style's.
|
|
105
|
+
const textAttrs = { ...bracketOf('text', appearance), ...stmt.textAttrs };
|
|
81
106
|
const node = {
|
|
82
107
|
name: stmt.name,
|
|
83
|
-
kind
|
|
84
|
-
|
|
85
|
-
|
|
108
|
+
kind,
|
|
109
|
+
body,
|
|
110
|
+
text,
|
|
111
|
+
lines: linesFor(text, textAttrs, `"${stmt.name}"`, stmt.line),
|
|
86
112
|
children: [],
|
|
87
113
|
x: 0,
|
|
88
114
|
y: 0,
|
|
89
115
|
width: 0,
|
|
90
116
|
height: 0,
|
|
91
117
|
inset: 0,
|
|
92
|
-
|
|
118
|
+
deckTexts: [],
|
|
93
119
|
headerHeight: 0,
|
|
94
|
-
|
|
120
|
+
reach: { left: 0, top: 0, right: 0, bottom: 0 },
|
|
121
|
+
banded: false,
|
|
122
|
+
textBox: { x: 0, y: 0, width: 0, height: 0 },
|
|
123
|
+
textSide: 'left',
|
|
124
|
+
textAttrs,
|
|
95
125
|
attrs: stmt.attrs,
|
|
96
|
-
appearance
|
|
126
|
+
appearance,
|
|
97
127
|
placements: stmt.placements,
|
|
98
128
|
line: stmt.line,
|
|
99
129
|
};
|
|
130
|
+
checkAttrs(kind, node.name, stmt.attrs, stmt.line);
|
|
131
|
+
checkStyleUse(kind, node.name, stmt.attrs, styles, stmt.line);
|
|
100
132
|
const cut = stmt.name.lastIndexOf('.');
|
|
101
133
|
if (cut === -1) {
|
|
102
134
|
roots.push(node);
|
|
@@ -112,9 +144,207 @@ function buildTree(statements, styles) {
|
|
|
112
144
|
}
|
|
113
145
|
nodes.push(node);
|
|
114
146
|
byName.set(stmt.name, node);
|
|
147
|
+
// `badge: X` is a shorthand, and this is its expansion:
|
|
148
|
+
// node <self>.badge icon: X right of <self> text
|
|
149
|
+
// set at the parent's text size, so the picture is two of the parent's
|
|
150
|
+
// lines tall. Read from the merged appearance, so a style carrying a badge
|
|
151
|
+
// gives one to every box wearing it. Only a box has a text to be beside;
|
|
152
|
+
// the other kinds refuse the word in `checkAttrs`, and a style's is unused.
|
|
153
|
+
const named = appearance['badge'];
|
|
154
|
+
if (named !== undefined && body.kind === 'shape') {
|
|
155
|
+
const badge = badgeChild(node, named);
|
|
156
|
+
badges.set(badge.name, node.name);
|
|
157
|
+
node.children.push(badge);
|
|
158
|
+
nodes.push(badge);
|
|
159
|
+
byName.set(badge.name, badge);
|
|
160
|
+
}
|
|
115
161
|
}
|
|
116
162
|
return { nodes, byName, roots };
|
|
117
163
|
}
|
|
164
|
+
/** The child `badge:` writes out, as `buildTree` describes. */
|
|
165
|
+
function badgeChild(parent, named) {
|
|
166
|
+
const icon = { icon: named };
|
|
167
|
+
const size = parent.textAttrs['size'];
|
|
168
|
+
return {
|
|
169
|
+
name: `${parent.name}.badge`,
|
|
170
|
+
kind: 'icon',
|
|
171
|
+
body: bodyFor(icon, icon, parent.line),
|
|
172
|
+
text: '',
|
|
173
|
+
lines: linesFor('', {}, `"${parent.name}.badge"`, parent.line),
|
|
174
|
+
children: [],
|
|
175
|
+
parent,
|
|
176
|
+
x: 0,
|
|
177
|
+
y: 0,
|
|
178
|
+
width: 0,
|
|
179
|
+
height: 0,
|
|
180
|
+
inset: 0,
|
|
181
|
+
deckTexts: [],
|
|
182
|
+
headerHeight: 0,
|
|
183
|
+
reach: { left: 0, top: 0, right: 0, bottom: 0 },
|
|
184
|
+
banded: false,
|
|
185
|
+
textBox: { x: 0, y: 0, width: 0, height: 0 },
|
|
186
|
+
textSide: 'center',
|
|
187
|
+
textAttrs: size === undefined ? {} : { size },
|
|
188
|
+
attrs: icon,
|
|
189
|
+
appearance: icon,
|
|
190
|
+
placements: [
|
|
191
|
+
{
|
|
192
|
+
kind: 'offset',
|
|
193
|
+
direction: 'right',
|
|
194
|
+
targets: [{ name: parent.name, part: 'text' }],
|
|
195
|
+
line: parent.line,
|
|
196
|
+
},
|
|
197
|
+
],
|
|
198
|
+
line: parent.line,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* "a node", "an edge". Only the kind words are ever passed here and only `edge`
|
|
203
|
+
* begins with a vowel, but writing `a ${word}` produced "a edge" the day the
|
|
204
|
+
* keyword changed, so the article follows the word rather than being assumed.
|
|
205
|
+
*/
|
|
206
|
+
function article(word) {
|
|
207
|
+
return `${/^[aeiou]/.test(word) ? 'an' : 'a'} ${word}`;
|
|
208
|
+
}
|
|
209
|
+
/** The same phrase opening a sentence. */
|
|
210
|
+
function capital(phrase) {
|
|
211
|
+
return phrase.charAt(0).toUpperCase() + phrase.slice(1);
|
|
212
|
+
}
|
|
213
|
+
/** Which kind a body makes the node. */
|
|
214
|
+
const KIND_OF_BODY = {
|
|
215
|
+
shape: 'shape',
|
|
216
|
+
icon: 'icon',
|
|
217
|
+
none: 'none',
|
|
218
|
+
};
|
|
219
|
+
/** How each kind reads in an error, and what it is actually made of. */
|
|
220
|
+
const KIND_WORD = {
|
|
221
|
+
shape: 'node',
|
|
222
|
+
icon: 'node drawn as a picture',
|
|
223
|
+
none: 'node with no body',
|
|
224
|
+
edge: 'edge',
|
|
225
|
+
};
|
|
226
|
+
const KIND_PARTS = {
|
|
227
|
+
shape: 'a fill, a border and text',
|
|
228
|
+
icon: 'a picture and the text under it',
|
|
229
|
+
none: 'text and nothing else',
|
|
230
|
+
edge: 'a line and its text',
|
|
231
|
+
};
|
|
232
|
+
/**
|
|
233
|
+
* An attribute is refused on a kind that has no use for it — the same rule as
|
|
234
|
+
* an unknown `diagram` key, and for the same reason: a key that silently does
|
|
235
|
+
* nothing looks like the tool being broken. A color names a part and so is a
|
|
236
|
+
* special case of this, which is why the two checks are one.
|
|
237
|
+
*
|
|
238
|
+
* What is checked is what the author wrote *on this statement*, not what a
|
|
239
|
+
* style contributed. A style is a bundle meant to be shared across kinds — the
|
|
240
|
+
* benchmark's `synced` carries a fill and a border for the green boxes and a
|
|
241
|
+
* line for the four edges joining them — so a key it carries that this kind has
|
|
242
|
+
* no part for is simply unused, and is not a mistake anybody made here. That is
|
|
243
|
+
* forced rather than chosen: checking the merged appearance would refuse the
|
|
244
|
+
* benchmark's own central idiom four times over. `checkStyleUse` is what keeps
|
|
245
|
+
* the permissiveness honest.
|
|
246
|
+
*/
|
|
247
|
+
function checkAttrs(kind, name, attrs, line) {
|
|
248
|
+
const allowed = ATTR_KEYS[kind];
|
|
249
|
+
// In the author's own order, so the error names the first offending word as
|
|
250
|
+
// it is read rather than the first in some list of ours.
|
|
251
|
+
for (const [written, value] of Object.entries(attrs)) {
|
|
252
|
+
const key = topKey(written);
|
|
253
|
+
if (allowed.includes(key))
|
|
254
|
+
continue;
|
|
255
|
+
// Quoted back the way it was written. A bracketed value arrives one dotted
|
|
256
|
+
// key at a time, and `contents: match` is not a line anybody could look for.
|
|
257
|
+
const wrote = written === key ? `${key}: ${value}` : `${key}: (${written.slice(key.length + 1)}: ${value})`;
|
|
258
|
+
if (!ALL_ATTR_KEYS.includes(key)) {
|
|
259
|
+
// Nothing anywhere in the language answers to this word, so the only
|
|
260
|
+
// remedy is the vocabulary itself.
|
|
261
|
+
throw new SourceError(`"${name}" has ${wrote}, which is not an attribute. ${capital(article(KIND_WORD[kind]))} takes ${allowed.join(', ')}`, line);
|
|
262
|
+
}
|
|
263
|
+
// A real word in the wrong place, and the two sorts of word want different
|
|
264
|
+
// explanations. A color names a *part*, so saying what the kind is made of
|
|
265
|
+
// is the whole reason it has no such color, and the remedy is the narrower
|
|
266
|
+
// list of parts it does have: `line:` on a box is a different mistake from
|
|
267
|
+
// `border:` on a note, and one hint cannot serve both.
|
|
268
|
+
if (COLOR_KEYS.includes(key)) {
|
|
269
|
+
const parts = COLOR_PARTS[kind].map((part) => `\`${part}\``).join(', ');
|
|
270
|
+
throw new SourceError(`"${name}" is ${article(KIND_WORD[kind])} and has ${wrote}. ${capital(article(KIND_WORD[kind]))} is ${KIND_PARTS[kind]}, so it has no ${key} — it takes ${parts}`, line);
|
|
271
|
+
}
|
|
272
|
+
// Anything else names no part, so what the kind is made of explains
|
|
273
|
+
// nothing. What does explain it is where the word *does* belong, which is
|
|
274
|
+
// also the more useful thing to be told: the author has usually written a
|
|
275
|
+
// real statement about the wrong half of the diagram.
|
|
276
|
+
throw new SourceError(`"${name}" is ${article(KIND_WORD[kind])} and has ${wrote}. \`${key}:\` belongs to ` +
|
|
277
|
+
`${listKinds(belongTo(key))} — ${article(KIND_WORD[kind])} takes ${allowed.join(', ')}`, line);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* A style may carry keys this kind has no use for, but it may not carry *only*
|
|
282
|
+
* those. Partial overlap is the normal case and the reason styles exist; zero
|
|
283
|
+
* overlap is a style name written on the wrong sort of thing, and nothing else.
|
|
284
|
+
*
|
|
285
|
+
* This is the check that lets `checkAttrs` ignore style-contributed keys
|
|
286
|
+
* without the silence coming back. It cannot catch a style that names every key
|
|
287
|
+
* in the language, since such a style contributes to everything by
|
|
288
|
+
* construction — that hole is left open, because nobody writes one by accident.
|
|
289
|
+
*/
|
|
290
|
+
function checkStyleUse(kind, name, attrs, styles, line) {
|
|
291
|
+
const named = attrs['style'];
|
|
292
|
+
if (named === undefined)
|
|
293
|
+
return;
|
|
294
|
+
const base = styles.get(named);
|
|
295
|
+
if (base === undefined)
|
|
296
|
+
return; // `appearanceOf` reports the missing style.
|
|
297
|
+
// A style naming another style is the one way to carry nothing at all: the
|
|
298
|
+
// parser already refuses one with no attributes, and `appearanceOf` does not
|
|
299
|
+
// recurse, so the name would sit there doing nothing.
|
|
300
|
+
const carried = [...new Set(Object.keys(base).map(topKey))].filter((key) => key !== 'style');
|
|
301
|
+
if (carried.length === 0) {
|
|
302
|
+
throw new SourceError(`style "${named}" carries nothing but a style name`, line);
|
|
303
|
+
}
|
|
304
|
+
if (carried.some((key) => ATTR_KEYS[kind].includes(key)))
|
|
305
|
+
return;
|
|
306
|
+
throw new SourceError(`style "${named}" gives "${name}" nothing. It carries ${carried.join(' and ')}; ` +
|
|
307
|
+
`${article(KIND_WORD[kind])} is ${KIND_PARTS[kind]}`, line);
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* A style is a bundle spanning kinds, so its keys cannot be checked against any
|
|
311
|
+
* one of them — but a word that is an attribute of *nothing* is a misspelling
|
|
312
|
+
* wherever it sits, and a style was the last place in the language where one
|
|
313
|
+
* could hide.
|
|
314
|
+
*/
|
|
315
|
+
function checkStyleKeys(statements) {
|
|
316
|
+
for (const stmt of statements) {
|
|
317
|
+
if (stmt.kind !== 'style')
|
|
318
|
+
continue;
|
|
319
|
+
for (const [key, value] of Object.entries(stmt.attrs)) {
|
|
320
|
+
if (ALL_ATTR_KEYS.includes(topKey(key)))
|
|
321
|
+
continue;
|
|
322
|
+
throw new SourceError(`style "${stmt.name}" has ${key}: ${value}, which is not an attribute. ` +
|
|
323
|
+
`The attributes are ${ALL_ATTR_KEYS.join(', ')}`, stmt.line);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* The attribute a key belongs to. A bracketed value arrives under dotted keys —
|
|
329
|
+
* `text: (color: muted)` is stored as `text.color` — so that a style merges into
|
|
330
|
+
* a node exactly the way every other attribute does; every check above asks
|
|
331
|
+
* about the part, which is the half before the dot.
|
|
332
|
+
*/
|
|
333
|
+
function topKey(key) {
|
|
334
|
+
const dot = key.indexOf('.');
|
|
335
|
+
return dot === -1 ? key : key.slice(0, dot);
|
|
336
|
+
}
|
|
337
|
+
/** Which kinds understand an attribute, in the order the table declares them. */
|
|
338
|
+
function belongTo(key) {
|
|
339
|
+
return Object.keys(ATTR_KEYS).filter((kind) => ATTR_KEYS[kind].includes(key));
|
|
340
|
+
}
|
|
341
|
+
/** "an edge", "a node or a glyph body", "a node, a note or a glyph body". */
|
|
342
|
+
function listKinds(kinds) {
|
|
343
|
+
const words = kinds.map((kind) => article(KIND_WORD[kind]));
|
|
344
|
+
if (words.length <= 1)
|
|
345
|
+
return words[0] ?? 'nothing';
|
|
346
|
+
return `${words.slice(0, -1).join(', ')} or ${words[words.length - 1]}`;
|
|
347
|
+
}
|
|
118
348
|
function appearanceOf(attrs, styles, line) {
|
|
119
349
|
const named = attrs['style'];
|
|
120
350
|
if (named === undefined)
|
|
@@ -131,129 +361,201 @@ function applyDecks(statements, byName) {
|
|
|
131
361
|
const node = byName.get(stmt.name);
|
|
132
362
|
if (!node)
|
|
133
363
|
throw new SourceError(`deck names "${stmt.name}", which does not exist`, stmt.line);
|
|
134
|
-
node.
|
|
364
|
+
node.deckTexts = stmt.texts;
|
|
135
365
|
}
|
|
136
366
|
}
|
|
137
|
-
function
|
|
138
|
-
const
|
|
367
|
+
function buildEdges(statements, byName, styles) {
|
|
368
|
+
const edges = [];
|
|
139
369
|
for (const stmt of statements) {
|
|
140
|
-
if (stmt.kind !== '
|
|
370
|
+
if (stmt.kind !== 'edge')
|
|
141
371
|
continue;
|
|
142
372
|
const from = byName.get(stmt.from);
|
|
143
373
|
const to = byName.get(stmt.to);
|
|
144
374
|
if (!from)
|
|
145
|
-
throw new SourceError(`
|
|
375
|
+
throw new SourceError(`edge from "${stmt.from}", which does not exist`, stmt.line);
|
|
146
376
|
if (!to)
|
|
147
|
-
throw new SourceError(`
|
|
377
|
+
throw new SourceError(`edge to "${stmt.to}", which does not exist`, stmt.line);
|
|
148
378
|
const between = stmt.between && {
|
|
149
|
-
nodes: stmt.between.targets.map((name) => {
|
|
379
|
+
nodes: stmt.between.targets.map(({ name, part }) => {
|
|
380
|
+
if (part !== undefined) {
|
|
381
|
+
// A passage is the gap between two boxes, and a side or a point has
|
|
382
|
+
// no gap on either hand. Refused by name rather than dropped.
|
|
383
|
+
throw new SourceError(`edge passes between "${name} ${part}", and a passage runs between two whole boxes ` +
|
|
384
|
+
`— drop "${part}"`, stmt.line);
|
|
385
|
+
}
|
|
150
386
|
const node = byName.get(name);
|
|
151
387
|
if (!node) {
|
|
152
|
-
throw new SourceError(`
|
|
388
|
+
throw new SourceError(`edge passes between "${name}", which does not exist`, stmt.line);
|
|
153
389
|
}
|
|
154
390
|
return node;
|
|
155
391
|
}),
|
|
156
392
|
...(stmt.between.axis !== undefined ? { axis: stmt.between.axis } : {}),
|
|
157
393
|
};
|
|
158
|
-
|
|
394
|
+
const appearance = appearanceOf(stmt.attrs, styles, stmt.line);
|
|
395
|
+
const what = `${stmt.from} -> ${stmt.to}`;
|
|
396
|
+
checkAttrs('edge', what, stmt.attrs, stmt.line);
|
|
397
|
+
checkStyleUse('edge', what, stmt.attrs, styles, stmt.line);
|
|
398
|
+
if (stmt.attrs['url'] !== undefined && stmt.text === undefined) {
|
|
399
|
+
// Wrapping the line works, and `LINE_WIDTH` is 1.5 — a destination that
|
|
400
|
+
// technically has a target and practically has none. That is the silent
|
|
401
|
+
// defect shape the language refuses everywhere else, so it is refused
|
|
402
|
+
// here rather than shipped and explained.
|
|
403
|
+
throw new SourceError(`edge ${what} has a url and no text. A destination needs something to click, and a line ` +
|
|
404
|
+
'is too thin to be it — give the edge a text, or put the url on one of the nodes', stmt.line);
|
|
405
|
+
}
|
|
406
|
+
const textAttrs = { ...bracketOf('text', appearance), ...stmt.textAttrs };
|
|
407
|
+
edges.push({
|
|
159
408
|
from,
|
|
160
409
|
to,
|
|
161
410
|
both: stmt.both,
|
|
162
|
-
|
|
411
|
+
textAttrs,
|
|
412
|
+
...(stmt.text !== undefined
|
|
413
|
+
? { text: stmt.text, lines: linesFor(stmt.text, textAttrs, `edge ${what}`, stmt.line) }
|
|
414
|
+
: {}),
|
|
163
415
|
...(between ? { between } : {}),
|
|
164
416
|
attrs: stmt.attrs,
|
|
165
|
-
appearance
|
|
417
|
+
appearance,
|
|
166
418
|
line: stmt.line,
|
|
167
419
|
});
|
|
168
420
|
}
|
|
169
|
-
return
|
|
421
|
+
return edges;
|
|
170
422
|
}
|
|
171
423
|
/**
|
|
172
424
|
* Give a node a width and height, sizing its children first. Also records each
|
|
173
425
|
* child's offset within this node, which pass three turns into absolute
|
|
174
426
|
* coordinates once this node itself is placed.
|
|
175
427
|
*/
|
|
176
|
-
function sizeNode(node,
|
|
428
|
+
function sizeNode(node, edges, measurer, fontSize, local) {
|
|
177
429
|
for (const child of node.children)
|
|
178
|
-
sizeNode(child,
|
|
430
|
+
sizeNode(child, edges, measurer, fontSize, local);
|
|
179
431
|
// Text is measured at the size it will be drawn at — the size lives in
|
|
180
432
|
// `constants.ts` precisely so the resolver reserving the room and the
|
|
181
433
|
// renderer filling it cannot disagree about how much room there is.
|
|
182
|
-
const textSize = fontSizeFor(node.kind, node.
|
|
434
|
+
const textSize = fontSizeFor(node.kind, node.textAttrs, fontSize, node.line);
|
|
183
435
|
const lineHeight = measurer.lineHeight(textSize);
|
|
184
436
|
// A node with empty text takes no room for it. This is what makes an
|
|
185
437
|
// invisible grouping container size to exactly its contents.
|
|
186
|
-
const
|
|
187
|
-
const
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
//
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
if (node.appearance[key] !== undefined) {
|
|
196
|
-
throw new SourceError(`"${node.name}" is a note and has ${key}: ${node.appearance[key]}. A note is bare text, with no box to ${key === 'icon' ? 'decorate' : 'replace'}`, node.line);
|
|
197
|
-
}
|
|
438
|
+
const hasText = node.lines.some((line) => plain(line).length > 0);
|
|
439
|
+
const textWidth = hasText ? widestLine(node.lines, measurer, textSize) : 0;
|
|
440
|
+
const textHeight = hasText ? node.lines.length * lineHeight : 0;
|
|
441
|
+
const glyphSide = ICON_LINES * lineHeight;
|
|
442
|
+
if (node.body.kind === 'none') {
|
|
443
|
+
// No body, so the node is its text: no padding, no outline, no children.
|
|
444
|
+
if (node.children.length > 0) {
|
|
445
|
+
throw new SourceError(`"${node.name}" has shape: none and has children. With no body there is no box for ` +
|
|
446
|
+
'anything to go inside', node.line);
|
|
198
447
|
}
|
|
199
|
-
node.width =
|
|
200
|
-
node.height =
|
|
448
|
+
node.width = textWidth;
|
|
449
|
+
node.height = textHeight;
|
|
450
|
+
node.textBox = textBoxIn({ x: 0, y: 0, width: node.width, height: node.height }, (node.textSide = textStyleFor(node.textAttrs, node.line, 'start', 'center').side), textWidth, textHeight);
|
|
201
451
|
return;
|
|
202
452
|
}
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
// Drawn as a glyph, so there is no box to pad and the node's size is the
|
|
207
|
-
// picture's. A label goes under it rather than inside it, which is the
|
|
453
|
+
if (node.body.kind === 'icon') {
|
|
454
|
+
// Drawn as a picture, so there is no box to pad and the node's size is the
|
|
455
|
+
// picture's. A text goes under it rather than inside it, which is the
|
|
208
456
|
// arrangement that makes a row of these read as captioned things.
|
|
209
457
|
if (node.children.length > 0) {
|
|
210
|
-
throw new SourceError(`"${node.name}" is drawn as a
|
|
458
|
+
throw new SourceError(`"${node.name}" is drawn as a picture and has children. A picture is not a box, so nothing can go inside it`, node.line);
|
|
211
459
|
}
|
|
212
|
-
node.width = Math.max(glyphSide,
|
|
213
|
-
node.height = glyphSide + (
|
|
460
|
+
node.width = Math.max(glyphSide, textWidth);
|
|
461
|
+
node.height = glyphSide + (hasText ? ICON_GAP + textHeight : 0);
|
|
462
|
+
// The caption is under the picture and nowhere else, so only the
|
|
463
|
+
// horizontal half of `at` reaches it.
|
|
464
|
+
node.textBox = textBoxIn({ x: 0, y: glyphSide + ICON_GAP, width: node.width, height: textHeight }, (node.textSide = textStyleFor(node.textAttrs, node.line, 'middle', 'center').side), textWidth, textHeight);
|
|
214
465
|
return;
|
|
215
466
|
}
|
|
216
|
-
//
|
|
217
|
-
//
|
|
218
|
-
//
|
|
219
|
-
|
|
220
|
-
const icon = iconFor(node.appearance, node.line);
|
|
221
|
-
const iconSide = icon === undefined ? 0 : glyphSide;
|
|
222
|
-
const iconRoom = icon === undefined ? 0 : iconSide + (hasLabel ? ICON_GAP : 0);
|
|
467
|
+
// Read before the split, so a misspelt word is refused on a childless node
|
|
468
|
+
// too. The *absence* of children makes the setting inert, which stays silent;
|
|
469
|
+
// a value the language does not have is wrong wherever it is written.
|
|
470
|
+
const contents = contentsStyleFor(node);
|
|
223
471
|
if (node.children.length === 0) {
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
//
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
node.
|
|
233
|
-
node.
|
|
472
|
+
// `at` is read on a leaf too, and is inert wherever the box is exactly the
|
|
473
|
+
// size of what it holds — which is every leaf, since a leaf is sized from
|
|
474
|
+
// its own text. It bites once the box is sized by something else: a
|
|
475
|
+
// `widths:` that widens it, or a child placed beside the text. Inert-but-
|
|
476
|
+
// legal stays silent here, the same treatment `align` gets on a leaf with
|
|
477
|
+
// one line.
|
|
478
|
+
const style = textStyleFor(node.textAttrs, node.line, 'middle', 'center');
|
|
479
|
+
node.width = textWidth + PAD * 2;
|
|
480
|
+
node.height = textHeight + PAD * 2;
|
|
481
|
+
node.textBox = textBoxIn({
|
|
482
|
+
x: PAD,
|
|
483
|
+
y: leafTop(style.end, 0, node.height, textHeight),
|
|
484
|
+
width: node.width - PAD * 2,
|
|
485
|
+
height: textHeight,
|
|
486
|
+
}, (node.textSide = style.side), textWidth, textHeight);
|
|
487
|
+
}
|
|
488
|
+
else if (framedChildren(node).size > 0) {
|
|
489
|
+
// Something is placed against this node's own frame or text, so where its
|
|
490
|
+
// text sits and how big it is come out of one solve with its children.
|
|
491
|
+
const own = { textWidth, textHeight, contents };
|
|
492
|
+
const lay = (width) => {
|
|
493
|
+
layoutFramed(node, own, edges, measurer, fontSize, local, width - node.deckTexts.length * DECK_STEP);
|
|
494
|
+
applyDeck(node, local);
|
|
495
|
+
};
|
|
496
|
+
lay(0);
|
|
497
|
+
relayout.set(node, lay);
|
|
498
|
+
return;
|
|
234
499
|
}
|
|
235
500
|
else {
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
501
|
+
if (contents.widths === 'match')
|
|
502
|
+
matchWidths(node.children);
|
|
503
|
+
let content = layoutChildren(node.children, edges, measurer, fontSize, local);
|
|
504
|
+
if (contents.widths === 'fill') {
|
|
505
|
+
// The band is the wider of the title and the contents, so filling it
|
|
506
|
+
// needs the contents measured first. Where they already set the width
|
|
507
|
+
// this is `match` exactly; where the title wins it is the answer `match`
|
|
508
|
+
// could not give.
|
|
509
|
+
const band = Math.max(textWidth, content.width);
|
|
510
|
+
for (const child of node.children)
|
|
511
|
+
widenTo(child, band);
|
|
512
|
+
content = layoutChildren(node.children, edges, measurer, fontSize, local);
|
|
513
|
+
checkOneColumn(node, node.children, local);
|
|
514
|
+
}
|
|
515
|
+
// `headerHeight` is the band the text takes, whichever end of the box that
|
|
516
|
+
// band is at. Only the contents' offset depends on the side.
|
|
517
|
+
node.headerHeight = hasText ? textHeight + HEADER_GAP : 0;
|
|
518
|
+
node.width = Math.max(textWidth, content.width) + PAD * 2;
|
|
243
519
|
node.height = node.headerHeight + content.height + PAD * 2;
|
|
244
|
-
const
|
|
520
|
+
const style = textStyleFor(node.textAttrs, node.line);
|
|
521
|
+
if (style.end === 'center')
|
|
522
|
+
throw bandInMiddle(node, style.at);
|
|
523
|
+
const above = style.end === 'top' ? node.headerHeight : 0;
|
|
524
|
+
// The text has a band at one end of the box, and the contents have the
|
|
525
|
+
// other.
|
|
526
|
+
node.textBox = textBoxIn({
|
|
527
|
+
x: PAD,
|
|
528
|
+
y: style.end === 'top' ? PAD : node.height - PAD - textHeight,
|
|
529
|
+
width: node.width - PAD * 2,
|
|
530
|
+
height: textHeight,
|
|
531
|
+
}, (node.textSide = style.side), textWidth, textHeight);
|
|
532
|
+
// Where the title is wider than the contents, the slack is all on the right
|
|
533
|
+
// — every member sits at the smallest position its constraints allow and
|
|
534
|
+
// nothing pushes it along. `align` is what says where the block goes in it.
|
|
535
|
+
const slack = node.width - PAD * 2 - content.width;
|
|
536
|
+
const shift = contents.align === 'left' ? 0 : contents.align === 'center' ? slack / 2 : slack;
|
|
245
537
|
for (const child of node.children) {
|
|
246
538
|
const offset = local.get(child);
|
|
247
|
-
offset.x += PAD;
|
|
539
|
+
offset.x += PAD + shift;
|
|
248
540
|
offset.y += PAD + above;
|
|
249
541
|
}
|
|
542
|
+
node.banded = true;
|
|
250
543
|
}
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
544
|
+
applyDeck(node, local);
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* Make room for a deck's copies, once the node's own face has been sized.
|
|
548
|
+
*
|
|
549
|
+
* The copies sit behind and above-left, so the whole node grows by the depth
|
|
550
|
+
* of the stack and its own face moves down and right by the same.
|
|
551
|
+
*/
|
|
552
|
+
function applyDeck(node, local) {
|
|
553
|
+
if (node.deckTexts.length > 0) {
|
|
554
|
+
node.inset = node.deckTexts.length * DECK_STEP;
|
|
255
555
|
node.width += node.inset;
|
|
256
556
|
node.height += node.inset;
|
|
557
|
+
node.textBox.x += node.inset;
|
|
558
|
+
node.textBox.y += node.inset;
|
|
257
559
|
for (const child of node.children) {
|
|
258
560
|
const offset = local.get(child);
|
|
259
561
|
offset.x += node.inset;
|
|
@@ -262,20 +564,85 @@ function sizeNode(node, links, measurer, fontSize, local) {
|
|
|
262
564
|
}
|
|
263
565
|
}
|
|
264
566
|
/**
|
|
265
|
-
*
|
|
266
|
-
*
|
|
267
|
-
*
|
|
567
|
+
* Where a block of text ends up in the room it was given: the *ink* box, which
|
|
568
|
+
* is what `hub text` names as a placement target and what the renderer draws.
|
|
569
|
+
*
|
|
570
|
+
* Only the horizontal is decided here. The vertical is settled by whoever
|
|
571
|
+
* knows which end of the box the text sits at, which differs between a leaf, a
|
|
572
|
+
* container's band and a caption under a picture, and arrives as `room.y`.
|
|
268
573
|
*/
|
|
269
|
-
function
|
|
270
|
-
const
|
|
271
|
-
|
|
574
|
+
function textBoxIn(room, side, inkWidth, inkHeight) {
|
|
575
|
+
const x = side === 'left'
|
|
576
|
+
? room.x
|
|
577
|
+
: side === 'right'
|
|
578
|
+
? room.x + room.width - inkWidth
|
|
579
|
+
: room.x + (room.width - inkWidth) / 2;
|
|
580
|
+
return { x, y: room.y, width: inkWidth, height: inkHeight };
|
|
581
|
+
}
|
|
582
|
+
function contentsStyleFor(node) {
|
|
583
|
+
const written = bracketOf('contents', node.appearance);
|
|
584
|
+
const widths = written['widths'] ?? 'natural';
|
|
585
|
+
const align = written['align'] ?? 'left';
|
|
586
|
+
if (!CONTENT_WIDTHS.includes(widths)) {
|
|
587
|
+
throw new SourceError(`"${node.name}" has contents: (widths: ${widths}), which is not one of ${CONTENT_WIDTHS.join(', ')}`, node.line);
|
|
588
|
+
}
|
|
589
|
+
if (!CONTENT_ALIGNMENTS.includes(align)) {
|
|
590
|
+
throw new SourceError(`"${node.name}" has contents: (align: ${align}), which is not one of ${CONTENT_ALIGNMENTS.join(', ')}`, node.line);
|
|
591
|
+
}
|
|
592
|
+
return { widths, align };
|
|
593
|
+
}
|
|
594
|
+
/**
|
|
595
|
+
* `widths: match` widens every direct child to the widest one's natural width,
|
|
596
|
+
* before layoutChildren sizes and positions anything from those widths. A
|
|
597
|
+
* container's own children are already sized by this point.
|
|
598
|
+
*/
|
|
599
|
+
function matchWidths(children) {
|
|
600
|
+
const maxWidth = Math.max(...children.map((child) => child.width));
|
|
601
|
+
for (const child of children)
|
|
602
|
+
widenTo(child, maxWidth);
|
|
603
|
+
}
|
|
604
|
+
/**
|
|
605
|
+
* `widths: fill` makes every child as wide as the band, which only holds for
|
|
606
|
+
* a single column: two children set to the band's width and not one above the
|
|
607
|
+
* other make the contents wider than the band, the band grows with them, and
|
|
608
|
+
* no child is the width it was asked to be. Every child of a column starts at
|
|
609
|
+
* the same x once they are all one width, so a child that does not is the
|
|
610
|
+
* proof, and the pair is named rather than drawn wrong.
|
|
611
|
+
*/
|
|
612
|
+
function checkOneColumn(node, children, local) {
|
|
613
|
+
const [first, ...rest] = children;
|
|
614
|
+
if (!first)
|
|
615
|
+
return;
|
|
616
|
+
const off = rest.find((child) => Math.abs(local.get(child).x - local.get(first).x) > 0.5);
|
|
617
|
+
if (!off)
|
|
618
|
+
return;
|
|
619
|
+
throw new SourceError(`"${node.name}" has contents: (widths: fill), which makes every child the full width of the box, ` +
|
|
620
|
+
`and that only works in a single column — "${first.name}" and "${off.name}" are not one above the other. ` +
|
|
621
|
+
'Put them in one column, or use contents: (widths: match) to make them one width without filling the box', node.line);
|
|
622
|
+
}
|
|
623
|
+
/**
|
|
624
|
+
* Make a node wider than it was sized, carrying its text with it.
|
|
625
|
+
*
|
|
626
|
+
* `widths: match` and `widths: fill` both set a child's width after that child
|
|
627
|
+
* was sized from its own contents, and the text's box was worked out against
|
|
628
|
+
* the old one. A left-ranged text stays where it is; a centered or right-ranged
|
|
629
|
+
* one moves by its share of the difference.
|
|
630
|
+
*/
|
|
631
|
+
function widenTo(node, width) {
|
|
632
|
+
// A node with something placed against its own frame is laid out by a
|
|
633
|
+
// solve, and a wider frame is one more thing that solve has to hold: the
|
|
634
|
+
// things against its right edge move, and a centered text re-centers.
|
|
635
|
+
const again = relayout.get(node);
|
|
636
|
+
if (again) {
|
|
637
|
+
again(width);
|
|
272
638
|
return;
|
|
273
|
-
if (value !== 'widths') {
|
|
274
|
-
throw new SourceError(`"${node.name}" has align: ${value}, which is not one of widths`, node.line);
|
|
275
639
|
}
|
|
276
|
-
const
|
|
277
|
-
|
|
278
|
-
|
|
640
|
+
const grew = width - node.width;
|
|
641
|
+
node.width = width;
|
|
642
|
+
if (node.textSide === 'center')
|
|
643
|
+
node.textBox.x += grew / 2;
|
|
644
|
+
else if (node.textSide === 'right')
|
|
645
|
+
node.textBox.x += grew;
|
|
279
646
|
}
|
|
280
647
|
/**
|
|
281
648
|
* Position a container's children relative to each other. Children that make
|
|
@@ -283,15 +650,15 @@ function applyAlign(node) {
|
|
|
283
650
|
* siblings they name, by the same constraint pass that positions top-level
|
|
284
651
|
* nodes. A placement may only name a sibling — containment scopes the group.
|
|
285
652
|
*/
|
|
286
|
-
function layoutChildren(
|
|
287
|
-
const siblings = new Map(
|
|
653
|
+
function layoutChildren(children, edges, measurer, fontSize, local) {
|
|
654
|
+
const siblings = new Map(children.map((child) => [child.name, child]));
|
|
288
655
|
// Children that say nothing keep the written order, down the page and flush
|
|
289
656
|
// left. Written as constraints rather than a cursor so a placed sibling can
|
|
290
657
|
// push them along like anything else.
|
|
291
658
|
const stack = [];
|
|
292
659
|
const alignment = [];
|
|
293
|
-
const quiet =
|
|
294
|
-
const indexOf = new Map(
|
|
660
|
+
const quiet = children.filter((child) => child.placements.length === 0);
|
|
661
|
+
const indexOf = new Map(children.map((child, index) => [child, index]));
|
|
295
662
|
quiet.forEach((child, position) => {
|
|
296
663
|
const previous = quiet[position - 1];
|
|
297
664
|
if (!previous)
|
|
@@ -301,35 +668,38 @@ function layoutChildren(parent, links, measurer, fontSize, local) {
|
|
|
301
668
|
stack.push({ from: before, to: after, weight: previous.height + CHILD_GAP });
|
|
302
669
|
alignment.push(...fix(before, after, 0));
|
|
303
670
|
});
|
|
304
|
-
const positions = positionGroup(
|
|
671
|
+
const positions = positionGroup(children, (placement, owner) => placement.targets.map(({ name, part }) => {
|
|
305
672
|
const target = siblings.get(name);
|
|
306
673
|
if (!target) {
|
|
307
|
-
throw new SourceError(`"${owner.name}" is placed against "${name}", which is not one of its siblings`, placement.line);
|
|
674
|
+
throw new SourceError(`"${owner.name}" is placed against "${name}", which is not one of its siblings or its parent`, placement.line);
|
|
308
675
|
}
|
|
309
|
-
return {
|
|
676
|
+
return partOf({
|
|
310
677
|
name,
|
|
311
678
|
node: target,
|
|
312
679
|
index: indexOf.get(target),
|
|
313
680
|
offset: { x: 0, y: 0 },
|
|
314
681
|
width: target.width,
|
|
315
682
|
height: target.height,
|
|
316
|
-
};
|
|
317
|
-
}), { x: alignment, y: stack }, corridorsIn(
|
|
318
|
-
for (const child of
|
|
683
|
+
}, part, owner, placement.line);
|
|
684
|
+
}), { x: alignment, y: stack }, corridorsIn(edges, (node) => liftTo(node, indexOf, local), measurer, fontSize));
|
|
685
|
+
for (const child of children)
|
|
319
686
|
local.set(child, positions.get(child));
|
|
320
|
-
return extentOf(
|
|
687
|
+
return extentOf(children, local);
|
|
321
688
|
}
|
|
322
689
|
function extentOf(children, local) {
|
|
323
690
|
let minX = Infinity;
|
|
324
691
|
let minY = Infinity;
|
|
325
692
|
let maxX = -Infinity;
|
|
326
693
|
let maxY = -Infinity;
|
|
694
|
+
// What a child has placed outside itself is still part of it, so the block
|
|
695
|
+
// holding the child holds that too.
|
|
327
696
|
for (const child of children) {
|
|
328
697
|
const offset = local.get(child);
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
698
|
+
const { reach } = child;
|
|
699
|
+
minX = Math.min(minX, offset.x - reach.left);
|
|
700
|
+
minY = Math.min(minY, offset.y - reach.top);
|
|
701
|
+
maxX = Math.max(maxX, offset.x + child.width + reach.right);
|
|
702
|
+
maxY = Math.max(maxY, offset.y + child.height + reach.bottom);
|
|
333
703
|
}
|
|
334
704
|
for (const child of children) {
|
|
335
705
|
const offset = local.get(child);
|
|
@@ -338,8 +708,443 @@ function extentOf(children, local) {
|
|
|
338
708
|
}
|
|
339
709
|
return { width: maxX - minX, height: maxY - minY };
|
|
340
710
|
}
|
|
711
|
+
/** How to lay a framed node out again at a given width, which is what `widenTo` needs. */
|
|
712
|
+
const relayout = new WeakMap();
|
|
713
|
+
/**
|
|
714
|
+
* The children placed against their parent — its frame, a part of it, or its
|
|
715
|
+
* text — together with any child placed against one of those, and so on.
|
|
716
|
+
*
|
|
717
|
+
* These hang off the frame. The rest hang off the default position under the
|
|
718
|
+
* text, which is the contents stack as it always was. Nothing sorts a child
|
|
719
|
+
* into one or the other; it is read off what each placement names.
|
|
720
|
+
*/
|
|
721
|
+
function framedChildren(parent) {
|
|
722
|
+
const framed = new Set();
|
|
723
|
+
const byName = new Map(parent.children.map((child) => [child.name, child]));
|
|
724
|
+
let grew = true;
|
|
725
|
+
while (grew) {
|
|
726
|
+
grew = false;
|
|
727
|
+
for (const child of parent.children) {
|
|
728
|
+
if (framed.has(child))
|
|
729
|
+
continue;
|
|
730
|
+
const names = child.placements.flatMap((placement) => placement.targets.map((target) => target.name));
|
|
731
|
+
const hangs = names.some((name) => {
|
|
732
|
+
if (name === parent.name)
|
|
733
|
+
return true;
|
|
734
|
+
const sibling = byName.get(name);
|
|
735
|
+
return sibling !== undefined && framed.has(sibling);
|
|
736
|
+
});
|
|
737
|
+
if (hangs) {
|
|
738
|
+
framed.add(child);
|
|
739
|
+
grew = true;
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
return framed;
|
|
744
|
+
}
|
|
745
|
+
/**
|
|
746
|
+
* The sides of its parent's frame a child is not held inside by the padding.
|
|
747
|
+
*
|
|
748
|
+
* Every child is held a padding in from the frame on all four sides, which is
|
|
749
|
+
* how a box grows to hold what is in it. A child placed against a side is
|
|
750
|
+
* held by that placement instead — tucked a gap in with `inside`, straddling
|
|
751
|
+
* it with `on`, beyond it with `outside` — and holding it by the padding as
|
|
752
|
+
* well would contradict the placement whenever the gap is the smaller.
|
|
753
|
+
*
|
|
754
|
+
* `overlap: allow` on a child is "do not grow for me": it lies over whatever
|
|
755
|
+
* is there, and the frame is not held open around it at all.
|
|
756
|
+
*/
|
|
757
|
+
function freeSides(child, parent) {
|
|
758
|
+
if (allowsOverlap(child))
|
|
759
|
+
return new Set(PART_SIDES);
|
|
760
|
+
const free = new Set();
|
|
761
|
+
const across = (direction) => [
|
|
762
|
+
...(/left|right/.test(direction) ? ['left', 'right'] : []),
|
|
763
|
+
...(/above|below/.test(direction) ? ['top', 'bottom'] : []),
|
|
764
|
+
];
|
|
765
|
+
for (const placement of child.placements) {
|
|
766
|
+
for (const target of placement.targets) {
|
|
767
|
+
if (target.name !== parent.name || target.part === 'text')
|
|
768
|
+
continue;
|
|
769
|
+
if (placement.kind === 'offset' && placement.written !== 'inside') {
|
|
770
|
+
// Beyond the frame, so it neither holds the frame open nor is held in
|
|
771
|
+
// by it — on either axis. Held on the other one, a note taller than
|
|
772
|
+
// its parent's text would stretch the parent to fit a thing outside it.
|
|
773
|
+
for (const side of PART_SIDES)
|
|
774
|
+
free.add(side);
|
|
775
|
+
continue;
|
|
776
|
+
}
|
|
777
|
+
const words = (target.part ?? '').split('-');
|
|
778
|
+
for (const side of PART_SIDES)
|
|
779
|
+
if (words.includes(side))
|
|
780
|
+
free.add(side);
|
|
781
|
+
if (placement.kind === 'align' && placement.side !== 'center')
|
|
782
|
+
free.add(placement.side);
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
return free;
|
|
786
|
+
}
|
|
787
|
+
/**
|
|
788
|
+
* The parent's frame as a target, narrowed to the part that was named.
|
|
789
|
+
*
|
|
790
|
+
* The frame is two members of the child system, its top-left corner and its
|
|
791
|
+
* bottom-right, so a part of it is read off those two per axis: `right` is
|
|
792
|
+
* across at the bottom-right corner and spans both down, `top-center` spans
|
|
793
|
+
* both across and is down at the top-left. A part that spans comes as its two
|
|
794
|
+
* ends, and a placement against two targets already means the region that
|
|
795
|
+
* bounds them.
|
|
796
|
+
*/
|
|
797
|
+
function frameTargets(parent, part, first, last) {
|
|
798
|
+
const words = (part ?? '').split('-');
|
|
799
|
+
const pick = (near, far) => words.includes(near) ? [first] : words.includes(far) ? [last] : [first, last];
|
|
800
|
+
const xs = pick('left', 'right');
|
|
801
|
+
const ys = pick('top', 'bottom');
|
|
802
|
+
const name = part === undefined ? parent.name : `${parent.name} ${part}`;
|
|
803
|
+
const point = (x, y) => ({
|
|
804
|
+
name,
|
|
805
|
+
node: parent,
|
|
806
|
+
index: x,
|
|
807
|
+
byAxis: { x, y },
|
|
808
|
+
frame: true,
|
|
809
|
+
offset: { x: 0, y: 0 },
|
|
810
|
+
width: 0,
|
|
811
|
+
height: 0,
|
|
812
|
+
});
|
|
813
|
+
const start = point(xs[0], ys[0]);
|
|
814
|
+
const end = point(xs[xs.length - 1], ys[ys.length - 1]);
|
|
815
|
+
return xs.length === 1 && ys.length === 1 ? [start] : [start, end];
|
|
816
|
+
}
|
|
817
|
+
/**
|
|
818
|
+
* A stand-in member for something of the parent's own that takes room among
|
|
819
|
+
* its children: its text, its badge, the block of its contents, a corner of
|
|
820
|
+
* its frame. Named after the parent so an error about it reads in the
|
|
821
|
+
* author's words — `"hub text" and "star" overlap`.
|
|
822
|
+
*/
|
|
823
|
+
function stand(of, what, width, height) {
|
|
824
|
+
return {
|
|
825
|
+
...of,
|
|
826
|
+
name: `${of.name} ${what}`,
|
|
827
|
+
parent: undefined,
|
|
828
|
+
children: [],
|
|
829
|
+
placements: [],
|
|
830
|
+
attrs: {},
|
|
831
|
+
appearance: {},
|
|
832
|
+
deckTexts: [],
|
|
833
|
+
reach: NO_REACH,
|
|
834
|
+
x: 0,
|
|
835
|
+
y: 0,
|
|
836
|
+
width,
|
|
837
|
+
height,
|
|
838
|
+
};
|
|
839
|
+
}
|
|
840
|
+
function bandInMiddle(node, at) {
|
|
841
|
+
// A container's band is at one end precisely so its contents can have the
|
|
842
|
+
// other, so the three positions that name neither end have nothing to mean
|
|
843
|
+
// here. Refused rather than rounded to an end, for the reason every other
|
|
844
|
+
// word in the language is: a value that quietly becomes a different value
|
|
845
|
+
// looks like the tool being broken.
|
|
846
|
+
return new SourceError(`"${node.name}" holds things and its text is at: ${at}. A container's text sits at ` +
|
|
847
|
+
'the top or the bottom so its contents can have the other end, so name a position on ' +
|
|
848
|
+
'one of those edges', node.line);
|
|
849
|
+
}
|
|
850
|
+
/**
|
|
851
|
+
* Lay out a node that has something placed against its own frame or text.
|
|
852
|
+
*
|
|
853
|
+
* One solve holds everything: the children placed against the frame, the
|
|
854
|
+
* block of ordinary contents solved as it always was, the node's text and
|
|
855
|
+
* badge, and the frame's two corners. Every one of them is held a padding in
|
|
856
|
+
* from the frame, so the frame is the smallest box that holds them all, which
|
|
857
|
+
* is what a node's size has always been. A part of the frame is then just a
|
|
858
|
+
* target like any other.
|
|
859
|
+
*
|
|
860
|
+
* The band falls out rather than being decided. It exists because the
|
|
861
|
+
* contents stack below the text; where every child hangs off the frame
|
|
862
|
+
* instead, nothing is below the text, there is no band, and the node reads as
|
|
863
|
+
* a leaf — its text centered, and whatever is beside the text centered with
|
|
864
|
+
* it as one group.
|
|
865
|
+
*/
|
|
866
|
+
function layoutFramed(node, own, edges, measurer, fontSize, local, minWidth) {
|
|
867
|
+
const { textWidth, textHeight, contents } = own;
|
|
868
|
+
const hasText = textWidth > 0;
|
|
869
|
+
const framed = framedChildren(node);
|
|
870
|
+
const stacked = node.children.filter((child) => !framed.has(child));
|
|
871
|
+
const placed = node.children.filter((child) => framed.has(child));
|
|
872
|
+
const banded = stacked.length > 0;
|
|
873
|
+
const style = banded
|
|
874
|
+
? textStyleFor(node.textAttrs, node.line)
|
|
875
|
+
: textStyleFor(node.textAttrs, node.line, 'middle', 'center');
|
|
876
|
+
if (banded && style.end === 'center')
|
|
877
|
+
throw bandInMiddle(node, style.at);
|
|
878
|
+
// The children placed against this node's text. With it they make the
|
|
879
|
+
// text's row: the contents stack past all of them, and they range and
|
|
880
|
+
// center with the text as one group.
|
|
881
|
+
const againstText = (placement) => placement.targets.some((target) => target.name === node.name && target.part === 'text');
|
|
882
|
+
const withText = placed.filter((child) => child.placements.some(againstText));
|
|
883
|
+
let block = { width: 0, height: 0 };
|
|
884
|
+
if (banded) {
|
|
885
|
+
if (contents.widths === 'match')
|
|
886
|
+
matchWidths(stacked);
|
|
887
|
+
block = layoutChildren(stacked, edges, measurer, fontSize, local);
|
|
888
|
+
if (contents.widths === 'fill') {
|
|
889
|
+
// The row is the text and whatever stands beside it, each at its gap.
|
|
890
|
+
let row = textWidth;
|
|
891
|
+
for (const child of withText) {
|
|
892
|
+
const beside = child.placements.find((placement) => placement.kind === 'offset' && /left|right/.test(placement.direction) && againstText(placement));
|
|
893
|
+
if (beside)
|
|
894
|
+
row += child.width + (hasText ? gapFor(child, beside, node) : 0);
|
|
895
|
+
}
|
|
896
|
+
const across = Math.max(row, block.width);
|
|
897
|
+
for (const child of stacked)
|
|
898
|
+
widenTo(child, across);
|
|
899
|
+
block = layoutChildren(stacked, edges, measurer, fontSize, local);
|
|
900
|
+
checkOneColumn(node, stacked, local);
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
const members = [...placed];
|
|
904
|
+
const add = (member) => members.push(member) - 1;
|
|
905
|
+
const K = banded ? add(stand(node, 'contents', block.width, block.height)) : -1;
|
|
906
|
+
// An empty text is still somewhere, so a thing placed against it has a
|
|
907
|
+
// place to be: it is a point where the text would have been, and it takes
|
|
908
|
+
// no room — including the gap beside it, which `Target.empty` drops.
|
|
909
|
+
const T = hasText || withText.length > 0 ? add(stand(node, 'text', textWidth, textHeight)) : -1;
|
|
910
|
+
const TL = add(stand(node, 'frame', 0, 0));
|
|
911
|
+
const BR = add(stand(node, 'frame', 0, 0));
|
|
912
|
+
const x = [];
|
|
913
|
+
const y = [];
|
|
914
|
+
const hold = (index, free) => {
|
|
915
|
+
const { width, height, reach } = members[index];
|
|
916
|
+
if (!free.has('left'))
|
|
917
|
+
x.push({ from: TL, to: index, weight: PAD + reach.left });
|
|
918
|
+
if (!free.has('right'))
|
|
919
|
+
x.push({ from: index, to: BR, weight: width + reach.right + PAD });
|
|
920
|
+
if (!free.has('top'))
|
|
921
|
+
y.push({ from: TL, to: index, weight: PAD + reach.top });
|
|
922
|
+
if (!free.has('bottom'))
|
|
923
|
+
y.push({ from: index, to: BR, weight: height + reach.bottom + PAD });
|
|
924
|
+
};
|
|
925
|
+
placed.forEach((child, index) => hold(index, freeSides(child, node)));
|
|
926
|
+
for (const index of [K, T])
|
|
927
|
+
if (index >= 0)
|
|
928
|
+
hold(index, new Set());
|
|
929
|
+
if (minWidth > 0)
|
|
930
|
+
x.push({ from: TL, to: BR, weight: minWidth });
|
|
931
|
+
// The text's row, as members: the text and everything placed against it.
|
|
932
|
+
const row = [...(T >= 0 ? [T] : []), ...withText.map((child) => placed.indexOf(child))];
|
|
933
|
+
if (banded) {
|
|
934
|
+
// The text's row is at one end, the contents the other.
|
|
935
|
+
for (const index of row) {
|
|
936
|
+
const { height, reach } = members[index];
|
|
937
|
+
if (style.end === 'top')
|
|
938
|
+
y.push({ from: index, to: K, weight: height + reach.bottom + HEADER_GAP });
|
|
939
|
+
else
|
|
940
|
+
y.push({ from: K, to: index, weight: block.height + HEADER_GAP + reach.top });
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
const siblings = new Map(node.children.map((child) => [child.name, child]));
|
|
944
|
+
const indexOf = new Map(members.map((member, index) => [member, index]));
|
|
945
|
+
const locate = (placement, owner) => placement.targets.flatMap(({ name, part }) => {
|
|
946
|
+
if (name === node.name) {
|
|
947
|
+
if (part !== 'text')
|
|
948
|
+
return frameTargets(node, part, TL, BR);
|
|
949
|
+
const text = { x: 0, y: 0 };
|
|
950
|
+
return [
|
|
951
|
+
{
|
|
952
|
+
name: `${name} text`,
|
|
953
|
+
node,
|
|
954
|
+
index: T,
|
|
955
|
+
offset: text,
|
|
956
|
+
width: textWidth,
|
|
957
|
+
height: textHeight,
|
|
958
|
+
...(hasText ? {} : { empty: true }),
|
|
959
|
+
},
|
|
960
|
+
];
|
|
961
|
+
}
|
|
962
|
+
const sibling = siblings.get(name);
|
|
963
|
+
if (!sibling) {
|
|
964
|
+
throw new SourceError(`"${owner.name}" is placed against "${name}", which is not one of its siblings or its parent`, placement.line);
|
|
965
|
+
}
|
|
966
|
+
// A child of the contents is reached through the block that holds it,
|
|
967
|
+
// at the offset the contents solve already gave it.
|
|
968
|
+
const inBlock = !framed.has(sibling);
|
|
969
|
+
const target = {
|
|
970
|
+
name,
|
|
971
|
+
node: sibling,
|
|
972
|
+
index: inBlock ? K : indexOf.get(sibling),
|
|
973
|
+
offset: inBlock ? { ...local.get(sibling) } : { x: 0, y: 0 },
|
|
974
|
+
width: sibling.width,
|
|
975
|
+
height: sibling.height,
|
|
976
|
+
};
|
|
977
|
+
return [partOf(target, part, owner, placement.line)];
|
|
978
|
+
});
|
|
979
|
+
// Which way a child against a side stands from the rest of the box: across
|
|
980
|
+
// for a left or right side or corner, down for the top or bottom.
|
|
981
|
+
const facing = new Map();
|
|
982
|
+
placed.forEach((child, index) => {
|
|
983
|
+
for (const placement of child.placements) {
|
|
984
|
+
for (const target of placement.targets) {
|
|
985
|
+
if (target.name !== node.name || target.part === undefined || target.part === 'text')
|
|
986
|
+
continue;
|
|
987
|
+
const words = target.part.split('-');
|
|
988
|
+
if (words.includes('left') || words.includes('right'))
|
|
989
|
+
facing.set(index, 'x');
|
|
990
|
+
else if (words.includes('top') || words.includes('bottom'))
|
|
991
|
+
facing.set(index, 'y');
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
});
|
|
995
|
+
const across = (i, j) => facing.get(i) ?? facing.get(j);
|
|
996
|
+
// An edge end inside this node, as a member of this solve: a framed child
|
|
997
|
+
// or something inside one, or something in the contents, reached through
|
|
998
|
+
// the block at the offset the contents solve gave it. An end outside the
|
|
999
|
+
// node is none of this solve's business.
|
|
1000
|
+
const endOf = (end) => {
|
|
1001
|
+
let member = end;
|
|
1002
|
+
const offset = { x: 0, y: 0 };
|
|
1003
|
+
while (member.parent !== node) {
|
|
1004
|
+
const step = local.get(member);
|
|
1005
|
+
if (!step || !member.parent)
|
|
1006
|
+
return undefined;
|
|
1007
|
+
offset.x += step.x;
|
|
1008
|
+
offset.y += step.y;
|
|
1009
|
+
member = member.parent;
|
|
1010
|
+
}
|
|
1011
|
+
const inBlock = !framed.has(member);
|
|
1012
|
+
if (inBlock) {
|
|
1013
|
+
const step = local.get(member);
|
|
1014
|
+
offset.x += step.x;
|
|
1015
|
+
offset.y += step.y;
|
|
1016
|
+
}
|
|
1017
|
+
return {
|
|
1018
|
+
name: end.name,
|
|
1019
|
+
node: end,
|
|
1020
|
+
index: inBlock ? K : indexOf.get(member),
|
|
1021
|
+
offset,
|
|
1022
|
+
width: end.width,
|
|
1023
|
+
height: end.height,
|
|
1024
|
+
};
|
|
1025
|
+
};
|
|
1026
|
+
// Two ends both in the contents come out as one member and are skipped:
|
|
1027
|
+
// the contents solve already made room for that edge's text.
|
|
1028
|
+
const corridors = corridorsIn(edges, endOf, measurer, fontSize);
|
|
1029
|
+
const solve = (pins) =>
|
|
1030
|
+
// Everything here is inside one box, so a collision separates by the step
|
|
1031
|
+
// the contents stack by, not by the gap kept between strangers.
|
|
1032
|
+
positionGroup(members, locate, { x: [...x, ...pins.x], y: [...y, ...pins.y] }, corridors, across, CHILD_GAP);
|
|
1033
|
+
let solved = solve({ x: [], y: [] });
|
|
1034
|
+
// Where a text is centered or ranged right, where it goes depends on how
|
|
1035
|
+
// wide the frame came out, which depends on everything else. So it is
|
|
1036
|
+
// measured off the first solve and pinned, the move `settle` makes.
|
|
1037
|
+
const at = (index) => solved.get(members[index]);
|
|
1038
|
+
const inner = (axis) => ({
|
|
1039
|
+
start: at(TL)[axis] + PAD,
|
|
1040
|
+
size: at(BR)[axis] - at(TL)[axis] - PAD * 2,
|
|
1041
|
+
});
|
|
1042
|
+
const pins = { x: [], y: [] };
|
|
1043
|
+
const pin = (axis, index, to) => {
|
|
1044
|
+
if (to - at(index)[axis] <= 1e-9)
|
|
1045
|
+
return;
|
|
1046
|
+
pins[axis].push(...fix(TL, index, to - at(TL)[axis]));
|
|
1047
|
+
};
|
|
1048
|
+
// The text and the things placed against it range and center as a group,
|
|
1049
|
+
// which is the fan rule's sentence again: several things against one target
|
|
1050
|
+
// are balanced on it together.
|
|
1051
|
+
const extent = (axis) => {
|
|
1052
|
+
let start = Infinity;
|
|
1053
|
+
let end = -Infinity;
|
|
1054
|
+
for (const index of row) {
|
|
1055
|
+
const size = axis === 'x' ? members[index].width : members[index].height;
|
|
1056
|
+
start = Math.min(start, at(index)[axis]);
|
|
1057
|
+
end = Math.max(end, at(index)[axis] + size);
|
|
1058
|
+
}
|
|
1059
|
+
return { start, end };
|
|
1060
|
+
};
|
|
1061
|
+
if (banded) {
|
|
1062
|
+
if (T >= 0 && style.side !== 'left') {
|
|
1063
|
+
const { start, end } = extent('x');
|
|
1064
|
+
pin('x', T, at(T).x + (alignedAt(style.side, inner('x'), end - start) - start));
|
|
1065
|
+
}
|
|
1066
|
+
if (contents.align !== 'left')
|
|
1067
|
+
pin('x', K, alignedAt(contents.align, inner('x'), block.width));
|
|
1068
|
+
}
|
|
1069
|
+
else if (T >= 0) {
|
|
1070
|
+
const sides = { x: style.side, y: style.end };
|
|
1071
|
+
// The text and its group range in the room their own row (across) or
|
|
1072
|
+
// column (down) leaves them — between whatever sits beside, above or below
|
|
1073
|
+
// them — not in the whole box. Ranged against the whole width, a
|
|
1074
|
+
// right-ranged title runs into a child in the top-right corner, pushes it
|
|
1075
|
+
// out and grows the box; centered on the whole height, a title with things
|
|
1076
|
+
// placed below it and more in the bottom corners drops into the middle.
|
|
1077
|
+
const room = (axis) => {
|
|
1078
|
+
const cross = axis === 'x' ? 'y' : 'x';
|
|
1079
|
+
const whole = inner(axis);
|
|
1080
|
+
const along = extent(axis);
|
|
1081
|
+
const beside = extent(cross);
|
|
1082
|
+
const size = (member, on) => (on === 'x' ? member.width : member.height);
|
|
1083
|
+
let start = whole.start;
|
|
1084
|
+
let end = whole.start + whole.size;
|
|
1085
|
+
members.forEach((member, index) => {
|
|
1086
|
+
if (row.includes(index) || index === TL || index === BR)
|
|
1087
|
+
return;
|
|
1088
|
+
const c0 = at(index)[cross];
|
|
1089
|
+
if (c0 + size(member, cross) <= beside.start || c0 >= beside.end)
|
|
1090
|
+
return;
|
|
1091
|
+
const a0 = at(index)[axis];
|
|
1092
|
+
if (a0 + size(member, axis) <= along.start)
|
|
1093
|
+
start = Math.max(start, a0 + size(member, axis) + CHILD_GAP);
|
|
1094
|
+
else if (a0 >= along.end)
|
|
1095
|
+
end = Math.min(end, a0 - CHILD_GAP);
|
|
1096
|
+
});
|
|
1097
|
+
return { start, size: end - start };
|
|
1098
|
+
};
|
|
1099
|
+
for (const axis of AXES) {
|
|
1100
|
+
if (sides[axis] === 'left' || sides[axis] === 'top')
|
|
1101
|
+
continue;
|
|
1102
|
+
const { start, end } = extent(axis);
|
|
1103
|
+
const want = alignedAt(sides[axis], room(axis), end - start);
|
|
1104
|
+
pin(axis, T, at(T)[axis] + (want - start));
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
if (pins.x.length > 0 || pins.y.length > 0)
|
|
1108
|
+
solved = solve(pins);
|
|
1109
|
+
const origin = at(TL);
|
|
1110
|
+
const corner = at(BR);
|
|
1111
|
+
node.inset = 0;
|
|
1112
|
+
node.width = corner.x - origin.x;
|
|
1113
|
+
node.height = corner.y - origin.y;
|
|
1114
|
+
node.banded = banded;
|
|
1115
|
+
node.headerHeight = banded && hasText ? textHeight + HEADER_GAP : 0;
|
|
1116
|
+
node.textSide = style.side;
|
|
1117
|
+
const relative = (index, width, height) => ({
|
|
1118
|
+
x: at(index).x - origin.x,
|
|
1119
|
+
y: at(index).y - origin.y,
|
|
1120
|
+
width,
|
|
1121
|
+
height,
|
|
1122
|
+
});
|
|
1123
|
+
node.textBox = T >= 0 ? relative(T, textWidth, textHeight) : { x: 0, y: 0, width: 0, height: 0 };
|
|
1124
|
+
const reach = { left: 0, top: 0, right: 0, bottom: 0 };
|
|
1125
|
+
for (const child of placed) {
|
|
1126
|
+
const position = solved.get(child);
|
|
1127
|
+
const offset = { x: position.x - origin.x, y: position.y - origin.y };
|
|
1128
|
+
local.set(child, offset);
|
|
1129
|
+
// Whatever sticks out past the frame — a child placed outside it, or
|
|
1130
|
+
// something one of the children placed outside itself.
|
|
1131
|
+
reach.left = Math.max(reach.left, child.reach.left - offset.x);
|
|
1132
|
+
reach.top = Math.max(reach.top, child.reach.top - offset.y);
|
|
1133
|
+
reach.right = Math.max(reach.right, offset.x + child.width + child.reach.right - node.width);
|
|
1134
|
+
reach.bottom = Math.max(reach.bottom, offset.y + child.height + child.reach.bottom - node.height);
|
|
1135
|
+
}
|
|
1136
|
+
node.reach = reach;
|
|
1137
|
+
if (banded) {
|
|
1138
|
+
const shift = relative(K, 0, 0);
|
|
1139
|
+
for (const child of stacked) {
|
|
1140
|
+
const offset = local.get(child);
|
|
1141
|
+
offset.x += shift.x;
|
|
1142
|
+
offset.y += shift.y;
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
341
1146
|
// --- pass three: solve for positions -----------------------------------------
|
|
342
|
-
function placeRoots(roots, byName,
|
|
1147
|
+
function placeRoots(roots, byName, edges, measurer, fontSize, local) {
|
|
343
1148
|
const anchors = roots.filter((root) => root.placements.length === 0);
|
|
344
1149
|
if (anchors.length === 0) {
|
|
345
1150
|
throw new SourceError('every node is placed relative to another, so nothing anchors the diagram', 1);
|
|
@@ -352,7 +1157,7 @@ function placeRoots(roots, byName, links, measurer, fontSize, local) {
|
|
|
352
1157
|
// A placement may name something nested — `right of server.docker` places a top-level
|
|
353
1158
|
// node against a box inside another. Sizes and offsets within a container are
|
|
354
1159
|
// already settled, so a nested target is its root's position plus a constant.
|
|
355
|
-
const positions = positionGroup(roots, (placement, owner) => placement.targets.map((name) => {
|
|
1160
|
+
const positions = positionGroup(roots, (placement, owner) => placement.targets.map(({ name, part }) => {
|
|
356
1161
|
const target = byName.get(name);
|
|
357
1162
|
if (!target) {
|
|
358
1163
|
throw new SourceError(`"${owner.name}" is placed against "${name}", which does not exist`, placement.line);
|
|
@@ -365,15 +1170,15 @@ function placeRoots(roots, byName, links, measurer, fontSize, local) {
|
|
|
365
1170
|
offset.y += step.y;
|
|
366
1171
|
root = root.parent;
|
|
367
1172
|
}
|
|
368
|
-
return {
|
|
1173
|
+
return partOf({
|
|
369
1174
|
name,
|
|
370
1175
|
node: target,
|
|
371
1176
|
index: indexOf.get(root),
|
|
372
1177
|
offset,
|
|
373
1178
|
width: target.width,
|
|
374
1179
|
height: target.height,
|
|
375
|
-
};
|
|
376
|
-
}), { x: [], y: [] }, corridorsIn(
|
|
1180
|
+
}, part, owner, placement.line);
|
|
1181
|
+
}), { x: [], y: [] }, corridorsIn(edges, (node) => liftTo(node, indexOf, local), measurer, fontSize));
|
|
377
1182
|
for (const root of roots) {
|
|
378
1183
|
const position = positions.get(root);
|
|
379
1184
|
root.x = position.x;
|
|
@@ -391,12 +1196,17 @@ function spreadToChildren(node, local) {
|
|
|
391
1196
|
}
|
|
392
1197
|
}
|
|
393
1198
|
const AXES = ['x', 'y'];
|
|
1199
|
+
const NO_REACH = { left: 0, top: 0, right: 0, bottom: 0 };
|
|
394
1200
|
const AXIS_WORD = { x: 'horizontally', y: 'vertically' };
|
|
1201
|
+
/** Which member a target's position on this axis is measured from. */
|
|
1202
|
+
function memberOn(target, axis) {
|
|
1203
|
+
return target.byAxis ? target.byAxis[axis] : target.index;
|
|
1204
|
+
}
|
|
395
1205
|
/**
|
|
396
1206
|
* Where a node sits within the group being solved: which member holds it, and
|
|
397
|
-
* where inside that member.
|
|
1207
|
+
* where inside that member. An edge may name anything at any depth, so its ends
|
|
398
1208
|
* are lifted to the members of whichever group is being solved — and a node
|
|
399
|
-
* outside that group has no answer, which is how
|
|
1209
|
+
* outside that group has no answer, which is how an edge is sorted into the one
|
|
400
1210
|
* group where its two ends are different members.
|
|
401
1211
|
*/
|
|
402
1212
|
function liftTo(node, indexOf, local) {
|
|
@@ -419,28 +1229,28 @@ function liftTo(node, indexOf, local) {
|
|
|
419
1229
|
height: node.height,
|
|
420
1230
|
};
|
|
421
1231
|
}
|
|
422
|
-
/** The
|
|
423
|
-
function corridorsIn(
|
|
1232
|
+
/** The edges with text whose two ends are different members of this group. */
|
|
1233
|
+
function corridorsIn(edges, locate, measurer, fontSize) {
|
|
424
1234
|
const corridors = [];
|
|
425
|
-
for (const
|
|
426
|
-
// An
|
|
427
|
-
//
|
|
1235
|
+
for (const edge of edges) {
|
|
1236
|
+
// An edge with no text asks for nothing: every gap holds an arrowhead. And a
|
|
1237
|
+
// edge told to pass between two named things carries its text in *that*
|
|
428
1238
|
// corridor rather than in the gap between its own ends, so widening this one
|
|
429
|
-
// would make room where the
|
|
430
|
-
if (
|
|
1239
|
+
// would make room where the text never goes.
|
|
1240
|
+
if (edge.text === undefined || edge.between)
|
|
431
1241
|
continue;
|
|
432
|
-
const from = locate(
|
|
433
|
-
const to = locate(
|
|
1242
|
+
const from = locate(edge.from);
|
|
1243
|
+
const to = locate(edge.to);
|
|
434
1244
|
if (!from || !to || from.index === to.index)
|
|
435
1245
|
continue;
|
|
436
|
-
// The clearance is doubled because the
|
|
1246
|
+
// The clearance is doubled because the text is drawn at the *midpoint* of
|
|
437
1247
|
// the line, so the room it needs is symmetric about that point whatever sits
|
|
438
1248
|
// at either end. The arrowhead is charged on both sides for the same reason:
|
|
439
1249
|
// it covers `ARROW_LENGTH` of the line it arrives on, and reserving that at
|
|
440
1250
|
// one end only would move the midpoint rather than lengthen the run.
|
|
441
|
-
const extent = (axis) =>
|
|
442
|
-
(
|
|
443
|
-
corridors.push({
|
|
1251
|
+
const extent = (axis) => textExtent(edge.lines, edge.textAttrs, axis, measurer, fontSize, edge.line) +
|
|
1252
|
+
(TEXT_CLEARANCE + ARROW_LENGTH) * 2;
|
|
1253
|
+
corridors.push({ edge, from, to, need: { x: extent('x'), y: extent('y') } });
|
|
444
1254
|
}
|
|
445
1255
|
return corridors;
|
|
446
1256
|
}
|
|
@@ -456,10 +1266,23 @@ function corridorsIn(links, locate, measurer, fontSize) {
|
|
|
456
1266
|
* hold whatever is put in it and closes again when that is removed. No number
|
|
457
1267
|
* anywhere has to be guessed, and nothing is ever tried and rejected.
|
|
458
1268
|
*/
|
|
459
|
-
function positionGroup(members, locate, extra, corridors = []) {
|
|
1269
|
+
function positionGroup(members, locate, extra, corridors = [], across, clearance = SEPARATION_GAP) {
|
|
460
1270
|
const constraints = { x: [...extra.x], y: [...extra.y] };
|
|
461
1271
|
const indexOf = new Map(members.map((member, index) => [member, index]));
|
|
462
1272
|
const pending = [];
|
|
1273
|
+
/** Pairs an overlay put on top of each other, which is the point of it. */
|
|
1274
|
+
const overlaid = [];
|
|
1275
|
+
// Several nodes saying the identical thing are one list, running down the
|
|
1276
|
+
// page in the order they were written. The first carries the placement for
|
|
1277
|
+
// the whole list; each of the rest hangs a fixed step below the one before,
|
|
1278
|
+
// and places itself across as it said.
|
|
1279
|
+
const fans = fansIn(members);
|
|
1280
|
+
for (const fan of fans.values()) {
|
|
1281
|
+
for (let at = 1; at < fan.members.length; at += 1) {
|
|
1282
|
+
const previous = fan.members[at - 1];
|
|
1283
|
+
constraints.y.push(...fix(indexOf.get(previous), indexOf.get(fan.members[at]), previous.height + CHILD_GAP));
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
463
1286
|
for (const node of members) {
|
|
464
1287
|
// Checked here as well as in `gapFor`, so a misspelt node-wide gap is caught
|
|
465
1288
|
// on a node whose placements all name their own or are alignments — and on
|
|
@@ -468,13 +1291,23 @@ function positionGroup(members, locate, extra, corridors = []) {
|
|
|
468
1291
|
if (node.placements.length === 0)
|
|
469
1292
|
continue;
|
|
470
1293
|
const me = indexOf.get(node);
|
|
471
|
-
const
|
|
1294
|
+
const fan = fans.get(node);
|
|
1295
|
+
// A later member of a fan has its vertical from the list, so its own
|
|
1296
|
+
// placements speak only across; the first speaks for the whole list.
|
|
1297
|
+
const follows = fan !== undefined && fan.members[0] !== node;
|
|
1298
|
+
const size = { x: node.width, y: fan ? fan.height : node.height };
|
|
472
1299
|
const located = node.placements.map((placement) => ({ placement, targets: locate(placement, node) }));
|
|
1300
|
+
const speaks = (axis) => !(follows && axis === 'y');
|
|
473
1301
|
const spokenFor = { x: false, y: false };
|
|
474
1302
|
for (const { placement } of located) {
|
|
475
1303
|
if (placement.kind === 'align') {
|
|
476
1304
|
spokenFor[placement.axis] = true;
|
|
477
1305
|
}
|
|
1306
|
+
else if (placement.kind === 'on') {
|
|
1307
|
+
// An overlay names a point of a box, so it settles both axes at once.
|
|
1308
|
+
spokenFor.x = true;
|
|
1309
|
+
spokenFor.y = true;
|
|
1310
|
+
}
|
|
478
1311
|
else {
|
|
479
1312
|
if (/left|right/.test(placement.direction))
|
|
480
1313
|
spokenFor.x = true;
|
|
@@ -485,20 +1318,40 @@ function positionGroup(members, locate, extra, corridors = []) {
|
|
|
485
1318
|
// Aligning to several targets means aligning to the box that just bounds
|
|
486
1319
|
// them. That box is a constant only while its members hold still relative
|
|
487
1320
|
// to one another; otherwise the alignment waits for the first solution.
|
|
488
|
-
const alignOn = (axis,
|
|
489
|
-
|
|
1321
|
+
const alignOn = (axis, side, targets, placement) => {
|
|
1322
|
+
if (!speaks(axis))
|
|
1323
|
+
return;
|
|
1324
|
+
const anchor = sharedMember(targets, axis);
|
|
490
1325
|
if (anchor === undefined) {
|
|
491
|
-
pending.push({ node, me, axis,
|
|
1326
|
+
pending.push({ node, me, axis, side, targets, placement, own: size[axis] });
|
|
492
1327
|
return;
|
|
493
1328
|
}
|
|
494
1329
|
const span = spanOf(targets, axis, () => 0);
|
|
495
|
-
constraints[axis].push(...fix(anchor, me, alignedAt(
|
|
1330
|
+
constraints[axis].push(...fix(anchor, me, alignedAt(side, span, size[axis]), placement));
|
|
496
1331
|
};
|
|
497
1332
|
for (const { placement, targets } of located) {
|
|
498
1333
|
if (placement.kind === 'align') {
|
|
499
|
-
alignOn(placement.axis, placement.
|
|
1334
|
+
alignOn(placement.axis, placement.side, targets, placement);
|
|
1335
|
+
continue;
|
|
1336
|
+
}
|
|
1337
|
+
if (placement.kind === 'on') {
|
|
1338
|
+
// The node's center at the part's center, on both axes. That is the
|
|
1339
|
+
// ordinary center alignment the language already has on one axis, said
|
|
1340
|
+
// twice — which is why `on` needs nothing of its own in the solver.
|
|
1341
|
+
// A side of a parent's frame comes as its two ends, so centering on
|
|
1342
|
+
// the part is centering on everything it came as.
|
|
1343
|
+
overlaid.push([me, targets[0].index]);
|
|
1344
|
+
for (const axis of AXES)
|
|
1345
|
+
alignOn(axis, 'center', targets, placement);
|
|
500
1346
|
continue;
|
|
501
1347
|
}
|
|
1348
|
+
// Saying `inside` is the author stating the overlap, so there is
|
|
1349
|
+
// nothing for the separation pass to report. `outside` is clear of the
|
|
1350
|
+
// box by construction and needs no exemption.
|
|
1351
|
+
if (placement.written === 'inside') {
|
|
1352
|
+
for (const target of targets)
|
|
1353
|
+
overlaid.push([me, target.index]);
|
|
1354
|
+
}
|
|
502
1355
|
// One constraint per target, so the node clears the furthest of them.
|
|
503
1356
|
// Taking that maximum is what longest paths already does, which is why a
|
|
504
1357
|
// direction against a whole region needs nothing added to the solver.
|
|
@@ -508,62 +1361,58 @@ function positionGroup(members, locate, extra, corridors = []) {
|
|
|
508
1361
|
// default. That is what lets a node wedged between two things sit tight
|
|
509
1362
|
// against one of them and wide of the other.
|
|
510
1363
|
for (const target of targets) {
|
|
511
|
-
const gap = gapFor(node, placement, target.node);
|
|
1364
|
+
const gap = target.empty ? 0 : gapFor(node, placement, target.node);
|
|
1365
|
+
// Tucked inside the frame of the box that holds it, a child is *at*
|
|
1366
|
+
// that edge rather than at least so far from it: the frame is also
|
|
1367
|
+
// held open around every child, which is a pull the other way, and
|
|
1368
|
+
// without this the child would sit wherever that left it.
|
|
1369
|
+
const exact = target.frame === true && placement.written === 'inside';
|
|
1370
|
+
const push = (axis, from, to, weight) => {
|
|
1371
|
+
if (!speaks(axis))
|
|
1372
|
+
return;
|
|
1373
|
+
constraints[axis].push({ from, to, weight, placement });
|
|
1374
|
+
if (exact)
|
|
1375
|
+
constraints[axis].push({ from: to, to: from, weight: -weight, placement });
|
|
1376
|
+
};
|
|
1377
|
+
const x = memberOn(target, 'x');
|
|
1378
|
+
const y = memberOn(target, 'y');
|
|
1379
|
+
// The gap is between what each has placed outside itself, not only
|
|
1380
|
+
// their boxes: a note beside a box is part of it.
|
|
1381
|
+
const theirs = target.reach ?? NO_REACH;
|
|
1382
|
+
const mine = node.reach;
|
|
512
1383
|
if (direction.includes('right')) {
|
|
513
|
-
|
|
514
|
-
from: target.index,
|
|
515
|
-
to: me,
|
|
516
|
-
weight: target.offset.x + target.width + gap,
|
|
517
|
-
placement,
|
|
518
|
-
});
|
|
519
|
-
}
|
|
520
|
-
if (direction.includes('left')) {
|
|
521
|
-
constraints.x.push({
|
|
522
|
-
from: me,
|
|
523
|
-
to: target.index,
|
|
524
|
-
weight: node.width + gap - target.offset.x,
|
|
525
|
-
placement,
|
|
526
|
-
});
|
|
1384
|
+
push('x', x, me, target.offset.x + target.width + theirs.right + gap + mine.left);
|
|
527
1385
|
}
|
|
1386
|
+
if (direction.includes('left'))
|
|
1387
|
+
push('x', me, x, size.x + mine.right + gap + theirs.left - target.offset.x);
|
|
528
1388
|
if (direction.includes('below')) {
|
|
529
|
-
|
|
530
|
-
from: target.index,
|
|
531
|
-
to: me,
|
|
532
|
-
weight: target.offset.y + target.height + gap,
|
|
533
|
-
placement,
|
|
534
|
-
});
|
|
535
|
-
}
|
|
536
|
-
if (direction.includes('above')) {
|
|
537
|
-
constraints.y.push({
|
|
538
|
-
from: me,
|
|
539
|
-
to: target.index,
|
|
540
|
-
weight: node.height + gap - target.offset.y,
|
|
541
|
-
placement,
|
|
542
|
-
});
|
|
1389
|
+
push('y', y, me, target.offset.y + target.height + theirs.bottom + gap + mine.top);
|
|
543
1390
|
}
|
|
1391
|
+
if (direction.includes('above'))
|
|
1392
|
+
push('y', me, y, size.y + mine.bottom + gap + theirs.top - target.offset.y);
|
|
544
1393
|
}
|
|
545
1394
|
}
|
|
546
|
-
// An axis nobody spoke to falls back to the
|
|
1395
|
+
// An axis nobody spoke to falls back to the center line of whatever the
|
|
547
1396
|
// node was placed against, which is why "right of docker" alone is a whole
|
|
548
1397
|
// position. Two different targets would decide which row the node shares,
|
|
549
1398
|
// so that is refused rather than guessed — but two targets named by one
|
|
550
|
-
// placement are a single region, and
|
|
1399
|
+
// placement are a single region, and centering on it is unambiguous.
|
|
551
1400
|
for (const axis of AXES) {
|
|
552
|
-
if (spokenFor[axis])
|
|
1401
|
+
if (spokenFor[axis] || !speaks(axis))
|
|
553
1402
|
continue;
|
|
554
1403
|
const offers = located.filter((entry) => entry.placement.kind === 'offset');
|
|
555
1404
|
const first = offers[0];
|
|
556
1405
|
if (!first) {
|
|
557
1406
|
throw new SourceError(`"${node.name}" says nothing about where it sits ${AXIS_WORD[axis]}`, node.placements[0].line);
|
|
558
1407
|
}
|
|
559
|
-
const named = (entry) => entry.placement.targets.join('\u0000');
|
|
1408
|
+
const named = (entry) => entry.placement.targets.map(nameTarget).join('\u0000');
|
|
560
1409
|
const other = offers.find((entry) => named(entry) !== named(first));
|
|
561
1410
|
if (other) {
|
|
562
1411
|
throw new SourceError(`"${node.name}" does not say where it sits ${AXIS_WORD[axis]}: ` +
|
|
563
1412
|
`"${describePlacement(first.placement)}" and "${describePlacement(other.placement)}" ` +
|
|
564
1413
|
`would put it in different places`, other.placement.line);
|
|
565
1414
|
}
|
|
566
|
-
alignOn(axis, '
|
|
1415
|
+
alignOn(axis, 'center', first.targets, first.placement);
|
|
567
1416
|
}
|
|
568
1417
|
}
|
|
569
1418
|
const solved = { x: [], y: [] };
|
|
@@ -575,21 +1424,78 @@ function positionGroup(members, locate, extra, corridors = []) {
|
|
|
575
1424
|
solved[axis] = outcome.positions;
|
|
576
1425
|
}
|
|
577
1426
|
};
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
1427
|
+
// An edge's text is given room only once everything is where it goes: a
|
|
1428
|
+
// box still waiting to be centered, or still on top of another, is not yet
|
|
1429
|
+
// where it will be, and room judged against it goes in the wrong gap. Room
|
|
1430
|
+
// can move things in turn, so the centering and pulling-in are measured
|
|
1431
|
+
// again from what the file said, and the texts looked at again, until a
|
|
1432
|
+
// look adds nothing. What was placed, separated or widened stays.
|
|
1433
|
+
//
|
|
1434
|
+
// It stops: each text can be given room once across and once down, room
|
|
1435
|
+
// is never taken back, and a look that gives none ends it.
|
|
1436
|
+
const lasting = { x: [...constraints.x], y: [...constraints.y] };
|
|
1437
|
+
const made = new Set();
|
|
1438
|
+
const keep = (step) => {
|
|
1439
|
+
const before = { x: constraints.x.length, y: constraints.y.length };
|
|
1440
|
+
const changed = step();
|
|
1441
|
+
for (const axis of AXES)
|
|
1442
|
+
lasting[axis].push(...constraints[axis].slice(before[axis]));
|
|
1443
|
+
return changed === true;
|
|
1444
|
+
};
|
|
1445
|
+
for (;;) {
|
|
1446
|
+
constraints.x = [...lasting.x];
|
|
1447
|
+
constraints.y = [...lasting.y];
|
|
1448
|
+
solveAll();
|
|
1449
|
+
settle(pending, members, constraints, solved, solveAll);
|
|
1450
|
+
snug(members, constraints, solved, solveAll);
|
|
1451
|
+
keep(() => separate(members, constraints, solved, solveAll, overlaid, across, clearance));
|
|
1452
|
+
if (!keep(() => room(corridors, constraints, solved, solveAll, made)))
|
|
1453
|
+
break;
|
|
1454
|
+
}
|
|
583
1455
|
confirm(pending, solved);
|
|
584
1456
|
return new Map(members.map((member, index) => [
|
|
585
1457
|
member,
|
|
586
1458
|
{ x: solved.x[index], y: solved.y[index] },
|
|
587
1459
|
]));
|
|
588
1460
|
}
|
|
589
|
-
/** The member every target belongs to, or nothing if they are spread across several. */
|
|
590
|
-
function sharedMember(targets) {
|
|
591
|
-
const first = targets[0]
|
|
592
|
-
return targets.every((target) => target
|
|
1461
|
+
/** The member every target belongs to on this axis, or nothing if they are spread across several. */
|
|
1462
|
+
function sharedMember(targets, axis) {
|
|
1463
|
+
const first = memberOn(targets[0], axis);
|
|
1464
|
+
return targets.every((target) => memberOn(target, axis) === first) ? first : undefined;
|
|
1465
|
+
}
|
|
1466
|
+
/**
|
|
1467
|
+
* The fans in a group: two or more members whose placements say the identical
|
|
1468
|
+
* thing, each member mapped to the one fan it is in.
|
|
1469
|
+
*
|
|
1470
|
+
* `b right of a`, `c right of a` and `d right of a` otherwise put three boxes
|
|
1471
|
+
* on one spot, and what they mean is "a points at three things" — the three
|
|
1472
|
+
* balanced against `a`, which no chain of placements can say. The same holds
|
|
1473
|
+
* of a part: three children `inside parent right` are a column against that
|
|
1474
|
+
* edge. The list runs down the page whatever the direction, including at a
|
|
1475
|
+
* corner, where it stacks into the corner and grows down.
|
|
1476
|
+
*
|
|
1477
|
+
* A node saying `overlap: allow` has asked for the literal pile, and gets it.
|
|
1478
|
+
*/
|
|
1479
|
+
function fansIn(members) {
|
|
1480
|
+
const bySaying = new Map();
|
|
1481
|
+
for (const node of members) {
|
|
1482
|
+
if (node.placements.length === 0 || allowsOverlap(node))
|
|
1483
|
+
continue;
|
|
1484
|
+
const saying = node.placements.map(describePlacement).join('\n');
|
|
1485
|
+
const list = bySaying.get(saying) ?? [];
|
|
1486
|
+
list.push(node);
|
|
1487
|
+
bySaying.set(saying, list);
|
|
1488
|
+
}
|
|
1489
|
+
const fans = new Map();
|
|
1490
|
+
for (const list of bySaying.values()) {
|
|
1491
|
+
if (list.length < 2)
|
|
1492
|
+
continue;
|
|
1493
|
+
const height = list.reduce((sum, node) => sum + node.height, 0) + CHILD_GAP * (list.length - 1);
|
|
1494
|
+
const fan = { members: list, height };
|
|
1495
|
+
for (const node of list)
|
|
1496
|
+
fans.set(node, fan);
|
|
1497
|
+
}
|
|
1498
|
+
return fans;
|
|
593
1499
|
}
|
|
594
1500
|
/** The stretch of one axis that just covers every target. */
|
|
595
1501
|
function spanOf(targets, axis, base) {
|
|
@@ -602,40 +1508,91 @@ function spanOf(targets, axis, base) {
|
|
|
602
1508
|
}
|
|
603
1509
|
return { start, size: end - start };
|
|
604
1510
|
}
|
|
605
|
-
/**
|
|
606
|
-
|
|
607
|
-
|
|
1511
|
+
/**
|
|
1512
|
+
* Narrow a target to the part of it the placement named — its text, one of its
|
|
1513
|
+
* four sides, or one of its nine points.
|
|
1514
|
+
*
|
|
1515
|
+
* A side comes back as a segment of zero thickness and a point as a rectangle
|
|
1516
|
+
* of no size at all, which is the whole of what a part is to the solver: an
|
|
1517
|
+
* extent, exactly as a whole box is, just a thinner one. Everything else — the
|
|
1518
|
+
* direction, the gap, the alignment on the axis nobody spoke to — then works
|
|
1519
|
+
* on it unchanged, which is why a part target needed nothing added to the
|
|
1520
|
+
* constraint system.
|
|
1521
|
+
*
|
|
1522
|
+
* Each position is read as two independent halves, one per axis, which is why
|
|
1523
|
+
* nine words need no table of nine entries: `top-right` is "right" across and
|
|
1524
|
+
* "top" down, `top-center` is "top" down and centered across.
|
|
1525
|
+
*/
|
|
1526
|
+
function partOf(target, part, owner, line) {
|
|
1527
|
+
// The whole node is what it has placed outside itself too; a part of it is
|
|
1528
|
+
// just that part.
|
|
1529
|
+
if (part === undefined)
|
|
1530
|
+
return { ...target, reach: target.node.reach };
|
|
1531
|
+
const box = { ...target, offset: { ...target.offset } };
|
|
1532
|
+
if (part === 'text') {
|
|
1533
|
+
const text = target.node.textBox;
|
|
1534
|
+
if (text.width === 0 && text.height === 0) {
|
|
1535
|
+
throw new SourceError(`"${owner.name}" is placed against "${target.name} text", and "${target.name}" has no text`, line);
|
|
1536
|
+
}
|
|
1537
|
+
box.offset.x += text.x;
|
|
1538
|
+
box.offset.y += text.y;
|
|
1539
|
+
box.width = text.width;
|
|
1540
|
+
box.height = text.height;
|
|
1541
|
+
return box;
|
|
1542
|
+
}
|
|
1543
|
+
const words = part.split('-');
|
|
1544
|
+
const isSide = PART_SIDES.includes(part);
|
|
1545
|
+
const narrow = (axis, near, far) => {
|
|
1546
|
+
const size = axis === 'x' ? box.width : box.height;
|
|
1547
|
+
const named = words.includes(near) ? 0 : words.includes(far) ? size : undefined;
|
|
1548
|
+
// A *side* leaves the axis it does not name alone: `right` is the whole
|
|
1549
|
+
// right edge, top to bottom, where `right-center` is the one point on it.
|
|
1550
|
+
if (named === undefined && isSide)
|
|
1551
|
+
return;
|
|
1552
|
+
box.offset[axis] += named ?? size / 2;
|
|
1553
|
+
if (axis === 'x')
|
|
1554
|
+
box.width = 0;
|
|
1555
|
+
else
|
|
1556
|
+
box.height = 0;
|
|
1557
|
+
};
|
|
1558
|
+
narrow('x', 'left', 'right');
|
|
1559
|
+
narrow('y', 'top', 'bottom');
|
|
1560
|
+
return box;
|
|
1561
|
+
}
|
|
1562
|
+
/** Where a node of this size sits so that the named side of it meets the span's. */
|
|
1563
|
+
function alignedAt(side, span, own) {
|
|
1564
|
+
if (side === 'center')
|
|
608
1565
|
return span.start + (span.size - own) / 2;
|
|
609
|
-
if (
|
|
1566
|
+
if (side === 'top' || side === 'left')
|
|
610
1567
|
return span.start;
|
|
611
1568
|
return span.start + span.size - own;
|
|
612
1569
|
}
|
|
613
1570
|
/**
|
|
614
|
-
* Widen a corridor to hold the
|
|
1571
|
+
* Widen a corridor to hold the text of the edge crossing it.
|
|
615
1572
|
*
|
|
616
|
-
* This is the one place
|
|
617
|
-
* measure-then-constrain move `settle` makes rather than
|
|
618
|
-
* outright: the first solution says which gap each
|
|
1573
|
+
* This is the one place an edge reaches the constraint system, and it is the same
|
|
1574
|
+
* measure-then-constrain move `settle` makes rather than edges joining the graph
|
|
1575
|
+
* outright: the first solution says which gap each text actually falls in, and
|
|
619
1576
|
* from there the room it needs is an ordinary minimum distance like any other.
|
|
620
1577
|
* Nothing is nudged and no layout is repaired — a constraint the file already
|
|
621
1578
|
* implied is derived and the whole system is solved again.
|
|
622
1579
|
*
|
|
623
1580
|
* Which gap that is, is derived and never chosen. A pair clear of each other on
|
|
624
|
-
* exactly one axis has exactly one corridor between them, and the
|
|
1581
|
+
* exactly one axis has exactly one corridor between them, and the text is in
|
|
625
1582
|
* it. A pair clear on *both* axes sits corner to corner, so the line runs
|
|
626
1583
|
* diagonally through open space and there is no corridor to widen; a pair clear
|
|
627
1584
|
* on neither overlaps, which is the separation pass's business and not this
|
|
628
1585
|
* one's. Both are left alone, which is why this only ever moves boxes that a
|
|
629
|
-
*
|
|
1586
|
+
* text is genuinely wedged between.
|
|
630
1587
|
*
|
|
631
1588
|
* A gap being a minimum does the rest. Where the corridor is already wide enough
|
|
632
1589
|
* — because the author said `gap: wide`, or because something else is in there
|
|
633
|
-
* — the constraint is slack and nothing moves; delete the
|
|
1590
|
+
* — the constraint is slack and nothing moves; delete the text and the corridor
|
|
634
1591
|
* closes back to whatever the file asked for.
|
|
635
1592
|
*/
|
|
636
|
-
function room(corridors, constraints, solved, solveAll) {
|
|
1593
|
+
function room(corridors, constraints, solved, solveAll, made) {
|
|
637
1594
|
let added = false;
|
|
638
|
-
for (const { from, to, need } of corridors) {
|
|
1595
|
+
for (const [index, { from, to, need }] of corridors.entries()) {
|
|
639
1596
|
const clear = (axis) => {
|
|
640
1597
|
const at = (end) => solved[axis][end.index] + end.offset[axis];
|
|
641
1598
|
const size = (end) => (axis === 'x' ? end.width : end.height);
|
|
@@ -649,6 +1606,11 @@ function room(corridors, constraints, solved, solveAll) {
|
|
|
649
1606
|
if (open.length !== 1)
|
|
650
1607
|
continue;
|
|
651
1608
|
const axis = open[0];
|
|
1609
|
+
// Given already, and a minimum stays met: asking again would widen
|
|
1610
|
+
// nothing and only keep the caller looking.
|
|
1611
|
+
if (made.has(`${index}:${axis}`))
|
|
1612
|
+
continue;
|
|
1613
|
+
made.add(`${index}:${axis}`);
|
|
652
1614
|
const { before, after } = clear(axis);
|
|
653
1615
|
constraints[axis].push({
|
|
654
1616
|
from: before.index,
|
|
@@ -662,6 +1624,7 @@ function room(corridors, constraints, solved, solveAll) {
|
|
|
662
1624
|
}
|
|
663
1625
|
if (added)
|
|
664
1626
|
solveAll();
|
|
1627
|
+
return added;
|
|
665
1628
|
}
|
|
666
1629
|
/**
|
|
667
1630
|
* Fix the alignments that had to wait, by measuring what they align to.
|
|
@@ -687,16 +1650,19 @@ function settle(pending, members, constraints, solved, solveAll) {
|
|
|
687
1650
|
const reach = reachability(members.length, constraints[axis]);
|
|
688
1651
|
for (const entry of here) {
|
|
689
1652
|
for (const target of entry.targets) {
|
|
690
|
-
if (target.
|
|
1653
|
+
if (target.frame)
|
|
1654
|
+
continue;
|
|
1655
|
+
const at = memberOn(target, axis);
|
|
1656
|
+
if (at !== entry.me && !reach[entry.me][at])
|
|
691
1657
|
continue;
|
|
692
1658
|
throw new SourceError(`"${entry.node.name}" is ${describePlacement(entry.placement)}, but "${target.name}" ` +
|
|
693
1659
|
`is placed ${AXIS_WORD[axis]} against "${entry.node.name}" in turn, so there is no ` +
|
|
694
1660
|
`arrangement where each waits for the other`, entry.placement.line);
|
|
695
1661
|
}
|
|
696
|
-
const span = spanOf(entry.targets, axis, (target) => solved[axis][target
|
|
697
|
-
const own = axis === 'x' ? entry.node.width : entry.node.height;
|
|
698
|
-
const anchor = entry.targets[0]
|
|
699
|
-
constraints[axis].push(...fix(anchor, entry.me, alignedAt(entry.
|
|
1662
|
+
const span = spanOf(entry.targets, axis, (target) => solved[axis][memberOn(target, axis)]);
|
|
1663
|
+
const own = entry.own ?? (axis === 'x' ? entry.node.width : entry.node.height);
|
|
1664
|
+
const anchor = memberOn(entry.targets[0], axis);
|
|
1665
|
+
constraints[axis].push(...fix(anchor, entry.me, alignedAt(entry.side, span, own) - solved[axis][anchor], entry.placement));
|
|
700
1666
|
}
|
|
701
1667
|
}
|
|
702
1668
|
solveAll();
|
|
@@ -713,9 +1679,9 @@ function settle(pending, members, constraints, solved, solveAll) {
|
|
|
713
1679
|
function confirm(pending, solved) {
|
|
714
1680
|
for (const entry of pending) {
|
|
715
1681
|
const { axis } = entry;
|
|
716
|
-
const span = spanOf(entry.targets, axis, (target) => solved[axis][target
|
|
717
|
-
const own = axis === 'x' ? entry.node.width : entry.node.height;
|
|
718
|
-
if (Math.abs(alignedAt(entry.
|
|
1682
|
+
const span = spanOf(entry.targets, axis, (target) => solved[axis][memberOn(target, axis)]);
|
|
1683
|
+
const own = entry.own ?? (axis === 'x' ? entry.node.width : entry.node.height);
|
|
1684
|
+
if (Math.abs(alignedAt(entry.side, span, own) - solved[axis][entry.me]) <= 0.5)
|
|
719
1685
|
continue;
|
|
720
1686
|
throw new SourceError(`"${entry.node.name}" cannot be ${describePlacement(entry.placement)}: keeping boxes off ` +
|
|
721
1687
|
`each other moved them apart after that region was measured`, entry.placement.line);
|
|
@@ -810,21 +1776,31 @@ function snug(members, constraints, solved, solveAll) {
|
|
|
810
1776
|
* Members here are always siblings, or the roots of the diagram, so no member
|
|
811
1777
|
* ever contains another and containment needs no exemption of its own.
|
|
812
1778
|
*/
|
|
813
|
-
function separate(members, constraints, solved, solveAll) {
|
|
1779
|
+
function separate(members, constraints, solved, solveAll, overlaid = [], across, clearance = SEPARATION_GAP) {
|
|
814
1780
|
const eligible = members.map(allowsOverlap).map((allowed) => !allowed);
|
|
815
1781
|
if (eligible.filter(Boolean).length < 2)
|
|
816
1782
|
return;
|
|
1783
|
+
// An overlay and the box it is on overlap by construction, so that one pair
|
|
1784
|
+
// is exempt while both remain ordinary boxes to everything else. This is a
|
|
1785
|
+
// pair exemption rather than `overlap: allow` on the node for exactly that
|
|
1786
|
+
// reason: a badge sitting on its box says nothing about the box next door.
|
|
1787
|
+
const stamped = new Set(overlaid.map(([a, b]) => pairKey(a, b)));
|
|
817
1788
|
// Adding only, so the number of separations is bounded; the cap is a
|
|
818
1789
|
// backstop against a bug rather than an expected outcome.
|
|
819
1790
|
for (let round = 0; round < members.length * members.length + 1; round += 1) {
|
|
820
1791
|
let reach;
|
|
821
1792
|
let added = false;
|
|
822
|
-
|
|
1793
|
+
// One separation per round, then solve again: a pair that collided only
|
|
1794
|
+
// because another pair had not yet been moved apart is not a collision,
|
|
1795
|
+
// and separating it anyway leaves an ordering that later moves drag along.
|
|
1796
|
+
scan: for (let i = 0; i < members.length; i += 1) {
|
|
823
1797
|
if (!eligible[i])
|
|
824
1798
|
continue;
|
|
825
1799
|
for (let j = i + 1; j < members.length; j += 1) {
|
|
826
1800
|
if (!eligible[j])
|
|
827
1801
|
continue;
|
|
1802
|
+
if (stamped.has(pairKey(i, j)))
|
|
1803
|
+
continue;
|
|
828
1804
|
const over = overlapOf(members, solved, i, j);
|
|
829
1805
|
if (!over)
|
|
830
1806
|
continue;
|
|
@@ -835,14 +1811,22 @@ function separate(members, constraints, solved, solveAll) {
|
|
|
835
1811
|
if (order)
|
|
836
1812
|
orders[axis] = order;
|
|
837
1813
|
}
|
|
838
|
-
|
|
1814
|
+
// A child against a side of its parent's frame has said which way it
|
|
1815
|
+
// stands from the rest of the box — `inside p right` is beside it —
|
|
1816
|
+
// so where that way is open, it is the way, and the smaller overlap
|
|
1817
|
+
// is not asked.
|
|
1818
|
+
const named = across?.(i, j);
|
|
1819
|
+
const axis = named !== undefined && orders[named] ? named : pickAxis(orders, over);
|
|
839
1820
|
if (!axis)
|
|
840
1821
|
throw unordered(members[i], members[j]);
|
|
841
1822
|
const { before, after } = orders[axis];
|
|
842
1823
|
const span = axis === 'x' ? members[before].width : members[before].height;
|
|
843
|
-
|
|
1824
|
+
const [near, far] = axis === 'x' ? ['left', 'right'] : ['top', 'bottom'];
|
|
1825
|
+
const past = members[before].reach[far] + members[after].reach[near];
|
|
1826
|
+
constraints[axis].push({ from: before, to: after, weight: span + past + clearance });
|
|
844
1827
|
reach = undefined;
|
|
845
1828
|
added = true;
|
|
1829
|
+
break scan;
|
|
846
1830
|
}
|
|
847
1831
|
}
|
|
848
1832
|
if (!added)
|
|
@@ -850,13 +1834,16 @@ function separate(members, constraints, solved, solveAll) {
|
|
|
850
1834
|
solveAll();
|
|
851
1835
|
}
|
|
852
1836
|
}
|
|
1837
|
+
function pairKey(a, b) {
|
|
1838
|
+
return a < b ? `${a}:${b}` : `${b}:${a}`;
|
|
1839
|
+
}
|
|
853
1840
|
/** How far two members share space on each axis, or nothing if they are clear of each other. */
|
|
854
1841
|
function overlapOf(members, solved, i, j) {
|
|
855
1842
|
const shared = (axis) => {
|
|
856
|
-
const
|
|
857
|
-
const
|
|
858
|
-
const
|
|
859
|
-
return Math.min(
|
|
1843
|
+
const [near, far] = axis === 'x' ? ['left', 'right'] : ['top', 'bottom'];
|
|
1844
|
+
const start = (index) => solved[axis][index] - members[index].reach[near];
|
|
1845
|
+
const end = (index) => solved[axis][index] + (axis === 'x' ? members[index].width : members[index].height) + members[index].reach[far];
|
|
1846
|
+
return Math.min(end(i), end(j)) - Math.max(start(i), start(j));
|
|
860
1847
|
};
|
|
861
1848
|
const x = shared('x');
|
|
862
1849
|
const y = shared('y');
|
|
@@ -919,8 +1906,26 @@ function noRoom(contradiction, axis, members) {
|
|
|
919
1906
|
function gapFor(node, placement, target) {
|
|
920
1907
|
if (placement.gap !== undefined)
|
|
921
1908
|
return namedGap(node, placement.gap, placement.line);
|
|
1909
|
+
// Against the box that holds it, a child is spaced as that box spaces what it
|
|
1910
|
+
// holds: tucked in by the padding, and deaf to the box's own `gap:`, which
|
|
1911
|
+
// says how that box stands off its neighbours and not how it holds things.
|
|
1912
|
+
const own = target.children.includes(node);
|
|
1913
|
+
if (own && placement.written === 'inside')
|
|
1914
|
+
return PAD;
|
|
1915
|
+
// Beside or below the text of the box holding it, as the contents sit below
|
|
1916
|
+
// a title: a band said by placing things below the text is the band the
|
|
1917
|
+
// contents would have made.
|
|
1918
|
+
if (own && placement.targets.some((each) => each.name === target.name && each.part === 'text'))
|
|
1919
|
+
return CHILD_GAP;
|
|
1920
|
+
// An `inside` placement is an inset rather than a standoff, and the two want
|
|
1921
|
+
// different defaults: "beside that box" reads as room to breathe, "tucked in
|
|
1922
|
+
// that corner" reads as close to it. A node-wide `gap:`, which says how this
|
|
1923
|
+
// node stands off its *neighbours*, has no business setting an inset either
|
|
1924
|
+
// — so `inside` takes `tight` and stops there unless the placement says.
|
|
1925
|
+
if (placement.written === 'inside')
|
|
1926
|
+
return namedGap(node, 'tight', placement.line);
|
|
922
1927
|
const mine = node.attrs['gap'];
|
|
923
|
-
const theirs = target.attrs['gap'];
|
|
1928
|
+
const theirs = own ? undefined : target.attrs['gap'];
|
|
924
1929
|
// Only a gap somebody actually wrote down counts. Reading an absent one as the
|
|
925
1930
|
// default would make it a floor rather than a fallback, and every `gap: tight`
|
|
926
1931
|
// placed against a silent node would quietly widen back to normal.
|
|
@@ -933,59 +1938,92 @@ function gapFor(node, placement, target) {
|
|
|
933
1938
|
return namedGap(node, undefined, node.line);
|
|
934
1939
|
return Math.max(...stated);
|
|
935
1940
|
}
|
|
1941
|
+
/**
|
|
1942
|
+
* A gap is one of the named steps or a plain number of pixels. The names are
|
|
1943
|
+
* the default because retuning `tight` moves every tight gap together, but a
|
|
1944
|
+
* number is no less relative: it is still a minimum distance from the target,
|
|
1945
|
+
* and nothing unrelated moving can make it wrong.
|
|
1946
|
+
*/
|
|
936
1947
|
function namedGap(node, named, line) {
|
|
937
1948
|
const gap = GAPS[named ?? 'normal'];
|
|
938
|
-
if (gap
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
1949
|
+
if (gap !== undefined)
|
|
1950
|
+
return gap;
|
|
1951
|
+
if (named !== undefined && /^\d+(\.\d+)?$/.test(named))
|
|
1952
|
+
return Number(named);
|
|
1953
|
+
const known = Object.keys(GAPS).join(', ');
|
|
1954
|
+
const unit = named?.match(/^(\d+(?:\.\d+)?)px$/);
|
|
1955
|
+
const hint = unit
|
|
1956
|
+
? `; write "gap: ${unit[1]}", a gap's number is already in pixels`
|
|
1957
|
+
: named?.startsWith('-')
|
|
1958
|
+
? '; a gap is a distance and cannot be negative, and "overlap: allow" is what lets two boxes meet'
|
|
1959
|
+
: '';
|
|
1960
|
+
throw new SourceError(`"${node.name}" asks for gap: ${named}, which is not one of ${known} or a number of pixels${hint}`, line);
|
|
943
1961
|
}
|
|
944
1962
|
// --- shared helpers ----------------------------------------------------------
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
1963
|
+
/** The keys a bracketed attribute contributed, with its prefix taken off. */
|
|
1964
|
+
function bracketOf(key, attrs) {
|
|
1965
|
+
const found = {};
|
|
1966
|
+
for (const [written, value] of Object.entries(attrs)) {
|
|
1967
|
+
if (written.startsWith(`${key}.`))
|
|
1968
|
+
found[written.slice(key.length + 1)] = value;
|
|
1969
|
+
}
|
|
1970
|
+
return found;
|
|
1971
|
+
}
|
|
1972
|
+
/**
|
|
1973
|
+
* Every style the markup in this file names, resolved to the color it lends.
|
|
1974
|
+
*
|
|
1975
|
+
* A style is the only thing markup may name — never a color — so that a marked
|
|
1976
|
+
* word borrows a meaning the file already has instead of restating a value that
|
|
1977
|
+
* goes stale the day the thing it means is recolored. Both ways that can fail
|
|
1978
|
+
* are refused by name: a style nobody declared, and one that says nothing about
|
|
1979
|
+
* text and so would lend nothing.
|
|
1980
|
+
*/
|
|
1981
|
+
function markupColors(nodes, edges, styles) {
|
|
1982
|
+
const colors = {};
|
|
1983
|
+
const used = [
|
|
1984
|
+
...nodes.map((node) => ({ lines: node.lines, what: `"${node.name}"`, line: node.line })),
|
|
1985
|
+
...edges.flatMap((edge) => edge.lines ? [{ lines: edge.lines, what: `edge ${edge.from.name} -> ${edge.to.name}`, line: edge.line }] : []),
|
|
1986
|
+
];
|
|
1987
|
+
for (const { lines, what, line } of used) {
|
|
1988
|
+
for (const name of markupStyles(lines)) {
|
|
1989
|
+
const style = styles.get(name);
|
|
1990
|
+
if (style === undefined) {
|
|
1991
|
+
throw new SourceError(`${what}: its text marks [${name}], and there is no style called "${name}"`, line);
|
|
1992
|
+
}
|
|
1993
|
+
const color = style['text.color'];
|
|
1994
|
+
if (color === undefined) {
|
|
1995
|
+
throw new SourceError(`${what}: its text marks [${name}], and style "${name}" says nothing about text — ` +
|
|
1996
|
+
`write \`style ${name} text: (color: …)\``, line);
|
|
1997
|
+
}
|
|
1998
|
+
colors[name] = color;
|
|
1999
|
+
}
|
|
2000
|
+
}
|
|
2001
|
+
return colors;
|
|
950
2002
|
}
|
|
951
2003
|
/**
|
|
952
|
-
* Split a
|
|
953
|
-
* `
|
|
954
|
-
*
|
|
2004
|
+
* Split a text into the lines that get drawn. ` / ` always breaks a line, and
|
|
2005
|
+
* `(wrap: n)` additionally folds each of those at word boundaries, which is
|
|
2006
|
+
* what stops a long note running across the whole diagram.
|
|
955
2007
|
*
|
|
956
|
-
* The
|
|
2008
|
+
* The wrap is a character count rather than a distance. It says how much text
|
|
957
2009
|
* fits on a line, not where anything sits, so it stays a property of the text
|
|
958
2010
|
* and never becomes a coordinate in disguise.
|
|
2011
|
+
*
|
|
2012
|
+
* Markup is read off first, so everything downstream works in runs: a break or
|
|
2013
|
+
* a fold inside a marked-up stretch carries the mark onto both lines, which is
|
|
2014
|
+
* what makes markup general where the `subtext:` slice it replaced could only
|
|
2015
|
+
* ever reach the tail of a text.
|
|
959
2016
|
*/
|
|
960
|
-
function linesFor(text,
|
|
961
|
-
const
|
|
2017
|
+
function linesFor(text, textAttrs, subject, line) {
|
|
2018
|
+
const lines = splitRuns(parseMarkup(text, subject, line));
|
|
2019
|
+
const stated = textAttrs['wrap'];
|
|
962
2020
|
if (stated === undefined)
|
|
963
|
-
return
|
|
2021
|
+
return lines;
|
|
964
2022
|
const columns = Number(stated);
|
|
965
2023
|
if (!Number.isInteger(columns) || columns < 1) {
|
|
966
|
-
throw new SourceError(`
|
|
967
|
-
}
|
|
968
|
-
return splitLines(text).flatMap((part) => wrap(part, columns));
|
|
969
|
-
}
|
|
970
|
-
/** Fold one line onto several at word boundaries, never exceeding `columns`. */
|
|
971
|
-
function wrap(text, columns) {
|
|
972
|
-
const lines = [];
|
|
973
|
-
let current = '';
|
|
974
|
-
for (const word of text.split(/\s+/).filter(Boolean)) {
|
|
975
|
-
if (current.length === 0) {
|
|
976
|
-
current = word;
|
|
977
|
-
}
|
|
978
|
-
else if (current.length + 1 + word.length <= columns) {
|
|
979
|
-
current += ` ${word}`;
|
|
980
|
-
}
|
|
981
|
-
else {
|
|
982
|
-
lines.push(current);
|
|
983
|
-
current = word;
|
|
984
|
-
}
|
|
2024
|
+
throw new SourceError(`wrap must be a whole number of characters, not "${stated}"`, line);
|
|
985
2025
|
}
|
|
986
|
-
|
|
987
|
-
lines.push(current);
|
|
988
|
-
return lines.length > 0 ? lines : [''];
|
|
2026
|
+
return lines.flatMap((part) => wrapLine(part, columns));
|
|
989
2027
|
}
|
|
990
2028
|
function bounds(nodes) {
|
|
991
2029
|
let minX = Infinity;
|