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