reladraw 0.15.1 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -7
- package/SYNTAX.md +818 -680
- package/dist/ast.d.ts +342 -18
- package/dist/ast.js +138 -27
- package/dist/checks.d.ts +58 -0
- package/dist/checks.js +891 -0
- package/dist/cli.js +88 -12
- package/dist/constants.d.ts +15 -55
- package/dist/constants.js +30 -78
- package/dist/grammar.d.ts +2 -2
- package/dist/grammar.js +70 -10
- package/dist/index.d.ts +20 -0
- package/dist/index.js +18 -1
- package/dist/lexer.d.ts +12 -0
- package/dist/lexer.js +34 -0
- package/dist/lines.d.ts +96 -0
- package/dist/lines.js +1968 -0
- package/dist/linetexts.d.ts +44 -0
- package/dist/linetexts.js +412 -0
- package/dist/model.d.ts +187 -36
- package/dist/model.js +23 -1
- package/dist/parser.js +1811 -107
- package/dist/place.d.ts +501 -0
- package/dist/place.js +2226 -0
- package/dist/render.d.ts +3 -1
- package/dist/render.js +691 -2493
- package/dist/resolve.d.ts +1 -1
- package/dist/resolve.js +1195 -1789
- package/dist/size.d.ts +64 -0
- package/dist/size.js +222 -0
- package/dist/themes.d.ts +17 -0
- package/dist/themes.js +28 -3
- package/package.json +2 -2
- package/dist/constrain.d.ts +0 -56
- package/dist/constrain.js +0 -95
package/dist/resolve.js
CHANGED
|
@@ -1,10 +1,258 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { TEXT_GAP } from './linetexts.js';
|
|
2
|
+
import { POINT_KEYS, REGION_KEYS, ALL_ATTR_KEYS, ATTR_KEYS, MARKS, COLOR_KEYS, COLOR_PARTS, CHILD_ALIGNMENTS, GATHER_PLACES, CONTENT_WIDTHS, SIZE_KEY_NAMES, LINE_VALUES, NODE_CORNERS, PADDING_SIDES, describePlacement, nameTarget, partWordHint, } from './ast.js';
|
|
3
|
+
import { DEFAULT_FONT_SIZE, DEFAULT_MARGIN, GAPS, ICON_GAP, ICON_LINES, LINE_WIDTH, PAD, THICKNESS, fontSizeFor, widestLine, } from './constants.js';
|
|
4
4
|
import { SourceError } from './errors.js';
|
|
5
5
|
import { bodyFor, pastedIcon } from './icons.js';
|
|
6
6
|
import { monospaceMeasurer } from './measure.js';
|
|
7
7
|
import { markupStyles, parseMarkup, plain, splitRuns, wrapLine } from './text.js';
|
|
8
|
+
import { joinedToText } from './model.js';
|
|
9
|
+
import { checkOpacity, gapPixels, Placer, regionTextAt } from './place.js';
|
|
10
|
+
/**
|
|
11
|
+
* What a node's own contents make of it, before anything is placed: a leaf's
|
|
12
|
+
* size from its text, a picture's size, and for a container its title, its
|
|
13
|
+
* padding and its layout. A container's size comes from its children, in the
|
|
14
|
+
* placing pass.
|
|
15
|
+
*/
|
|
16
|
+
function factsFor(node, measurer, fontSize) {
|
|
17
|
+
const textSize = fontSizeFor(node.kind, node.textAttrs, fontSize, node.line);
|
|
18
|
+
const lineHeight = measurer.lineHeight(textSize);
|
|
19
|
+
const hasText = node.lines.some((line) => plain(line).length > 0);
|
|
20
|
+
const across = hasText ? widestLine(node.lines, measurer, textSize) : 0;
|
|
21
|
+
const tall = hasText ? node.lines.length * lineHeight : 0;
|
|
22
|
+
// A turned text (design.org 7.2) takes the room of its turned ink: as wide
|
|
23
|
+
// as its lines are tall, as tall as its widest line.
|
|
24
|
+
const turned = node.textAttrs['reads'] !== undefined;
|
|
25
|
+
const textWidth = turned ? tall : across;
|
|
26
|
+
const textHeight = turned ? across : tall;
|
|
27
|
+
const glyphSide = ICON_LINES * lineHeight;
|
|
28
|
+
const title = titlePlaceOf(node);
|
|
29
|
+
const layout = layoutOf(node);
|
|
30
|
+
const venn = vennOf(node);
|
|
31
|
+
const circle = isCircle(node);
|
|
32
|
+
// A deck that draws no outline of its own is its cards, with nothing round them.
|
|
33
|
+
const undrawnDeck = node.appearance['layout'] === 'deck' && node.appearance['fill'] === undefined && node.appearance['border'] === undefined;
|
|
34
|
+
const padding = node.body.kind === 'none' || (undrawnDeck && node.appearance['padding'] === undefined)
|
|
35
|
+
? 0
|
|
36
|
+
: node.appearance['padding'] !== undefined
|
|
37
|
+
? gapPixels(node.appearance['padding'], `"${node.name}" padding:`, node.line)
|
|
38
|
+
: PAD;
|
|
39
|
+
// `padding-right: 4`: one side's padding, over `padding:` there.
|
|
40
|
+
const pad = { top: padding, right: padding, bottom: padding, left: padding };
|
|
41
|
+
for (const side of PADDING_SIDES) {
|
|
42
|
+
const written = node.appearance[`padding-${side}`];
|
|
43
|
+
if (written === undefined)
|
|
44
|
+
continue;
|
|
45
|
+
if (circle) {
|
|
46
|
+
throw new SourceError(`"${node.name}" padding-${side}: a circle has no sides to pad one of — use padding:`, node.line);
|
|
47
|
+
}
|
|
48
|
+
pad[side] = gapPixels(written, `"${node.name}" padding-${side}:`, node.line);
|
|
49
|
+
}
|
|
50
|
+
node.textSide = 'center';
|
|
51
|
+
const measures = {
|
|
52
|
+
lineHeight,
|
|
53
|
+
charWidth: measurer.measure('0'.repeat(10), textSize).width / 10,
|
|
54
|
+
gather: gatherOf(node),
|
|
55
|
+
};
|
|
56
|
+
if (node.body.kind === 'icon') {
|
|
57
|
+
if (node.children.length > 0) {
|
|
58
|
+
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);
|
|
59
|
+
}
|
|
60
|
+
const width = Math.max(glyphSide, textWidth);
|
|
61
|
+
return {
|
|
62
|
+
natural: { width, height: glyphSide + (hasText ? ICON_GAP + textHeight : 0) },
|
|
63
|
+
textBox: { x: (width - textWidth) / 2, y: glyphSide + ICON_GAP, width: textWidth, height: textHeight },
|
|
64
|
+
hasText,
|
|
65
|
+
padding,
|
|
66
|
+
pad,
|
|
67
|
+
...measures,
|
|
68
|
+
title,
|
|
69
|
+
...(layout ? { layout } : {}),
|
|
70
|
+
circle,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
if (node.children.length === 0 && circle) {
|
|
74
|
+
const side = Math.hypot(textWidth, textHeight) + padding * 2;
|
|
75
|
+
return {
|
|
76
|
+
natural: { width: side, height: side },
|
|
77
|
+
textBox: { x: (side - textWidth) / 2, y: (side - textHeight) / 2, width: textWidth, height: textHeight },
|
|
78
|
+
hasText,
|
|
79
|
+
padding,
|
|
80
|
+
pad,
|
|
81
|
+
...measures,
|
|
82
|
+
title,
|
|
83
|
+
circle,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
if (layout && node.children.length === 0) {
|
|
87
|
+
throw new SourceError(`"${node.name}" has layout: but no children — a node with no children is not a container`, node.line);
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
natural: { width: textWidth + pad.left + pad.right, height: textHeight + pad.top + pad.bottom },
|
|
91
|
+
textBox: { x: pad.left, y: pad.top, width: textWidth, height: textHeight },
|
|
92
|
+
hasText,
|
|
93
|
+
padding,
|
|
94
|
+
pad,
|
|
95
|
+
...measures,
|
|
96
|
+
title,
|
|
97
|
+
...(layout ? { layout } : {}),
|
|
98
|
+
...(venn ? { venn } : {}),
|
|
99
|
+
circle,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Where a container's title sits: `(inside top)` by default, `(on top)`,
|
|
104
|
+
* `(outside top)`, or `(center)`, with `align:` saying where along the side.
|
|
105
|
+
* `at:` is gone from a node's text, refused with the side form.
|
|
106
|
+
*/
|
|
107
|
+
function titlePlaceOf(node) {
|
|
108
|
+
const at = node.textAttrs['at'];
|
|
109
|
+
if (at !== undefined) {
|
|
110
|
+
throw new SourceError(at === 'center'
|
|
111
|
+
? `"${node.name}": at: is gone from a node's text — a leaf's text is centered with nothing written, ` +
|
|
112
|
+
"and a container's title goes in the middle with (center)"
|
|
113
|
+
: `"${node.name}": at: is gone from a node's text — a title says which side it sits against, ` +
|
|
114
|
+
'such as (inside top, align: left), (on top) or (outside bottom)', node.line);
|
|
115
|
+
}
|
|
116
|
+
const written = node.textAttrs['place'] ?? 'inside top';
|
|
117
|
+
if (written === 'center')
|
|
118
|
+
return { how: 'center', side: 'center', along: 'center' };
|
|
119
|
+
const [how, side] = written.split(' ');
|
|
120
|
+
const across = side === 'top' || side === 'bottom';
|
|
121
|
+
const words = across ? ['left', 'center', 'right'] : ['top', 'center', 'bottom'];
|
|
122
|
+
const align = node.textAttrs['align'] ?? 'center';
|
|
123
|
+
// A leaf's align: with no side lines its text up (left, center, right), so
|
|
124
|
+
// top and bottom are a side's words written without the side.
|
|
125
|
+
if (node.children.length === 0 && node.textAttrs['place'] === undefined && (align === 'top' || align === 'bottom')) {
|
|
126
|
+
throw new SourceError(`"${node.name}": align: ${align} says where along a left or right side — name the side, such as (inside left, align: ${align}), ` +
|
|
127
|
+
`or write (inside ${align}) to put the text against the ${align}`, node.line);
|
|
128
|
+
}
|
|
129
|
+
if ((node.children.length > 0 || node.textAttrs['place'] !== undefined) && !words.includes(align)) {
|
|
130
|
+
throw new SourceError(`"${node.name}": a title on the ${side} side takes align: ${words.join(', ')} — not ${align}`, node.line);
|
|
131
|
+
}
|
|
132
|
+
const along = align === words[0] ? 'start' : align === words[2] ? 'end' : 'center';
|
|
133
|
+
return { how, side, along };
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Where `gather-children:` puts the children in the box's spare room, on each
|
|
137
|
+
* axis: against the start, the middle, or the end. The middle unless it says.
|
|
138
|
+
*/
|
|
139
|
+
function gatherOf(node) {
|
|
140
|
+
const written = node.appearance['gather-children'];
|
|
141
|
+
if (written === undefined)
|
|
142
|
+
return { x: 'center', y: 'center' };
|
|
143
|
+
if (!GATHER_PLACES.includes(written)) {
|
|
144
|
+
throw new SourceError(`"${node.name}" has gather-children: ${written}, which is not one of ${GATHER_PLACES.join(', ')}`, node.line);
|
|
145
|
+
}
|
|
146
|
+
if (node.children.length === 0) {
|
|
147
|
+
throw new SourceError(`"${node.name}" has gather-children: but no children to gather`, node.line);
|
|
148
|
+
}
|
|
149
|
+
const parts = written.split('-');
|
|
150
|
+
return {
|
|
151
|
+
x: parts.includes('left') ? 'start' : parts.includes('right') ? 'end' : 'center',
|
|
152
|
+
y: parts.includes('top') ? 'start' : parts.includes('bottom') ? 'end' : 'center',
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
/** `layout: column (gap: 0, widths: match, align-children: left)`, read and checked. */
|
|
156
|
+
function layoutOf(node) {
|
|
157
|
+
const kind = node.appearance['layout'];
|
|
158
|
+
if (node.attrs['cards'] !== undefined && kind !== 'deck') {
|
|
159
|
+
throw new SourceError(`"${node.name}" has cards:, which are a deck's — add layout: deck`, node.line);
|
|
160
|
+
}
|
|
161
|
+
if (kind === undefined || kind === 'venn')
|
|
162
|
+
return undefined;
|
|
163
|
+
if (kind !== 'column' && kind !== 'row' && kind !== 'deck') {
|
|
164
|
+
throw new SourceError(`"${node.name}" has layout: ${kind}, which is not one of column, row, venn, deck`, node.line);
|
|
165
|
+
}
|
|
166
|
+
const bracket = bracketOf('layout', node.appearance);
|
|
167
|
+
if (kind === 'deck') {
|
|
168
|
+
const [key] = Object.keys(bracket);
|
|
169
|
+
if (key !== undefined) {
|
|
170
|
+
throw new SourceError(`"${node.name}" has layout: deck (${key}: ${bracket[key]}), and a deck takes nothing in its bracket`, node.line);
|
|
171
|
+
}
|
|
172
|
+
return { kind, widths: 'natural', align: 'center' };
|
|
173
|
+
}
|
|
174
|
+
if (bracket['heights'] !== undefined) {
|
|
175
|
+
throw new SourceError(`"${node.name}" has layout: ${kind} (heights: ${bracket['heights']}), and heights: belongs to layout: venn — ` +
|
|
176
|
+
'a column or row sizes its children with widths:', node.line);
|
|
177
|
+
}
|
|
178
|
+
const widths = bracket['widths'] ?? 'natural';
|
|
179
|
+
if (!CONTENT_WIDTHS.includes(widths)) {
|
|
180
|
+
throw new SourceError(`"${node.name}" has layout: (widths: ${widths}), which is not one of ${CONTENT_WIDTHS.join(', ')}`, node.line);
|
|
181
|
+
}
|
|
182
|
+
if (widths === 'fill' && kind === 'row') {
|
|
183
|
+
throw new SourceError(`"${node.name}" has layout: row (widths: fill), and children side by side can't all be the full width — use widths: match`, node.line);
|
|
184
|
+
}
|
|
185
|
+
// Two asks, two words: lining the children up with each other, here, and
|
|
186
|
+
// where the group sits in the box's spare room, gather-children: on the box.
|
|
187
|
+
if (bracket['align'] !== undefined) {
|
|
188
|
+
throw new SourceError(`"${node.name}" has layout: (align: ${bracket['align']}), and align: in a layout is gone — ` +
|
|
189
|
+
`write align-children: ${bracket['align']} to line the children up with each other, ` +
|
|
190
|
+
`or gather-children: ${bracket['align']} on "${node.name}" to put them against that side of its room`, node.line);
|
|
191
|
+
}
|
|
192
|
+
const words = CHILD_ALIGNMENTS[kind];
|
|
193
|
+
const alignWord = bracket['align-children'] ?? 'center';
|
|
194
|
+
if (!words.includes(alignWord)) {
|
|
195
|
+
throw new SourceError(`"${node.name}" has layout: ${kind} (align-children: ${alignWord}), and a ${kind} lines its children up by ${words.join(', ')}`, node.line);
|
|
196
|
+
}
|
|
197
|
+
const align = alignWord === words[0] ? 'start' : alignWord === words[2] ? 'end' : 'center';
|
|
198
|
+
const centers = bracket['centers'];
|
|
199
|
+
if (centers !== undefined && bracket['gap'] !== undefined) {
|
|
200
|
+
throw new SourceError(`"${node.name}": layout: says gap: and centers:, which is the spacing said twice — keep one`, node.line);
|
|
201
|
+
}
|
|
202
|
+
if (centers !== undefined) {
|
|
203
|
+
return { kind, centers: gapPixels(centers, `"${node.name}" layout: (centers: …)`, node.line), widths, align };
|
|
204
|
+
}
|
|
205
|
+
const gap = bracket['gap'] === undefined ? PAD : gapPixels(bracket['gap'], `"${node.name}" layout: (gap: …)`, node.line);
|
|
206
|
+
return { kind, gap, widths, align };
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* `layout: venn (heights: equal)`, read and checked: two sets and the overlap
|
|
210
|
+
* between them, as three children in written order (design.org 5.x).
|
|
211
|
+
*/
|
|
212
|
+
function vennOf(node) {
|
|
213
|
+
if (node.appearance['layout'] !== 'venn')
|
|
214
|
+
return undefined;
|
|
215
|
+
const bracket = bracketOf('layout', node.appearance);
|
|
216
|
+
for (const key of Object.keys(bracket)) {
|
|
217
|
+
if (key === 'heights' || key === 'gap' || key === 'align-children')
|
|
218
|
+
continue;
|
|
219
|
+
throw new SourceError(`"${node.name}" has layout: venn (${key}: ${bracket[key]}), and a venn takes only heights:, gap: and align-children: in its bracket`, node.line);
|
|
220
|
+
}
|
|
221
|
+
const heights = bracket['heights'] ?? 'natural';
|
|
222
|
+
if (heights !== 'natural' && heights !== 'equal') {
|
|
223
|
+
throw new SourceError(`"${node.name}" has layout: venn (heights: ${heights}) — write heights: equal to give both sets the taller one's height, or leave it out`, node.line);
|
|
224
|
+
}
|
|
225
|
+
const parts = node.children.filter((child) => child !== node.heading && !joinedToText(child));
|
|
226
|
+
if (parts.length !== 3) {
|
|
227
|
+
throw new SourceError(`"${node.name}" has layout: venn and ${parts.length} ${parts.length === 1 ? 'child' : 'children'} — a venn has exactly three, ` +
|
|
228
|
+
'in order: the left set, the overlap, and the right set', node.line);
|
|
229
|
+
}
|
|
230
|
+
for (const [index, part] of parts.entries()) {
|
|
231
|
+
const role = ['the left set', 'the overlap', 'the right set'][index];
|
|
232
|
+
if (part.children.length === 0) {
|
|
233
|
+
throw new SourceError(`"${part.name}" is ${role} of venn "${node.name}" and holds nothing — put what belongs there inside it, such as node ${part.name}.a "A"`, part.line);
|
|
234
|
+
}
|
|
235
|
+
const sizes = SIZE_KEY_NAMES.filter((key) => part.appearance[key] !== undefined);
|
|
236
|
+
if (sizes.length > 0) {
|
|
237
|
+
throw new SourceError(`"${part.name}" states ${sizes.map((key) => `${key}:`).join(' ')}, but it is ${role} of venn "${node.name}", which wraps each part round what it holds — drop it` +
|
|
238
|
+
(sizes.includes('height') ? ', or give the venn (heights: equal)' : ''), part.line);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
const gap = bracket['gap'] === undefined ? GAPS['normal'] : gapPixels(bracket['gap'], `"${node.name}" layout: venn (gap: …)`, node.line);
|
|
242
|
+
// `align-children: top` lines the two sets' tops up, as a row lines up its children.
|
|
243
|
+
const alignWord = bracket['align-children'] ?? 'center';
|
|
244
|
+
const align = { top: 'start', center: 'center', bottom: 'end' }[alignWord];
|
|
245
|
+
if (align === undefined) {
|
|
246
|
+
throw new SourceError(`"${node.name}" has layout: venn (align-children: ${alignWord}) — a venn lines its two sets up by top, center or bottom`, node.line);
|
|
247
|
+
}
|
|
248
|
+
if (align !== 'center') {
|
|
249
|
+
const stated = [parts[0], parts[2]].find((part) => part.placements.length > 0);
|
|
250
|
+
if (stated !== undefined) {
|
|
251
|
+
throw new SourceError(`"${stated.name}" is lined up by "${describePlacement(stated.placements[0])}", and venn "${node.name}" already says align-children: ${alignWord} — say it once`, stated.line);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return { heights, gap, align, parts: parts };
|
|
255
|
+
}
|
|
8
256
|
/**
|
|
9
257
|
* Turn a parsed document into solved geometry.
|
|
10
258
|
*
|
|
@@ -25,28 +273,272 @@ export function resolve(doc, options = {}) {
|
|
|
25
273
|
checkStyleKeys(doc.statements);
|
|
26
274
|
const defaults = collectDefaults(doc.statements, styles);
|
|
27
275
|
const icons = collectIcons(doc.statements, options.readIconFile);
|
|
28
|
-
const { nodes, byName, roots } = buildTree(doc.statements, styles, defaults, icons);
|
|
276
|
+
const { nodes, byName, roots } = buildTree(writeOutCards(doc.statements), styles, defaults, icons);
|
|
277
|
+
// From here on, `edge each child of a -> b` and `edge a -> b, c` are the lines they stand for.
|
|
278
|
+
const statements = expandEach(doc.statements, byName);
|
|
29
279
|
// Edges are resolved to nodes before anything is sized, because a labeled
|
|
30
280
|
// edge claims room in the gap it crosses and so has to be in hand while the
|
|
31
281
|
// gaps are being worked out. Nothing here reads geometry.
|
|
32
|
-
const
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
282
|
+
const named = collectNamed(doc.statements, byName);
|
|
283
|
+
const edges = buildEdges(statements, byName, named, styles, defaults, measurer, fontSize);
|
|
284
|
+
const facts = new Map(nodes.map((node) => [node, factsFor(node, measurer, fontSize)]));
|
|
285
|
+
const markTexts = measureMarkTexts(named, measurer, fontSize);
|
|
286
|
+
const regionTexts = new Map([...markTexts].flatMap(([name, text]) => (text.region ? [[name, text.region]] : [])));
|
|
287
|
+
const placer = new Placer(roots, byName, facts, named, regionTexts);
|
|
288
|
+
placer.run();
|
|
289
|
+
const marks = buildMarks(named, placer.placeNamed(), styles, markTexts);
|
|
290
|
+
locateStops(edges, placer, byName, named);
|
|
291
|
+
locateEvenWith(edges, placer, byName, named);
|
|
292
|
+
locateTextTargets(edges, placer);
|
|
293
|
+
normalize(nodes, marks, margin, [
|
|
294
|
+
...edges.flatMap((edge) => (edge.viaAt ?? []).filter((at) => at !== undefined)),
|
|
295
|
+
...edges.flatMap((edge) => [edge.evenAt?.from, edge.evenAt?.to].filter((at) => at !== undefined)),
|
|
296
|
+
...edges.flatMap((edge) => edge.texts.flatMap((text) => (text.target ? [text.target] : []))),
|
|
297
|
+
...edges.flatMap((edge) => edge.texts.flatMap((text) => (text.evenAt ?? []).map((even) => even.rect))),
|
|
298
|
+
]);
|
|
299
|
+
standInForMarks(edges, marks);
|
|
300
|
+
const extent = bounds(nodes, marks);
|
|
301
|
+
const look = (kind) => defaults.get(`guide ${kind}`) ?? {};
|
|
39
302
|
return {
|
|
40
303
|
nodes,
|
|
41
304
|
roots,
|
|
42
305
|
edges,
|
|
43
|
-
markup: markupColors(nodes, edges, styles),
|
|
306
|
+
markup: markupColors(nodes, edges, styles, marks),
|
|
44
307
|
diagram: collectDiagram(doc.statements),
|
|
45
308
|
width: Math.ceil(extent.maxX + margin),
|
|
46
309
|
height: Math.ceil(extent.maxY + margin),
|
|
47
310
|
margin,
|
|
311
|
+
marks,
|
|
312
|
+
depths: collectDepths(statements, byName, named),
|
|
313
|
+
guideLooks: { node: look('node'), edge: look('edge'), point: look('point'), region: look('region') },
|
|
48
314
|
};
|
|
49
315
|
}
|
|
316
|
+
/**
|
|
317
|
+
* `edge each child of a -> b` as one line from each direct child of `a`, in the
|
|
318
|
+
* order they were written, so it stays true as children are added (design.org
|
|
319
|
+
* 6.6). A guide child is reference, not part of the picture, and a heading is
|
|
320
|
+
* the box's label, so neither gets a line.
|
|
321
|
+
*/
|
|
322
|
+
function expandEach(statements, byName) {
|
|
323
|
+
return statements.flatMap((stmt) => {
|
|
324
|
+
if (stmt.kind === 'edge' && stmt.several !== undefined) {
|
|
325
|
+
// `edge a -> b, c, d`: one line to each, in the order written. A text is
|
|
326
|
+
// drawn once, on the first, so a text by the shared end labels the group.
|
|
327
|
+
const { several, ...one } = stmt;
|
|
328
|
+
return several.names.map((name, index) => ({ ...one, texts: index === 0 ? one.texts : [], [several.side]: name }));
|
|
329
|
+
}
|
|
330
|
+
if (stmt.kind !== 'edge' || stmt.each === undefined)
|
|
331
|
+
return [stmt];
|
|
332
|
+
const name = stmt[stmt.each];
|
|
333
|
+
const said = `edge ${stmt.each === 'from' ? 'each child of ' : ''}${stmt.from} ${stmt.arrow} ${stmt.each === 'to' ? 'each child of ' : ''}${stmt.to}`;
|
|
334
|
+
const container = byName.get(name);
|
|
335
|
+
if (container === undefined) {
|
|
336
|
+
throw new SourceError(`${said}: there is no node named ${name}`, stmt.line);
|
|
337
|
+
}
|
|
338
|
+
const children = container.children.filter((child) => !child.guide && child !== container.heading);
|
|
339
|
+
if (children.length === 0) {
|
|
340
|
+
throw new SourceError(`${said}: ${name} has no children, so this draws no lines — name ${name} itself, or give it children`, stmt.line);
|
|
341
|
+
}
|
|
342
|
+
const { each: _, ...one } = stmt;
|
|
343
|
+
return children.map((child) => ({ ...one, [stmt.each]: child.name }));
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
// --- named points and regions ------------------------------------------------
|
|
347
|
+
/** Every `point` and `region`, by name. A name is one thing's: a node's, a point's or a region's. */
|
|
348
|
+
function collectNamed(statements, byName) {
|
|
349
|
+
const named = new Map();
|
|
350
|
+
for (const stmt of statements) {
|
|
351
|
+
if (stmt.kind !== 'point' && stmt.kind !== 'region')
|
|
352
|
+
continue;
|
|
353
|
+
const node = byName.get(stmt.name);
|
|
354
|
+
if (node)
|
|
355
|
+
throw new SourceError(`${stmt.kind} "${stmt.name}" has the name of a node, declared on line ${node.line} — give it another`, stmt.line);
|
|
356
|
+
const earlier = named.get(stmt.name);
|
|
357
|
+
if (earlier)
|
|
358
|
+
throw new SourceError(`"${stmt.name}" is declared twice, here and as a ${earlier.kind} on line ${earlier.line}`, stmt.line);
|
|
359
|
+
named.set(stmt.name, stmt);
|
|
360
|
+
}
|
|
361
|
+
return named;
|
|
362
|
+
}
|
|
363
|
+
/** The names a point or region is worked out from, following named points and regions through. */
|
|
364
|
+
function namesIn(target, named, seen = new Set()) {
|
|
365
|
+
const point = target.point;
|
|
366
|
+
const direct = !point
|
|
367
|
+
? [target.name]
|
|
368
|
+
: point.kind === 'between'
|
|
369
|
+
? [point.a.name, point.b.name]
|
|
370
|
+
: point.kind === 'from'
|
|
371
|
+
? point.lines.map((line) => line.target.name)
|
|
372
|
+
: namesIn(point.from, named, seen);
|
|
373
|
+
const found = [];
|
|
374
|
+
for (const name of direct) {
|
|
375
|
+
if (seen.has(name))
|
|
376
|
+
continue;
|
|
377
|
+
seen.add(name);
|
|
378
|
+
found.push(name);
|
|
379
|
+
const stmt = named.get(name);
|
|
380
|
+
if (!stmt)
|
|
381
|
+
continue;
|
|
382
|
+
const parts = stmt.kind === 'point' ? [stmt.point] : stmt.corners;
|
|
383
|
+
for (const part of parts)
|
|
384
|
+
found.push(...namesIn(part, named, seen));
|
|
385
|
+
}
|
|
386
|
+
return found;
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* Every point's and region's text, measured before anything is placed, since
|
|
390
|
+
* an inside text grows its region (design.org 7.4). Set small, as an aside is:
|
|
391
|
+
* it labels a spot or an area rather than being a thing in the diagram.
|
|
392
|
+
*/
|
|
393
|
+
function measureMarkTexts(named, measurer, fontSize) {
|
|
394
|
+
const measured = new Map();
|
|
395
|
+
for (const [name, stmt] of named) {
|
|
396
|
+
if (!stmt.text)
|
|
397
|
+
continue;
|
|
398
|
+
const { text, textAttrs } = stmt.text;
|
|
399
|
+
const subject = `${stmt.kind} "${name}"`;
|
|
400
|
+
const lines = linesFor(text, textAttrs, subject, stmt.line);
|
|
401
|
+
const size = fontSizeFor('none', textAttrs, fontSize, stmt.line);
|
|
402
|
+
const lineHeight = measurer.lineHeight(size);
|
|
403
|
+
const across = widestLine(lines, measurer, size);
|
|
404
|
+
const tall = lines.length * lineHeight;
|
|
405
|
+
const turned = textAttrs['reads'] !== undefined;
|
|
406
|
+
const width = turned ? tall : across;
|
|
407
|
+
const height = turned ? across : tall;
|
|
408
|
+
const gap = textAttrs['gap'] === undefined ? TEXT_GAP : gapPixels(textAttrs['gap'], `${subject}: gap:`, stmt.line);
|
|
409
|
+
const place = textAttrs['place'];
|
|
410
|
+
const base = { lines, textAttrs, size, lineHeight, width, height, gap };
|
|
411
|
+
if (stmt.kind === 'point') {
|
|
412
|
+
measured.set(name, { ...base, side: (place ?? 'above') });
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
const [how, side] = (place ?? 'outside top').split(' ');
|
|
416
|
+
const align = textAttrs['align'];
|
|
417
|
+
const along = align === 'left' || align === 'top' ? 'start' : align === 'right' || align === 'bottom' ? 'end' : 'center';
|
|
418
|
+
measured.set(name, { ...base, region: { width, height, gap, how, side, along } });
|
|
419
|
+
}
|
|
420
|
+
return measured;
|
|
421
|
+
}
|
|
422
|
+
/** The dot's radius, which a point's text keeps its gap from. */
|
|
423
|
+
const DOT_RADIUS = 3;
|
|
424
|
+
/**
|
|
425
|
+
* Where a point's text sits: a gap off the dot's edge the stated way, centered
|
|
426
|
+
* across on a side; at a corner, its nearest corner off the dot diagonally.
|
|
427
|
+
*/
|
|
428
|
+
function pointTextBox(x, y, text) {
|
|
429
|
+
const side = text.side ?? 'above';
|
|
430
|
+
const straight = DOT_RADIUS + text.gap;
|
|
431
|
+
const diagonal = DOT_RADIUS * Math.SQRT1_2 + text.gap;
|
|
432
|
+
const off = side.includes('-') ? diagonal : straight;
|
|
433
|
+
const left = side.endsWith('left') ? x - off - text.width : side.endsWith('right') ? x + off : x - text.width / 2;
|
|
434
|
+
const top = side.startsWith('above') ? y - off - text.height : side.startsWith('below') ? y + off : y - text.height / 2;
|
|
435
|
+
return { x: left, y: top, width: text.width, height: text.height };
|
|
436
|
+
}
|
|
437
|
+
function buildMarks(named, placed, styles, texts = new Map()) {
|
|
438
|
+
const marks = [];
|
|
439
|
+
for (const [name, stmt] of named) {
|
|
440
|
+
const at = placed.get(name);
|
|
441
|
+
const appearance = appearanceOf(stmt.attrs, styles, stmt.line);
|
|
442
|
+
const allowed = stmt.kind === 'point' ? POINT_KEYS : REGION_KEYS;
|
|
443
|
+
for (const key of Object.keys(appearance)) {
|
|
444
|
+
if (!allowed.includes(topKey(key))) {
|
|
445
|
+
throw new SourceError(`${stmt.kind} "${name}" wears a style with ${topKey(key)}:, which a ${stmt.kind} doesn't have — it takes ${allowed.map((k) => `${k}:`).join(', ')}`, stmt.line);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
// A style may carry it, so it is checked as worn.
|
|
449
|
+
if (appearance['opacity'] !== undefined)
|
|
450
|
+
checkOpacity(appearance['opacity'], `region "${name}"`, stmt.line);
|
|
451
|
+
const targets = stmt.kind === 'point' ? [stmt.point] : stmt.corners;
|
|
452
|
+
const names = [...new Set(targets.flatMap((target) => namesIn(target, named)))];
|
|
453
|
+
const text = texts.get(name);
|
|
454
|
+
let box;
|
|
455
|
+
if (text?.region) {
|
|
456
|
+
const x = regionTextAt({ start: at.x, size: at.width }, text.region, 'x');
|
|
457
|
+
const y = regionTextAt({ start: at.y, size: at.height }, text.region, 'y');
|
|
458
|
+
box = { x: x.start, y: y.start, width: x.size, height: y.size };
|
|
459
|
+
}
|
|
460
|
+
else if (text) {
|
|
461
|
+
box = pointTextBox(at.x, at.y, text);
|
|
462
|
+
}
|
|
463
|
+
marks.push({
|
|
464
|
+
kind: stmt.kind,
|
|
465
|
+
name,
|
|
466
|
+
...at,
|
|
467
|
+
names,
|
|
468
|
+
appearance,
|
|
469
|
+
guide: stmt.guide === true,
|
|
470
|
+
...(text && box ? { text: { lines: text.lines, textAttrs: text.textAttrs, size: text.size, lineHeight: text.lineHeight, box } } : {}),
|
|
471
|
+
line: stmt.line,
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
return marks;
|
|
475
|
+
}
|
|
476
|
+
/** Every `depth:`, read into its clauses, each name checked to be something drawn. */
|
|
477
|
+
function collectDepths(statements, byName, named) {
|
|
478
|
+
const depths = [];
|
|
479
|
+
// A line is `edge <n>` in the drawing order, by its place among the edges;
|
|
480
|
+
// a leg is named with the line it belongs to.
|
|
481
|
+
const lines = new Map();
|
|
482
|
+
const legs = new Map();
|
|
483
|
+
let count = 0;
|
|
484
|
+
// A bundle is a line too, in its place among them.
|
|
485
|
+
for (const stmt of statements) {
|
|
486
|
+
if (stmt.kind !== 'edge' && stmt.kind !== 'bundle')
|
|
487
|
+
continue;
|
|
488
|
+
if (stmt.name !== undefined)
|
|
489
|
+
lines.set(stmt.name, `edge ${count}`);
|
|
490
|
+
for (const entry of stmt.via ?? [])
|
|
491
|
+
if (entry.leg)
|
|
492
|
+
legs.set(entry.leg.name, `edge ${count}`);
|
|
493
|
+
count += 1;
|
|
494
|
+
}
|
|
495
|
+
let edge = 0;
|
|
496
|
+
for (const stmt of statements) {
|
|
497
|
+
if (stmt.kind === 'edge' || stmt.kind === 'bundle')
|
|
498
|
+
edge += 1;
|
|
499
|
+
if (stmt.kind !== 'node' && stmt.kind !== 'edge' && stmt.kind !== 'point' && stmt.kind !== 'region')
|
|
500
|
+
continue;
|
|
501
|
+
const written = stmt.attrs['depth'];
|
|
502
|
+
if (written === undefined)
|
|
503
|
+
continue;
|
|
504
|
+
const subject = stmt.kind === 'edge' ? `edge ${edge - 1}` : stmt.name;
|
|
505
|
+
const said = stmt.kind === 'edge' ? `edge ${stmt.from} ${stmt.arrow} ${stmt.to}` : `"${stmt.name}"`;
|
|
506
|
+
const clauses = written.split('; ').map((clause) => {
|
|
507
|
+
const way = clause.startsWith('behind ') ? 'behind' : 'in front of';
|
|
508
|
+
const shown = clause.slice(way.length + 1).split(', ');
|
|
509
|
+
const names = [];
|
|
510
|
+
const legsNamed = [];
|
|
511
|
+
for (const name of shown) {
|
|
512
|
+
if (byName.has(name) || named.has(name)) {
|
|
513
|
+
names.push(name);
|
|
514
|
+
}
|
|
515
|
+
else if (lines.has(name)) {
|
|
516
|
+
if (lines.get(name) === subject)
|
|
517
|
+
throw new SourceError(`${said}: depth: ${way} "${name}", which is this line`, stmt.line);
|
|
518
|
+
names.push(lines.get(name));
|
|
519
|
+
}
|
|
520
|
+
else if (legs.has(name)) {
|
|
521
|
+
// A leg limits depth to where two lines cross on it, and only a line crosses one.
|
|
522
|
+
const line = legs.get(name);
|
|
523
|
+
if (stmt.kind !== 'edge') {
|
|
524
|
+
const owner = [...lines].find(([, key]) => key === line)?.[0];
|
|
525
|
+
throw new SourceError(`${said}: depth: ${way} "${name}" — ${name} is a leg, and a leg limits depth: to where two lines cross on it; ` +
|
|
526
|
+
`${said} is not a line, so name the whole line${owner ? `, depth: ${way} ${owner}` : ''}`, stmt.line);
|
|
527
|
+
}
|
|
528
|
+
if (line === subject)
|
|
529
|
+
throw new SourceError(`${said}: depth: ${way} "${name}", which is a leg of this line`, stmt.line);
|
|
530
|
+
legsNamed.push({ leg: name, line });
|
|
531
|
+
}
|
|
532
|
+
else {
|
|
533
|
+
throw new SourceError(`${said}: depth: ${way} "${name}", which does not exist`, stmt.line);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
return { way, names, shown, legs: legsNamed };
|
|
537
|
+
});
|
|
538
|
+
depths.push({ subject, written: said, clauses, line: stmt.line });
|
|
539
|
+
}
|
|
540
|
+
return depths;
|
|
541
|
+
}
|
|
50
542
|
// --- pass one: the containment tree -----------------------------------------
|
|
51
543
|
function collectStyles(statements) {
|
|
52
544
|
const styles = new Map();
|
|
@@ -137,22 +629,34 @@ function buildTree(statements, styles, defaults, icons) {
|
|
|
137
629
|
const nodes = [];
|
|
138
630
|
const byName = new Map();
|
|
139
631
|
const roots = [];
|
|
140
|
-
/** Each badge
|
|
632
|
+
/** Each name `badge:` writes out, and the node whose `badge:` it was. */
|
|
141
633
|
const badges = new Map();
|
|
142
634
|
// Which nodes will hold children has to be known before any node is built,
|
|
143
635
|
// because it decides which default a node wears, and its children are
|
|
144
|
-
// declared after it. A child is a dotted name
|
|
145
|
-
//
|
|
146
|
-
//
|
|
636
|
+
// declared after it. A child is a dotted name; the heading and what is
|
|
637
|
+
// joined to the text don't count (design.org 5.6, 9.3), so a badged box with
|
|
638
|
+
// nothing else in it is a leaf, and neither `badge:` nor anything a default
|
|
639
|
+
// says can change the answer.
|
|
640
|
+
const headings = new Map();
|
|
641
|
+
const joins = new Map();
|
|
642
|
+
for (const stmt of statements) {
|
|
643
|
+
if (stmt.kind !== 'node')
|
|
644
|
+
continue;
|
|
645
|
+
if (stmt.attrs['heading'] !== undefined)
|
|
646
|
+
headings.set(stmt.name, stmt.attrs['heading']);
|
|
647
|
+
if (stmt.attrs['join-with'] !== undefined)
|
|
648
|
+
joins.set(stmt.name, stmt.attrs['join-with']);
|
|
649
|
+
}
|
|
147
650
|
const parents = new Set();
|
|
148
651
|
for (const stmt of statements) {
|
|
149
652
|
if (stmt.kind !== 'node')
|
|
150
653
|
continue;
|
|
151
654
|
const cut = stmt.name.lastIndexOf('.');
|
|
152
|
-
if (cut
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
655
|
+
if (cut === -1)
|
|
656
|
+
continue;
|
|
657
|
+
const parent = stmt.name.slice(0, cut);
|
|
658
|
+
if (headings.get(parent) !== stmt.name && !joinsTextByName(stmt.name, parent, joins))
|
|
659
|
+
parents.add(parent);
|
|
156
660
|
}
|
|
157
661
|
for (const stmt of statements) {
|
|
158
662
|
if (stmt.kind !== 'node')
|
|
@@ -160,8 +664,8 @@ function buildTree(statements, styles, defaults, icons) {
|
|
|
160
664
|
if (byName.has(stmt.name)) {
|
|
161
665
|
const owner = badges.get(stmt.name);
|
|
162
666
|
if (owner !== undefined) {
|
|
163
|
-
throw new SourceError(`"${stmt.name}" is the
|
|
164
|
-
`"${owner}" and write the
|
|
667
|
+
throw new SourceError(`"${stmt.name}" is the picture that "${owner}"'s badge: writes out. Drop badge: from ` +
|
|
668
|
+
`"${owner}" and write the child yourself, joined to the text, or give this node another name`, stmt.line);
|
|
165
669
|
}
|
|
166
670
|
throw new SourceError(`"${stmt.name}" is declared twice`, stmt.line);
|
|
167
671
|
}
|
|
@@ -176,16 +680,44 @@ function buildTree(statements, styles, defaults, icons) {
|
|
|
176
680
|
// exception: a picture usually is the statement, and the default would
|
|
177
681
|
// caption a row of cubes a, b, db1, c, db2. `""` then says what writing
|
|
178
682
|
// nothing says, which is only true here — on a box it carries information.
|
|
179
|
-
|
|
683
|
+
// A box with a heading has no text of its own: the heading is its title,
|
|
684
|
+
// and where it sits is said in `heading:`'s bracket.
|
|
685
|
+
const headed = stmt.attrs['heading'] !== undefined;
|
|
686
|
+
if (headed && stmt.statedText) {
|
|
687
|
+
throw new SourceError(`"${stmt.name}" has a text and heading:, which is two headings — put the text in ` +
|
|
688
|
+
`"${stmt.attrs['heading']}", or drop heading:`, stmt.line);
|
|
689
|
+
}
|
|
690
|
+
if (headed && Object.keys(stmt.textAttrs).length > 0) {
|
|
691
|
+
throw new SourceError(`"${stmt.name}" has heading:, so it has no text of its own to bracket — where the heading sits goes ` +
|
|
692
|
+
`in heading:'s bracket, such as heading: ${stmt.attrs['heading']} (inside top, align: left)`, stmt.line);
|
|
693
|
+
}
|
|
694
|
+
const text = headed || (body.kind === 'icon' && !stmt.statedText) ? '' : stmt.text;
|
|
180
695
|
// A style has no string of its own, so it says what it has to say about a
|
|
181
696
|
// text through `text: (…)`, which arrives here under dotted keys. What the
|
|
182
697
|
// node wrote in its own brackets wins, key by key, exactly as its own
|
|
183
698
|
// attributes win over the style's.
|
|
184
|
-
const textAttrs =
|
|
699
|
+
const textAttrs = headed
|
|
700
|
+
? {
|
|
701
|
+
...(stmt.attrs['heading.place'] !== undefined ? { place: stmt.attrs['heading.place'] } : {}),
|
|
702
|
+
...(stmt.attrs['heading.align'] !== undefined ? { align: stmt.attrs['heading.align'] } : {}),
|
|
703
|
+
}
|
|
704
|
+
: { ...bracketOf('text', appearance), ...stmt.textAttrs };
|
|
705
|
+
// A card's text sits at its top left, where it shows above the card in
|
|
706
|
+
// front of it, unless it says where.
|
|
707
|
+
const deck = byName.get(stmt.name.slice(0, Math.max(stmt.name.lastIndexOf('.'), 0)));
|
|
708
|
+
if (deck?.appearance['layout'] === 'deck' && textAttrs['place'] === undefined && textAttrs['align'] === undefined) {
|
|
709
|
+
textAttrs['place'] = 'inside top';
|
|
710
|
+
textAttrs['align'] = 'left';
|
|
711
|
+
}
|
|
712
|
+
// A deck draws no outline of its own for a title to sit inside, so its
|
|
713
|
+
// title goes above, centered on the stack, unless it says where.
|
|
714
|
+
if (appearance['layout'] === 'deck' && textAttrs['place'] === undefined)
|
|
715
|
+
textAttrs['place'] = 'outside top';
|
|
185
716
|
const node = {
|
|
186
717
|
name: stmt.name,
|
|
187
718
|
kind,
|
|
188
719
|
body,
|
|
720
|
+
corners: 'rounded',
|
|
189
721
|
text,
|
|
190
722
|
lines: linesFor(text, textAttrs, `"${stmt.name}"`, stmt.line),
|
|
191
723
|
children: [],
|
|
@@ -193,26 +725,25 @@ function buildTree(statements, styles, defaults, icons) {
|
|
|
193
725
|
y: 0,
|
|
194
726
|
width: 0,
|
|
195
727
|
height: 0,
|
|
196
|
-
inset: 0,
|
|
197
|
-
deckTexts: [],
|
|
198
728
|
headerHeight: 0,
|
|
199
729
|
reach: { left: 0, top: 0, right: 0, bottom: 0 },
|
|
200
730
|
banded: false,
|
|
201
731
|
textBox: { x: 0, y: 0, width: 0, height: 0 },
|
|
202
732
|
textSide: 'left',
|
|
203
733
|
textAttrs,
|
|
734
|
+
...(stmt.titlePlacements ? { titlePlacements: stmt.titlePlacements } : {}),
|
|
204
735
|
attrs: stmt.attrs,
|
|
205
736
|
appearance,
|
|
206
737
|
placements: stmt.placements,
|
|
738
|
+
...(stmt.guide ? { guide: true } : {}),
|
|
207
739
|
line: stmt.line,
|
|
208
740
|
};
|
|
209
741
|
checkAttrs(kind, node.name, stmt.attrs, stmt.line);
|
|
210
742
|
checkStyleUse(kind, node.name, stmt.attrs, styles, stmt.line);
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
const
|
|
214
|
-
|
|
215
|
-
node.deckTexts = deck.split('\n');
|
|
743
|
+
// A shown guide wears `default guide node` over its styles and defaults, as
|
|
744
|
+
// the renderer does for its colors; its own words beat both.
|
|
745
|
+
const guideLook = stmt.guide ? defaults.get('guide node')?.['corners'] : undefined;
|
|
746
|
+
node.corners = cornersOf(node, stmt.attrs['corners'] ?? guideLook ?? appearance['corners'], stmt.attrs['corners'] !== undefined);
|
|
216
747
|
const cut = stmt.name.lastIndexOf('.');
|
|
217
748
|
if (cut === -1) {
|
|
218
749
|
roots.push(node);
|
|
@@ -228,59 +759,249 @@ function buildTree(statements, styles, defaults, icons) {
|
|
|
228
759
|
}
|
|
229
760
|
nodes.push(node);
|
|
230
761
|
byName.set(stmt.name, node);
|
|
231
|
-
// `badge: X` is a shorthand
|
|
232
|
-
//
|
|
233
|
-
//
|
|
234
|
-
//
|
|
235
|
-
//
|
|
236
|
-
// the other kinds refuse the word in `checkAttrs`, and a style's is unused.
|
|
762
|
+
// `badge: X` is a shorthand for a picture beside the text, joined to it
|
|
763
|
+
// (design.org 5.9). Read from the merged appearance, so a style
|
|
764
|
+
// or a default carrying a badge gives one to every box wearing it. Only a
|
|
765
|
+
// box has a text to be beside; the other kinds refuse the word in
|
|
766
|
+
// `checkAttrs`, and a style's is unused.
|
|
237
767
|
const named = appearance['badge'];
|
|
238
768
|
if (named !== undefined && body.kind === 'shape') {
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
769
|
+
if (headed) {
|
|
770
|
+
throw new SourceError(`"${stmt.name}" has badge: and heading:, but a box with a heading has no text of its own for the ` +
|
|
771
|
+
'badge to sit beside — put the picture in the heading, or drop heading:', stmt.line);
|
|
772
|
+
}
|
|
773
|
+
const made = writeOutBadge(node, named, icons);
|
|
774
|
+
badges.set(made.name, node.name);
|
|
775
|
+
nodes.push(made);
|
|
776
|
+
byName.set(made.name, made);
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
for (const node of nodes) {
|
|
780
|
+
const written = node.attrs['join-with'];
|
|
781
|
+
if (written !== undefined)
|
|
782
|
+
node.joinWith = joinTarget(node, written, byName);
|
|
783
|
+
}
|
|
784
|
+
for (const node of nodes)
|
|
785
|
+
checkJoinedPlacements(node);
|
|
786
|
+
// A title is placed against the box's own children, so a box needs some.
|
|
787
|
+
for (const node of nodes) {
|
|
788
|
+
const [placement] = node.titlePlacements ?? [];
|
|
789
|
+
if (placement && node.children.length === 0) {
|
|
790
|
+
throw new SourceError(`"${node.name}"'s title is placed "${describePlacement(placement)}", but "${node.name}" has no children ` +
|
|
791
|
+
'for it to be placed against — a title is placed against the box it names, by its sides, or against its children', placement.line);
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
// A heading written by hand names one of the node's own children, declared
|
|
795
|
+
// after it, which places nothing itself: it goes where the title would.
|
|
796
|
+
for (const [name, child] of headings) {
|
|
797
|
+
const node = byName.get(name);
|
|
798
|
+
const heading = byName.get(child);
|
|
799
|
+
if (!heading || heading.parent !== node) {
|
|
800
|
+
throw new SourceError(heading
|
|
801
|
+
? `"${name}" has heading: ${child}, which is not one of its own children — a heading is a child, such as ${name}.head`
|
|
802
|
+
: `"${name}" has heading: ${child}, which does not exist — declare it as a child, such as node ${child} "" shape: none`, node.line);
|
|
244
803
|
}
|
|
804
|
+
const [placement] = heading.placements;
|
|
805
|
+
if (placement) {
|
|
806
|
+
throw new SourceError(`"${child}" is "${name}"'s heading, which goes where the title would, so it places nothing itself — ` +
|
|
807
|
+
`drop "${describePlacement(placement)}", and say where the heading sits in heading:'s bracket`, placement.line);
|
|
808
|
+
}
|
|
809
|
+
node.heading = heading;
|
|
245
810
|
}
|
|
811
|
+
for (const node of nodes)
|
|
812
|
+
joinBesideTitle(node);
|
|
246
813
|
return { nodes, byName, roots };
|
|
247
814
|
}
|
|
248
|
-
/**
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
815
|
+
/**
|
|
816
|
+
* What `cards:` writes out: one child per text, front first, numbered from 1,
|
|
817
|
+
* declared right after the deck as if written by hand:
|
|
818
|
+
*
|
|
819
|
+
* node <self>.1 "Drive 1"
|
|
820
|
+
* node <self>.2 "Drive 2"
|
|
821
|
+
*
|
|
822
|
+
* Cards that hold anything are written by hand, every one of them, so a deck
|
|
823
|
+
* with `cards:` may have no other children.
|
|
824
|
+
*/
|
|
825
|
+
function writeOutCards(statements) {
|
|
826
|
+
const out = [];
|
|
827
|
+
for (const stmt of statements) {
|
|
828
|
+
out.push(stmt);
|
|
829
|
+
if (stmt.kind !== 'node' || stmt.attrs['cards'] === undefined)
|
|
830
|
+
continue;
|
|
831
|
+
const texts = stmt.attrs['cards'].split('\n');
|
|
832
|
+
const written = statements.find((other) => other.kind === 'node' && other.name.startsWith(`${stmt.name}.`) && other.name !== stmt.attrs['heading']);
|
|
833
|
+
if (written) {
|
|
834
|
+
throw new SourceError(`"${stmt.name}" has cards: and a child written out, "${written.name}" — once a card holds something, every card is ` +
|
|
835
|
+
`written as a child, front first: ${texts.map((text, index) => `node ${stmt.name}.${index + 1} "${text}"`).join(', ')}`, written.line);
|
|
836
|
+
}
|
|
837
|
+
texts.forEach((text, index) => {
|
|
838
|
+
out.push({
|
|
839
|
+
kind: 'node',
|
|
840
|
+
name: `${stmt.name}.${index + 1}`,
|
|
841
|
+
text,
|
|
842
|
+
statedText: true,
|
|
843
|
+
textAttrs: {},
|
|
844
|
+
placements: [],
|
|
845
|
+
attrs: {},
|
|
846
|
+
line: stmt.line,
|
|
847
|
+
});
|
|
848
|
+
});
|
|
849
|
+
}
|
|
850
|
+
return out;
|
|
851
|
+
}
|
|
852
|
+
/**
|
|
853
|
+
* What `badge:` writes out, which is this:
|
|
854
|
+
*
|
|
855
|
+
* node <self>.badge icon: X 1 right of <self> text join-with: <self> text
|
|
856
|
+
*
|
|
857
|
+
* The text stays the node's own, and the two are one unit placed by the text's
|
|
858
|
+
* position words. The picture is set at the text's size, so it is two of its
|
|
859
|
+
* lines tall. An empty text takes no room, and that includes the unit beside
|
|
860
|
+
* it, so the picture alone is the unit.
|
|
861
|
+
*/
|
|
862
|
+
function writeOutBadge(node, named, icons) {
|
|
863
|
+
const attrs = { icon: named };
|
|
864
|
+
const size = node.textAttrs['size'];
|
|
865
|
+
const textAttrs = size === undefined ? {} : { size };
|
|
866
|
+
const body = bodyFor(attrs, attrs, node.line, icons);
|
|
867
|
+
const hasText = node.lines.some((line) => plain(line).length > 0);
|
|
868
|
+
const made = {
|
|
869
|
+
name: `${node.name}.badge`,
|
|
870
|
+
kind: KIND_OF_BODY[body.kind],
|
|
871
|
+
body,
|
|
872
|
+
corners: 'rounded',
|
|
256
873
|
text: '',
|
|
257
|
-
lines: linesFor('',
|
|
874
|
+
lines: linesFor('', textAttrs, `"${node.name}"`, node.line),
|
|
258
875
|
children: [],
|
|
259
|
-
parent,
|
|
876
|
+
parent: node,
|
|
877
|
+
joinWith: 'text',
|
|
260
878
|
x: 0,
|
|
261
879
|
y: 0,
|
|
262
880
|
width: 0,
|
|
263
881
|
height: 0,
|
|
264
|
-
inset: 0,
|
|
265
|
-
deckTexts: [],
|
|
266
882
|
headerHeight: 0,
|
|
267
883
|
reach: { left: 0, top: 0, right: 0, bottom: 0 },
|
|
268
884
|
banded: false,
|
|
269
885
|
textBox: { x: 0, y: 0, width: 0, height: 0 },
|
|
270
886
|
textSide: 'center',
|
|
271
|
-
textAttrs
|
|
272
|
-
attrs
|
|
273
|
-
appearance:
|
|
887
|
+
textAttrs,
|
|
888
|
+
attrs,
|
|
889
|
+
appearance: attrs,
|
|
274
890
|
placements: [
|
|
275
891
|
{
|
|
276
892
|
kind: 'offset',
|
|
277
893
|
direction: 'right',
|
|
278
|
-
targets: [{ name:
|
|
279
|
-
|
|
894
|
+
targets: [{ name: node.name, part: 'text' }],
|
|
895
|
+
...(hasText ? { gap: '1' } : {}),
|
|
896
|
+
line: node.line,
|
|
280
897
|
},
|
|
281
898
|
],
|
|
282
|
-
line:
|
|
899
|
+
line: node.line,
|
|
283
900
|
};
|
|
901
|
+
node.children.push(made);
|
|
902
|
+
return made;
|
|
903
|
+
}
|
|
904
|
+
/**
|
|
905
|
+
* Whether a child is joined to its container's text, directly or through
|
|
906
|
+
* siblings, judged from the statements before any node is built. A sibling
|
|
907
|
+
* `badge:` writes out is joined to the text.
|
|
908
|
+
*/
|
|
909
|
+
function joinsTextByName(name, parent, joins) {
|
|
910
|
+
const seen = new Set();
|
|
911
|
+
for (let at = name; at !== undefined && !seen.has(at); at = joins.get(at)) {
|
|
912
|
+
seen.add(at);
|
|
913
|
+
const target = joins.get(at);
|
|
914
|
+
if (target === `${parent} text`)
|
|
915
|
+
return true;
|
|
916
|
+
if (target === `${parent}.badge` && !joins.has(target))
|
|
917
|
+
return true;
|
|
918
|
+
}
|
|
919
|
+
return false;
|
|
920
|
+
}
|
|
921
|
+
/** What `join-with:` names: the node's container's text, or a sibling it is placed against. */
|
|
922
|
+
function joinTarget(node, written, byName) {
|
|
923
|
+
const parent = node.parent;
|
|
924
|
+
const [name, part] = written.split(' ');
|
|
925
|
+
const example = parent ? `join-with: ${parent.name} text` : 'join-with: hub text';
|
|
926
|
+
if (part === 'text') {
|
|
927
|
+
if (!parent || name !== parent.name) {
|
|
928
|
+
throw new SourceError(`"${node.name}" has join-with: ${written}, but a node joins only its own container's text — ` +
|
|
929
|
+
(parent ? `write ${example}, or join a sibling` : 'a top-level node has no container, so place it without joining'), node.line);
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
else {
|
|
933
|
+
const named = byName.get(name);
|
|
934
|
+
if (!named)
|
|
935
|
+
throw new SourceError(`"${node.name}" has join-with: ${written}, which does not exist`, node.line);
|
|
936
|
+
if (named === node || named.parent !== parent) {
|
|
937
|
+
throw new SourceError(`"${node.name}" has join-with: ${written}, but a node joins ${parent ? `its container's text, such as ${example}, or ` : ''}a sibling it is placed against`, node.line);
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
const placedAgainst = node.placements.some((placement) => placement.targets.some((target) => part === 'text' ? target.name === name && target.part === 'text' : target.name === name));
|
|
941
|
+
if (!placedAgainst) {
|
|
942
|
+
throw new SourceError(`"${node.name}" has join-with: ${written}, but none of its placements is against "${written}", so nothing fixes ` +
|
|
943
|
+
`where it sits beside it — place it against "${written}", such as 1 right of ${written}`, node.line);
|
|
944
|
+
}
|
|
945
|
+
return part === 'text' ? 'text' : byName.get(name);
|
|
946
|
+
}
|
|
947
|
+
/**
|
|
948
|
+
* A child placed against a title its side words place goes with the title,
|
|
949
|
+
* when another child anchors the frame (design.org 7.2): it is joined to the
|
|
950
|
+
* text as `join-with:` would join it, and so is whatever is placed only
|
|
951
|
+
* against it. The box then holds two groups nothing relates, each fully
|
|
952
|
+
* stated; if they land on each other, that is an overlap like any other.
|
|
953
|
+
*/
|
|
954
|
+
function joinBesideTitle(node) {
|
|
955
|
+
if (node.children.length === 0 || node.titlePlacements?.length || node.heading || node.attrs['layout'] !== undefined)
|
|
956
|
+
return;
|
|
957
|
+
const free = node.children.filter((child) => !joinedToText(child));
|
|
958
|
+
if (!free.some((child) => child.placements.length === 0))
|
|
959
|
+
return;
|
|
960
|
+
for (let changed = true; changed;) {
|
|
961
|
+
changed = false;
|
|
962
|
+
for (const child of free) {
|
|
963
|
+
if (child.joinWith !== undefined || child.placements.length === 0)
|
|
964
|
+
continue;
|
|
965
|
+
let onText = false;
|
|
966
|
+
let sibling;
|
|
967
|
+
const besideTitle = child.placements.every((placement) => placement.targets.every((target) => {
|
|
968
|
+
if (target.point || target.fraction)
|
|
969
|
+
return false;
|
|
970
|
+
if (target.name === node.name && target.part === 'text')
|
|
971
|
+
return (onText = true);
|
|
972
|
+
const named = node.children.find((one) => one.name === target.name);
|
|
973
|
+
if (named === undefined || named === child || !joinedToText(named))
|
|
974
|
+
return false;
|
|
975
|
+
sibling ??= named;
|
|
976
|
+
return true;
|
|
977
|
+
}));
|
|
978
|
+
if (!besideTitle)
|
|
979
|
+
continue;
|
|
980
|
+
child.joinWith = onText ? 'text' : sibling;
|
|
981
|
+
changed = true;
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
/**
|
|
986
|
+
* A node joined to its container's text is placed only against the text and
|
|
987
|
+
* what is joined to it, so the unit has one shape wherever the text's position
|
|
988
|
+
* words put it.
|
|
989
|
+
*/
|
|
990
|
+
function checkJoinedPlacements(node) {
|
|
991
|
+
const parent = node.parent;
|
|
992
|
+
if (!parent || !joinedToText(node))
|
|
993
|
+
return;
|
|
994
|
+
for (const placement of node.placements) {
|
|
995
|
+
for (const target of placement.targets) {
|
|
996
|
+
if (target.name === parent.name && target.part === 'text')
|
|
997
|
+
continue;
|
|
998
|
+
const named = parent.children.find((child) => child.name === target.name);
|
|
999
|
+
if (named && joinedToText(named))
|
|
1000
|
+
continue;
|
|
1001
|
+
throw new SourceError(`"${node.name}" is joined to "${parent.name} text", so it is placed only against the text and what is ` +
|
|
1002
|
+
`joined to it, but "${describePlacement(placement)}" is against "${nameTarget(target)}" — drop it, or the join`, placement.line);
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
284
1005
|
}
|
|
285
1006
|
/**
|
|
286
1007
|
* "a node", "an edge". Only the kind words are ever passed here and only `edge`
|
|
@@ -338,8 +1059,8 @@ function checkAttrs(kind, name, attrs, line) {
|
|
|
338
1059
|
continue;
|
|
339
1060
|
// Quoted back the way it was written. A bracketed value arrives one dotted
|
|
340
1061
|
// key at a time, and `contents: match` is not a line anybody could look for.
|
|
341
|
-
// A deck's
|
|
342
|
-
const shown = key === '
|
|
1062
|
+
// A deck's cards are stored joined on a line break; quote them back as written.
|
|
1063
|
+
const shown = key === 'cards' ? value.split('\n').map((text) => `"${text}"`).join(' ') : value;
|
|
343
1064
|
const wrote = written === key ? `${key}: ${shown}` : `${key}: (${written.slice(key.length + 1)}: ${shown})`;
|
|
344
1065
|
if (!ALL_ATTR_KEYS.includes(key)) {
|
|
345
1066
|
// Nothing anywhere in the language answers to this word, so the only
|
|
@@ -355,6 +1076,15 @@ function checkAttrs(kind, name, attrs, line) {
|
|
|
355
1076
|
const parts = COLOR_PARTS[kind].map((part) => `\`${part}\``).join(', ');
|
|
356
1077
|
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);
|
|
357
1078
|
}
|
|
1079
|
+
// Corners belong to an outline, so what the kind is made of explains their
|
|
1080
|
+
// absence, as it does a color's. A line has corners too, but they are a
|
|
1081
|
+
// property of the line and go in its bracket.
|
|
1082
|
+
if (key === 'corners') {
|
|
1083
|
+
throw new SourceError(kind === 'edge'
|
|
1084
|
+
? `"${name}" is an edge and has ${wrote}. A line's corners go in its bracket — write \`line: (corners: ${shown})\``
|
|
1085
|
+
: `"${name}" is ${article(KIND_WORD[kind])} and has ${wrote}. ${capital(article(KIND_WORD[kind]))} is ${KIND_PARTS[kind]}, ` +
|
|
1086
|
+
'so it has no corners — `corners:` takes a node with an outline', line);
|
|
1087
|
+
}
|
|
358
1088
|
// A word only the whole drawing takes, such as a theme.
|
|
359
1089
|
if (belongTo(key).length === 0) {
|
|
360
1090
|
throw new SourceError(`"${name}" is ${article(KIND_WORD[kind])} and has ${wrote}. \`${key}:\` is said about the ` +
|
|
@@ -368,6 +1098,27 @@ function checkAttrs(kind, name, attrs, line) {
|
|
|
368
1098
|
`${listKinds(belongTo(key))} — ${article(KIND_WORD[kind])} takes ${allowed.join(', ')}`, line);
|
|
369
1099
|
}
|
|
370
1100
|
}
|
|
1101
|
+
/**
|
|
1102
|
+
* How a node's corners are drawn. A value is checked wherever it came from — a
|
|
1103
|
+
* style's `corners: sharpp` is as much a typo as a node's — but a circle refuses
|
|
1104
|
+
* `corners:` only when the node itself says it, since a circle has none. From a
|
|
1105
|
+
* style or a default it lands on the outlines that have corners, the way a
|
|
1106
|
+
* line's `corners: rounded` from a style lands only on lines with corners. A
|
|
1107
|
+
* node drawn as a picture or with no body has no outline; `checkAttrs` has
|
|
1108
|
+
* already refused the word written on one, and a style's is unused.
|
|
1109
|
+
*/
|
|
1110
|
+
function cornersOf(node, value, written) {
|
|
1111
|
+
if (node.body.kind !== 'shape' || value === undefined)
|
|
1112
|
+
return NODE_CORNERS[0];
|
|
1113
|
+
if (!NODE_CORNERS.includes(value)) {
|
|
1114
|
+
throw new SourceError(`"${node.name}" has corners: ${value}, which is not a way to draw corners — use ${NODE_CORNERS.join(' or ')}`, node.line);
|
|
1115
|
+
}
|
|
1116
|
+
if (written && node.body.outline === 'circle') {
|
|
1117
|
+
throw new SourceError(`"${node.name}" is a circle and has corners: ${value}. A circle has no corners to make ${value} — ` +
|
|
1118
|
+
'`corners:` takes a `rectangle` or a `document`', node.line);
|
|
1119
|
+
}
|
|
1120
|
+
return value;
|
|
1121
|
+
}
|
|
371
1122
|
/**
|
|
372
1123
|
* A style may carry keys this kind has no use for, but it may not carry *only*
|
|
373
1124
|
* those. Partial overlap is the normal case and the reason styles exist; zero
|
|
@@ -410,6 +1161,21 @@ function checkStyleKeys(statements) {
|
|
|
410
1161
|
if (stmt.kind !== 'style')
|
|
411
1162
|
continue;
|
|
412
1163
|
for (const [key, value] of Object.entries(stmt.attrs)) {
|
|
1164
|
+
if (topKey(key) === 'heading') {
|
|
1165
|
+
throw new SourceError(`style "${stmt.name}" has heading:, which names one node's own child, so it is written on that node`, stmt.line);
|
|
1166
|
+
}
|
|
1167
|
+
if (topKey(key) === 'join-with') {
|
|
1168
|
+
throw new SourceError(`style "${stmt.name}" has join-with:, which names the one thing a node moves with, so it is written on that node`, stmt.line);
|
|
1169
|
+
}
|
|
1170
|
+
if (topKey(key) === 'depth') {
|
|
1171
|
+
throw new SourceError(`style "${stmt.name}" has depth:, which moves one thing in the drawing order, so it is written on that thing`, stmt.line);
|
|
1172
|
+
}
|
|
1173
|
+
if (topKey(key) === 'cards') {
|
|
1174
|
+
throw new SourceError(`style "${stmt.name}" has cards:, which are one deck's own children, so it is written on that deck`, stmt.line);
|
|
1175
|
+
}
|
|
1176
|
+
if (topKey(key) === 'from' || topKey(key) === 'to') {
|
|
1177
|
+
throw new SourceError(`style "${stmt.name}" has ${key}:, which says where one line ends, so it is written on that edge`, stmt.line);
|
|
1178
|
+
}
|
|
413
1179
|
if (ALL_ATTR_KEYS.includes(topKey(key)))
|
|
414
1180
|
continue;
|
|
415
1181
|
throw new SourceError(`style "${stmt.name}" has ${key}: ${value}, which is not an attribute. ` +
|
|
@@ -486,57 +1252,110 @@ function markAt(end, stmt, appearance, what) {
|
|
|
486
1252
|
const given = appearance[key];
|
|
487
1253
|
return given === undefined ? 'none' : check(given);
|
|
488
1254
|
}
|
|
489
|
-
function buildEdges(statements, byName, styles, defaults) {
|
|
1255
|
+
function buildEdges(statements, byName, named, styles, defaults, measurer, fontSize) {
|
|
490
1256
|
const edges = [];
|
|
1257
|
+
// A line's name is used before or after it is written, so every name is
|
|
1258
|
+
// known before any end is looked up. A name belongs to one thing.
|
|
1259
|
+
const lineNames = new Map();
|
|
491
1260
|
for (const stmt of statements) {
|
|
492
|
-
if (stmt.kind !== 'edge')
|
|
1261
|
+
if ((stmt.kind !== 'edge' && stmt.kind !== 'bundle') || stmt.name === undefined)
|
|
493
1262
|
continue;
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
1263
|
+
if (byName.has(stmt.name) || named.has(stmt.name) || lineNames.has(stmt.name)) {
|
|
1264
|
+
const was = byName.has(stmt.name)
|
|
1265
|
+
? "a node's"
|
|
1266
|
+
: named.has(stmt.name)
|
|
1267
|
+
? `the ${named.get(stmt.name).kind}'s on line ${named.get(stmt.name).line}`
|
|
1268
|
+
: `the line's on line ${lineNames.get(stmt.name)}`;
|
|
1269
|
+
throw new SourceError(`line "${stmt.name}": the name is already ${was} — a name belongs to one thing`, stmt.line);
|
|
1270
|
+
}
|
|
1271
|
+
lineNames.set(stmt.name, stmt.line);
|
|
1272
|
+
}
|
|
1273
|
+
// A named leg works anywhere a line does, an end included (design.org 4.6).
|
|
1274
|
+
// Clashes between leg names and other names are refused in `lines.ts`.
|
|
1275
|
+
const legNames = new Map();
|
|
1276
|
+
for (const stmt of statements) {
|
|
1277
|
+
if (stmt.kind !== 'edge' && stmt.kind !== 'bundle')
|
|
1278
|
+
continue;
|
|
1279
|
+
for (const entry of stmt.via ?? []) {
|
|
1280
|
+
if (entry.leg && !lineNames.has(entry.leg.name) && !byName.has(entry.leg.name))
|
|
1281
|
+
legNames.set(entry.leg.name, stmt.line);
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
for (const [name, line] of legNames)
|
|
1285
|
+
lineNames.set(name, line);
|
|
1286
|
+
// An end on another line (`edge log audit -> req`) has no box: it stands on
|
|
1287
|
+
// a point of that line, which `lines.ts` fills in once the line is drawn.
|
|
1288
|
+
const onLines = new Map();
|
|
1289
|
+
// An end at a named point or region stands on a box of its own, put where
|
|
1290
|
+
// the mark is once everything is placed (`standInForMarks`).
|
|
1291
|
+
const onMarks = new Map();
|
|
1292
|
+
const endNode = (name, which, line) => {
|
|
1293
|
+
const node = byName.get(name);
|
|
1294
|
+
if (node)
|
|
1295
|
+
return node;
|
|
1296
|
+
if (named.has(name)) {
|
|
1297
|
+
const mark = onMarks.get(name) ?? placeholder(name, line);
|
|
1298
|
+
onMarks.set(name, mark);
|
|
1299
|
+
return mark;
|
|
1300
|
+
}
|
|
1301
|
+
if (!lineNames.has(name))
|
|
1302
|
+
throw new SourceError(`edge ${which} "${name}", which does not exist`, line);
|
|
1303
|
+
const point = onLines.get(name) ?? placeholder(name, line);
|
|
1304
|
+
onLines.set(name, point);
|
|
1305
|
+
return point;
|
|
1306
|
+
};
|
|
1307
|
+
const pending = [];
|
|
1308
|
+
for (const stmt of statements) {
|
|
1309
|
+
if (stmt.kind === 'bundle') {
|
|
1310
|
+
// A line with no ends: its path is its via: list, and its ends stand
|
|
1311
|
+
// where that starts and finishes, so they are placeholders like an end
|
|
1312
|
+
// on a line.
|
|
1313
|
+
const what = `bundle ${stmt.name}`;
|
|
1314
|
+
const appearance = { ...defaults.get('edge'), ...appearanceOf(stmt.attrs, styles, stmt.line) };
|
|
1315
|
+
const asEdge = { marks: {}, attrs: stmt.attrs, line: stmt.line };
|
|
1316
|
+
const between = stmt.between?.map((name) => {
|
|
508
1317
|
const node = byName.get(name);
|
|
509
|
-
if (!node)
|
|
510
|
-
throw new SourceError(
|
|
511
|
-
}
|
|
1318
|
+
if (!node)
|
|
1319
|
+
throw new SourceError(`${what} runs between ${stmt.between[0]} and ${stmt.between[1]}, and ${name} does not exist`, stmt.line);
|
|
512
1320
|
return node;
|
|
513
|
-
})
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
1321
|
+
});
|
|
1322
|
+
if (between && between[0] === between[1]) {
|
|
1323
|
+
throw new SourceError(`${what} runs from ${between[0].name} to itself — a bundle between two boxes names two`, stmt.line);
|
|
1324
|
+
}
|
|
1325
|
+
edges.push({
|
|
1326
|
+
from: placeholder(stmt.name, stmt.line),
|
|
1327
|
+
to: placeholder(stmt.name, stmt.line),
|
|
1328
|
+
marks: { from: markAt('from', asEdge, appearance, what), to: markAt('to', asEdge, appearance, what) },
|
|
1329
|
+
texts: [],
|
|
1330
|
+
arrow: '--',
|
|
1331
|
+
name: stmt.name,
|
|
1332
|
+
...(stmt.via !== undefined ? { via: stmt.via } : {}),
|
|
1333
|
+
look: lineLook(stmt.attrs, appearance, what, stmt.line),
|
|
1334
|
+
attrs: stmt.attrs,
|
|
1335
|
+
appearance,
|
|
1336
|
+
// A parallel bundle's path is where its members run, not a line of its
|
|
1337
|
+
// own; a bundle between two boxes has no path, and its members are
|
|
1338
|
+
// drawn themselves, a collapsed one's on top of each other.
|
|
1339
|
+
...(stmt.arrangement !== 'collapsed' || between ? { guide: true } : {}),
|
|
1340
|
+
bundle: {
|
|
1341
|
+
arrangement: stmt.arrangement,
|
|
1342
|
+
gap: stmt.gap === undefined ? GAPS['tight'] : gapPixels(stmt.gap, `bundle "${stmt.name}" layout: (gap: …)`, stmt.line),
|
|
1343
|
+
...(stmt.order ? { order: stmt.order } : {}),
|
|
1344
|
+
...(between ? { between } : {}),
|
|
1345
|
+
},
|
|
1346
|
+
line: stmt.line,
|
|
1347
|
+
});
|
|
1348
|
+
continue;
|
|
1349
|
+
}
|
|
1350
|
+
if (stmt.kind !== 'edge')
|
|
1351
|
+
continue;
|
|
1352
|
+
const from = endNode(stmt.from, 'from', stmt.line);
|
|
1353
|
+
const to = endNode(stmt.to, 'to', stmt.line);
|
|
535
1354
|
const appearance = { ...defaults.get('edge'), ...appearanceOf(stmt.attrs, styles, stmt.line) };
|
|
536
1355
|
const what = `${stmt.from} ${stmt.arrow} ${stmt.to}`;
|
|
537
1356
|
checkAttrs('edge', what, stmt.attrs, stmt.line);
|
|
538
1357
|
checkStyleUse('edge', what, stmt.attrs, styles, stmt.line);
|
|
539
|
-
if (stmt.attrs['url'] !== undefined && stmt.
|
|
1358
|
+
if (stmt.attrs['url'] !== undefined && stmt.texts.length === 0) {
|
|
540
1359
|
// Wrapping the line works, and `LINE_WIDTH` is 1.5 — a destination that
|
|
541
1360
|
// technically has a target and practically has none. That is the silent
|
|
542
1361
|
// defect shape the language refuses everywhere else, so it is refused
|
|
@@ -544,7 +1363,20 @@ function buildEdges(statements, byName, styles, defaults) {
|
|
|
544
1363
|
throw new SourceError(`edge ${what} has a url and no text. A destination needs something to click, and a line ` +
|
|
545
1364
|
'is too thin to be it — give the edge a text, or put the url on one of the nodes', stmt.line);
|
|
546
1365
|
}
|
|
547
|
-
const
|
|
1366
|
+
const texts = stmt.texts.map(({ text, textAttrs: own, place }) => {
|
|
1367
|
+
const textAttrs = { ...bracketOf('text', appearance), ...own };
|
|
1368
|
+
const lines = linesFor(text, textAttrs, `edge ${what}`, stmt.line);
|
|
1369
|
+
const size = fontSizeFor('edge', textAttrs, fontSize, stmt.line);
|
|
1370
|
+
return {
|
|
1371
|
+
text,
|
|
1372
|
+
lines,
|
|
1373
|
+
textAttrs,
|
|
1374
|
+
place,
|
|
1375
|
+
size,
|
|
1376
|
+
width: widestLine(lines, measurer, size),
|
|
1377
|
+
height: lines.length * measurer.lineHeight(size),
|
|
1378
|
+
};
|
|
1379
|
+
});
|
|
548
1380
|
edges.push({
|
|
549
1381
|
from,
|
|
550
1382
|
to,
|
|
@@ -552,1734 +1384,305 @@ function buildEdges(statements, byName, styles, defaults) {
|
|
|
552
1384
|
from: markAt('from', stmt, appearance, what),
|
|
553
1385
|
to: markAt('to', stmt, appearance, what),
|
|
554
1386
|
},
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
...(
|
|
560
|
-
...(
|
|
1387
|
+
texts,
|
|
1388
|
+
arrow: stmt.arrow,
|
|
1389
|
+
...(stmt.name !== undefined ? { name: stmt.name } : {}),
|
|
1390
|
+
...(stmt.via !== undefined ? { via: stmt.via } : {}),
|
|
1391
|
+
...(stmt.straight !== undefined ? { straight: stmt.straight } : {}),
|
|
1392
|
+
...(stmt.evenWith !== undefined ? { evenWith: stmt.evenWith } : {}),
|
|
561
1393
|
look: lineLook(stmt.attrs, appearance, what, stmt.line),
|
|
562
1394
|
attrs: stmt.attrs,
|
|
563
1395
|
appearance,
|
|
1396
|
+
...(stmt.guide ? { guide: true } : {}),
|
|
1397
|
+
...(named.has(stmt.from) || named.has(stmt.to)
|
|
1398
|
+
? {
|
|
1399
|
+
onMark: {
|
|
1400
|
+
...(named.has(stmt.from) ? { from: named.get(stmt.from).kind } : {}),
|
|
1401
|
+
...(named.has(stmt.to) ? { to: named.get(stmt.to).kind } : {}),
|
|
1402
|
+
},
|
|
1403
|
+
}
|
|
1404
|
+
: {}),
|
|
564
1405
|
line: stmt.line,
|
|
565
1406
|
});
|
|
1407
|
+
pending.push({
|
|
1408
|
+
edge: edges[edges.length - 1],
|
|
1409
|
+
...(onLines.has(stmt.from) && !byName.has(stmt.from) ? { from: stmt.from } : {}),
|
|
1410
|
+
...(onLines.has(stmt.to) && !byName.has(stmt.to) ? { to: stmt.to } : {}),
|
|
1411
|
+
});
|
|
1412
|
+
}
|
|
1413
|
+
const byLine = new Map(edges.filter((edge) => edge.name !== undefined).map((edge) => [edge.name, edge]));
|
|
1414
|
+
for (const edge of edges) {
|
|
1415
|
+
for (const entry of edge.via ?? []) {
|
|
1416
|
+
if (entry.leg && legNames.has(entry.leg.name))
|
|
1417
|
+
byLine.set(entry.leg.name, edge);
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
for (const { edge, from, to } of pending) {
|
|
1421
|
+
if (from === undefined && to === undefined)
|
|
1422
|
+
continue;
|
|
1423
|
+
edge.onLine = {
|
|
1424
|
+
...(from !== undefined ? { from: byLine.get(from) } : {}),
|
|
1425
|
+
...(to !== undefined ? { to: byLine.get(to) } : {}),
|
|
1426
|
+
};
|
|
566
1427
|
}
|
|
567
1428
|
return edges;
|
|
568
1429
|
}
|
|
569
1430
|
/**
|
|
570
|
-
*
|
|
571
|
-
*
|
|
572
|
-
*
|
|
573
|
-
* it on a curved line, which has no corners. From a style or a default it
|
|
574
|
-
* applies to the edges that have corners, the way a style's `fill:` lands on
|
|
575
|
-
* the nodes that have an inside.
|
|
1431
|
+
* An end at a named point or region is a box where the mark is: of no size at
|
|
1432
|
+
* a point, the region's rectangle at a region. Done after the page is
|
|
1433
|
+
* normalized, since the marks move with it.
|
|
576
1434
|
*/
|
|
577
|
-
function
|
|
578
|
-
const
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
const value = appearance['line.thickness'];
|
|
590
|
-
if (value !== undefined && /^[0-9.]/.test(value)) {
|
|
591
|
-
if (!/^\d+(\.\d+)?$/.test(value) || Number(value) <= 0) {
|
|
592
|
-
throw new SourceError(`edge ${what}: "thickness: ${value}" — a thickness is thin, normal, thick, ` +
|
|
593
|
-
'or a plain number of pixels greater than zero, such as `thickness: 3`', line);
|
|
594
|
-
}
|
|
595
|
-
return Number(value);
|
|
1435
|
+
function standInForMarks(edges, marks) {
|
|
1436
|
+
const byName = new Map(marks.map((mark) => [mark.name, mark]));
|
|
1437
|
+
for (const edge of edges) {
|
|
1438
|
+
for (const which of ['from', 'to']) {
|
|
1439
|
+
if (edge.onMark?.[which] === undefined)
|
|
1440
|
+
continue;
|
|
1441
|
+
const node = edge[which];
|
|
1442
|
+
const mark = byName.get(node.name);
|
|
1443
|
+
node.x = mark.x;
|
|
1444
|
+
node.y = mark.y;
|
|
1445
|
+
node.width = mark.width;
|
|
1446
|
+
node.height = mark.height;
|
|
596
1447
|
}
|
|
597
|
-
return THICKNESS[word('thickness')];
|
|
598
|
-
})();
|
|
599
|
-
const look = {
|
|
600
|
-
path: word('path'),
|
|
601
|
-
corners: word('corners'),
|
|
602
|
-
crossing: word('crossing'),
|
|
603
|
-
pattern: word('pattern'),
|
|
604
|
-
thickness,
|
|
605
|
-
};
|
|
606
|
-
if (look.path === 'curved' && written['line.corners'] === 'rounded') {
|
|
607
|
-
throw new SourceError(`edge ${what}: a curved line has no corners to round — \`corners: rounded\` takes a ` +
|
|
608
|
-
'`path: square` or `path: straight` line', line);
|
|
609
|
-
}
|
|
610
|
-
return look;
|
|
611
|
-
}
|
|
612
|
-
/**
|
|
613
|
-
* Give a node a width and height, sizing its children first. Also records each
|
|
614
|
-
* child's offset within this node, which pass three turns into absolute
|
|
615
|
-
* coordinates once this node itself is placed.
|
|
616
|
-
*/
|
|
617
|
-
function sizeNode(node, edges, measurer, fontSize, local) {
|
|
618
|
-
for (const child of node.children)
|
|
619
|
-
sizeNode(child, edges, measurer, fontSize, local);
|
|
620
|
-
// Text is measured at the size it will be drawn at — the size lives in
|
|
621
|
-
// `constants.ts` precisely so the resolver reserving the room and the
|
|
622
|
-
// renderer filling it cannot disagree about how much room there is.
|
|
623
|
-
const textSize = fontSizeFor(node.kind, node.textAttrs, fontSize, node.line);
|
|
624
|
-
const lineHeight = measurer.lineHeight(textSize);
|
|
625
|
-
// A node with empty text takes no room for it. This is what makes an
|
|
626
|
-
// invisible grouping container size to exactly its contents.
|
|
627
|
-
const hasText = node.lines.some((line) => plain(line).length > 0);
|
|
628
|
-
const textWidth = hasText ? widestLine(node.lines, measurer, textSize) : 0;
|
|
629
|
-
const textHeight = hasText ? node.lines.length * lineHeight : 0;
|
|
630
|
-
const glyphSide = ICON_LINES * lineHeight;
|
|
631
|
-
if (node.body.kind === 'none') {
|
|
632
|
-
// No body, so the node is its text: no padding, no outline, no children.
|
|
633
|
-
if (node.children.length > 0) {
|
|
634
|
-
throw new SourceError(`"${node.name}" has shape: none and has children. With no body there is no box for ` +
|
|
635
|
-
'anything to go inside', node.line);
|
|
636
|
-
}
|
|
637
|
-
node.width = textWidth;
|
|
638
|
-
node.height = textHeight;
|
|
639
|
-
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);
|
|
640
|
-
return;
|
|
641
1448
|
}
|
|
642
|
-
if (node.body.kind === 'icon') {
|
|
643
|
-
// Drawn as a picture, so there is no box to pad and the node's size is the
|
|
644
|
-
// picture's. A text goes under it rather than inside it, which is the
|
|
645
|
-
// arrangement that makes a row of these read as captioned things.
|
|
646
|
-
if (node.children.length > 0) {
|
|
647
|
-
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);
|
|
648
|
-
}
|
|
649
|
-
node.width = Math.max(glyphSide, textWidth);
|
|
650
|
-
node.height = glyphSide + (hasText ? ICON_GAP + textHeight : 0);
|
|
651
|
-
// The caption is under the picture and nowhere else, so only the
|
|
652
|
-
// horizontal half of `at` reaches it.
|
|
653
|
-
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);
|
|
654
|
-
return;
|
|
655
|
-
}
|
|
656
|
-
// Read before the split, so a misspelt word is refused on a childless node
|
|
657
|
-
// too. The *absence* of children makes the setting inert, which stays silent;
|
|
658
|
-
// a value the language does not have is wrong wherever it is written.
|
|
659
|
-
const contents = contentsStyleFor(node);
|
|
660
|
-
const circle = isCircle(node);
|
|
661
|
-
if (circle)
|
|
662
|
-
refuseOnCircle(node);
|
|
663
|
-
if (node.children.length === 0) {
|
|
664
|
-
// `at` is read on a leaf too, and is inert wherever the box is exactly the
|
|
665
|
-
// size of what it holds — which is every leaf, since a leaf is sized from
|
|
666
|
-
// its own text. It bites once the box is sized by something else: a
|
|
667
|
-
// `widths:` that widens it, or a child placed beside the text. Inert-but-
|
|
668
|
-
// legal stays silent here, the same treatment `align` gets on a leaf with
|
|
669
|
-
// one line.
|
|
670
|
-
const style = textStyleFor(node.textAttrs, node.line, 'middle', 'center');
|
|
671
|
-
node.width = textWidth + PAD * 2;
|
|
672
|
-
node.height = textHeight + PAD * 2;
|
|
673
|
-
node.textBox = textBoxIn({
|
|
674
|
-
x: PAD,
|
|
675
|
-
y: leafTop(style.end, 0, node.height, textHeight),
|
|
676
|
-
width: node.width - PAD * 2,
|
|
677
|
-
height: textHeight,
|
|
678
|
-
}, (node.textSide = style.side), textWidth, textHeight);
|
|
679
|
-
}
|
|
680
|
-
else if (framedChildren(node).size > 0) {
|
|
681
|
-
// Something is placed against this node's own frame or text, so where its
|
|
682
|
-
// text sits and how big it is come out of one solve with its children.
|
|
683
|
-
const own = { textWidth, textHeight, contents };
|
|
684
|
-
const lay = (width) => {
|
|
685
|
-
layoutFramed(node, own, edges, measurer, fontSize, local, width - node.deckTexts.length * DECK_STEP);
|
|
686
|
-
applyDeck(node, local);
|
|
687
|
-
};
|
|
688
|
-
lay(0);
|
|
689
|
-
relayout.set(node, lay);
|
|
690
|
-
return;
|
|
691
|
-
}
|
|
692
|
-
else {
|
|
693
|
-
if (contents.widths === 'match')
|
|
694
|
-
matchWidths(node.children, local);
|
|
695
|
-
let content = layoutChildren(node.children, edges, measurer, fontSize, local);
|
|
696
|
-
if (contents.widths === 'fill') {
|
|
697
|
-
// The band is the wider of the title and the contents, so filling it
|
|
698
|
-
// needs the contents measured first. Where they already set the width
|
|
699
|
-
// this is `match` exactly; where the title wins it is the answer `match`
|
|
700
|
-
// could not give.
|
|
701
|
-
const band = Math.max(textWidth, content.width);
|
|
702
|
-
for (const child of node.children)
|
|
703
|
-
widenTo(child, band, local);
|
|
704
|
-
content = layoutChildren(node.children, edges, measurer, fontSize, local);
|
|
705
|
-
checkOneColumn(node, node.children, local);
|
|
706
|
-
}
|
|
707
|
-
// `headerHeight` is the band the text takes, whichever end of the box that
|
|
708
|
-
// band is at. Only the contents' offset depends on the side.
|
|
709
|
-
node.headerHeight = hasText ? textHeight + HEADER_GAP : 0;
|
|
710
|
-
node.width = Math.max(textWidth, content.width) + PAD * 2;
|
|
711
|
-
node.height = node.headerHeight + content.height + PAD * 2;
|
|
712
|
-
const style = textStyleFor(node.textAttrs, node.line);
|
|
713
|
-
if (style.end === 'center')
|
|
714
|
-
throw bandInMiddle(node, style.at);
|
|
715
|
-
const above = style.end === 'top' ? node.headerHeight : 0;
|
|
716
|
-
// The text has a band at one end of the box, and the contents have the
|
|
717
|
-
// other.
|
|
718
|
-
node.textBox = textBoxIn({
|
|
719
|
-
x: PAD,
|
|
720
|
-
y: style.end === 'top' ? PAD : node.height - PAD - textHeight,
|
|
721
|
-
width: node.width - PAD * 2,
|
|
722
|
-
height: textHeight,
|
|
723
|
-
}, (node.textSide = style.side), textWidth, textHeight);
|
|
724
|
-
// Where the title is wider than the contents, the slack is all on the right
|
|
725
|
-
// — every member sits at the smallest position its constraints allow and
|
|
726
|
-
// nothing pushes it along. `align` is what says where the block goes in it.
|
|
727
|
-
const slack = node.width - PAD * 2 - content.width;
|
|
728
|
-
const shift = contents.align === 'left' ? 0 : contents.align === 'center' ? slack / 2 : slack;
|
|
729
|
-
for (const child of node.children) {
|
|
730
|
-
const offset = local.get(child);
|
|
731
|
-
offset.x += PAD + shift;
|
|
732
|
-
offset.y += PAD + above;
|
|
733
|
-
}
|
|
734
|
-
node.banded = true;
|
|
735
|
-
}
|
|
736
|
-
if (circle)
|
|
737
|
-
circumscribe(node, local);
|
|
738
|
-
applyDeck(node, local);
|
|
739
|
-
}
|
|
740
|
-
function isCircle(node) {
|
|
741
|
-
return node.body.kind === 'shape' && node.body.outline === 'circle';
|
|
742
|
-
}
|
|
743
|
-
/**
|
|
744
|
-
* Grow a node sized as a rectangle into the circle around what it holds.
|
|
745
|
-
*
|
|
746
|
-
* The circle passes a padding outside the corners of the padded-in block —
|
|
747
|
-
* the text of a leaf, or the band and contents of a container — so whatever
|
|
748
|
-
* fits in the rectangle fits in the circle, clear of its edge by the same
|
|
749
|
-
* padding at the corners where it comes closest. Everything inside moves to
|
|
750
|
-
* keep the block centered.
|
|
751
|
-
*/
|
|
752
|
-
function circumscribe(node, local) {
|
|
753
|
-
const side = Math.hypot(node.width - PAD * 2, node.height - PAD * 2) + PAD * 2;
|
|
754
|
-
moveInside(node, (side - node.width) / 2, (side - node.height) / 2, local);
|
|
755
|
-
node.width = side;
|
|
756
|
-
node.height = side;
|
|
757
1449
|
}
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
for (const child of node.children) {
|
|
762
|
-
const offset = local.get(child);
|
|
763
|
-
offset.x += dx;
|
|
764
|
-
offset.y += dy;
|
|
765
|
-
}
|
|
766
|
-
}
|
|
767
|
-
/**
|
|
768
|
-
* What a circle does not take yet, refused by name rather than drawn as an
|
|
769
|
-
* ellipse or with a copy's text outside the outline.
|
|
770
|
-
*
|
|
771
|
-
* Something placed against the circle's own edge or text — a badge included —
|
|
772
|
-
* sizes the frame in the same solve as the thing placed, and a circle's corner
|
|
773
|
-
* points are a fraction of a radius that solve does not know yet. A deck's
|
|
774
|
-
* copies write their text at the top-left of the face, which on a circle is
|
|
775
|
-
* outside it.
|
|
776
|
-
*/
|
|
777
|
-
function refuseOnCircle(node) {
|
|
778
|
-
const [framed] = framedChildren(node);
|
|
779
|
-
if (node.attrs['badge'] !== undefined || node.appearance['badge'] !== undefined) {
|
|
780
|
-
throw new SourceError(`"${node.name}" is a circle with a badge:, and a badge is not built for circles yet — ` +
|
|
781
|
-
'make it a rectangle, or place the badge as a node of its own against the circle', node.line);
|
|
782
|
-
}
|
|
783
|
-
if (framed !== undefined) {
|
|
784
|
-
throw new SourceError(`"${framed.name}" is placed against "${node.name}" itself, and "${node.name}" is a circle. ` +
|
|
785
|
-
'Something placed against a circle\'s own edge or text is not built yet — place it against ' +
|
|
786
|
-
`another node, or make "${node.name}" a rectangle`, framed.line);
|
|
787
|
-
}
|
|
788
|
-
if (node.deckTexts.length > 0) {
|
|
789
|
-
throw new SourceError(`"${node.name}" is a circle with a deck:, and a deck is not built for circles yet`, node.line);
|
|
790
|
-
}
|
|
791
|
-
}
|
|
792
|
-
/**
|
|
793
|
-
* Make room for a deck's copies, once the node's own face has been sized.
|
|
794
|
-
*
|
|
795
|
-
* The copies sit behind and above-left, so the whole node grows by the depth
|
|
796
|
-
* of the stack and its own face moves down and right by the same.
|
|
797
|
-
*/
|
|
798
|
-
function applyDeck(node, local) {
|
|
799
|
-
if (node.deckTexts.length > 0) {
|
|
800
|
-
node.inset = node.deckTexts.length * DECK_STEP;
|
|
801
|
-
node.width += node.inset;
|
|
802
|
-
node.height += node.inset;
|
|
803
|
-
node.textBox.x += node.inset;
|
|
804
|
-
node.textBox.y += node.inset;
|
|
805
|
-
for (const child of node.children) {
|
|
806
|
-
const offset = local.get(child);
|
|
807
|
-
offset.x += node.inset;
|
|
808
|
-
offset.y += node.inset;
|
|
809
|
-
}
|
|
810
|
-
}
|
|
811
|
-
}
|
|
812
|
-
/**
|
|
813
|
-
* Where a block of text ends up in the room it was given: the *ink* box, which
|
|
814
|
-
* is what `hub text` names as a placement target and what the renderer draws.
|
|
815
|
-
*
|
|
816
|
-
* Only the horizontal is decided here. The vertical is settled by whoever
|
|
817
|
-
* knows which end of the box the text sits at, which differs between a leaf, a
|
|
818
|
-
* container's band and a caption under a picture, and arrives as `room.y`.
|
|
819
|
-
*/
|
|
820
|
-
function textBoxIn(room, side, inkWidth, inkHeight) {
|
|
821
|
-
const x = side === 'left'
|
|
822
|
-
? room.x
|
|
823
|
-
: side === 'right'
|
|
824
|
-
? room.x + room.width - inkWidth
|
|
825
|
-
: room.x + (room.width - inkWidth) / 2;
|
|
826
|
-
return { x, y: room.y, width: inkWidth, height: inkHeight };
|
|
827
|
-
}
|
|
828
|
-
function contentsStyleFor(node) {
|
|
829
|
-
const written = bracketOf('contents', node.appearance);
|
|
830
|
-
const widths = written['widths'] ?? 'natural';
|
|
831
|
-
const align = written['align'] ?? 'left';
|
|
832
|
-
if (!CONTENT_WIDTHS.includes(widths)) {
|
|
833
|
-
throw new SourceError(`"${node.name}" has contents: (widths: ${widths}), which is not one of ${CONTENT_WIDTHS.join(', ')}`, node.line);
|
|
834
|
-
}
|
|
835
|
-
if (!CONTENT_ALIGNMENTS.includes(align)) {
|
|
836
|
-
throw new SourceError(`"${node.name}" has contents: (align: ${align}), which is not one of ${CONTENT_ALIGNMENTS.join(', ')}`, node.line);
|
|
837
|
-
}
|
|
838
|
-
return { widths, align };
|
|
839
|
-
}
|
|
840
|
-
/**
|
|
841
|
-
* `widths: match` widens every direct child to the widest one's natural width,
|
|
842
|
-
* before layoutChildren sizes and positions anything from those widths. A
|
|
843
|
-
* container's own children are already sized by this point.
|
|
844
|
-
*/
|
|
845
|
-
function matchWidths(children, local) {
|
|
846
|
-
const maxWidth = Math.max(...children.map((child) => child.width));
|
|
847
|
-
for (const child of children)
|
|
848
|
-
widenTo(child, maxWidth, local);
|
|
849
|
-
}
|
|
850
|
-
/**
|
|
851
|
-
* `widths: fill` makes every child as wide as the band, which only holds for
|
|
852
|
-
* a single column: two children set to the band's width and not one above the
|
|
853
|
-
* other make the contents wider than the band, the band grows with them, and
|
|
854
|
-
* no child is the width it was asked to be. Every child of a column starts at
|
|
855
|
-
* the same x once they are all one width, so a child that does not is the
|
|
856
|
-
* proof, and the pair is named rather than drawn wrong.
|
|
857
|
-
*/
|
|
858
|
-
function checkOneColumn(node, children, local) {
|
|
859
|
-
const [first, ...rest] = children;
|
|
860
|
-
if (!first)
|
|
861
|
-
return;
|
|
862
|
-
const off = rest.find((child) => Math.abs(local.get(child).x - local.get(first).x) > 0.5);
|
|
863
|
-
if (!off)
|
|
864
|
-
return;
|
|
865
|
-
throw new SourceError(`"${node.name}" has contents: (widths: fill), which makes every child the full width of the box, ` +
|
|
866
|
-
`and that only works in a single column — "${first.name}" and "${off.name}" are not one above the other. ` +
|
|
867
|
-
'Put them in one column, or use contents: (widths: match) to make them one width without filling the box', node.line);
|
|
868
|
-
}
|
|
869
|
-
/**
|
|
870
|
-
* Make a node wider than it was sized, carrying its text with it.
|
|
871
|
-
*
|
|
872
|
-
* `widths: match` and `widths: fill` both set a child's width after that child
|
|
873
|
-
* was sized from its own contents, and the text's box was worked out against
|
|
874
|
-
* the old one. A left-ranged text stays where it is; a centered or right-ranged
|
|
875
|
-
* one moves by its share of the difference.
|
|
876
|
-
*
|
|
877
|
-
* A circle grows both ways, since a wider circle is a bigger one, and what is
|
|
878
|
-
* inside it stays centered.
|
|
879
|
-
*/
|
|
880
|
-
function widenTo(node, width, local) {
|
|
881
|
-
if (isCircle(node)) {
|
|
882
|
-
const grew = width - node.width;
|
|
883
|
-
moveInside(node, grew / 2, grew / 2, local);
|
|
884
|
-
node.width = width;
|
|
885
|
-
node.height = width;
|
|
886
|
-
return;
|
|
887
|
-
}
|
|
888
|
-
// A node with something placed against its own frame is laid out by a
|
|
889
|
-
// solve, and a wider frame is one more thing that solve has to hold: the
|
|
890
|
-
// things against its right edge move, and a centered text re-centers.
|
|
891
|
-
const again = relayout.get(node);
|
|
892
|
-
if (again) {
|
|
893
|
-
again(width);
|
|
894
|
-
return;
|
|
895
|
-
}
|
|
896
|
-
const grew = width - node.width;
|
|
897
|
-
node.width = width;
|
|
898
|
-
if (node.textSide === 'center')
|
|
899
|
-
node.textBox.x += grew / 2;
|
|
900
|
-
else if (node.textSide === 'right')
|
|
901
|
-
node.textBox.x += grew;
|
|
902
|
-
}
|
|
903
|
-
/**
|
|
904
|
-
* Position a container's children relative to each other. Children that make
|
|
905
|
-
* no placement stack vertically in written order; the rest are solved against the
|
|
906
|
-
* siblings they name, by the same constraint pass that positions top-level
|
|
907
|
-
* nodes. A placement may only name a sibling — containment scopes the group.
|
|
908
|
-
*/
|
|
909
|
-
function layoutChildren(children, edges, measurer, fontSize, local) {
|
|
910
|
-
const siblings = new Map(children.map((child) => [child.name, child]));
|
|
911
|
-
// Children that say nothing keep the written order, down the page and flush
|
|
912
|
-
// left. Written as constraints rather than a cursor so a placed sibling can
|
|
913
|
-
// push them along like anything else.
|
|
914
|
-
const stack = [];
|
|
915
|
-
const alignment = [];
|
|
916
|
-
const quiet = children.filter((child) => child.placements.length === 0);
|
|
917
|
-
const indexOf = new Map(children.map((child, index) => [child, index]));
|
|
918
|
-
quiet.forEach((child, position) => {
|
|
919
|
-
const previous = quiet[position - 1];
|
|
920
|
-
if (!previous)
|
|
921
|
-
return;
|
|
922
|
-
const before = indexOf.get(previous);
|
|
923
|
-
const after = indexOf.get(child);
|
|
924
|
-
stack.push({ from: before, to: after, weight: previous.height + CHILD_GAP });
|
|
925
|
-
alignment.push(...fix(before, after, 0));
|
|
926
|
-
});
|
|
927
|
-
const positions = positionGroup(children, (placement, owner) => placement.targets.map(({ name, part }) => {
|
|
928
|
-
const target = siblings.get(name);
|
|
929
|
-
if (!target) {
|
|
930
|
-
throw new SourceError(`"${owner.name}" is placed against "${name}", which is not one of its siblings or its parent`, placement.line);
|
|
931
|
-
}
|
|
932
|
-
return partOf({
|
|
933
|
-
name,
|
|
934
|
-
node: target,
|
|
935
|
-
index: indexOf.get(target),
|
|
936
|
-
offset: { x: 0, y: 0 },
|
|
937
|
-
width: target.width,
|
|
938
|
-
height: target.height,
|
|
939
|
-
}, part, owner, placement.line);
|
|
940
|
-
}), { x: alignment, y: stack }, corridorsIn(edges, (node) => liftTo(node, indexOf, local), measurer, fontSize));
|
|
941
|
-
for (const child of children)
|
|
942
|
-
local.set(child, positions.get(child));
|
|
943
|
-
return extentOf(children, local);
|
|
944
|
-
}
|
|
945
|
-
function extentOf(children, local) {
|
|
946
|
-
let minX = Infinity;
|
|
947
|
-
let minY = Infinity;
|
|
948
|
-
let maxX = -Infinity;
|
|
949
|
-
let maxY = -Infinity;
|
|
950
|
-
// What a child has placed outside itself is still part of it, so the block
|
|
951
|
-
// holding the child holds that too.
|
|
952
|
-
for (const child of children) {
|
|
953
|
-
const offset = local.get(child);
|
|
954
|
-
const { reach } = child;
|
|
955
|
-
minX = Math.min(minX, offset.x - reach.left);
|
|
956
|
-
minY = Math.min(minY, offset.y - reach.top);
|
|
957
|
-
maxX = Math.max(maxX, offset.x + child.width + reach.right);
|
|
958
|
-
maxY = Math.max(maxY, offset.y + child.height + reach.bottom);
|
|
959
|
-
}
|
|
960
|
-
for (const child of children) {
|
|
961
|
-
const offset = local.get(child);
|
|
962
|
-
offset.x -= minX;
|
|
963
|
-
offset.y -= minY;
|
|
964
|
-
}
|
|
965
|
-
return { width: maxX - minX, height: maxY - minY };
|
|
966
|
-
}
|
|
967
|
-
/** How to lay a framed node out again at a given width, which is what `widenTo` needs. */
|
|
968
|
-
const relayout = new WeakMap();
|
|
969
|
-
/**
|
|
970
|
-
* The children placed against their parent — its frame, a part of it, or its
|
|
971
|
-
* text — together with any child placed against one of those, and so on.
|
|
972
|
-
*
|
|
973
|
-
* These hang off the frame. The rest hang off the default position under the
|
|
974
|
-
* text, which is the contents stack as it always was. Nothing sorts a child
|
|
975
|
-
* into one or the other; it is read off what each placement names.
|
|
976
|
-
*/
|
|
977
|
-
function framedChildren(parent) {
|
|
978
|
-
const framed = new Set();
|
|
979
|
-
const byName = new Map(parent.children.map((child) => [child.name, child]));
|
|
980
|
-
let grew = true;
|
|
981
|
-
while (grew) {
|
|
982
|
-
grew = false;
|
|
983
|
-
for (const child of parent.children) {
|
|
984
|
-
if (framed.has(child))
|
|
985
|
-
continue;
|
|
986
|
-
const names = child.placements.flatMap((placement) => placement.targets.map((target) => target.name));
|
|
987
|
-
const hangs = names.some((name) => {
|
|
988
|
-
if (name === parent.name)
|
|
989
|
-
return true;
|
|
990
|
-
const sibling = byName.get(name);
|
|
991
|
-
return sibling !== undefined && framed.has(sibling);
|
|
992
|
-
});
|
|
993
|
-
if (hangs) {
|
|
994
|
-
framed.add(child);
|
|
995
|
-
grew = true;
|
|
996
|
-
}
|
|
997
|
-
}
|
|
998
|
-
}
|
|
999
|
-
return framed;
|
|
1000
|
-
}
|
|
1001
|
-
/**
|
|
1002
|
-
* The sides of its parent's frame a child is not held inside by the padding.
|
|
1003
|
-
*
|
|
1004
|
-
* Every child is held a padding in from the frame on all four sides, which is
|
|
1005
|
-
* how a box grows to hold what is in it. A child placed against a side is
|
|
1006
|
-
* held by that placement instead — tucked a gap in with `inside`, straddling
|
|
1007
|
-
* it with `on`, beyond it with `outside` — and holding it by the padding as
|
|
1008
|
-
* well would contradict the placement whenever the gap is the smaller.
|
|
1009
|
-
*
|
|
1010
|
-
* `overlap: allow` on a child is "do not grow for me": it lies over whatever
|
|
1011
|
-
* is there, and the frame is not held open around it at all.
|
|
1012
|
-
*/
|
|
1013
|
-
function freeSides(child, parent) {
|
|
1014
|
-
if (allowsOverlap(child))
|
|
1015
|
-
return new Set(PART_SIDES);
|
|
1016
|
-
const free = new Set();
|
|
1017
|
-
const across = (direction) => [
|
|
1018
|
-
...(/left|right/.test(direction) ? ['left', 'right'] : []),
|
|
1019
|
-
...(/above|below/.test(direction) ? ['top', 'bottom'] : []),
|
|
1020
|
-
];
|
|
1021
|
-
for (const placement of child.placements) {
|
|
1022
|
-
for (const target of placement.targets) {
|
|
1023
|
-
if (target.name !== parent.name || target.part === 'text')
|
|
1024
|
-
continue;
|
|
1025
|
-
if (placement.kind === 'offset' && placement.written !== 'inside') {
|
|
1026
|
-
// Beyond the frame, so it neither holds the frame open nor is held in
|
|
1027
|
-
// by it — on either axis. Held on the other one, a note taller than
|
|
1028
|
-
// its parent's text would stretch the parent to fit a thing outside it.
|
|
1029
|
-
for (const side of PART_SIDES)
|
|
1030
|
-
free.add(side);
|
|
1031
|
-
continue;
|
|
1032
|
-
}
|
|
1033
|
-
const words = (target.part ?? '').split('-');
|
|
1034
|
-
for (const side of PART_SIDES)
|
|
1035
|
-
if (words.includes(side))
|
|
1036
|
-
free.add(side);
|
|
1037
|
-
if (placement.kind === 'align' && placement.side !== 'center')
|
|
1038
|
-
free.add(placement.side);
|
|
1039
|
-
}
|
|
1040
|
-
}
|
|
1041
|
-
return free;
|
|
1042
|
-
}
|
|
1043
|
-
/**
|
|
1044
|
-
* The parent's frame as a target, narrowed to the part that was named.
|
|
1045
|
-
*
|
|
1046
|
-
* The frame is two members of the child system, its top-left corner and its
|
|
1047
|
-
* bottom-right, so a part of it is read off those two per axis: `right` is
|
|
1048
|
-
* across at the bottom-right corner and spans both down, `top-center` spans
|
|
1049
|
-
* both across and is down at the top-left. A part that spans comes as its two
|
|
1050
|
-
* ends, and a placement against two targets already means the region that
|
|
1051
|
-
* bounds them.
|
|
1052
|
-
*/
|
|
1053
|
-
function frameTargets(parent, part, first, last) {
|
|
1054
|
-
const words = (part ?? '').split('-');
|
|
1055
|
-
const pick = (near, far) => words.includes(near) ? [first] : words.includes(far) ? [last] : [first, last];
|
|
1056
|
-
const xs = pick('left', 'right');
|
|
1057
|
-
const ys = pick('top', 'bottom');
|
|
1058
|
-
const name = part === undefined ? parent.name : `${parent.name} ${part}`;
|
|
1059
|
-
const point = (x, y) => ({
|
|
1450
|
+
/** A box of no size standing for a point on a line, which `lines.ts` puts in place once the line is drawn. */
|
|
1451
|
+
function placeholder(name, line) {
|
|
1452
|
+
return {
|
|
1060
1453
|
name,
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1454
|
+
kind: 'none',
|
|
1455
|
+
body: { kind: 'none' },
|
|
1456
|
+
corners: 'rounded',
|
|
1457
|
+
text: '',
|
|
1458
|
+
lines: [],
|
|
1459
|
+
children: [],
|
|
1460
|
+
x: 0,
|
|
1461
|
+
y: 0,
|
|
1066
1462
|
width: 0,
|
|
1067
1463
|
height: 0,
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
* A stand-in member for something of the parent's own that takes room among
|
|
1075
|
-
* its children: its text, its badge, the block of its contents, a corner of
|
|
1076
|
-
* its frame. Named after the parent so an error about it reads in the
|
|
1077
|
-
* author's words — `"hub text" and "star" overlap`.
|
|
1078
|
-
*/
|
|
1079
|
-
function stand(of, what, width, height) {
|
|
1080
|
-
return {
|
|
1081
|
-
...of,
|
|
1082
|
-
name: `${of.name} ${what}`,
|
|
1083
|
-
parent: undefined,
|
|
1084
|
-
children: [],
|
|
1085
|
-
placements: [],
|
|
1464
|
+
headerHeight: 0,
|
|
1465
|
+
reach: { left: 0, top: 0, right: 0, bottom: 0 },
|
|
1466
|
+
banded: false,
|
|
1467
|
+
textBox: { x: 0, y: 0, width: 0, height: 0 },
|
|
1468
|
+
textSide: 'center',
|
|
1469
|
+
textAttrs: {},
|
|
1086
1470
|
attrs: {},
|
|
1087
1471
|
appearance: {},
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
x: 0,
|
|
1091
|
-
y: 0,
|
|
1092
|
-
width,
|
|
1093
|
-
height,
|
|
1472
|
+
placements: [],
|
|
1473
|
+
line,
|
|
1094
1474
|
};
|
|
1095
1475
|
}
|
|
1096
|
-
function bandInMiddle(node, at) {
|
|
1097
|
-
// A container's band is at one end precisely so its contents can have the
|
|
1098
|
-
// other, so the three positions that name neither end have nothing to mean
|
|
1099
|
-
// here. Refused rather than rounded to an end, for the reason every other
|
|
1100
|
-
// word in the language is: a value that quietly becomes a different value
|
|
1101
|
-
// looks like the tool being broken.
|
|
1102
|
-
return new SourceError(`"${node.name}" holds things and its text is at: ${at}. A container's text sits at ` +
|
|
1103
|
-
'the top or the bottom so its contents can have the other end, so name a position on ' +
|
|
1104
|
-
'one of those edges', node.line);
|
|
1105
|
-
}
|
|
1106
1476
|
/**
|
|
1107
|
-
*
|
|
1108
|
-
*
|
|
1109
|
-
*
|
|
1110
|
-
* block of ordinary contents solved as it always was, the node's text and
|
|
1111
|
-
* badge, and the frame's two corners. Every one of them is held a padding in
|
|
1112
|
-
* from the frame, so the frame is the smallest box that holds them all, which
|
|
1113
|
-
* is what a node's size has always been. A part of the frame is then just a
|
|
1114
|
-
* target like any other.
|
|
1115
|
-
*
|
|
1116
|
-
* The band falls out rather than being decided. It exists because the
|
|
1117
|
-
* contents stack below the text; where every child hangs off the frame
|
|
1118
|
-
* instead, nothing is below the text, there is no band, and the node reads as
|
|
1119
|
-
* a leaf — its text centered, and whatever is beside the text centered with
|
|
1120
|
-
* it as one group.
|
|
1477
|
+
* Where the box each text on a line is placed by sits: `straight right from c`,
|
|
1478
|
+
* `above s1`. And a text's `on:` must name one of its own line's legs, since a
|
|
1479
|
+
* leg is part of one line (design.org 7.3).
|
|
1121
1480
|
*/
|
|
1122
|
-
function
|
|
1123
|
-
const
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
: textStyleFor(node.textAttrs, node.line, 'middle', 'center');
|
|
1132
|
-
if (banded && style.end === 'center')
|
|
1133
|
-
throw bandInMiddle(node, style.at);
|
|
1134
|
-
// The children placed against this node's text. With it they make the
|
|
1135
|
-
// text's row: the contents stack past all of them, and they range and
|
|
1136
|
-
// center with the text as one group.
|
|
1137
|
-
const againstText = (placement) => placement.targets.some((target) => target.name === node.name && target.part === 'text');
|
|
1138
|
-
const withText = placed.filter((child) => child.placements.some(againstText));
|
|
1139
|
-
let block = { width: 0, height: 0 };
|
|
1140
|
-
if (banded) {
|
|
1141
|
-
if (contents.widths === 'match')
|
|
1142
|
-
matchWidths(stacked, local);
|
|
1143
|
-
block = layoutChildren(stacked, edges, measurer, fontSize, local);
|
|
1144
|
-
if (contents.widths === 'fill') {
|
|
1145
|
-
// The row is the text and whatever stands beside it, each at its gap.
|
|
1146
|
-
let row = textWidth;
|
|
1147
|
-
for (const child of withText) {
|
|
1148
|
-
const beside = child.placements.find((placement) => placement.kind === 'offset' && /left|right/.test(placement.direction) && againstText(placement));
|
|
1149
|
-
if (beside)
|
|
1150
|
-
row += child.width + (hasText ? gapFor(child, beside, node) : 0);
|
|
1481
|
+
function locateTextTargets(edges, placer) {
|
|
1482
|
+
for (const edge of edges) {
|
|
1483
|
+
const subject = `edge ${edge.from.name} ${edge.arrow} ${edge.to.name}`;
|
|
1484
|
+
const legs = (edge.via ?? []).map((entry) => entry.leg?.name).filter((name) => name !== undefined);
|
|
1485
|
+
for (const text of edge.texts) {
|
|
1486
|
+
const on = text.place.on;
|
|
1487
|
+
if (on !== undefined && !legs.includes(on)) {
|
|
1488
|
+
throw new SourceError(`${subject}: the text "${text.text}" is on: ${on}, and ${on} is not a leg of this line — ` +
|
|
1489
|
+
(legs.length > 0 ? `its legs are ${legs.join(', ')}` : 'a leg is named in via:, such as `via: 2 below b bottom, leg rise`'), edge.line);
|
|
1151
1490
|
}
|
|
1152
|
-
const
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
const TL = add(stand(node, 'frame', 0, 0));
|
|
1167
|
-
const BR = add(stand(node, 'frame', 0, 0));
|
|
1168
|
-
const x = [];
|
|
1169
|
-
const y = [];
|
|
1170
|
-
const hold = (index, free) => {
|
|
1171
|
-
const { width, height, reach } = members[index];
|
|
1172
|
-
if (!free.has('left'))
|
|
1173
|
-
x.push({ from: TL, to: index, weight: PAD + reach.left });
|
|
1174
|
-
if (!free.has('right'))
|
|
1175
|
-
x.push({ from: index, to: BR, weight: width + reach.right + PAD });
|
|
1176
|
-
if (!free.has('top'))
|
|
1177
|
-
y.push({ from: TL, to: index, weight: PAD + reach.top });
|
|
1178
|
-
if (!free.has('bottom'))
|
|
1179
|
-
y.push({ from: index, to: BR, weight: height + reach.bottom + PAD });
|
|
1180
|
-
};
|
|
1181
|
-
placed.forEach((child, index) => hold(index, freeSides(child, node)));
|
|
1182
|
-
for (const index of [K, T])
|
|
1183
|
-
if (index >= 0)
|
|
1184
|
-
hold(index, new Set());
|
|
1185
|
-
if (minWidth > 0)
|
|
1186
|
-
x.push({ from: TL, to: BR, weight: minWidth });
|
|
1187
|
-
// The text's row, as members: the text and everything placed against it.
|
|
1188
|
-
const row = [...(T >= 0 ? [T] : []), ...withText.map((child) => placed.indexOf(child))];
|
|
1189
|
-
if (banded) {
|
|
1190
|
-
// The text's row is at one end, the contents the other.
|
|
1191
|
-
for (const index of row) {
|
|
1192
|
-
const { height, reach } = members[index];
|
|
1193
|
-
if (style.end === 'top')
|
|
1194
|
-
y.push({ from: index, to: K, weight: height + reach.bottom + HEADER_GAP });
|
|
1195
|
-
else
|
|
1196
|
-
y.push({ from: K, to: index, weight: block.height + HEADER_GAP + reach.top });
|
|
1197
|
-
}
|
|
1198
|
-
}
|
|
1199
|
-
const siblings = new Map(node.children.map((child) => [child.name, child]));
|
|
1200
|
-
const indexOf = new Map(members.map((member, index) => [member, index]));
|
|
1201
|
-
const locate = (placement, owner) => placement.targets.flatMap(({ name, part }) => {
|
|
1202
|
-
if (name === node.name) {
|
|
1203
|
-
if (part !== 'text')
|
|
1204
|
-
return frameTargets(node, part, TL, BR);
|
|
1205
|
-
const text = { x: 0, y: 0 };
|
|
1206
|
-
return [
|
|
1207
|
-
{
|
|
1208
|
-
name: `${name} text`,
|
|
1209
|
-
node,
|
|
1210
|
-
index: T,
|
|
1211
|
-
offset: text,
|
|
1212
|
-
width: textWidth,
|
|
1213
|
-
height: textHeight,
|
|
1214
|
-
...(hasText ? {} : { empty: true }),
|
|
1215
|
-
},
|
|
1216
|
-
];
|
|
1217
|
-
}
|
|
1218
|
-
const sibling = siblings.get(name);
|
|
1219
|
-
if (!sibling) {
|
|
1220
|
-
throw new SourceError(`"${owner.name}" is placed against "${name}", which is not one of its siblings or its parent`, placement.line);
|
|
1221
|
-
}
|
|
1222
|
-
// A child of the contents is reached through the block that holds it,
|
|
1223
|
-
// at the offset the contents solve already gave it.
|
|
1224
|
-
const inBlock = !framed.has(sibling);
|
|
1225
|
-
const target = {
|
|
1226
|
-
name,
|
|
1227
|
-
node: sibling,
|
|
1228
|
-
index: inBlock ? K : indexOf.get(sibling),
|
|
1229
|
-
offset: inBlock ? { ...local.get(sibling) } : { x: 0, y: 0 },
|
|
1230
|
-
width: sibling.width,
|
|
1231
|
-
height: sibling.height,
|
|
1232
|
-
};
|
|
1233
|
-
return [partOf(target, part, owner, placement.line)];
|
|
1234
|
-
});
|
|
1235
|
-
// Which way a child against a side stands from the rest of the box: across
|
|
1236
|
-
// for a left or right side or corner, down for the top or bottom.
|
|
1237
|
-
const facing = new Map();
|
|
1238
|
-
placed.forEach((child, index) => {
|
|
1239
|
-
for (const placement of child.placements) {
|
|
1240
|
-
for (const target of placement.targets) {
|
|
1241
|
-
if (target.name !== node.name || target.part === undefined || target.part === 'text')
|
|
1242
|
-
continue;
|
|
1243
|
-
const words = target.part.split('-');
|
|
1244
|
-
if (words.includes('left') || words.includes('right'))
|
|
1245
|
-
facing.set(index, 'x');
|
|
1246
|
-
else if (words.includes('top') || words.includes('bottom'))
|
|
1247
|
-
facing.set(index, 'y');
|
|
1491
|
+
const by = text.place.straight ?? text.place.by;
|
|
1492
|
+
if (by)
|
|
1493
|
+
text.target = placer.locate(by.target, subject, edge.line);
|
|
1494
|
+
if (text.place.even) {
|
|
1495
|
+
text.evenAt = text.place.even.map(({ placement }) => {
|
|
1496
|
+
// Several targets line up with the stretch round them all, as in a placement.
|
|
1497
|
+
const rects = placement.targets.map((target) => placer.locate(target, subject, edge.line));
|
|
1498
|
+
const x = Math.min(...rects.map((rect) => rect.x));
|
|
1499
|
+
const y = Math.min(...rects.map((rect) => rect.y));
|
|
1500
|
+
const width = Math.max(...rects.map((rect) => rect.x + rect.width)) - x;
|
|
1501
|
+
const height = Math.max(...rects.map((rect) => rect.y + rect.height)) - y;
|
|
1502
|
+
const side = placement.side === 'left' || placement.side === 'top' ? 'start' : placement.side === 'center' ? 'center' : 'end';
|
|
1503
|
+
return { axis: placement.axis, side, rect: { x, y, width, height } };
|
|
1504
|
+
});
|
|
1248
1505
|
}
|
|
1249
1506
|
}
|
|
1250
|
-
});
|
|
1251
|
-
const across = (i, j) => facing.get(i) ?? facing.get(j);
|
|
1252
|
-
// An edge end inside this node, as a member of this solve: a framed child
|
|
1253
|
-
// or something inside one, or something in the contents, reached through
|
|
1254
|
-
// the block at the offset the contents solve gave it. An end outside the
|
|
1255
|
-
// node is none of this solve's business.
|
|
1256
|
-
const endOf = (end) => {
|
|
1257
|
-
let member = end;
|
|
1258
|
-
const offset = { x: 0, y: 0 };
|
|
1259
|
-
while (member.parent !== node) {
|
|
1260
|
-
const step = local.get(member);
|
|
1261
|
-
if (!step || !member.parent)
|
|
1262
|
-
return undefined;
|
|
1263
|
-
offset.x += step.x;
|
|
1264
|
-
offset.y += step.y;
|
|
1265
|
-
member = member.parent;
|
|
1266
|
-
}
|
|
1267
|
-
const inBlock = !framed.has(member);
|
|
1268
|
-
if (inBlock) {
|
|
1269
|
-
const step = local.get(member);
|
|
1270
|
-
offset.x += step.x;
|
|
1271
|
-
offset.y += step.y;
|
|
1272
|
-
}
|
|
1273
|
-
return {
|
|
1274
|
-
name: end.name,
|
|
1275
|
-
node: end,
|
|
1276
|
-
index: inBlock ? K : indexOf.get(member),
|
|
1277
|
-
offset,
|
|
1278
|
-
width: end.width,
|
|
1279
|
-
height: end.height,
|
|
1280
|
-
};
|
|
1281
|
-
};
|
|
1282
|
-
// Two ends both in the contents come out as one member and are skipped:
|
|
1283
|
-
// the contents solve already made room for that edge's text.
|
|
1284
|
-
const corridors = corridorsIn(edges, endOf, measurer, fontSize);
|
|
1285
|
-
const solve = (pins) =>
|
|
1286
|
-
// Everything here is inside one box, so a collision separates by the step
|
|
1287
|
-
// the contents stack by, not by the gap kept between strangers.
|
|
1288
|
-
positionGroup(members, locate, { x: [...x, ...pins.x], y: [...y, ...pins.y] }, corridors, across, CHILD_GAP);
|
|
1289
|
-
let solved = solve({ x: [], y: [] });
|
|
1290
|
-
// Where a text is centered or ranged right, where it goes depends on how
|
|
1291
|
-
// wide the frame came out, which depends on everything else. So it is
|
|
1292
|
-
// measured off the first solve and pinned, the move `settle` makes.
|
|
1293
|
-
const at = (index) => solved.get(members[index]);
|
|
1294
|
-
const inner = (axis) => ({
|
|
1295
|
-
start: at(TL)[axis] + PAD,
|
|
1296
|
-
size: at(BR)[axis] - at(TL)[axis] - PAD * 2,
|
|
1297
|
-
});
|
|
1298
|
-
const pins = { x: [], y: [] };
|
|
1299
|
-
const pin = (axis, index, to) => {
|
|
1300
|
-
if (to - at(index)[axis] <= 1e-9)
|
|
1301
|
-
return;
|
|
1302
|
-
pins[axis].push(...fix(TL, index, to - at(TL)[axis]));
|
|
1303
|
-
};
|
|
1304
|
-
// The text and the things placed against it range and center as a group,
|
|
1305
|
-
// which is the fan rule's sentence again: several things against one target
|
|
1306
|
-
// are balanced on it together.
|
|
1307
|
-
const extent = (axis) => {
|
|
1308
|
-
let start = Infinity;
|
|
1309
|
-
let end = -Infinity;
|
|
1310
|
-
for (const index of row) {
|
|
1311
|
-
const size = axis === 'x' ? members[index].width : members[index].height;
|
|
1312
|
-
start = Math.min(start, at(index)[axis]);
|
|
1313
|
-
end = Math.max(end, at(index)[axis] + size);
|
|
1314
|
-
}
|
|
1315
|
-
return { start, end };
|
|
1316
|
-
};
|
|
1317
|
-
if (banded) {
|
|
1318
|
-
if (T >= 0 && style.side !== 'left') {
|
|
1319
|
-
const { start, end } = extent('x');
|
|
1320
|
-
pin('x', T, at(T).x + (alignedAt(style.side, inner('x'), end - start) - start));
|
|
1321
|
-
}
|
|
1322
|
-
if (contents.align !== 'left')
|
|
1323
|
-
pin('x', K, alignedAt(contents.align, inner('x'), block.width));
|
|
1324
|
-
}
|
|
1325
|
-
else if (T >= 0) {
|
|
1326
|
-
const sides = { x: style.side, y: style.end };
|
|
1327
|
-
// The text and its group range in the room their own row (across) or
|
|
1328
|
-
// column (down) leaves them — between whatever sits beside, above or below
|
|
1329
|
-
// them — not in the whole box. Ranged against the whole width, a
|
|
1330
|
-
// right-ranged title runs into a child in the top-right corner, pushes it
|
|
1331
|
-
// out and grows the box; centered on the whole height, a title with things
|
|
1332
|
-
// placed below it and more in the bottom corners drops into the middle.
|
|
1333
|
-
const room = (axis) => {
|
|
1334
|
-
const cross = axis === 'x' ? 'y' : 'x';
|
|
1335
|
-
const whole = inner(axis);
|
|
1336
|
-
const along = extent(axis);
|
|
1337
|
-
const beside = extent(cross);
|
|
1338
|
-
const size = (member, on) => (on === 'x' ? member.width : member.height);
|
|
1339
|
-
let start = whole.start;
|
|
1340
|
-
let end = whole.start + whole.size;
|
|
1341
|
-
members.forEach((member, index) => {
|
|
1342
|
-
if (row.includes(index) || index === TL || index === BR)
|
|
1343
|
-
return;
|
|
1344
|
-
const c0 = at(index)[cross];
|
|
1345
|
-
if (c0 + size(member, cross) <= beside.start || c0 >= beside.end)
|
|
1346
|
-
return;
|
|
1347
|
-
const a0 = at(index)[axis];
|
|
1348
|
-
if (a0 + size(member, axis) <= along.start)
|
|
1349
|
-
start = Math.max(start, a0 + size(member, axis) + CHILD_GAP);
|
|
1350
|
-
else if (a0 >= along.end)
|
|
1351
|
-
end = Math.min(end, a0 - CHILD_GAP);
|
|
1352
|
-
});
|
|
1353
|
-
return { start, size: end - start };
|
|
1354
|
-
};
|
|
1355
|
-
for (const axis of AXES) {
|
|
1356
|
-
if (sides[axis] === 'left' || sides[axis] === 'top')
|
|
1357
|
-
continue;
|
|
1358
|
-
const { start, end } = extent(axis);
|
|
1359
|
-
const want = alignedAt(sides[axis], room(axis), end - start);
|
|
1360
|
-
pin(axis, T, at(T)[axis] + (want - start));
|
|
1361
|
-
}
|
|
1362
|
-
}
|
|
1363
|
-
if (pins.x.length > 0 || pins.y.length > 0)
|
|
1364
|
-
solved = solve(pins);
|
|
1365
|
-
const origin = at(TL);
|
|
1366
|
-
const corner = at(BR);
|
|
1367
|
-
node.inset = 0;
|
|
1368
|
-
node.width = corner.x - origin.x;
|
|
1369
|
-
node.height = corner.y - origin.y;
|
|
1370
|
-
node.banded = banded;
|
|
1371
|
-
node.headerHeight = banded && hasText ? textHeight + HEADER_GAP : 0;
|
|
1372
|
-
node.textSide = style.side;
|
|
1373
|
-
const relative = (index, width, height) => ({
|
|
1374
|
-
x: at(index).x - origin.x,
|
|
1375
|
-
y: at(index).y - origin.y,
|
|
1376
|
-
width,
|
|
1377
|
-
height,
|
|
1378
|
-
});
|
|
1379
|
-
node.textBox = T >= 0 ? relative(T, textWidth, textHeight) : { x: 0, y: 0, width: 0, height: 0 };
|
|
1380
|
-
const reach = { left: 0, top: 0, right: 0, bottom: 0 };
|
|
1381
|
-
for (const child of placed) {
|
|
1382
|
-
const position = solved.get(child);
|
|
1383
|
-
const offset = { x: position.x - origin.x, y: position.y - origin.y };
|
|
1384
|
-
local.set(child, offset);
|
|
1385
|
-
// Whatever sticks out past the frame — a child placed outside it, or
|
|
1386
|
-
// something one of the children placed outside itself.
|
|
1387
|
-
reach.left = Math.max(reach.left, child.reach.left - offset.x);
|
|
1388
|
-
reach.top = Math.max(reach.top, child.reach.top - offset.y);
|
|
1389
|
-
reach.right = Math.max(reach.right, offset.x + child.width + child.reach.right - node.width);
|
|
1390
|
-
reach.bottom = Math.max(reach.bottom, offset.y + child.height + child.reach.bottom - node.height);
|
|
1391
|
-
}
|
|
1392
|
-
node.reach = reach;
|
|
1393
|
-
if (banded) {
|
|
1394
|
-
const shift = relative(K, 0, 0);
|
|
1395
|
-
for (const child of stacked) {
|
|
1396
|
-
const offset = local.get(child);
|
|
1397
|
-
offset.x += shift.x;
|
|
1398
|
-
offset.y += shift.y;
|
|
1399
|
-
}
|
|
1400
|
-
}
|
|
1401
|
-
}
|
|
1402
|
-
// --- pass three: solve for positions -----------------------------------------
|
|
1403
|
-
function placeRoots(roots, byName, edges, measurer, fontSize, local) {
|
|
1404
|
-
const anchors = roots.filter((root) => root.placements.length === 0);
|
|
1405
|
-
if (anchors.length === 0) {
|
|
1406
|
-
throw new SourceError('every node is placed relative to another, so nothing anchors the diagram', 1);
|
|
1407
|
-
}
|
|
1408
|
-
if (anchors.length > 1) {
|
|
1409
|
-
const names = anchors.map((node) => `"${node.name}"`).join(', ');
|
|
1410
|
-
throw new SourceError(`exactly one node may say nothing about where it goes, but ${anchors.length} do: ${names}`, anchors[1].line);
|
|
1411
|
-
}
|
|
1412
|
-
const indexOf = new Map(roots.map((root, index) => [root, index]));
|
|
1413
|
-
// A placement may name something nested — `right of server.docker` places a top-level
|
|
1414
|
-
// node against a box inside another. Sizes and offsets within a container are
|
|
1415
|
-
// already settled, so a nested target is its root's position plus a constant.
|
|
1416
|
-
const positions = positionGroup(roots, (placement, owner) => placement.targets.map(({ name, part }) => {
|
|
1417
|
-
const target = byName.get(name);
|
|
1418
|
-
if (!target) {
|
|
1419
|
-
throw new SourceError(`"${owner.name}" is placed against "${name}", which does not exist`, placement.line);
|
|
1420
|
-
}
|
|
1421
|
-
let root = target;
|
|
1422
|
-
const offset = { x: 0, y: 0 };
|
|
1423
|
-
while (root.parent) {
|
|
1424
|
-
const step = local.get(root);
|
|
1425
|
-
offset.x += step.x;
|
|
1426
|
-
offset.y += step.y;
|
|
1427
|
-
root = root.parent;
|
|
1428
|
-
}
|
|
1429
|
-
return partOf({
|
|
1430
|
-
name,
|
|
1431
|
-
node: target,
|
|
1432
|
-
index: indexOf.get(root),
|
|
1433
|
-
offset,
|
|
1434
|
-
width: target.width,
|
|
1435
|
-
height: target.height,
|
|
1436
|
-
}, part, owner, placement.line);
|
|
1437
|
-
}), { x: [], y: [] }, corridorsIn(edges, (node) => liftTo(node, indexOf, local), measurer, fontSize));
|
|
1438
|
-
for (const root of roots) {
|
|
1439
|
-
const position = positions.get(root);
|
|
1440
|
-
root.x = position.x;
|
|
1441
|
-
root.y = position.y;
|
|
1442
|
-
spreadToChildren(root, local);
|
|
1443
1507
|
}
|
|
1444
1508
|
}
|
|
1445
|
-
/** Once a node has an absolute position, its whole subtree follows from the offsets. */
|
|
1446
|
-
function spreadToChildren(node, local) {
|
|
1447
|
-
for (const child of node.children) {
|
|
1448
|
-
const offset = local.get(child);
|
|
1449
|
-
child.x = node.x + offset.x;
|
|
1450
|
-
child.y = node.y + offset.y;
|
|
1451
|
-
spreadToChildren(child, local);
|
|
1452
|
-
}
|
|
1453
|
-
}
|
|
1454
|
-
const AXES = ['x', 'y'];
|
|
1455
|
-
const NO_REACH = { left: 0, top: 0, right: 0, bottom: 0 };
|
|
1456
|
-
const AXIS_WORD = { x: 'horizontally', y: 'vertically' };
|
|
1457
|
-
/** Which member a target's position on this axis is measured from. */
|
|
1458
|
-
function memberOn(target, axis) {
|
|
1459
|
-
return target.byAxis ? target.byAxis[axis] : target.index;
|
|
1460
|
-
}
|
|
1461
1509
|
/**
|
|
1462
|
-
*
|
|
1463
|
-
*
|
|
1464
|
-
*
|
|
1465
|
-
* the edge turns back over the top, or with `to` below, where it goes round).
|
|
1466
|
-
* The same side on both is left out on purpose: that edge goes round the box
|
|
1467
|
-
* behind, and its text rides on the stretch over that box and the gap, so the
|
|
1468
|
-
* room made in the gap is what keeps a long text clear of the line's turns.
|
|
1510
|
+
* Where the thing each `to: left, even with cat1` end lines up with is, once
|
|
1511
|
+
* everything is placed. Several targets (`even with a and b`) are the region
|
|
1512
|
+
* round them all, as in a placement.
|
|
1469
1513
|
*/
|
|
1470
|
-
function
|
|
1471
|
-
|
|
1472
|
-
const SIDES = {
|
|
1473
|
-
left: { axis: 'x', first: true },
|
|
1474
|
-
right: { axis: 'x', first: false },
|
|
1475
|
-
top: { axis: 'y', first: true },
|
|
1476
|
-
bottom: { axis: 'y', first: false },
|
|
1477
|
-
};
|
|
1478
|
-
const f = SIDES[String(fromSide)];
|
|
1479
|
-
const t = SIDES[String(toSide)];
|
|
1480
|
-
if (!f || !t)
|
|
1481
|
-
return [];
|
|
1482
|
-
const byFrom = { axis: f.axis, fromFirst: f.first };
|
|
1483
|
-
const byTo = { axis: t.axis, fromFirst: !t.first };
|
|
1484
|
-
if (f.axis !== t.axis)
|
|
1485
|
-
return [byFrom, byTo];
|
|
1486
|
-
return byFrom.fromFirst === byTo.fromFirst ? [byFrom] : [];
|
|
1487
|
-
}
|
|
1488
|
-
/**
|
|
1489
|
-
* Where a node sits within the group being solved: which member holds it, and
|
|
1490
|
-
* where inside that member. An edge may name anything at any depth, so its ends
|
|
1491
|
-
* are lifted to the members of whichever group is being solved — and a node
|
|
1492
|
-
* outside that group has no answer, which is how an edge is sorted into the one
|
|
1493
|
-
* group where its two ends are different members.
|
|
1494
|
-
*/
|
|
1495
|
-
function liftTo(node, indexOf, local) {
|
|
1496
|
-
let member = node;
|
|
1497
|
-
const offset = { x: 0, y: 0 };
|
|
1498
|
-
while (!indexOf.has(member)) {
|
|
1499
|
-
const step = local.get(member);
|
|
1500
|
-
if (!step || !member.parent)
|
|
1501
|
-
return undefined;
|
|
1502
|
-
offset.x += step.x;
|
|
1503
|
-
offset.y += step.y;
|
|
1504
|
-
member = member.parent;
|
|
1505
|
-
}
|
|
1506
|
-
return {
|
|
1507
|
-
name: node.name,
|
|
1508
|
-
node,
|
|
1509
|
-
index: indexOf.get(member),
|
|
1510
|
-
offset,
|
|
1511
|
-
width: node.width,
|
|
1512
|
-
height: node.height,
|
|
1513
|
-
};
|
|
1514
|
-
}
|
|
1515
|
-
/** The edges whose two ends are different members of this group. */
|
|
1516
|
-
function corridorsIn(edges, locate, measurer, fontSize) {
|
|
1517
|
-
const corridors = [];
|
|
1514
|
+
function locateEvenWith(edges, placer, byName, named) {
|
|
1515
|
+
const lineNames = new Set(edges.map((edge) => edge.name).filter((name) => name !== undefined));
|
|
1518
1516
|
for (const edge of edges) {
|
|
1519
|
-
|
|
1520
|
-
// corridor rather than in the gap between its own ends, so widening this one
|
|
1521
|
-
// would make room where the text never goes.
|
|
1522
|
-
// An edge that says which side of something it passes is routed round it,
|
|
1523
|
-
// and its text rides on that route for the same reason.
|
|
1524
|
-
if (edge.between || edge.passes)
|
|
1517
|
+
if (!edge.evenWith)
|
|
1525
1518
|
continue;
|
|
1526
|
-
const
|
|
1527
|
-
const
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
need: { x: bare, y: bare },
|
|
1540
|
-
away: facingAway(edge.attrs['from'], edge.attrs['to']),
|
|
1519
|
+
const subject = `edge ${edge.from.name} ${edge.arrow} ${edge.to.name}`;
|
|
1520
|
+
const evenAt = {};
|
|
1521
|
+
for (const which of ['from', 'to']) {
|
|
1522
|
+
const targets = edge.evenWith[which];
|
|
1523
|
+
if (!targets)
|
|
1524
|
+
continue;
|
|
1525
|
+
const rects = targets.map((target) => {
|
|
1526
|
+
if (target.point === undefined && !byName.has(target.name) && !named.has(target.name)) {
|
|
1527
|
+
throw new SourceError(lineNames.has(target.name)
|
|
1528
|
+
? `${subject}: "${which}: ${edge.attrs[which]}" — ${target.name} is a line; an end is even with a box, a part of one, or a point`
|
|
1529
|
+
: `${subject}: "${which}: ${edge.attrs[which]}" names ${target.name}, which does not exist${partWordHint(target.name, byName.keys())}`, edge.line);
|
|
1530
|
+
}
|
|
1531
|
+
return placer.locate(target, subject, edge.line);
|
|
1541
1532
|
});
|
|
1542
|
-
|
|
1533
|
+
const x = Math.min(...rects.map((rect) => rect.x));
|
|
1534
|
+
const y = Math.min(...rects.map((rect) => rect.y));
|
|
1535
|
+
evenAt[which] = {
|
|
1536
|
+
x,
|
|
1537
|
+
y,
|
|
1538
|
+
width: Math.max(...rects.map((rect) => rect.x + rect.width)) - x,
|
|
1539
|
+
height: Math.max(...rects.map((rect) => rect.y + rect.height)) - y,
|
|
1540
|
+
};
|
|
1543
1541
|
}
|
|
1544
|
-
|
|
1545
|
-
// the line, so the room it needs is symmetric about that point whatever sits
|
|
1546
|
-
// at either end. The arrowhead is charged on both sides for the same reason:
|
|
1547
|
-
// it covers the head's length of the line it arrives on, and reserving that
|
|
1548
|
-
// at one end only would move the midpoint rather than lengthen the run. A
|
|
1549
|
-
// thick line's head is longer, and takes the room it needs. A line with no
|
|
1550
|
-
// mark at either end is charged for none.
|
|
1551
|
-
const head = marked > 0 ? arrowLength(edge.look.thickness) : 0;
|
|
1552
|
-
const extent = (axis) => textExtent(edge.lines, edge.textAttrs, axis, measurer, fontSize, edge.line) +
|
|
1553
|
-
(TEXT_CLEARANCE + head) * 2;
|
|
1554
|
-
corridors.push({
|
|
1555
|
-
edge,
|
|
1556
|
-
from,
|
|
1557
|
-
to,
|
|
1558
|
-
need: { x: extent('x'), y: extent('y') },
|
|
1559
|
-
away: facingAway(edge.attrs['from'], edge.attrs['to']),
|
|
1560
|
-
});
|
|
1542
|
+
edge.evenAt = evenAt;
|
|
1561
1543
|
}
|
|
1562
|
-
return corridors;
|
|
1563
1544
|
}
|
|
1564
1545
|
/**
|
|
1565
|
-
*
|
|
1566
|
-
*
|
|
1567
|
-
*
|
|
1568
|
-
* where nothing is further apart than its placements require — which is what an
|
|
1569
|
-
* author does by hand when they push two things apart to fit something between
|
|
1570
|
-
* them and then pull the slack back out.
|
|
1571
|
-
*
|
|
1572
|
-
* Because a gap is a floor rather than a fixed distance, a corridor widens to
|
|
1573
|
-
* hold whatever is put in it and closes again when that is removed. No number
|
|
1574
|
-
* anywhere has to be guessed, and nothing is ever tried and rejected.
|
|
1546
|
+
* Where each `via:` stop that names something placed is, worked out by the
|
|
1547
|
+
* placer once everything is placed. A stop naming a line or a leg is left to
|
|
1548
|
+
* `lines.ts`, which has the lines.
|
|
1575
1549
|
*/
|
|
1576
|
-
function
|
|
1577
|
-
const
|
|
1578
|
-
const
|
|
1579
|
-
const
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
// Several nodes saying the identical thing are one list, running down the
|
|
1583
|
-
// page in the order they were written. The first carries the placement for
|
|
1584
|
-
// the whole list; each of the rest hangs a fixed step below the one before,
|
|
1585
|
-
// and places itself across as it said.
|
|
1586
|
-
const fans = fansIn(members);
|
|
1587
|
-
for (const fan of fans.values()) {
|
|
1588
|
-
for (let at = 1; at < fan.members.length; at += 1) {
|
|
1589
|
-
const previous = fan.members[at - 1];
|
|
1590
|
-
constraints.y.push(...fix(indexOf.get(previous), indexOf.get(fan.members[at]), previous.height + CHILD_GAP));
|
|
1591
|
-
}
|
|
1592
|
-
}
|
|
1593
|
-
for (const node of members) {
|
|
1594
|
-
// Checked here as well as in `gapFor`, so a misspelt node-wide gap is caught
|
|
1595
|
-
// on a node whose placements all name their own or are alignments — and on
|
|
1596
|
-
// one that carries no placements at all, where it now still has an effect.
|
|
1597
|
-
namedGap(node, node.attrs['gap'], node.line);
|
|
1598
|
-
if (node.placements.length === 0)
|
|
1550
|
+
function locateStops(edges, placer, byName, named) {
|
|
1551
|
+
const lineNames = new Set(edges.map((edge) => edge.name).filter((name) => name !== undefined));
|
|
1552
|
+
const legNames = new Set(edges.flatMap((edge) => (edge.via ?? []).map((entry) => entry.leg?.name)).filter((name) => name !== undefined));
|
|
1553
|
+
const bundles = new Set(edges.filter((edge) => edge.bundle).map((edge) => edge.name));
|
|
1554
|
+
for (const edge of edges) {
|
|
1555
|
+
if (!edge.via)
|
|
1599
1556
|
continue;
|
|
1600
|
-
const
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
spokenFor.y = true;
|
|
1623
|
-
}
|
|
1624
|
-
}
|
|
1625
|
-
// Aligning to several targets means aligning to the box that just bounds
|
|
1626
|
-
// them. That box is a constant only while its members hold still relative
|
|
1627
|
-
// to one another; otherwise the alignment waits for the first solution.
|
|
1628
|
-
const alignOn = (axis, side, targets, placement) => {
|
|
1629
|
-
if (!speaks(axis))
|
|
1630
|
-
return;
|
|
1631
|
-
const anchor = sharedMember(targets, axis);
|
|
1632
|
-
if (anchor === undefined) {
|
|
1633
|
-
pending.push({ node, me, axis, side, targets, placement, own: size[axis] });
|
|
1634
|
-
return;
|
|
1635
|
-
}
|
|
1636
|
-
const span = spanOf(targets, axis, () => 0);
|
|
1637
|
-
constraints[axis].push(...fix(anchor, me, alignedAt(side, span, size[axis]), placement));
|
|
1638
|
-
};
|
|
1639
|
-
for (const { placement, targets } of located) {
|
|
1640
|
-
if (placement.kind === 'align') {
|
|
1641
|
-
alignOn(placement.axis, placement.side, targets, placement);
|
|
1642
|
-
continue;
|
|
1643
|
-
}
|
|
1644
|
-
if (placement.kind === 'on') {
|
|
1645
|
-
// The node's center at the part's center, on both axes. That is the
|
|
1646
|
-
// ordinary center alignment the language already has on one axis, said
|
|
1647
|
-
// twice — which is why `on` needs nothing of its own in the solver.
|
|
1648
|
-
// A side of a parent's frame comes as its two ends, so centering on
|
|
1649
|
-
// the part is centering on everything it came as.
|
|
1650
|
-
overlaid.push([me, targets[0].index]);
|
|
1651
|
-
for (const axis of AXES)
|
|
1652
|
-
alignOn(axis, 'center', targets, placement);
|
|
1653
|
-
continue;
|
|
1557
|
+
const subject = edge.bundle ? `bundle ${edge.name}` : `edge ${edge.from.name} ${edge.arrow} ${edge.to.name}`;
|
|
1558
|
+
edge.viaAt = edge.via.map((entry) => {
|
|
1559
|
+
const stop = entry.stop;
|
|
1560
|
+
if (!stop)
|
|
1561
|
+
return undefined;
|
|
1562
|
+
if (stop.kind === 'place' && stop.target.point === undefined && bundles.has(stop.target.name)) {
|
|
1563
|
+
// `via: trunk`: the whole of a bundle, in this line's lane (6.6).
|
|
1564
|
+
const name = stop.target.name;
|
|
1565
|
+
if (edge.bundle) {
|
|
1566
|
+
throw new SourceError(`${subject}: "via: ${stop.written}" — a bundle's path is its own; ${name} is a bundle, and bundles don't run inside each other`, edge.line);
|
|
1567
|
+
}
|
|
1568
|
+
if (stop.target.part !== undefined || stop.target.fraction !== undefined || stop.mode !== 'outside') {
|
|
1569
|
+
throw new SourceError(`${subject}: "via: ${stop.written}" — ${name} is a bundle, and a line joins it whole, written \`via: ${name}\``, edge.line);
|
|
1570
|
+
}
|
|
1571
|
+
if (stop.gap !== undefined) {
|
|
1572
|
+
throw new SourceError(`${subject}: "via: ${stop.written}" — the gap between a bundle's lines is the bundle's, written \`bundle ${name} layout: parallel (gap: ${stop.gap})\``, edge.line);
|
|
1573
|
+
}
|
|
1574
|
+
if (entry.leg) {
|
|
1575
|
+
throw new SourceError(`${subject}: "leg ${entry.leg.name} ${stop.written}" — ${name} is a bundle, which has legs of its own; name the leg on the bundle`, edge.line);
|
|
1576
|
+
}
|
|
1577
|
+
entry.stop = { kind: 'bundle', bundle: name, ...(stop.order ? { order: stop.order } : {}), written: stop.written };
|
|
1578
|
+
return undefined;
|
|
1654
1579
|
}
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
// box by construction and needs no exemption.
|
|
1658
|
-
if (placement.written === 'inside') {
|
|
1659
|
-
for (const target of targets)
|
|
1660
|
-
overlaid.push([me, target.index]);
|
|
1580
|
+
if (stop.kind === 'place' && stop.order !== undefined) {
|
|
1581
|
+
throw new SourceError(`${subject}: "via: ${stop.written}" — ${stop.order.way}: is a line's place in a bundle's order, and ${stop.target.name} is not a bundle`, edge.line);
|
|
1661
1582
|
}
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
const { direction } = placement;
|
|
1666
|
-
// A gap belongs to the relationship rather than to either box in it, so
|
|
1667
|
-
// each placement may name its own and the node's `gap:` is only the
|
|
1668
|
-
// default. That is what lets a node wedged between two things sit tight
|
|
1669
|
-
// against one of them and wide of the other.
|
|
1670
|
-
for (const target of targets) {
|
|
1671
|
-
const gap = target.empty ? 0 : gapFor(node, placement, target.node);
|
|
1672
|
-
// Tucked inside the frame of the box that holds it, a child is *at*
|
|
1673
|
-
// that edge rather than at least so far from it: the frame is also
|
|
1674
|
-
// held open around every child, which is a pull the other way, and
|
|
1675
|
-
// without this the child would sit wherever that left it.
|
|
1676
|
-
const exact = target.frame === true && placement.written === 'inside';
|
|
1677
|
-
const push = (axis, from, to, weight) => {
|
|
1678
|
-
if (!speaks(axis))
|
|
1679
|
-
return;
|
|
1680
|
-
constraints[axis].push({ from, to, weight, placement });
|
|
1681
|
-
if (exact)
|
|
1682
|
-
constraints[axis].push({ from: to, to: from, weight: -weight, placement });
|
|
1683
|
-
};
|
|
1684
|
-
const x = memberOn(target, 'x');
|
|
1685
|
-
const y = memberOn(target, 'y');
|
|
1686
|
-
// The gap is between what each has placed outside itself, not only
|
|
1687
|
-
// their boxes: a note beside a box is part of it.
|
|
1688
|
-
const theirs = target.reach ?? NO_REACH;
|
|
1689
|
-
const mine = node.reach;
|
|
1690
|
-
if (direction.includes('right')) {
|
|
1691
|
-
push('x', x, me, target.offset.x + target.width + theirs.right + gap + mine.left);
|
|
1692
|
-
}
|
|
1693
|
-
if (direction.includes('left'))
|
|
1694
|
-
push('x', me, x, size.x + mine.right + gap + theirs.left - target.offset.x);
|
|
1695
|
-
if (direction.includes('below')) {
|
|
1696
|
-
push('y', y, me, target.offset.y + target.height + theirs.bottom + gap + mine.top);
|
|
1583
|
+
if (stop.kind === 'through') {
|
|
1584
|
+
if (!named.has(stop.region) || named.get(stop.region).kind !== 'region') {
|
|
1585
|
+
throw new SourceError(`${subject}: "via: ${stop.written}" — ${stop.region} is not a region; name one with \`region ${stop.region} …\``, edge.line);
|
|
1697
1586
|
}
|
|
1698
|
-
|
|
1699
|
-
push('y', me, y, size.y + mine.bottom + gap + theirs.top - target.offset.y);
|
|
1587
|
+
return placer.locate({ name: stop.region }, subject, edge.line);
|
|
1700
1588
|
}
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
const first = offers[0];
|
|
1712
|
-
if (!first) {
|
|
1713
|
-
throw new SourceError(`"${node.name}" says nothing about where it sits ${AXIS_WORD[axis]}`, node.placements[0].line);
|
|
1589
|
+
if (stop.kind !== 'place')
|
|
1590
|
+
return undefined;
|
|
1591
|
+
const target = stop.target;
|
|
1592
|
+
if (target.point === undefined && (lineNames.has(target.name) || legNames.has(target.name))) {
|
|
1593
|
+
if (target.part !== undefined || target.fraction !== undefined) {
|
|
1594
|
+
throw new SourceError(`${subject}: "via: ${stop.written}" — ${target.name} is a line, and a point on it is written \`${target.name} at 1/3\`, \`${target.name} start\` or \`${target.name} end\``, edge.line);
|
|
1595
|
+
}
|
|
1596
|
+
// A bare line or leg name is its middle.
|
|
1597
|
+
entry.stop = { kind: 'online', line: target.name, at: 1 / 2, written: stop.written };
|
|
1598
|
+
return undefined;
|
|
1714
1599
|
}
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
if (other) {
|
|
1718
|
-
throw new SourceError(`"${node.name}" does not say where it sits ${AXIS_WORD[axis]}: ` +
|
|
1719
|
-
`"${describePlacement(first.placement)}" and "${describePlacement(other.placement)}" ` +
|
|
1720
|
-
`would put it in different places`, other.placement.line);
|
|
1600
|
+
if (target.point === undefined && !byName.has(target.name) && !named.has(target.name)) {
|
|
1601
|
+
throw new SourceError(`${subject}: "via: ${stop.written}" names ${target.name}, which does not exist${partWordHint(target.name, byName.keys())}`, edge.line);
|
|
1721
1602
|
}
|
|
1722
|
-
|
|
1723
|
-
}
|
|
1724
|
-
}
|
|
1725
|
-
const solved = { x: [], y: [] };
|
|
1726
|
-
const solveAll = () => {
|
|
1727
|
-
for (const axis of AXES) {
|
|
1728
|
-
const outcome = tightest(members.length, constraints[axis]);
|
|
1729
|
-
if ('contradiction' in outcome)
|
|
1730
|
-
throw noRoom(outcome.contradiction, axis, members);
|
|
1731
|
-
solved[axis] = outcome.positions;
|
|
1732
|
-
}
|
|
1733
|
-
};
|
|
1734
|
-
// An edge's text is given room only once everything is where it goes: a
|
|
1735
|
-
// box still waiting to be centered, or still on top of another, is not yet
|
|
1736
|
-
// where it will be, and room judged against it goes in the wrong gap. Room
|
|
1737
|
-
// can move things in turn, so the centering and pulling-in are measured
|
|
1738
|
-
// again from what the file said, and the texts looked at again, until a
|
|
1739
|
-
// look adds nothing. What was placed, separated or widened stays.
|
|
1740
|
-
//
|
|
1741
|
-
// It stops: each text can be given room once across and once down, room
|
|
1742
|
-
// is never taken back, and a look that gives none ends it.
|
|
1743
|
-
const lasting = { x: [...constraints.x], y: [...constraints.y] };
|
|
1744
|
-
const made = new Set();
|
|
1745
|
-
const keep = (step) => {
|
|
1746
|
-
const before = { x: constraints.x.length, y: constraints.y.length };
|
|
1747
|
-
const changed = step();
|
|
1748
|
-
for (const axis of AXES)
|
|
1749
|
-
lasting[axis].push(...constraints[axis].slice(before[axis]));
|
|
1750
|
-
return changed === true;
|
|
1751
|
-
};
|
|
1752
|
-
for (;;) {
|
|
1753
|
-
constraints.x = [...lasting.x];
|
|
1754
|
-
constraints.y = [...lasting.y];
|
|
1755
|
-
solveAll();
|
|
1756
|
-
settle(pending, members, constraints, solved, solveAll);
|
|
1757
|
-
snug(members, constraints, solved, solveAll);
|
|
1758
|
-
keep(() => separate(members, constraints, solved, solveAll, overlaid, across, clearance));
|
|
1759
|
-
if (!keep(() => room(corridors, constraints, solved, solveAll, made)))
|
|
1760
|
-
break;
|
|
1761
|
-
}
|
|
1762
|
-
confirm(pending, solved);
|
|
1763
|
-
return new Map(members.map((member, index) => [
|
|
1764
|
-
member,
|
|
1765
|
-
{ x: solved.x[index], y: solved.y[index] },
|
|
1766
|
-
]));
|
|
1767
|
-
}
|
|
1768
|
-
/** The member every target belongs to on this axis, or nothing if they are spread across several. */
|
|
1769
|
-
function sharedMember(targets, axis) {
|
|
1770
|
-
const first = memberOn(targets[0], axis);
|
|
1771
|
-
return targets.every((target) => memberOn(target, axis) === first) ? first : undefined;
|
|
1772
|
-
}
|
|
1773
|
-
/**
|
|
1774
|
-
* The fans in a group: two or more members whose placements say the identical
|
|
1775
|
-
* thing, each member mapped to the one fan it is in.
|
|
1776
|
-
*
|
|
1777
|
-
* `b right of a`, `c right of a` and `d right of a` otherwise put three boxes
|
|
1778
|
-
* on one spot, and what they mean is "a points at three things" — the three
|
|
1779
|
-
* balanced against `a`, which no chain of placements can say. The same holds
|
|
1780
|
-
* of a part: three children `inside parent right` are a column against that
|
|
1781
|
-
* edge. The list runs down the page whatever the direction, including at a
|
|
1782
|
-
* corner, where it stacks into the corner and grows down.
|
|
1783
|
-
*
|
|
1784
|
-
* A node saying `overlap: allow` has asked for the literal pile, and gets it.
|
|
1785
|
-
*/
|
|
1786
|
-
function fansIn(members) {
|
|
1787
|
-
const bySaying = new Map();
|
|
1788
|
-
for (const node of members) {
|
|
1789
|
-
if (node.placements.length === 0 || allowsOverlap(node))
|
|
1790
|
-
continue;
|
|
1791
|
-
const saying = node.placements.map(describePlacement).join('\n');
|
|
1792
|
-
const list = bySaying.get(saying) ?? [];
|
|
1793
|
-
list.push(node);
|
|
1794
|
-
bySaying.set(saying, list);
|
|
1795
|
-
}
|
|
1796
|
-
const fans = new Map();
|
|
1797
|
-
for (const list of bySaying.values()) {
|
|
1798
|
-
if (list.length < 2)
|
|
1799
|
-
continue;
|
|
1800
|
-
const height = list.reduce((sum, node) => sum + node.height, 0) + CHILD_GAP * (list.length - 1);
|
|
1801
|
-
const fan = { members: list, height };
|
|
1802
|
-
for (const node of list)
|
|
1803
|
-
fans.set(node, fan);
|
|
1603
|
+
return placer.locate(target, subject, edge.line);
|
|
1604
|
+
});
|
|
1804
1605
|
}
|
|
1805
|
-
return fans;
|
|
1806
|
-
}
|
|
1807
|
-
/** The stretch of one axis that just covers every target. */
|
|
1808
|
-
function spanOf(targets, axis, base) {
|
|
1809
|
-
let start = Infinity;
|
|
1810
|
-
let end = -Infinity;
|
|
1811
|
-
for (const target of targets) {
|
|
1812
|
-
const at = base(target) + target.offset[axis];
|
|
1813
|
-
start = Math.min(start, at);
|
|
1814
|
-
end = Math.max(end, at + (axis === 'x' ? target.width : target.height));
|
|
1815
|
-
}
|
|
1816
|
-
return { start, size: end - start };
|
|
1817
1606
|
}
|
|
1818
1607
|
/**
|
|
1819
|
-
*
|
|
1820
|
-
*
|
|
1821
|
-
*
|
|
1822
|
-
*
|
|
1823
|
-
*
|
|
1824
|
-
*
|
|
1825
|
-
* direction, the gap, the alignment on the axis nobody spoke to — then works
|
|
1826
|
-
* on it unchanged, which is why a part target needed nothing added to the
|
|
1827
|
-
* constraint system.
|
|
1828
|
-
*
|
|
1829
|
-
* Each position is read as two independent halves, one per axis, which is why
|
|
1830
|
-
* nine words need no table of nine entries: `top-right` is "right" across and
|
|
1831
|
-
* "top" down, `top-center` is "top" down and centered across.
|
|
1608
|
+
* The line's bracket, checked, with a default for everything unsaid. A value is
|
|
1609
|
+
* checked wherever it came from — a style's `path: squar` is as much a typo as
|
|
1610
|
+
* an edge's — but `corners: rounded` is refused only when the edge itself says
|
|
1611
|
+
* it on a curved line, which has no corners. From a style or a default it
|
|
1612
|
+
* applies to the edges that have corners, the way a style's `fill:` lands on
|
|
1613
|
+
* the nodes that have an inside.
|
|
1832
1614
|
*/
|
|
1833
|
-
function
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
if (text.width === 0 && text.height === 0) {
|
|
1842
|
-
throw new SourceError(`"${owner.name}" is placed against "${target.name} text", and "${target.name}" has no text`, line);
|
|
1615
|
+
function lineLook(written, appearance, what, line) {
|
|
1616
|
+
const word = (property) => {
|
|
1617
|
+
const value = appearance[`line.${property}`];
|
|
1618
|
+
if (value === undefined)
|
|
1619
|
+
return LINE_VALUES[property][0];
|
|
1620
|
+
if (!LINE_VALUES[property].includes(value)) {
|
|
1621
|
+
throw new SourceError(`edge ${what}: "${property}: ${value}" is not a ${property} — use ${(property === 'thickness' ? ['thin', 'normal', 'thick'] : LINE_VALUES[property]).join(', ')}` +
|
|
1622
|
+
(property === 'thickness' ? ', a multiple of normal such as 2, or pixels such as 3px' : ''), line);
|
|
1843
1623
|
}
|
|
1844
|
-
|
|
1845
|
-
box.offset.y += text.y;
|
|
1846
|
-
box.width = text.width;
|
|
1847
|
-
box.height = text.height;
|
|
1848
|
-
return box;
|
|
1849
|
-
}
|
|
1850
|
-
const words = part.split('-');
|
|
1851
|
-
const isSide = PART_SIDES.includes(part);
|
|
1852
|
-
const narrow = (axis, near, far) => {
|
|
1853
|
-
const size = axis === 'x' ? box.width : box.height;
|
|
1854
|
-
const named = words.includes(near) ? 0 : words.includes(far) ? size : undefined;
|
|
1855
|
-
// A *side* leaves the axis it does not name alone: `right` is the whole
|
|
1856
|
-
// right edge, top to bottom, where `right-center` is the one point on it.
|
|
1857
|
-
if (named === undefined && isSide)
|
|
1858
|
-
return;
|
|
1859
|
-
box.offset[axis] += named ?? size / 2;
|
|
1860
|
-
if (axis === 'x')
|
|
1861
|
-
box.width = 0;
|
|
1862
|
-
else
|
|
1863
|
-
box.height = 0;
|
|
1624
|
+
return value;
|
|
1864
1625
|
};
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
box.offset.x += words.includes('left') ? pull : -pull;
|
|
1874
|
-
box.offset.y += words.includes('top') ? pull : -pull;
|
|
1875
|
-
}
|
|
1876
|
-
return box;
|
|
1877
|
-
}
|
|
1878
|
-
/** Where a node of this size sits so that the named side of it meets the span's. */
|
|
1879
|
-
function alignedAt(side, span, own) {
|
|
1880
|
-
if (side === 'center')
|
|
1881
|
-
return span.start + (span.size - own) / 2;
|
|
1882
|
-
if (side === 'top' || side === 'left')
|
|
1883
|
-
return span.start;
|
|
1884
|
-
return span.start + span.size - own;
|
|
1885
|
-
}
|
|
1886
|
-
/**
|
|
1887
|
-
* Widen a corridor to hold the text of the edge crossing it.
|
|
1888
|
-
*
|
|
1889
|
-
* This is the one place an edge reaches the constraint system, and it is the same
|
|
1890
|
-
* measure-then-constrain move `settle` makes rather than edges joining the graph
|
|
1891
|
-
* outright: the first solution says which gap each text actually falls in, and
|
|
1892
|
-
* from there the room it needs is an ordinary minimum distance like any other.
|
|
1893
|
-
* Nothing is nudged and no layout is repaired — a constraint the file already
|
|
1894
|
-
* implied is derived and the whole system is solved again.
|
|
1895
|
-
*
|
|
1896
|
-
* Which gap that is, is derived and never chosen. A pair clear of each other on
|
|
1897
|
-
* exactly one axis has exactly one corridor between them, and the text is in
|
|
1898
|
-
* it. A pair clear on *both* axes sits corner to corner, so the line runs
|
|
1899
|
-
* diagonally through open space and there is no corridor to widen; a pair clear
|
|
1900
|
-
* on neither overlaps, which is the separation pass's business and not this
|
|
1901
|
-
* one's. Both are left alone, which is why this only ever moves boxes that a
|
|
1902
|
-
* text is genuinely wedged between.
|
|
1903
|
-
*
|
|
1904
|
-
* A gap being a minimum does the rest. Where the corridor is already wide enough
|
|
1905
|
-
* — because the author said `gap: wide`, or because something else is in there
|
|
1906
|
-
* — the constraint is slack and nothing moves; delete the text and the corridor
|
|
1907
|
-
* closes back to whatever the file asked for.
|
|
1908
|
-
*/
|
|
1909
|
-
function room(corridors, constraints, solved, solveAll, made) {
|
|
1910
|
-
let added = false;
|
|
1911
|
-
for (const [index, { from, to, need, away }] of corridors.entries()) {
|
|
1912
|
-
const clear = (axis) => {
|
|
1913
|
-
const at = (end) => solved[axis][end.index] + end.offset[axis];
|
|
1914
|
-
const size = (end) => (axis === 'x' ? end.width : end.height);
|
|
1915
|
-
if (at(to) - (at(from) + size(from)) > 1e-9)
|
|
1916
|
-
return { before: from, after: to };
|
|
1917
|
-
if (at(from) - (at(to) + size(to)) > 1e-9)
|
|
1918
|
-
return { before: to, after: from };
|
|
1919
|
-
return undefined;
|
|
1920
|
-
};
|
|
1921
|
-
const open = AXES.filter((axis) => clear(axis));
|
|
1922
|
-
if (open.length !== 1)
|
|
1923
|
-
continue;
|
|
1924
|
-
const axis = open[0];
|
|
1925
|
-
// Ends facing away along the only open axis mean the boxes share a row, and
|
|
1926
|
-
// the renderer takes the line over the top with its text — see `planLoops`.
|
|
1927
|
-
// Widening the gap would make room where the text never goes.
|
|
1928
|
-
if (away.some((way) => way.axis === axis && (clear(axis).before === from) === way.fromFirst))
|
|
1929
|
-
continue;
|
|
1930
|
-
// Given already, and a minimum stays met: asking again would widen
|
|
1931
|
-
// nothing and only keep the caller looking.
|
|
1932
|
-
if (made.has(`${index}:${axis}`))
|
|
1933
|
-
continue;
|
|
1934
|
-
made.add(`${index}:${axis}`);
|
|
1935
|
-
const { before, after } = clear(axis);
|
|
1936
|
-
constraints[axis].push({
|
|
1937
|
-
from: before.index,
|
|
1938
|
-
to: after.index,
|
|
1939
|
-
weight: before.offset[axis] +
|
|
1940
|
-
(axis === 'x' ? before.width : before.height) +
|
|
1941
|
-
need[axis] -
|
|
1942
|
-
after.offset[axis],
|
|
1943
|
-
});
|
|
1944
|
-
added = true;
|
|
1945
|
-
}
|
|
1946
|
-
if (added)
|
|
1947
|
-
solveAll();
|
|
1948
|
-
return added;
|
|
1949
|
-
}
|
|
1950
|
-
/**
|
|
1951
|
-
* Fix the alignments that had to wait, by measuring what they align to.
|
|
1952
|
-
*
|
|
1953
|
-
* The first solution says where every target actually landed, so the region
|
|
1954
|
-
* they bound is now a number rather than an expression, and the alignment
|
|
1955
|
-
* becomes an ordinary constraint at a fixed distance. Nothing already solved is
|
|
1956
|
-
* moved by hand; a distance is read off and added, which is the same shape as
|
|
1957
|
-
* the separation pass and not the repair pass this design refuses.
|
|
1958
|
-
*
|
|
1959
|
-
* That is exact so long as the region does not depend on the node being aligned
|
|
1960
|
-
* to it. Where it does, measuring changes the thing measured and there is no
|
|
1961
|
-
* order that settles, so the file is refused rather than iterated at. The test
|
|
1962
|
-
* is the same reachability the separation pass uses.
|
|
1963
|
-
*/
|
|
1964
|
-
function settle(pending, members, constraints, solved, solveAll) {
|
|
1965
|
-
if (pending.length === 0)
|
|
1966
|
-
return;
|
|
1967
|
-
for (const axis of AXES) {
|
|
1968
|
-
const here = pending.filter((entry) => entry.axis === axis);
|
|
1969
|
-
if (here.length === 0)
|
|
1970
|
-
continue;
|
|
1971
|
-
const reach = reachability(members.length, constraints[axis]);
|
|
1972
|
-
for (const entry of here) {
|
|
1973
|
-
for (const target of entry.targets) {
|
|
1974
|
-
if (target.frame)
|
|
1975
|
-
continue;
|
|
1976
|
-
const at = memberOn(target, axis);
|
|
1977
|
-
if (at !== entry.me && !reach[entry.me][at])
|
|
1978
|
-
continue;
|
|
1979
|
-
throw new SourceError(`"${entry.node.name}" is ${describePlacement(entry.placement)}, but "${target.name}" ` +
|
|
1980
|
-
`is placed ${AXIS_WORD[axis]} against "${entry.node.name}" in turn, so there is no ` +
|
|
1981
|
-
`arrangement where each waits for the other`, entry.placement.line);
|
|
1626
|
+
const thickness = (() => {
|
|
1627
|
+
const value = appearance['line.thickness'];
|
|
1628
|
+
if (value !== undefined && /^[0-9.]/.test(value)) {
|
|
1629
|
+
// A bare number is a multiple of normal; `px` fixes it in pixels (design.org 3.8).
|
|
1630
|
+
const number = /^(\d*\.?\d+)(px)?$/.exec(value);
|
|
1631
|
+
if (!number || Number(number[1]) <= 0) {
|
|
1632
|
+
throw new SourceError(`edge ${what}: "thickness: ${value}" — a thickness is thin, normal, thick, ` +
|
|
1633
|
+
'a multiple of normal greater than zero such as `thickness: 2`, or pixels such as `thickness: 3px`', line);
|
|
1982
1634
|
}
|
|
1983
|
-
|
|
1984
|
-
const own = entry.own ?? (axis === 'x' ? entry.node.width : entry.node.height);
|
|
1985
|
-
const anchor = memberOn(entry.targets[0], axis);
|
|
1986
|
-
constraints[axis].push(...fix(anchor, entry.me, alignedAt(entry.side, span, own) - solved[axis][anchor], entry.placement));
|
|
1635
|
+
return number[2] ? Number(number[1]) : LINE_WIDTH * Number(number[1]);
|
|
1987
1636
|
}
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
* diagnostics work exists to prevent.
|
|
1999
|
-
*/
|
|
2000
|
-
function confirm(pending, solved) {
|
|
2001
|
-
for (const entry of pending) {
|
|
2002
|
-
const { axis } = entry;
|
|
2003
|
-
const span = spanOf(entry.targets, axis, (target) => solved[axis][memberOn(target, axis)]);
|
|
2004
|
-
const own = entry.own ?? (axis === 'x' ? entry.node.width : entry.node.height);
|
|
2005
|
-
if (Math.abs(alignedAt(entry.side, span, own) - solved[axis][entry.me]) <= 0.5)
|
|
2006
|
-
continue;
|
|
2007
|
-
throw new SourceError(`"${entry.node.name}" cannot be ${describePlacement(entry.placement)}: keeping boxes off ` +
|
|
2008
|
-
`each other moved them apart after that region was measured`, entry.placement.line);
|
|
2009
|
-
}
|
|
2010
|
-
}
|
|
2011
|
-
/**
|
|
2012
|
-
* Pull in a node that nothing pushes back the other way.
|
|
2013
|
-
*
|
|
2014
|
-
* Every constraint reads "this one is at least so far along the axis from that
|
|
2015
|
-
* one", and the solve puts each member at the smallest position its constraints
|
|
2016
|
-
* allow. That is the tightest arrangement for anything with something behind
|
|
2017
|
-
* it — but `left of X` and `above X` bound *X*, not the node that wrote them,
|
|
2018
|
-
* so a node carrying only those has nothing behind it at all. It settles at the
|
|
2019
|
-
* far edge of the drawing while the thing it names is carried away by the rest
|
|
2020
|
-
* of the diagram, which is neither what the file says nor what the language
|
|
2021
|
-
* promises: as close together as the placements allow.
|
|
2022
|
-
*
|
|
2023
|
-
* The remedy is to look the other way for exactly those members. A member with
|
|
2024
|
-
* no incoming edge cannot be pushed by anything, so moving it along the axis
|
|
2025
|
-
* disturbs nothing; the furthest it may travel is set by whichever of its own
|
|
2026
|
-
* placements binds first, and pinning that one placement to an exact distance
|
|
2027
|
-
* puts it there. Every other placement it wrote had more room to spare and is
|
|
2028
|
-
* still satisfied.
|
|
2029
|
-
*
|
|
2030
|
-
* The pins are worked out against the first solution and applied together,
|
|
2031
|
-
* which keeps them independent: a member with no incoming edge is never the
|
|
2032
|
-
* target of another member's pin, because being a target is what an incoming
|
|
2033
|
-
* edge is.
|
|
2034
|
-
*/
|
|
2035
|
-
function snug(members, constraints, solved, solveAll) {
|
|
2036
|
-
const pins = [];
|
|
2037
|
-
for (const axis of AXES) {
|
|
2038
|
-
const pushed = new Array(members.length).fill(false);
|
|
2039
|
-
for (const constraint of constraints[axis])
|
|
2040
|
-
pushed[constraint.to] = true;
|
|
2041
|
-
for (let index = 0; index < members.length; index += 1) {
|
|
2042
|
-
if (pushed[index])
|
|
2043
|
-
continue;
|
|
2044
|
-
let binding;
|
|
2045
|
-
let slack = Infinity;
|
|
2046
|
-
for (const constraint of constraints[axis]) {
|
|
2047
|
-
if (constraint.from !== index)
|
|
2048
|
-
continue;
|
|
2049
|
-
const room = solved[axis][constraint.to] - solved[axis][index] - constraint.weight;
|
|
2050
|
-
if (room < slack) {
|
|
2051
|
-
slack = room;
|
|
2052
|
-
binding = constraint;
|
|
2053
|
-
}
|
|
2054
|
-
}
|
|
2055
|
-
// Nothing to travel toward, or already against it.
|
|
2056
|
-
if (!binding || slack <= 1e-9)
|
|
2057
|
-
continue;
|
|
2058
|
-
pins.push({
|
|
2059
|
-
axis,
|
|
2060
|
-
constraint: {
|
|
2061
|
-
from: binding.to,
|
|
2062
|
-
to: index,
|
|
2063
|
-
weight: -binding.weight,
|
|
2064
|
-
...(binding.placement ? { placement: binding.placement } : {}),
|
|
2065
|
-
},
|
|
2066
|
-
});
|
|
1637
|
+
return THICKNESS[word('thickness')];
|
|
1638
|
+
})();
|
|
1639
|
+
const turn = (() => {
|
|
1640
|
+
const value = appearance['line.turn'];
|
|
1641
|
+
if (value === undefined)
|
|
1642
|
+
return undefined;
|
|
1643
|
+
const ratio = /^(\d+)\/(\d+)$/.exec(value);
|
|
1644
|
+
const number = ratio ? Number(ratio[1]) / Number(ratio[2]) : /^\d*\.?\d+$/.test(value) ? Number(value) : NaN;
|
|
1645
|
+
if (!(number >= 0 && number <= 1)) {
|
|
1646
|
+
throw new SourceError(`edge ${what}: "turn: ${value}" — a turn is a fraction of the way across from the start's box, such as \`turn: 1/3\``, line);
|
|
2067
1647
|
}
|
|
1648
|
+
return number;
|
|
1649
|
+
})();
|
|
1650
|
+
const stepOut = appearance['line.step-out'] === undefined
|
|
1651
|
+
? GAPS['normal']
|
|
1652
|
+
: gapPixels(appearance['line.step-out'], `edge ${what}: step-out:`, line);
|
|
1653
|
+
const look = {
|
|
1654
|
+
path: word('path'),
|
|
1655
|
+
corners: word('corners'),
|
|
1656
|
+
crossing: word('crossing'),
|
|
1657
|
+
pattern: word('pattern'),
|
|
1658
|
+
thickness,
|
|
1659
|
+
curve: word('curve'),
|
|
1660
|
+
turn,
|
|
1661
|
+
turnWritten: written['line.turn'],
|
|
1662
|
+
stepOut,
|
|
1663
|
+
};
|
|
1664
|
+
// Said on the edge itself, a property its path doesn't have is refused; from
|
|
1665
|
+
// a style or a default it lands on the lines that have it.
|
|
1666
|
+
if (look.path === 'straight' && (written['line.turn'] !== undefined || written['line.step-out'] !== undefined)) {
|
|
1667
|
+
const key = written['line.turn'] !== undefined ? 'turn' : 'step-out';
|
|
1668
|
+
throw new SourceError(`edge ${what}: a straight line has no route to ${key === 'turn' ? 'move' : 'step out of'} — \`${key}:\` takes a ` +
|
|
1669
|
+
'`path: square` or `path: curved` line', line);
|
|
2068
1670
|
}
|
|
2069
|
-
if (
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
constraints[pin.axis].push(pin.constraint);
|
|
2073
|
-
solveAll();
|
|
2074
|
-
}
|
|
2075
|
-
/**
|
|
2076
|
-
* Push apart any two boxes that landed on top of each other.
|
|
2077
|
-
*
|
|
2078
|
-
* Two boxes not overlapping is a placement the author never has to write, but on
|
|
2079
|
-
* its own it says nothing about *which way* to separate them — left, right,
|
|
2080
|
-
* above and below all satisfy it, and choosing among four is the search this
|
|
2081
|
-
* whole design refuses. So the direction is never chosen. It is read off the
|
|
2082
|
-
* constraints already built from the file: if the file lets one box travel away
|
|
2083
|
-
* from the other along an axis and offers no way back, that is the only
|
|
2084
|
-
* separation consistent with what was written. Where nothing in the file orders
|
|
2085
|
-
* a pair on either axis, this reports the pair instead of guessing.
|
|
2086
|
-
*
|
|
2087
|
-
* One case is genuinely free. When both axes already imply an order, either
|
|
2088
|
-
* would do, and the tie is broken by separating along the axis where the two
|
|
2089
|
-
* overlap least — the smallest movement, and the one a person makes by hand.
|
|
2090
|
-
* That is the single place the tool decides something nobody wrote.
|
|
2091
|
-
*
|
|
2092
|
-
* The loop only ever adds constraints and re-solves the whole system, so no
|
|
2093
|
-
* arrangement is ever tried and rejected and it settles without backtracking.
|
|
2094
|
-
* A solved layout is never nudged in place; that is a different thing and it is
|
|
2095
|
-
* the thing the design rules out.
|
|
2096
|
-
*
|
|
2097
|
-
* Members here are always siblings, or the roots of the diagram, so no member
|
|
2098
|
-
* ever contains another and containment needs no exemption of its own.
|
|
2099
|
-
*/
|
|
2100
|
-
function separate(members, constraints, solved, solveAll, overlaid = [], across, clearance = SEPARATION_GAP) {
|
|
2101
|
-
const eligible = members.map(allowsOverlap).map((allowed) => !allowed);
|
|
2102
|
-
if (eligible.filter(Boolean).length < 2)
|
|
2103
|
-
return;
|
|
2104
|
-
// An overlay and the box it is on overlap by construction, so that one pair
|
|
2105
|
-
// is exempt while both remain ordinary boxes to everything else. This is a
|
|
2106
|
-
// pair exemption rather than `overlap: allow` on the node for exactly that
|
|
2107
|
-
// reason: a badge sitting on its box says nothing about the box next door.
|
|
2108
|
-
const stamped = new Set(overlaid.map(([a, b]) => pairKey(a, b)));
|
|
2109
|
-
// Adding only, so the number of separations is bounded; the cap is a
|
|
2110
|
-
// backstop against a bug rather than an expected outcome.
|
|
2111
|
-
for (let round = 0; round < members.length * members.length + 1; round += 1) {
|
|
2112
|
-
let reach;
|
|
2113
|
-
let added = false;
|
|
2114
|
-
// One separation per round, then solve again: a pair that collided only
|
|
2115
|
-
// because another pair had not yet been moved apart is not a collision,
|
|
2116
|
-
// and separating it anyway leaves an ordering that later moves drag along.
|
|
2117
|
-
scan: for (let i = 0; i < members.length; i += 1) {
|
|
2118
|
-
if (!eligible[i])
|
|
2119
|
-
continue;
|
|
2120
|
-
for (let j = i + 1; j < members.length; j += 1) {
|
|
2121
|
-
if (!eligible[j])
|
|
2122
|
-
continue;
|
|
2123
|
-
if (stamped.has(pairKey(i, j)))
|
|
2124
|
-
continue;
|
|
2125
|
-
const over = overlapOf(members, solved, i, j);
|
|
2126
|
-
if (!over)
|
|
2127
|
-
continue;
|
|
2128
|
-
reach ??= { x: reachability(members.length, constraints.x), y: reachability(members.length, constraints.y) };
|
|
2129
|
-
const orders = {};
|
|
2130
|
-
for (const axis of AXES) {
|
|
2131
|
-
const order = impliedOrder(reach[axis], i, j);
|
|
2132
|
-
if (order)
|
|
2133
|
-
orders[axis] = order;
|
|
2134
|
-
}
|
|
2135
|
-
// A child against a side of its parent's frame has said which way it
|
|
2136
|
-
// stands from the rest of the box — `inside p right` is beside it —
|
|
2137
|
-
// so where that way is open, it is the way, and the smaller overlap
|
|
2138
|
-
// is not asked.
|
|
2139
|
-
const named = across?.(i, j);
|
|
2140
|
-
const axis = named !== undefined && orders[named] ? named : pickAxis(orders, over);
|
|
2141
|
-
if (!axis)
|
|
2142
|
-
throw unordered(members[i], members[j]);
|
|
2143
|
-
const { before, after } = orders[axis];
|
|
2144
|
-
const span = axis === 'x' ? members[before].width : members[before].height;
|
|
2145
|
-
const [near, far] = axis === 'x' ? ['left', 'right'] : ['top', 'bottom'];
|
|
2146
|
-
const past = members[before].reach[far] + members[after].reach[near];
|
|
2147
|
-
constraints[axis].push({ from: before, to: after, weight: span + past + clearance });
|
|
2148
|
-
reach = undefined;
|
|
2149
|
-
added = true;
|
|
2150
|
-
break scan;
|
|
2151
|
-
}
|
|
2152
|
-
}
|
|
2153
|
-
if (!added)
|
|
2154
|
-
return;
|
|
2155
|
-
solveAll();
|
|
1671
|
+
if (look.path !== 'curved' && written['line.curve'] !== undefined) {
|
|
1672
|
+
throw new SourceError(`edge ${what}: \`curve:\` says how widely a curved line swings, and this line is ${look.path} — ` +
|
|
1673
|
+
(look.path === 'square' ? 'for rounded corners write `corners: rounded`' : 'write `path: curved` with it'), line);
|
|
2156
1674
|
}
|
|
1675
|
+
if (look.path === 'curved' && written['line.corners'] === 'rounded') {
|
|
1676
|
+
throw new SourceError(`edge ${what}: a curved line has no corners to round — \`corners: rounded\` takes a ` +
|
|
1677
|
+
'`path: square` or `path: straight` line', line);
|
|
1678
|
+
}
|
|
1679
|
+
return look;
|
|
2157
1680
|
}
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
/** How far two members share space on each axis, or nothing if they are clear of each other. */
|
|
2162
|
-
function overlapOf(members, solved, i, j) {
|
|
2163
|
-
const shared = (axis) => {
|
|
2164
|
-
const [near, far] = axis === 'x' ? ['left', 'right'] : ['top', 'bottom'];
|
|
2165
|
-
const start = (index) => solved[axis][index] - members[index].reach[near];
|
|
2166
|
-
const end = (index) => solved[axis][index] + (axis === 'x' ? members[index].width : members[index].height) + members[index].reach[far];
|
|
2167
|
-
return Math.min(end(i), end(j)) - Math.max(start(i), start(j));
|
|
2168
|
-
};
|
|
2169
|
-
const x = shared('x');
|
|
2170
|
-
const y = shared('y');
|
|
2171
|
-
return x > 1e-9 && y > 1e-9 ? { x, y } : undefined;
|
|
2172
|
-
}
|
|
2173
|
-
/** Which of two members the file lets the other move past, if either. */
|
|
2174
|
-
function impliedOrder(reach, i, j) {
|
|
2175
|
-
const forward = reach[i][j];
|
|
2176
|
-
const backward = reach[j][i];
|
|
2177
|
-
if (forward === backward)
|
|
2178
|
-
return undefined;
|
|
2179
|
-
return forward ? { before: i, after: j } : { before: j, after: i };
|
|
2180
|
-
}
|
|
2181
|
-
/** Of the axes that can separate a pair, the one where they overlap least. */
|
|
2182
|
-
function pickAxis(orders, over) {
|
|
2183
|
-
const available = AXES.filter((axis) => orders[axis]);
|
|
2184
|
-
if (available.length < 2)
|
|
2185
|
-
return available[0];
|
|
2186
|
-
return over.x <= over.y ? 'x' : 'y';
|
|
2187
|
-
}
|
|
2188
|
-
function allowsOverlap(node) {
|
|
2189
|
-
const value = node.attrs['overlap'];
|
|
2190
|
-
if (value === undefined)
|
|
2191
|
-
return false;
|
|
2192
|
-
if (value !== 'allow') {
|
|
2193
|
-
throw new SourceError(`"${node.name}" has overlap: ${value}, which is not one of allow`, node.line);
|
|
2194
|
-
}
|
|
2195
|
-
return true;
|
|
2196
|
-
}
|
|
2197
|
-
function unordered(first, second) {
|
|
2198
|
-
const [earlier, later] = first.line <= second.line ? [first, second] : [second, first];
|
|
2199
|
-
return new SourceError(`"${earlier.name}" and "${later.name}" overlap, and nothing says which side of the ` +
|
|
2200
|
-
`other either one sits on — place one against the other, or say overlap: allow`, later.line);
|
|
2201
|
-
}
|
|
2202
|
-
/** Report a set of placements that cannot all hold, in the words they were written in. */
|
|
2203
|
-
function noRoom(contradiction, axis, members) {
|
|
2204
|
-
const { placements } = contradiction;
|
|
2205
|
-
if (placements.length === 0) {
|
|
2206
|
-
return new SourceError(`these placements run in a circle ${AXIS_WORD[axis]} and cannot all hold`, members[0]?.line ?? 1);
|
|
2207
|
-
}
|
|
2208
|
-
const quoted = placements.map((placement) => `"${describePlacement(placement)}"`).join(' and ');
|
|
2209
|
-
return new SourceError(`${quoted} cannot all hold — they leave no room ${AXIS_WORD[axis]}`, placements[placements.length - 1].line);
|
|
2210
|
-
}
|
|
2211
|
-
/**
|
|
2212
|
-
* How far this placement holds the node off its target.
|
|
2213
|
-
*
|
|
2214
|
-
* A gap is a property of the relationship, not of either box in it, so the
|
|
2215
|
-
* placement's own bracketed gap is the specific statement about this pair and
|
|
2216
|
-
* wins outright. Where it says nothing, `gap:` on a node is a default — and both
|
|
2217
|
-
* ends of the relationship may offer one. The node doing the placing wrote its
|
|
2218
|
-
* gap down; the target had someone else's placement written against it. Neither
|
|
2219
|
-
* is more entitled than the other, so the larger applies, which is the only
|
|
2220
|
-
* answer consistent with a gap being a minimum in the first place.
|
|
2221
|
-
*
|
|
2222
|
-
* That is what makes `gap: wide` on a node that carries no placements of its own
|
|
2223
|
-
* do the obvious thing rather than nothing at all: an author looking at two boxes
|
|
2224
|
-
* pushed too close together has no reason to know which of the two happened to
|
|
2225
|
-
* name the other.
|
|
2226
|
-
*/
|
|
2227
|
-
function gapFor(node, placement, target) {
|
|
2228
|
-
if (placement.gap !== undefined)
|
|
2229
|
-
return namedGap(node, placement.gap, placement.line);
|
|
2230
|
-
// Against the box that holds it, a child is spaced as that box spaces what it
|
|
2231
|
-
// holds: tucked in by the padding, and deaf to the box's own `gap:`, which
|
|
2232
|
-
// says how that box stands off its neighbours and not how it holds things.
|
|
2233
|
-
const own = target.children.includes(node);
|
|
2234
|
-
if (own && placement.written === 'inside')
|
|
2235
|
-
return PAD;
|
|
2236
|
-
// Beside or below the text of the box holding it, as the contents sit below
|
|
2237
|
-
// a title: a band said by placing things below the text is the band the
|
|
2238
|
-
// contents would have made.
|
|
2239
|
-
if (own && placement.targets.some((each) => each.name === target.name && each.part === 'text'))
|
|
2240
|
-
return CHILD_GAP;
|
|
2241
|
-
// An `inside` placement is an inset rather than a standoff, and the two want
|
|
2242
|
-
// different defaults: "beside that box" reads as room to breathe, "tucked in
|
|
2243
|
-
// that corner" reads as close to it. A node-wide `gap:`, which says how this
|
|
2244
|
-
// node stands off its *neighbours*, has no business setting an inset either
|
|
2245
|
-
// — so `inside` takes `tight` and stops there unless the placement says.
|
|
2246
|
-
if (placement.written === 'inside')
|
|
2247
|
-
return namedGap(node, 'tight', placement.line);
|
|
2248
|
-
const mine = node.attrs['gap'];
|
|
2249
|
-
const theirs = own ? undefined : target.attrs['gap'];
|
|
2250
|
-
// Only a gap somebody actually wrote down counts. Reading an absent one as the
|
|
2251
|
-
// default would make it a floor rather than a fallback, and every `gap: tight`
|
|
2252
|
-
// placed against a silent node would quietly widen back to normal.
|
|
2253
|
-
const stated = [];
|
|
2254
|
-
if (mine !== undefined)
|
|
2255
|
-
stated.push(namedGap(node, mine, node.line));
|
|
2256
|
-
if (theirs !== undefined)
|
|
2257
|
-
stated.push(namedGap(target, theirs, target.line));
|
|
2258
|
-
if (stated.length === 0)
|
|
2259
|
-
return namedGap(node, undefined, node.line);
|
|
2260
|
-
return Math.max(...stated);
|
|
2261
|
-
}
|
|
2262
|
-
/**
|
|
2263
|
-
* A gap is one of the named steps or a plain number of pixels. The names are
|
|
2264
|
-
* the default because retuning `tight` moves every tight gap together, but a
|
|
2265
|
-
* number is no less relative: it is still a minimum distance from the target,
|
|
2266
|
-
* and nothing unrelated moving can make it wrong.
|
|
2267
|
-
*/
|
|
2268
|
-
function namedGap(node, named, line) {
|
|
2269
|
-
const gap = GAPS[named ?? 'normal'];
|
|
2270
|
-
if (gap !== undefined)
|
|
2271
|
-
return gap;
|
|
2272
|
-
if (named !== undefined && /^\d+(\.\d+)?$/.test(named))
|
|
2273
|
-
return Number(named);
|
|
2274
|
-
const known = Object.keys(GAPS).join(', ');
|
|
2275
|
-
const unit = named?.match(/^(\d+(?:\.\d+)?)px$/);
|
|
2276
|
-
const hint = unit
|
|
2277
|
-
? `; write "gap: ${unit[1]}", a gap's number is already in pixels`
|
|
2278
|
-
: named?.startsWith('-')
|
|
2279
|
-
? '; a gap is a distance and cannot be negative, and "overlap: allow" is what lets two boxes meet'
|
|
2280
|
-
: '';
|
|
2281
|
-
throw new SourceError(`"${node.name}" asks for gap: ${named}, which is not one of ${known} or a number of pixels${hint}`, line);
|
|
1681
|
+
// --- pass two: sizes, bottom-up ---------------------------------------------
|
|
1682
|
+
function isCircle(node) {
|
|
1683
|
+
return node.body.kind === 'shape' && node.body.outline === 'circle';
|
|
2282
1684
|
}
|
|
1685
|
+
// --- pass three: solve for positions -----------------------------------------
|
|
2283
1686
|
// --- shared helpers ----------------------------------------------------------
|
|
2284
1687
|
/** The keys a bracketed attribute contributed, with its prefix taken off. */
|
|
2285
1688
|
function bracketOf(key, attrs) {
|
|
@@ -2299,11 +1702,12 @@ function bracketOf(key, attrs) {
|
|
|
2299
1702
|
* are refused by name: a style nobody declared, and one that says nothing about
|
|
2300
1703
|
* text and so would lend nothing.
|
|
2301
1704
|
*/
|
|
2302
|
-
function markupColors(nodes, edges, styles) {
|
|
1705
|
+
function markupColors(nodes, edges, styles, marks = []) {
|
|
2303
1706
|
const colors = {};
|
|
2304
1707
|
const used = [
|
|
2305
1708
|
...nodes.map((node) => ({ lines: node.lines, what: `"${node.name}"`, line: node.line })),
|
|
2306
|
-
...edges.flatMap((edge) => edge.
|
|
1709
|
+
...edges.flatMap((edge) => edge.texts.map((text) => ({ lines: text.lines, what: `edge ${edge.from.name} -> ${edge.to.name}`, line: edge.line }))),
|
|
1710
|
+
...marks.flatMap((mark) => (mark.text ? [{ lines: mark.text.lines, what: `${mark.kind} "${mark.name}"`, line: mark.line }] : [])),
|
|
2307
1711
|
];
|
|
2308
1712
|
for (const { lines, what, line } of used) {
|
|
2309
1713
|
for (const name of markupStyles(lines)) {
|
|
@@ -2346,26 +1750,28 @@ function linesFor(text, textAttrs, subject, line) {
|
|
|
2346
1750
|
}
|
|
2347
1751
|
return lines.flatMap((part) => wrapLine(part, columns));
|
|
2348
1752
|
}
|
|
2349
|
-
function bounds(nodes) {
|
|
1753
|
+
function bounds(nodes, marks) {
|
|
2350
1754
|
let minX = Infinity;
|
|
2351
1755
|
let minY = Infinity;
|
|
2352
1756
|
let maxX = -Infinity;
|
|
2353
1757
|
let maxY = -Infinity;
|
|
2354
|
-
for (const
|
|
2355
|
-
minX = Math.min(minX,
|
|
2356
|
-
minY = Math.min(minY,
|
|
2357
|
-
maxX = Math.max(maxX,
|
|
2358
|
-
maxY = Math.max(maxY,
|
|
1758
|
+
for (const box of [...nodes, ...marks, ...marks.flatMap((mark) => (mark.text ? [mark.text.box] : []))]) {
|
|
1759
|
+
minX = Math.min(minX, box.x);
|
|
1760
|
+
minY = Math.min(minY, box.y);
|
|
1761
|
+
maxX = Math.max(maxX, box.x + box.width);
|
|
1762
|
+
maxY = Math.max(maxY, box.y + box.height);
|
|
2359
1763
|
}
|
|
2360
1764
|
return { minX, minY, maxX, maxY };
|
|
2361
1765
|
}
|
|
2362
1766
|
/** Shift everything so the diagram starts at the margin rather than wherever the anchor fell. */
|
|
2363
|
-
function normalize(nodes, margin) {
|
|
2364
|
-
const { minX, minY } = bounds(nodes);
|
|
1767
|
+
function normalize(nodes, marks, margin, stops = []) {
|
|
1768
|
+
const { minX, minY } = bounds(nodes, marks);
|
|
2365
1769
|
const dx = margin - minX;
|
|
2366
1770
|
const dy = margin - minY;
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
1771
|
+
// A line's stops move with everything else, but don't widen the page: the
|
|
1772
|
+
// renderer grows it to hold whatever the lines reach.
|
|
1773
|
+
for (const box of [...nodes, ...marks, ...marks.flatMap((mark) => (mark.text ? [mark.text.box] : [])), ...stops]) {
|
|
1774
|
+
box.x += dx;
|
|
1775
|
+
box.y += dy;
|
|
2370
1776
|
}
|
|
2371
1777
|
}
|