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