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/dist/resolve.js CHANGED
@@ -1,10 +1,258 @@
1
- import { ALL_ATTR_KEYS, ATTR_KEYS, MARKS, PART_SIDES, COLOR_KEYS, COLOR_PARTS, CONTENT_ALIGNMENTS, CONTENT_WIDTHS, LINE_VALUES, describePlacement, nameTarget, } from './ast.js';
2
- import { BARE_EDGE_RUN, CHILD_GAP, DECK_STEP, DEFAULT_FONT_SIZE, DEFAULT_MARGIN, GAPS, HEADER_GAP, ICON_GAP, ICON_LINES, TEXT_CLEARANCE, PAD, SEPARATION_GAP, THICKNESS, arrowLength, fontSizeFor, leafTop, textExtent, textStyleFor, widestLine, } from './constants.js';
3
- import { fix, reachability, tightest } from './constrain.js';
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 edges = buildEdges(doc.statements, byName, styles, defaults);
33
- const local = new Map();
34
- for (const root of roots)
35
- sizeNode(root, edges, measurer, fontSize, local);
36
- placeRoots(roots, byName, edges, measurer, fontSize, local);
37
- normalize(nodes, margin);
38
- const extent = bounds(nodes);
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 child's name, and the node whose `badge:` it was written out from. */
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, or a badge the node's own
145
- // line or styles give it; a default cannot give a leaf a badge, so what
146
- // the defaults say cannot change the answer.
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 !== -1)
153
- parents.add(stmt.name.slice(0, cut));
154
- if (appearanceOf(stmt.attrs, styles, stmt.line)['badge'] !== undefined)
155
- parents.add(stmt.name);
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 child that "${owner}"'s badge: is written out as. Drop badge: from ` +
164
- `"${owner}" and write the icon here yourself, or give this node another name`, stmt.line);
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
- const text = body.kind === 'icon' && !stmt.statedText ? '' : stmt.text;
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 = { ...bracketOf('text', appearance), ...stmt.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
- // `deck:` holds one text per copy, joined on a line break the source cannot
212
- // contain. Like a badge, only a box has an outline to repeat.
213
- const deck = appearance['deck'];
214
- if (deck !== undefined && body.kind === 'shape')
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, and this is its expansion:
232
- // node <self>.badge icon: X right of <self> text
233
- // set at the parent's text size, so the picture is two of the parent's
234
- // lines tall. Read from the merged appearance, so a style carrying a badge
235
- // gives one to every box wearing it. Only a box has a text to be beside;
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
- const badge = badgeChild(node, named, icons);
240
- badges.set(badge.name, node.name);
241
- node.children.push(badge);
242
- nodes.push(badge);
243
- byName.set(badge.name, badge);
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
- /** The child `badge:` writes out, as `buildTree` describes. */
249
- function badgeChild(parent, named, icons) {
250
- const icon = { icon: named };
251
- const size = parent.textAttrs['size'];
252
- return {
253
- name: `${parent.name}.badge`,
254
- kind: 'icon',
255
- body: bodyFor(icon, icon, parent.line, icons),
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('', {}, `"${parent.name}.badge"`, parent.line),
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: size === undefined ? {} : { size },
272
- attrs: icon,
273
- appearance: icon,
887
+ textAttrs,
888
+ attrs,
889
+ appearance: attrs,
274
890
  placements: [
275
891
  {
276
892
  kind: 'offset',
277
893
  direction: 'right',
278
- targets: [{ name: parent.name, part: 'text' }],
279
- line: parent.line,
894
+ targets: [{ name: node.name, part: 'text' }],
895
+ ...(hasText ? { gap: '1' } : {}),
896
+ line: node.line,
280
897
  },
281
898
  ],
282
- line: parent.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 texts are stored joined on a line break; quote them back as written.
342
- const shown = key === 'deck' ? value.split('\n').map((text) => `"${text}"`).join(' ') : value;
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
- const from = byName.get(stmt.from);
495
- const to = byName.get(stmt.to);
496
- if (!from)
497
- throw new SourceError(`edge from "${stmt.from}", which does not exist`, stmt.line);
498
- if (!to)
499
- throw new SourceError(`edge to "${stmt.to}", which does not exist`, stmt.line);
500
- const between = stmt.between && {
501
- nodes: stmt.between.targets.map(({ name, part }) => {
502
- if (part !== undefined) {
503
- // A passage is the gap between two boxes, and a side or a point has
504
- // no gap on either hand. Refused by name rather than dropped.
505
- throw new SourceError(`edge passes between "${name} ${part}", and a passage runs between two whole boxes ` +
506
- `— drop "${part}"`, stmt.line);
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(`edge passes between "${name}", which does not exist`, stmt.line);
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
- ...(stmt.between.axis !== undefined ? { axis: stmt.between.axis } : {}),
515
- };
516
- const passes = stmt.passes?.map((placement) => {
517
- const written = describePlacement(placement);
518
- return {
519
- direction: placement.direction,
520
- written,
521
- nodes: placement.targets.map(({ name, part }) => {
522
- if (part !== undefined) {
523
- // The line passes a box, and a side or a point has no side of its
524
- // own to be passed on. Refused by name rather than dropped.
525
- throw new SourceError(`edge passes "${written}", and a line passes a whole box — drop "${part}"`, stmt.line);
526
- }
527
- const node = byName.get(name);
528
- if (!node) {
529
- throw new SourceError(`edge passes "${written}", and "${name}" does not exist`, stmt.line);
530
- }
531
- return node;
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.text === undefined) {
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 textAttrs = { ...bracketOf('text', appearance), ...stmt.textAttrs };
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
- textAttrs,
556
- ...(stmt.text !== undefined
557
- ? { text: stmt.text, lines: linesFor(stmt.text, textAttrs, `edge ${what}`, stmt.line) }
558
- : {}),
559
- ...(between ? { between } : {}),
560
- ...(passes ? { passes } : {}),
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
- * The line's bracket, checked, with a default for everything unsaid. A value is
571
- * checked wherever it came from — a style's `path: squar` is as much a typo as
572
- * an edge's — but `corners: rounded` is refused only when the edge itself says
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 lineLook(written, appearance, what, line) {
578
- const word = (property) => {
579
- const value = appearance[`line.${property}`];
580
- if (value === undefined)
581
- return LINE_VALUES[property][0];
582
- if (!LINE_VALUES[property].includes(value)) {
583
- throw new SourceError(`edge ${what}: "${property}: ${value}" is not a ${property} — use ${(property === 'thickness' ? ['thin', 'normal', 'thick'] : LINE_VALUES[property]).join(', ')}` +
584
- (property === 'thickness' ? ', or a number of pixels' : ''), line);
585
- }
586
- return value;
587
- };
588
- const thickness = (() => {
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
- function moveInside(node, dx, dy, local) {
759
- node.textBox.x += dx;
760
- node.textBox.y += dy;
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
- node: parent,
1062
- index: x,
1063
- byAxis: { x, y },
1064
- frame: true,
1065
- offset: { x: 0, y: 0 },
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
- const start = point(xs[0], ys[0]);
1070
- const end = point(xs[xs.length - 1], ys[ys.length - 1]);
1071
- return xs.length === 1 && ys.length === 1 ? [start] : [start, end];
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
- deckTexts: [],
1089
- reach: NO_REACH,
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
- * Lay out a node that has something placed against its own frame or text.
1108
- *
1109
- * One solve holds everything: the children placed against the frame, the
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 layoutFramed(node, own, edges, measurer, fontSize, local, minWidth) {
1123
- const { textWidth, textHeight, contents } = own;
1124
- const hasText = textWidth > 0;
1125
- const framed = framedChildren(node);
1126
- const stacked = node.children.filter((child) => !framed.has(child));
1127
- const placed = node.children.filter((child) => framed.has(child));
1128
- const banded = stacked.length > 0;
1129
- const style = banded
1130
- ? textStyleFor(node.textAttrs, node.line)
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 across = Math.max(row, block.width);
1153
- for (const child of stacked)
1154
- widenTo(child, across, local);
1155
- block = layoutChildren(stacked, edges, measurer, fontSize, local);
1156
- checkOneColumn(node, stacked, local);
1157
- }
1158
- }
1159
- const members = [...placed];
1160
- const add = (member) => members.push(member) - 1;
1161
- const K = banded ? add(stand(node, 'contents', block.width, block.height)) : -1;
1162
- // An empty text is still somewhere, so a thing placed against it has a
1163
- // place to be: it is a point where the text would have been, and it takes
1164
- // no room — including the gap beside it, which `Target.empty` drops.
1165
- const T = hasText || withText.length > 0 ? add(stand(node, 'text', textWidth, textHeight)) : -1;
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
- * The ways named sides can face away from the other end. Opposite sides face
1463
- * away together or not at all (`left right` with `from` first); sides at right
1464
- * angles each may, and either is enough (`left top` with `from` first, where
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 facingAway(fromSide, toSide) {
1471
- // The axis a side is on, and whether it faces away when its own end is first.
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
- // An edge told to pass between two named things carries its text in *that*
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 from = locate(edge.from);
1527
- const to = locate(edge.to);
1528
- if (!from || !to || from.index === to.index)
1529
- continue;
1530
- // With no text, the gap still has to show a run of line behind each mark,
1531
- // or two stacked children with an arrow between them draw as a head alone.
1532
- const marked = [edge.marks.from, edge.marks.to].filter((mark) => mark !== 'none').length;
1533
- if (edge.text === undefined) {
1534
- const bare = BARE_EDGE_RUN + arrowLength(edge.look.thickness) * marked;
1535
- corridors.push({
1536
- edge,
1537
- from,
1538
- to,
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
- continue;
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
- // The clearance is doubled because the text is drawn at the *midpoint* of
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
- * Position a set of nodes against each other from their placements.
1566
- *
1567
- * Every placement becomes a minimum distance, and the answer is the arrangement
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 positionGroup(members, locate, extra, corridors = [], across, clearance = SEPARATION_GAP) {
1577
- const constraints = { x: [...extra.x], y: [...extra.y] };
1578
- const indexOf = new Map(members.map((member, index) => [member, index]));
1579
- const pending = [];
1580
- /** Pairs an overlay put on top of each other, which is the point of it. */
1581
- const overlaid = [];
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 me = indexOf.get(node);
1601
- const fan = fans.get(node);
1602
- // A later member of a fan has its vertical from the list, so its own
1603
- // placements speak only across; the first speaks for the whole list.
1604
- const follows = fan !== undefined && fan.members[0] !== node;
1605
- const size = { x: node.width, y: fan ? fan.height : node.height };
1606
- const located = node.placements.map((placement) => ({ placement, targets: locate(placement, node) }));
1607
- const speaks = (axis) => !(follows && axis === 'y');
1608
- const spokenFor = { x: false, y: false };
1609
- for (const { placement } of located) {
1610
- if (placement.kind === 'align') {
1611
- spokenFor[placement.axis] = true;
1612
- }
1613
- else if (placement.kind === 'on') {
1614
- // An overlay names a point of a box, so it settles both axes at once.
1615
- spokenFor.x = true;
1616
- spokenFor.y = true;
1617
- }
1618
- else {
1619
- if (/left|right/.test(placement.direction))
1620
- spokenFor.x = true;
1621
- if (/above|below/.test(placement.direction))
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
- // Saying `inside` is the author stating the overlap, so there is
1656
- // nothing for the separation pass to report. `outside` is clear of the
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
- // One constraint per target, so the node clears the furthest of them.
1663
- // Taking that maximum is what longest paths already does, which is why a
1664
- // direction against a whole region needs nothing added to the solver.
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
- if (direction.includes('above'))
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
- // An axis nobody spoke to falls back to the center line of whatever the
1703
- // node was placed against, which is why "right of docker" alone is a whole
1704
- // position. Two different targets would decide which row the node shares,
1705
- // so that is refused rather than guessed — but two targets named by one
1706
- // placement are a single region, and centering on it is unambiguous.
1707
- for (const axis of AXES) {
1708
- if (spokenFor[axis] || !speaks(axis))
1709
- continue;
1710
- const offers = located.filter((entry) => entry.placement.kind === 'offset');
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
- const named = (entry) => entry.placement.targets.map(nameTarget).join('\u0000');
1716
- const other = offers.find((entry) => named(entry) !== named(first));
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
- alignOn(axis, 'center', first.targets, first.placement);
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
- * Narrow a target to the part of it the placement named — its text, one of its
1820
- * four sides, or one of its nine points.
1821
- *
1822
- * A side comes back as a segment of zero thickness and a point as a rectangle
1823
- * of no size at all, which is the whole of what a part is to the solver: an
1824
- * extent, exactly as a whole box is, just a thinner one. Everything else — the
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 partOf(target, part, owner, line) {
1834
- // The whole node is what it has placed outside itself too; a part of it is
1835
- // just that part.
1836
- if (part === undefined)
1837
- return { ...target, reach: target.node.reach };
1838
- const box = { ...target, offset: { ...target.offset } };
1839
- if (part === 'text') {
1840
- const text = target.node.textBox;
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
- box.offset.x += text.x;
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
- narrow('x', 'left', 'right');
1866
- narrow('y', 'top', 'bottom');
1867
- // A circle has no corners, so a corner of one is the point on it halfway
1868
- // between the two sides named: on the outline, as a rectangle's corner is.
1869
- const across = words.includes('left') || words.includes('right');
1870
- const down = words.includes('top') || words.includes('bottom');
1871
- if (isCircle(target.node) && across && down) {
1872
- const pull = (target.width / 2) * (1 - Math.SQRT1_2);
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
- const span = spanOf(entry.targets, axis, (target) => solved[axis][memberOn(target, axis)]);
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
- solveAll();
1990
- }
1991
- /**
1992
- * Check the measured alignments still hold.
1993
- *
1994
- * Separation runs afterwards and only ever adds, so it can push two targets
1995
- * apart and leave a region wider than it was when it was measured. Nothing here
1996
- * repairs that — the picture is reported as unbuildable, because silently
1997
- * drawing a node that is no longer level with what it names is the failure the
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 (pins.length === 0)
2070
- return;
2071
- for (const pin of pins)
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
- function pairKey(a, b) {
2159
- return a < b ? `${a}:${b}` : `${b}:${a}`;
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.lines ? [{ lines: edge.lines, what: `edge ${edge.from.name} -> ${edge.to.name}`, line: edge.line }] : []),
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 node of nodes) {
2355
- minX = Math.min(minX, node.x);
2356
- minY = Math.min(minY, node.y);
2357
- maxX = Math.max(maxX, node.x + node.width);
2358
- maxY = Math.max(maxY, node.y + node.height);
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
- for (const node of nodes) {
2368
- node.x += dx;
2369
- node.y += dy;
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
  }