reladraw 0.16.0 → 0.17.1

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/place.js ADDED
@@ -0,0 +1,2226 @@
1
+ import { describePlacement, describePoint, fractionAxis, nameTarget, PART_SIDES, partWordHint } from './ast.js';
2
+ import { DECK_STEP, GAPS, HEADER_GAP, UNIT } from './constants.js';
3
+ import { SourceError } from './errors.js';
4
+ import { isWithin, joinedToText } from './model.js';
5
+ import { SIZE_KEYS, storedSize } from './size.js';
6
+ /**
7
+ * How a region grows to hold its text, on one axis (design.org 7.4). Inside,
8
+ * it gains a band on that side as tall as the text and its gap, as a container
9
+ * makes room for its title; inside or on the border, it widens to hold the
10
+ * text along the side, from the end the text is aligned to. Outside, nothing.
11
+ */
12
+ export function regionGrown(extent, text, axis) {
13
+ const across = text.side === 'top' || text.side === 'bottom' ? 'x' : 'y';
14
+ const size = (on) => (on === 'x' ? text.width : text.height);
15
+ if (axis !== across) {
16
+ if (text.how !== 'inside')
17
+ return extent;
18
+ const band = size(axis) + text.gap;
19
+ return text.side === 'top' || text.side === 'left'
20
+ ? { start: extent.start - band, size: extent.size + band }
21
+ : { start: extent.start, size: extent.size + band };
22
+ }
23
+ const needed = size(axis) + text.gap * 2;
24
+ if (text.how === 'outside' || extent.size >= needed)
25
+ return extent;
26
+ const more = needed - extent.size;
27
+ const start = text.along === 'start' ? extent.start : text.along === 'end' ? extent.start - more : extent.start - more / 2;
28
+ return { start, size: needed };
29
+ }
30
+ /**
31
+ * Where a region's text sits on one axis, against the region as drawn (already
32
+ * grown): a gap inside or outside the side, or across the border; along the
33
+ * side, at its start, middle or end, held off the corner by the gap except
34
+ * outside, where its end is even with the region's edge, as a title's is.
35
+ */
36
+ export function regionTextAt(region, text, axis) {
37
+ const across = text.side === 'top' || text.side === 'bottom' ? 'x' : 'y';
38
+ const size = axis === 'x' ? text.width : text.height;
39
+ const end = region.start + region.size;
40
+ if (axis === across) {
41
+ const inset = text.how === 'outside' ? 0 : text.gap;
42
+ const start = text.along === 'start' ? region.start + inset : text.along === 'end' ? end - inset - size : region.start + region.size / 2 - size / 2;
43
+ return { start, size };
44
+ }
45
+ const low = text.side === 'top' || text.side === 'left';
46
+ const edge = low ? region.start : end;
47
+ if (text.how === 'on')
48
+ return { start: edge - size / 2, size };
49
+ const inward = text.how === 'inside' === low;
50
+ return { start: inward ? edge + text.gap : edge - text.gap - size, size };
51
+ }
52
+ const BOTH_AXES = ['x', 'y'];
53
+ /** Whether an axis is across or down, in a writer's words. */
54
+ const AXIS_SAID = { x: 'across', y: 'up and down' };
55
+ /** Which way a direction moves on each axis: past the far side, before the near one, or not at all. */
56
+ function component(direction, axis) {
57
+ if (axis === 'x') {
58
+ if (direction.includes('right'))
59
+ return 'after';
60
+ if (direction.includes('left'))
61
+ return 'before';
62
+ return undefined;
63
+ }
64
+ if (direction.startsWith('below'))
65
+ return 'after';
66
+ if (direction.startsWith('above'))
67
+ return 'before';
68
+ return undefined;
69
+ }
70
+ /**
71
+ * A gap as written: a gap word, a number in the file's unit, or a number of
72
+ * pixels with `px`. A distance is never negative; the words for an overlap are
73
+ * `inside` and `on`.
74
+ */
75
+ export function gapPixels(written, subject, line) {
76
+ const word = GAPS[written];
77
+ if (word !== undefined)
78
+ return word;
79
+ if (/^\d*\.?\d+$/.test(written))
80
+ return Number(written) * UNIT;
81
+ const pixels = /^(\d*\.?\d+)px$/.exec(written);
82
+ if (pixels)
83
+ return Number(pixels[1]);
84
+ if (written.startsWith('-')) {
85
+ throw new SourceError(`${subject}: a distance is never negative, so ${written} is not one — to overlap, place it inside or on the other`, line);
86
+ }
87
+ throw new SourceError(`${subject}: "${written}" is not a distance — write one of ${Object.keys(GAPS).join(', ')}, ` +
88
+ 'a number in the file\'s unit, or a number of pixels such as 12px', line);
89
+ }
90
+ /**
91
+ * A region's `opacity:`, an amount from none to full: `0` to `1.0`. The one
92
+ * look whose bare number is not a multiple of its normal (design.org 8.3).
93
+ */
94
+ export function checkOpacity(value, subject, line) {
95
+ if (/^\d*\.?\d+$/.test(value) && Number(value) <= 1)
96
+ return;
97
+ throw new SourceError(`${subject}: "opacity: ${value}" — opacity is an amount from 0 (none) to 1.0 (solid), such as opacity: 0.5`, line);
98
+ }
99
+ export class Placer {
100
+ roots;
101
+ byName;
102
+ facts;
103
+ named;
104
+ regionTexts;
105
+ starts = new Map();
106
+ sizes = new Map();
107
+ frames = new Map();
108
+ outlines = new Map();
109
+ working = new Set();
110
+ /** What is being worked out, innermost last, for reporting a circle. */
111
+ trail = [];
112
+ /** A node's size on its own, from what it states or holds, within its limits. */
113
+ owns = new Map();
114
+ /** A layout's child's position and size on each axis, worked out for the whole layout at once. */
115
+ laid = new Map();
116
+ /** Where each of a venn's parts' frames sits in the venn's, on each axis. */
117
+ vennPlans = new Map();
118
+ /** Where one venn part's frame sits in the venn's, up and down. */
119
+ vennShifts = new Map();
120
+ /** The band a laid-out container's children are aligned in, across its layout. */
121
+ bands = new Map();
122
+ /** Where a node joined to its container's text starts, measured from the text's start. */
123
+ unitStarts = new Map();
124
+ /** The stretch a container's text and what is joined to it cover, measured from the text's start. */
125
+ units = new Map();
126
+ /** Where a container's frame's origin is on the page. */
127
+ origins = new Map();
128
+ /** Containers whose anchor is being placed on the page, so a box outside them is measured on the page rather than in their frame. */
129
+ onPage = new Set();
130
+ /** Named points and regions being worked out, innermost last, to refuse one defined through itself. */
131
+ expanding = [];
132
+ constructor(roots, byName, facts,
133
+ /** Named points and regions (design.org 4.5), worked out wherever they are used. */
134
+ named = new Map(),
135
+ /** A region's text, which may grow the region to hold it (design.org 7.4). */
136
+ regionTexts = new Map()) {
137
+ this.roots = roots;
138
+ this.byName = byName;
139
+ this.facts = facts;
140
+ this.named = named;
141
+ this.regionTexts = regionTexts;
142
+ }
143
+ /**
144
+ * Where each named point and region is on the page. A point names one spot,
145
+ * so one that works out to a stretch (a side, a whole box) is refused.
146
+ */
147
+ placeNamed() {
148
+ const placed = new Map();
149
+ for (const [name, stmt] of this.named) {
150
+ const page = { name: `${stmt.kind} ${name}`, parent: undefined, children: [], placements: [], line: stmt.line };
151
+ const at = { name };
152
+ const x = this.extentOf(page, at, 'x', { kind: 'on', targets: [at], line: stmt.line });
153
+ const y = this.extentOf(page, at, 'y', { kind: 'on', targets: [at], line: stmt.line });
154
+ if (stmt.kind === 'point' && (x.size > 0.01 || y.size > 0.01)) {
155
+ throw new SourceError(`point "${name}" is "${nameTarget(stmt.point)}", which is a stretch rather than one point — ` +
156
+ 'name a point, such as a corner or a side\'s -center, or a fraction along a side', stmt.line);
157
+ }
158
+ placed.set(name, { x: x.start, y: y.start, width: x.size, height: y.size });
159
+ }
160
+ return placed;
161
+ }
162
+ /**
163
+ * Where a target is on the page once everything is placed: a box, a side (a
164
+ * stretch on one axis), a point, a named point or region. For a line's
165
+ * `via:` stops (design.org 6.4).
166
+ */
167
+ locate(target, subject, line) {
168
+ const page = { name: subject, parent: undefined, children: [], placements: [], line };
169
+ const placement = { kind: 'on', targets: [target], line };
170
+ const x = this.extentOf(page, target, 'x', placement);
171
+ const y = this.extentOf(page, target, 'y', placement);
172
+ return { x: x.start, y: y.start, width: x.size, height: y.size };
173
+ }
174
+ /** Work out everything, and write it onto the nodes. */
175
+ run() {
176
+ this.checkAnchors(this.roots, undefined);
177
+ for (const node of this.byName.values()) {
178
+ for (const axis of BOTH_AXES) {
179
+ this.size(node, axis);
180
+ this.page(node, axis);
181
+ }
182
+ }
183
+ for (const node of this.byName.values()) {
184
+ node.x = this.page(node, 'x');
185
+ node.y = this.page(node, 'y');
186
+ node.width = this.size(node, 'x');
187
+ node.height = this.size(node, 'y');
188
+ }
189
+ for (const node of this.byName.values())
190
+ this.checkYielded(node);
191
+ for (const node of this.byName.values())
192
+ this.finish(node);
193
+ }
194
+ // --- the quantities ------------------------------------------------------
195
+ /** Where a node starts on the page. */
196
+ page(node, axis) {
197
+ const parent = node.parent;
198
+ if (!parent)
199
+ return this.start(node, axis);
200
+ return this.origin(parent, axis) + this.start(node, axis);
201
+ }
202
+ /**
203
+ * Where a container's frame's origin is on the page. A container that says
204
+ * nothing about where it goes shares its own container's origin, so it is
205
+ * known without its outline; one placed by what it holds is where its
206
+ * anchor goes; any other is found from where its box went.
207
+ */
208
+ origin(node, axis) {
209
+ const key = `${node.name}\n${axis}`;
210
+ const known = this.origins.get(key);
211
+ if (known !== undefined)
212
+ return known;
213
+ const worked = this.guard(`origin\n${key}`, node, axis, undefined, () => {
214
+ if (this.frameAnchor(node, axis))
215
+ return this.anchorOnPage(node, axis);
216
+ const above = node.parent ? this.origin(node.parent, axis) : 0;
217
+ if (this.floats(node))
218
+ return above;
219
+ this.refusePlacedTwice(node, axis);
220
+ return above + this.start(node, axis) - this.frame(node, axis).boxStart;
221
+ });
222
+ this.origins.set(key, worked);
223
+ return worked;
224
+ }
225
+ /**
226
+ * A container that says where it goes on one axis cannot also hold a child
227
+ * placed against a box outside it on that axis: both would say where the
228
+ * container goes, and it is refused even when the two agree.
229
+ */
230
+ refusePlacedTwice(node, axis) {
231
+ const own = this.statements(node, axis);
232
+ if (own.length === 0)
233
+ return;
234
+ // A venn set's stagger places only its box when what it holds is placed across (`vennAlign`).
235
+ const part = this.vennPart(node);
236
+ if (part === 0 || part === 2) {
237
+ const parts = this.facts.get(node.parent).venn.parts;
238
+ if (staggerOf(own[0].placement, parts[2 - part]) !== undefined)
239
+ return;
240
+ }
241
+ for (const child of this.contentOf(node)) {
242
+ for (const { placement } of this.statements(child, axis)) {
243
+ const outside = placement.targets.flatMap((target) => this.boxesNamed(target, [])).some((name) => {
244
+ const named = this.byName.get(name);
245
+ return named !== undefined && named !== node && !isWithin(named, node);
246
+ });
247
+ if (!outside)
248
+ continue;
249
+ throw new SourceError(`"${node.name}" is placed twice ${AXIS_SAID[axis]}: by its own "${describePlacement(own[0].placement)}" ` +
250
+ `and by "${child.name} ${describePlacement(placement)}", which is inside it — keep one of the two`, placement.line);
251
+ }
252
+ }
253
+ }
254
+ /** The origin a box outside `parent` is measured from: the page while `parent`'s anchor is being placed on it. */
255
+ originFor(parent, axis) {
256
+ return this.onPage.has(`${parent.name}\n${axis}`) ? 0 : this.origin(parent, axis);
257
+ }
258
+ /** Where the anchor of a container placed by what it holds goes on the page, which is its frame's origin. */
259
+ anchorOnPage(node, axis) {
260
+ const anchor = this.frameAnchor(node, axis);
261
+ if (anchor.placements.length === 0)
262
+ return this.origin(anchor, axis) + this.frame(anchor, axis).boxStart;
263
+ const key = `${node.name}\n${axis}`;
264
+ this.onPage.add(key);
265
+ try {
266
+ return this.placed(anchor, axis);
267
+ }
268
+ finally {
269
+ this.onPage.delete(key);
270
+ }
271
+ }
272
+ /**
273
+ * A container that is its frame's anchor: it says nothing about where it
274
+ * goes, and nothing inside it is placed against a box outside it in its
275
+ * stead. Its frame's origin is its own container's.
276
+ */
277
+ floats(node) {
278
+ const parent = node.parent;
279
+ if (node.placements.length > 0 || node.children.length === 0)
280
+ return false;
281
+ if (parent && (parent.heading === node || joinedToText(node) || this.facts.get(parent).layout || this.vennPart(node) !== undefined)) {
282
+ return false;
283
+ }
284
+ return true;
285
+ }
286
+ /**
287
+ * A container placed by what it holds, on one axis (design.org 5.5): it
288
+ * says nothing about where it goes, nothing inside it is its anchor, and a
289
+ * child is placed against boxes outside it. The first such child is the
290
+ * frame's anchor, and where it goes says where the container goes.
291
+ */
292
+ frameAnchor(node, axis) {
293
+ if (node.children.length === 0 || node.placements.length > 0)
294
+ return undefined;
295
+ const parent = node.parent;
296
+ if (parent && (parent.heading === node || joinedToText(node) || this.facts.get(parent).layout || this.vennPart(node) !== undefined)) {
297
+ return undefined;
298
+ }
299
+ const facts = this.facts.get(node);
300
+ if (facts.layout || facts.venn)
301
+ return undefined;
302
+ const content = this.contentOf(node);
303
+ if (this.anchoredOnTitle(node) || node.titlePlacements?.length)
304
+ return undefined;
305
+ if (content.some((child) => this.unplaced(child)))
306
+ return undefined;
307
+ return content.find((child) => this.fromOutside(child, axis, node));
308
+ }
309
+ /** A box that says nothing about where it goes, and so anchors its frame. */
310
+ unplaced(node) {
311
+ if (this.acrossParts(node))
312
+ return true;
313
+ if (node.placements.length > 0)
314
+ return false;
315
+ return !(this.frameAnchor(node, 'x') && this.frameAnchor(node, 'y'));
316
+ }
317
+ /** Whether everything a box is placed against on one axis is outside `of`. */
318
+ fromOutside(node, axis, of) {
319
+ if (node.placements.length === 0) {
320
+ const anchor = this.frameAnchor(node, axis);
321
+ return anchor !== undefined && this.fromOutside(anchor, axis, of);
322
+ }
323
+ const speaking = this.statements(node, axis).map((statement) => statement.placement);
324
+ const placements = speaking.length > 0
325
+ ? speaking
326
+ : node.placements.filter((placement) => placement.kind === 'offset' && component(placement.direction, axis) === undefined);
327
+ if (placements.length === 0)
328
+ return false;
329
+ const names = placements.flatMap((placement) => placement.targets.flatMap((target) => this.boxesNamed(target, [])));
330
+ return names.length > 0 && names.every((name) => {
331
+ const named = this.byName.get(name);
332
+ return named !== undefined && named !== of && !isWithin(named, of);
333
+ });
334
+ }
335
+ /** The boxes a target is worked out from: the box it names, or those a point or region it names is worked out from. */
336
+ boxesNamed(target, seen) {
337
+ if (target.point) {
338
+ const point = target.point;
339
+ if (point.kind === 'between')
340
+ return [...this.boxesNamed(point.a, seen), ...this.boxesNamed(point.b, seen)];
341
+ if (point.kind === 'from')
342
+ return point.lines.flatMap((line) => this.boxesNamed(line.target, seen));
343
+ return this.boxesNamed(point.from, seen);
344
+ }
345
+ if (this.byName.has(target.name))
346
+ return [target.name];
347
+ const defined = this.named.get(target.name);
348
+ if (!defined || seen.includes(target.name))
349
+ return [];
350
+ const further = [...seen, target.name];
351
+ if (defined.kind === 'point')
352
+ return this.boxesNamed(defined.point, further);
353
+ return defined.corners.flatMap((corner) => this.boxesNamed(corner, further));
354
+ }
355
+ /** Where a node starts in its container's frame, or on the page for the top level. */
356
+ start(node, axis) {
357
+ return this.remember(this.starts, node, axis, () => this.workOutStart(node, axis));
358
+ }
359
+ size(node, axis) {
360
+ return this.remember(this.sizes, node, axis, () => this.workOutSize(node, axis));
361
+ }
362
+ /** A container's box and title in its own frame, at the size it ends up. */
363
+ frame(node, axis) {
364
+ const key = `${node.name}\n${axis}`;
365
+ const known = this.frames.get(key);
366
+ if (known)
367
+ return known;
368
+ const worked = this.guard(`frame\n${key}`, node, axis, undefined, () => this.vennPart(node) === 1 ? this.stripFrame(node, axis) : this.fit(node, axis));
369
+ this.frames.set(key, worked);
370
+ return worked;
371
+ }
372
+ /** A container's outline around what it holds, before anything states its size. */
373
+ outline(node, axis) {
374
+ const key = `${node.name}\n${axis}`;
375
+ const known = this.outlines.get(key);
376
+ if (known)
377
+ return known;
378
+ const worked = this.guard(`outline\n${key}`, node, axis, undefined, () => this.workOutFrame(node, axis));
379
+ this.outlines.set(key, worked);
380
+ return worked;
381
+ }
382
+ remember(memo, node, axis, work) {
383
+ const key = `${node.name}\n${axis}`;
384
+ const known = memo.get(key);
385
+ if (known !== undefined)
386
+ return known;
387
+ const kind = memo === this.starts ? 'start' : memo === this.owns ? 'own' : memo === this.unitStarts ? 'unit' : 'size';
388
+ const worked = this.guard(`${kind}\n${key}`, node, axis, undefined, work);
389
+ memo.set(key, worked);
390
+ return worked;
391
+ }
392
+ /**
393
+ * Work something out, refusing a circle. The trail holds what was being
394
+ * worked out on the way here, and the placements that asked for each.
395
+ */
396
+ guard(key, node, axis, placement, work, size) {
397
+ if (this.working.has(key))
398
+ throw this.circle(node);
399
+ this.working.add(key);
400
+ this.trail.push({ node, axis, ...(placement ? { placement } : {}), ...(size ? { size } : {}) });
401
+ try {
402
+ return work();
403
+ }
404
+ finally {
405
+ this.working.delete(key);
406
+ this.trail.pop();
407
+ }
408
+ }
409
+ circle(node) {
410
+ const from = this.trail.findIndex((step) => step.node === node);
411
+ const loop = this.trail.slice(from === -1 ? 0 : from).filter((step) => step.placement || step.size);
412
+ const said = [
413
+ ...new Set(loop.map((step) => step.placement
414
+ ? step.node.titlePlacements?.includes(step.placement)
415
+ ? `"${step.node.name}"'s title "${describePlacement(step.placement)}"`
416
+ : `"${step.node.name} ${describePlacement(step.placement)}"`
417
+ : `"${step.node.name} ${step.size}"`)),
418
+ ];
419
+ const last = loop[loop.length - 1];
420
+ if (loop.some((step) => step.size)) {
421
+ return new SourceError(said.length === 1
422
+ ? `"${node.name}" is sized from itself: ${said[0]} — measure it from something worked out without it`
423
+ : `these depend on each other in a circle: ${said.join(', ')} — one of them has to be stated without the others`, last?.placement?.line ?? last?.node.line ?? node.line);
424
+ }
425
+ if (said.length < 2) {
426
+ return new SourceError(`"${node.name}" is placed against something that depends on where "${node.name}" goes` +
427
+ (said.length === 1 ? ` (${said[0]})` : '') +
428
+ ' — place it against something worked out without it', last?.placement?.line ?? node.line);
429
+ }
430
+ return new SourceError(`these placements depend on each other in a circle: ${said.join(', ')} — ` +
431
+ 'one of them has to be placed against something else', last?.placement?.line ?? node.line);
432
+ }
433
+ // --- a box's start -------------------------------------------------------
434
+ workOutStart(node, axis) {
435
+ const parent = node.parent;
436
+ // A heading goes where its container's title would.
437
+ if (parent && parent.heading === node)
438
+ return this.frame(parent, axis).titleStart;
439
+ // Joined to the text: wherever the text goes, the same distance from it.
440
+ if (parent && joinedToText(node))
441
+ return this.frame(parent, axis).textStart + this.unitStart(node, axis);
442
+ if (this.vennPart(node) !== undefined)
443
+ return this.vennStart(node, axis);
444
+ if (parent && this.facts.get(parent).layout)
445
+ return this.inLayout(node, axis).start;
446
+ // The anchor of a frame placed by what it holds sits at its origin.
447
+ if (parent && this.frameAnchor(parent, axis) === node)
448
+ return 0;
449
+ if (this.frameAnchor(node, axis)) {
450
+ // Placed by what it holds: its frame is where its anchor went.
451
+ const above = parent ? this.origin(parent, axis) : 0;
452
+ return this.origin(node, axis) - above + this.frame(node, axis).boxStart;
453
+ }
454
+ // A container that anchors its frame shares its container's origin, so
455
+ // its box starts where its outline does.
456
+ if (this.floats(node))
457
+ return this.frame(node, axis).boxStart;
458
+ if (node.placements.length === 0 || this.acrossParts(node)) {
459
+ // The anchor of its frame. One placed against a box in another part of
460
+ // a venn is still its part's anchor; the placement moves the part. A container's title, when something is
461
+ // placed against it, is the anchor instead; `checkAnchors` has made sure
462
+ // there is exactly one.
463
+ return 0;
464
+ }
465
+ return this.placed(node, axis);
466
+ }
467
+ /** Where a node joined to its container's text starts, from the text's start. */
468
+ unitStart(node, axis) {
469
+ return this.remember(this.unitStarts, node, axis, () => this.placed(node, axis));
470
+ }
471
+ /** A box's start from its own placements. */
472
+ placed(node, axis) {
473
+ const statements = this.statements(node, axis);
474
+ if (statements.length === 0)
475
+ return this.lone(node, axis);
476
+ if (statements.length === 1) {
477
+ const [only] = statements;
478
+ return this.apply(node, axis, only);
479
+ }
480
+ const [first, second] = statements;
481
+ if (first.kind !== 'align' && second.kind !== 'align' && first.kind !== second.kind) {
482
+ // Opposite placements: centered between the two at its own size. Each
483
+ // target stays where its own statement put it.
484
+ const after = first.kind === 'after' ? first : second;
485
+ const before = first.kind === 'after' ? second : first;
486
+ const low = this.edgeOf(node, axis, after);
487
+ const high = this.edgeOf(node, axis, before);
488
+ return (low + high) / 2 - this.size(node, axis) / 2;
489
+ }
490
+ // Two pinned edges position the box and size it; `pins` checked them.
491
+ const pins = this.pins(node, axis);
492
+ if (pins)
493
+ return pins.start;
494
+ throw this.overStated(node, axis, statements);
495
+ }
496
+ edgeOf(node, axis, statement) {
497
+ return this.guard(`edge\n${node.name}\n${axis}\n${statement.placement.line}`, node, axis, statement.placement, () => statement.kind === 'align' ? 0 : statement.edge());
498
+ }
499
+ /** A box's start from one statement. */
500
+ apply(node, axis, statement) {
501
+ return this.guard(`apply\n${node.name}\n${axis}`, node, axis, statement.placement, () => {
502
+ if (statement.kind !== 'align') {
503
+ // Measured from a part of this box (`center 12 right of a center`), or from its facing side.
504
+ const own = this.ownPart(node, statement.placement, axis);
505
+ return statement.kind === 'after' ? statement.edge() - own.start : statement.edge() - own.start - own.size;
506
+ }
507
+ const at = statement.at();
508
+ if (statement.side === 'start')
509
+ return at.start;
510
+ if (statement.side === 'end')
511
+ return at.start + at.size - this.size(node, axis);
512
+ return at.start + at.size / 2 - this.size(node, axis) / 2;
513
+ });
514
+ }
515
+ /**
516
+ * Nothing states this axis. A lone direction on the other axis centers the
517
+ * box on its target here, since walking right from something keeps you on
518
+ * its center line. Two of them leave it open which target to center on, and
519
+ * that is refused rather than decided.
520
+ */
521
+ lone(node, axis) {
522
+ const offsets = node.placements.filter((placement) => placement.kind === 'offset' && component(placement.direction, axis) === undefined);
523
+ if (offsets.length !== 1) {
524
+ const said = node.placements.map((placement) => `"${describePlacement(placement)}"`).join(' and ');
525
+ throw new SourceError(`"${node.name}" says where it goes ${AXIS_SAID[axis === 'x' ? 'y' : 'x']} (${said}) but not ` +
526
+ `${AXIS_SAID[axis]} — add a placement that says ${axis === 'x' ? 'how far across, such as x even with' : 'how high, such as even with'} one of them`, node.line);
527
+ }
528
+ const [offset] = offsets;
529
+ return this.guard(`lone\n${node.name}\n${axis}`, node, axis, offset, () => {
530
+ const at = this.region(node, offset, axis);
531
+ const own = this.ownPart(node, offset, axis);
532
+ return at.start + at.size / 2 - own.start - own.size / 2;
533
+ });
534
+ }
535
+ /**
536
+ * The stretch of this box a placement measures from, from the box's start:
537
+ * the part it names, or the whole box.
538
+ */
539
+ ownPart(node, placement, axis) {
540
+ const whole = { start: 0, size: this.size(node, axis) };
541
+ if (placement.kind !== 'offset' || placement.own === undefined)
542
+ return whole;
543
+ if (placement.own === 'text') {
544
+ if (!this.hasTitle(node)) {
545
+ throw new SourceError(`"${node.name}" measures "${describePlacement(placement)}" from its text, and it has none`, placement.line);
546
+ }
547
+ return this.textOf(node, axis, 0);
548
+ }
549
+ return narrow(whole, placement.own, axis, this.facts.get(node).circle, this.size(node, 'x'));
550
+ }
551
+ overStated(node, axis, statements) {
552
+ const said = [...new Set(statements.map((statement) => statement.placement))]
553
+ .map((placement) => `"${describePlacement(placement)}"`)
554
+ .join(' and ');
555
+ const directions = statements.filter((statement) => statement.kind !== 'align');
556
+ if (directions.length === 1 && directions.length < statements.length) {
557
+ // Bare, the direction would give way to the pins (`checkYielded`); it is
558
+ // the distance written on it that they would overrule.
559
+ const [direction] = directions;
560
+ const pins = statements
561
+ .filter((statement) => statement.kind === 'align')
562
+ .map((statement) => `"${describePlacement(statement.placement)}"`)
563
+ .join(' and ');
564
+ return new SourceError(`"${node.name}" says how far away "${describePlacement(direction.placement)}" is, but ${pins} already ` +
565
+ `say where it goes ${AXIS_SAID[axis]} — drop the distance to keep only the direction, or drop the direction`, direction.placement.line);
566
+ }
567
+ return new SourceError(`"${node.name}" says where it goes ${AXIS_SAID[axis]} more than once: ${said}. Each axis is stated ` +
568
+ 'once — keep one, or pin two opposite edges with even with to give it a size', statements[statements.length - 1].placement.line);
569
+ }
570
+ /** Every statement a node's placements make about one axis. */
571
+ statements(node, axis) {
572
+ const found = [];
573
+ for (const placement of node.placements) {
574
+ if (placement.kind === 'offset') {
575
+ const way = component(placement.direction, axis);
576
+ if (way === undefined)
577
+ continue;
578
+ found.push({
579
+ kind: way,
580
+ placement,
581
+ edge: () => {
582
+ const at = this.region(node, placement, axis);
583
+ const gap = this.gapOf(node, placement, axis);
584
+ return way === 'after' ? at.start + at.size + gap : at.start - gap;
585
+ },
586
+ });
587
+ }
588
+ else if (placement.kind === 'align') {
589
+ if (placement.axis !== axis)
590
+ continue;
591
+ found.push({
592
+ kind: 'align',
593
+ placement,
594
+ side: sideAlong(placement.side),
595
+ at: () => this.region(node, placement, axis),
596
+ });
597
+ }
598
+ else {
599
+ found.push({ kind: 'align', placement, side: 'center', at: () => this.region(node, placement, axis) });
600
+ }
601
+ }
602
+ // A bare direction beside an edge pinned on its axis gives up its distance
603
+ // to the pin and keeps only its side, checked once everything is placed:
604
+ // `below host.trx top even with cat0` (design.org 5.3).
605
+ if (!found.some((statement) => statement.kind === 'align'))
606
+ return found;
607
+ return found.filter((statement) => statement.kind === 'align' || !this.yields(statement.placement));
608
+ }
609
+ /** A direction with nothing said about its distance, which a pin on its axis may refine. */
610
+ yields(placement) {
611
+ return (placement.kind === 'offset' &&
612
+ placement.gap === undefined &&
613
+ placement.gaps === undefined &&
614
+ placement.own === undefined &&
615
+ placement.written === undefined);
616
+ }
617
+ /**
618
+ * The sides that gave up their distance to a pin still have to hold: a box
619
+ * said to be below another, and pinned beside it, is refused.
620
+ */
621
+ checkYielded(node) {
622
+ for (const axis of BOTH_AXES) {
623
+ const pinned = node.placements.some((placement) => placement.kind === 'align' && placement.axis === axis);
624
+ if (!pinned)
625
+ continue;
626
+ for (const placement of node.placements) {
627
+ if (!this.yields(placement))
628
+ continue;
629
+ const way = component(placement.direction, axis);
630
+ if (way === undefined)
631
+ continue;
632
+ const starts = placement.targets.map((target) => this.locate(target, node.name, placement.line));
633
+ const begin = axis === 'x' ? node.x : node.y;
634
+ const end = begin + (axis === 'x' ? node.width : node.height);
635
+ const held = way === 'after'
636
+ ? starts.every((at) => begin >= (axis === 'x' ? at.x + at.width : at.y + at.height) - 0.5)
637
+ : starts.every((at) => end <= (axis === 'x' ? at.x : at.y) + 0.5);
638
+ if (held)
639
+ continue;
640
+ const pins = node.placements
641
+ .filter((other) => other.kind === 'align' && other.axis === axis)
642
+ .map((other) => `"${describePlacement(other)}"`)
643
+ .join(' and ');
644
+ const [own, theirs, past] = axis === 'y'
645
+ ? way === 'after' ? ['top', 'bottom', 'above'] : ['bottom', 'top', 'below']
646
+ : way === 'after' ? ['left', 'right', 'left of'] : ['right', 'left', 'right of'];
647
+ throw new SourceError(`"${node.name}" is said to be ${describePlacement(placement)}, but ${pins} put its ${own} ${past} ` +
648
+ `the ${theirs} of what it names — move what it is pinned to, or drop "${describePlacement(placement)}"`, placement.line);
649
+ }
650
+ }
651
+ }
652
+ /**
653
+ * Two alignments pinning opposite edges: `top even with a bottom even with
654
+ * c` positions the box and sizes it, and counts as one statement.
655
+ */
656
+ pins(node, axis) {
657
+ const parent = node.parent;
658
+ if (parent && this.facts.get(parent).layout)
659
+ return undefined;
660
+ const statements = this.statements(node, axis);
661
+ if (statements.length !== 2)
662
+ return undefined;
663
+ const [first, second] = statements;
664
+ if (first.kind !== 'align' || second.kind !== 'align')
665
+ return undefined;
666
+ const sides = new Set([first.side, second.side]);
667
+ if (!sides.has('start') || !sides.has('end'))
668
+ return undefined;
669
+ const low = first.side === 'start' ? first : second;
670
+ const high = first.side === 'start' ? second : first;
671
+ const from = this.guard(`pin\n${node.name}\n${axis}\nlow`, node, axis, low.placement, () => low.at());
672
+ const to = this.guard(`pin\n${node.name}\n${axis}\nhigh`, node, axis, high.placement, () => high.at());
673
+ const start = from.start;
674
+ const end = to.start + to.size;
675
+ if (end < start) {
676
+ throw new SourceError(`"${node.name}" pins its ${axis === 'x' ? 'left' : 'top'} edge past its ${axis === 'x' ? 'right' : 'bottom'} ` +
677
+ `edge ("${describePlacement(low.placement)}" and "${describePlacement(high.placement)}"), which leaves it no size`, high.placement.line);
678
+ }
679
+ return { start, size: end - start };
680
+ }
681
+ /** The gap a placement leaves, in pixels. */
682
+ gapOf(node, placement, axis) {
683
+ const each = placement.gaps?.[axis];
684
+ if (each !== undefined)
685
+ return gapPixels(each, `"${node.name}"`, placement.line);
686
+ if (placement.gap !== undefined)
687
+ return gapPixels(placement.gap, `"${node.name}"`, placement.line);
688
+ const parent = node.parent;
689
+ const own = parent !== undefined && placement.targets.some((target) => target.name === parent.name);
690
+ if (own && placement.targets.some((target) => target.part === 'text')) {
691
+ // One unit, as beside any text of one's own. An empty text takes no
692
+ // room, and that includes the gap beside it.
693
+ return parent.heading !== undefined || this.facts.get(parent).hasText ? UNIT : 0;
694
+ }
695
+ if (placement.written === 'inside') {
696
+ if (!own)
697
+ return GAPS['tight'];
698
+ // `inside hub top-right` clears the padding of the sides it names.
699
+ const facts = this.facts.get(parent);
700
+ const part = placement.targets.find((target) => target.name === parent.name)?.part ?? '';
701
+ if (axis === 'x')
702
+ return part.includes('left') ? facts.pad.left : part.includes('right') ? facts.pad.right : facts.padding;
703
+ return part.includes('top') ? facts.pad.top : part.includes('bottom') ? facts.pad.bottom : facts.padding;
704
+ }
705
+ return GAPS['normal'];
706
+ }
707
+ // --- targets -------------------------------------------------------------
708
+ /** The extent on one axis of what a placement names: one target, or the region bounding several. */
709
+ region(node, placement, axis) {
710
+ let start = Infinity;
711
+ let end = -Infinity;
712
+ for (const target of placement.targets) {
713
+ const at = this.extentOf(node, target, axis, placement);
714
+ start = Math.min(start, at.start);
715
+ end = Math.max(end, at.start + at.size);
716
+ }
717
+ return { start, size: end - start };
718
+ }
719
+ /**
720
+ * One target's extent on one axis, in the frame `node` is placed in: a
721
+ * point written out, a named point or region, a fraction of the way across
722
+ * a box, or a box and its part.
723
+ */
724
+ extentOf(node, target, axis, placement) {
725
+ if (target.point)
726
+ return this.pointExtent(node, target.point, axis, placement);
727
+ const defined = this.byName.has(target.name) ? undefined : this.named.get(target.name);
728
+ if (defined) {
729
+ const whole = this.namedExtent(node, defined, axis, placement);
730
+ if (target.part === 'text') {
731
+ throw new SourceError(`"${node.name}" is placed against "${nameTarget(target)}", and a ${defined.kind} has no text`, placement.line);
732
+ }
733
+ return this.alongFraction(whole, target, axis, () => narrow(whole, target.part, axis, false, 0));
734
+ }
735
+ if (target.fraction) {
736
+ const whole = this.extentOf(node, { name: target.name }, axis, placement);
737
+ return this.alongFraction(whole, target, axis, () => target.part === undefined ? whole : this.extentOf(node, { name: target.name, part: target.part }, axis, placement));
738
+ }
739
+ return this.nodeExtent(node, target, axis, placement);
740
+ }
741
+ /**
742
+ * A fraction of the way across `whole` on its own axis (`3/4 down`, `1/3
743
+ * across from right`). On the other axis it is the side it runs along, or
744
+ * the whole box when it was written first.
745
+ */
746
+ alongFraction(whole, target, axis, otherwise) {
747
+ const fraction = target.fraction;
748
+ if (!fraction || fractionAxis(fraction) !== axis)
749
+ return otherwise();
750
+ const fromEnd = fraction.along === 'up' || fraction.from === 'right';
751
+ const at = fromEnd ? whole.start + whole.size * (1 - fraction.value) : whole.start + whole.size * fraction.value;
752
+ return { start: at, size: 0 };
753
+ }
754
+ /** A named point or region, worked out in the frame `node` is placed in, with a region's padding. */
755
+ namedExtent(node, stmt, axis, placement) {
756
+ if (this.expanding.includes(stmt.name)) {
757
+ const loop = [...this.expanding.slice(this.expanding.indexOf(stmt.name)), stmt.name];
758
+ throw new SourceError(loop.length === 2
759
+ ? `${stmt.kind} "${stmt.name}" is worked out from itself — name things worked out without it`
760
+ : `these are worked out from each other in a circle: ${loop.join(' → ')} — one of them has to name something else`, stmt.line);
761
+ }
762
+ this.expanding.push(stmt.name);
763
+ try {
764
+ if (stmt.kind === 'point')
765
+ return this.extentOf(node, stmt.point, axis, placement);
766
+ let extent;
767
+ const [first, second] = stmt.corners;
768
+ if (second === undefined) {
769
+ extent = this.betweenOf(node, first.point, axis, placement, stmt.name);
770
+ }
771
+ else {
772
+ const a = this.extentOf(node, first, axis, placement);
773
+ const b = this.extentOf(node, second, axis, placement);
774
+ const start = Math.min(a.start, b.start);
775
+ extent = { start, size: Math.max(a.start + a.size, b.start + b.size) - start };
776
+ }
777
+ // Outward, all round, then `padding-left:` and the rest over it on their side.
778
+ const outward = (key, fallback) => {
779
+ const written = stmt.attrs[key];
780
+ return written === undefined ? fallback : gapPixels(written, `region "${stmt.name}" ${key}:`, stmt.line);
781
+ };
782
+ const all = outward('padding', 0);
783
+ const before = outward(axis === 'x' ? 'padding-left' : 'padding-top', all);
784
+ const after = outward(axis === 'x' ? 'padding-right' : 'padding-bottom', all);
785
+ extent = { start: extent.start - before, size: extent.size + before + after };
786
+ const text = this.regionTexts.get(stmt.name);
787
+ return text ? regionGrown(extent, text, axis) : extent;
788
+ }
789
+ finally {
790
+ this.expanding.pop();
791
+ }
792
+ }
793
+ /** A point written out, on one axis. */
794
+ pointExtent(node, point, axis, placement) {
795
+ if (point.kind === 'between')
796
+ return this.betweenOf(node, point, axis, placement);
797
+ if (point.kind === 'from')
798
+ return this.crossingOf(node, point, axis, placement);
799
+ const from = point.from;
800
+ const namedPoint = !this.byName.has(from.name) && this.named.get(from.name)?.kind === 'point';
801
+ if (from.part === undefined && from.point === undefined && from.fraction === undefined && !namedPoint) {
802
+ throw new SourceError(`"${describePoint(point)}" moves a point, and "${from.name}" is a whole ${this.named.has(from.name) ? 'region' : 'box'} — ` +
803
+ `say which point, such as \`${point.distance} ${point.direction}${/left|right/.test(point.direction) ? ' of' : ''} ${from.name} ${point.direction.startsWith('above') ? 'top' : point.direction.startsWith('below') ? 'bottom' : point.direction}\``, placement.line);
804
+ }
805
+ const at = this.extentOf(node, from, axis, placement);
806
+ const way = component(point.direction, axis);
807
+ if (way === undefined)
808
+ return at;
809
+ const by = gapPixels(point.distance, `"${describePoint(point)}"`, placement.line);
810
+ return { start: at.start + (way === 'after' ? by : -by), size: at.size };
811
+ }
812
+ /**
813
+ * `between a and b`: the space between two things. On an axis where they are
814
+ * apart it is the gap, from `a`'s facing side to `b`'s; where they overlap,
815
+ * the stretch they share. As a point it is the middle of that, or `at` the
816
+ * fraction of the gap from `a`. As a region (`region` given) it is the whole
817
+ * of it, and `(span: both)` stretches it over both boxes where they overlap
818
+ * (design.org 4.2).
819
+ */
820
+ betweenOf(node, point, axis, placement, region) {
821
+ const pair = (on) => [this.extentOf(node, point.a, on, placement), this.extentOf(node, point.b, on, placement)];
822
+ const apart = (on) => {
823
+ const [a, b] = pair(on);
824
+ return a.start + a.size <= b.start + 0.01 || b.start + b.size <= a.start + 0.01;
825
+ };
826
+ const other = axis === 'x' ? 'y' : 'x';
827
+ const written = describePoint(point);
828
+ if (region !== undefined && point.span && apart('x') && apart('y')) {
829
+ const [ax, bx] = pair('x');
830
+ const [ay, by] = pair('y');
831
+ const aLeft = ax.start < bx.start;
832
+ const aAbove = ay.start < by.start;
833
+ const a = nameTarget(point.a);
834
+ const b = nameTarget(point.b);
835
+ throw new SourceError(`region "${region}": ${a} and ${b} are apart both ways, so there are two gaps to stretch — write the one meant by its corners: ` +
836
+ `\`region ${region} ${point.a.name} ${aAbove ? 'top' : 'bottom'}-${aLeft ? 'right' : 'left'} to ${point.b.name} ${aAbove ? 'bottom' : 'top'}-${aLeft ? 'left' : 'right'}\` (the gap across) or ` +
837
+ `\`region ${region} ${point.a.name} ${aAbove ? 'bottom' : 'top'}-${aLeft ? 'left' : 'right'} to ${point.b.name} ${aAbove ? 'top' : 'bottom'}-${aLeft ? 'right' : 'left'}\` (the gap up and down)`, placement.line);
838
+ }
839
+ const [a, b] = pair(axis);
840
+ if (apart(axis)) {
841
+ const aFirst = a.start + a.size <= b.start + 0.01;
842
+ const from = aFirst ? a.start + a.size : a.start;
843
+ const to = aFirst ? b.start : b.start + b.size;
844
+ if (region !== undefined)
845
+ return { start: Math.min(from, to), size: Math.abs(to - from) };
846
+ return { start: from + (to - from) * (point.at ?? 0.5), size: 0 };
847
+ }
848
+ if (region === undefined && point.at !== undefined && !apart(other)) {
849
+ throw new SourceError(`"${written}": ${nameTarget(point.a)} and ${nameTarget(point.b)} overlap, so there is no gap to go ${point.atWritten} of the way across`, placement.line);
850
+ }
851
+ const start = Math.max(a.start, b.start);
852
+ const end = Math.min(a.start + a.size, b.start + b.size);
853
+ if (region !== undefined && point.span) {
854
+ const outer = Math.min(a.start, b.start);
855
+ return { start: outer, size: Math.max(a.start + a.size, b.start + b.size) - outer };
856
+ }
857
+ if (region !== undefined)
858
+ return { start, size: end - start };
859
+ return { start: (start + end) / 2, size: 0 };
860
+ }
861
+ /**
862
+ * `down from a and left from b`: where a line straight down from `a` meets
863
+ * one straight left from `b`. The words also say which way the point lies
864
+ * from each, and that is checked (design.org 4.3).
865
+ */
866
+ crossingOf(node, point, axis, placement) {
867
+ const upright = point.lines.find((line) => line.way === 'up' || line.way === 'down');
868
+ const level = point.lines.find((line) => line.way === 'left' || line.way === 'right' || line.way === 'even');
869
+ const written = describePoint(point);
870
+ /**
871
+ * Where a line from this thing runs, on the axis it holds still. A line
872
+ * from a whole side starts at its middle; `even with` a side running the
873
+ * other way names no one height, as when placing a box, and is refused.
874
+ */
875
+ const runs = (line, on) => {
876
+ const at = this.extentOf(node, line.target, on, placement);
877
+ if (line.way === 'even' && line.target.part !== undefined && at.size > 0.01) {
878
+ throw new SourceError(`"${written}": ${nameTarget(line.target)} is a whole side, with no one height to be even with — name top or bottom, or a corner`, placement.line);
879
+ }
880
+ return at.start + at.size / 2;
881
+ };
882
+ const x = runs(upright, 'x');
883
+ const y = runs(level, 'y');
884
+ // Each line starts at its thing and goes the way it says; the meeting has to be on both.
885
+ const fromUpright = (() => {
886
+ const at = this.extentOf(node, upright.target, 'y', placement);
887
+ return at.start + at.size / 2;
888
+ })();
889
+ const fromLevel = (() => {
890
+ const at = this.extentOf(node, level.target, 'x', placement);
891
+ return at.start + at.size / 2;
892
+ })();
893
+ const wrong = (upright.way === 'down' && y <= fromUpright) || (upright.way === 'up' && y >= fromUpright)
894
+ ? `${upright.way} from ${nameTarget(upright.target)}`
895
+ : (level.way === 'right' && x <= fromLevel) || (level.way === 'left' && x >= fromLevel)
896
+ ? `${level.way} from ${nameTarget(level.target)}`
897
+ : undefined;
898
+ if (wrong !== undefined) {
899
+ throw new SourceError(`"${written}": the two lines never meet — the point where they would cross isn't ${wrong}. ` +
900
+ 'The words say which way the point lies from each, so one of them is the wrong way round', placement.line);
901
+ }
902
+ return { start: axis === 'x' ? x : y, size: 0 };
903
+ }
904
+ /** A box, or a part of one, on one axis, in the frame `node` is placed in. */
905
+ nodeExtent(node, target, axis, placement) {
906
+ const named = this.byName.get(target.name);
907
+ if (!named) {
908
+ throw new SourceError(`"${node.name}" is placed against "${target.name}", which does not exist${partWordHint(target.name, this.byName.keys())}`, placement.line);
909
+ }
910
+ if (named === node || isWithin(named, node)) {
911
+ throw new SourceError(`"${node.name}" is placed against ${named === node ? 'itself' : `"${named.name}", which is inside it`}`, placement.line);
912
+ }
913
+ const parent = node.parent;
914
+ let whole;
915
+ let text;
916
+ if (!parent) {
917
+ // The top level is placed on the page, so anything anywhere can be named.
918
+ whole = { start: this.page(named, axis), size: this.size(named, axis) };
919
+ if (target.part === 'text')
920
+ text = this.textOf(named, axis, whole.start);
921
+ }
922
+ else if (joinedToText(node)) {
923
+ // Joined to the text: measured from the text's start, so the unit has
924
+ // one shape wherever the text's position words put it. The tree pass
925
+ // allowed nothing else to be named.
926
+ if (named === parent) {
927
+ whole = { start: 0, size: 0 };
928
+ text = { start: 0, size: this.textSize(parent, axis) };
929
+ }
930
+ else {
931
+ whole = { start: this.unitStart(named, axis), size: this.size(named, axis) };
932
+ if (target.part === 'text')
933
+ text = this.textOf(named, axis, whole.start);
934
+ }
935
+ }
936
+ else if (named === parent && target.part === 'text' && this.anchoredOnTitle(parent)) {
937
+ // The title anchors the frame: the text is at the origin, known without the box.
938
+ whole = { start: 0, size: 0 };
939
+ text = { start: 0, size: this.textSize(parent, axis) };
940
+ }
941
+ else if (named.parent === parent && joinedToText(named) && this.anchoredOnTitle(parent)) {
942
+ // Joined to the text, which is at the origin.
943
+ whole = { start: this.unitStart(named, axis), size: this.size(named, axis) };
944
+ if (target.part === 'text')
945
+ text = this.textOf(named, axis, whole.start);
946
+ }
947
+ else if (named === parent.heading) {
948
+ // The heading is the title: at the origin when something is placed
949
+ // against it, and otherwise wherever the title goes.
950
+ whole = this.anchoredOnTitle(parent)
951
+ ? { start: 0, size: this.size(named, axis) }
952
+ : { start: this.start(named, axis), size: this.size(named, axis) };
953
+ if (target.part === 'text')
954
+ text = this.textOf(named, axis, whole.start);
955
+ }
956
+ else if (named === parent && target.part === 'text' && this.titleStates(parent, axis)) {
957
+ // The title is placed against the children on this axis, so it is
958
+ // known from them without the box.
959
+ const titleStart = this.titlePlaced(parent, axis);
960
+ const unit = this.hasUnit(parent) ? this.unit(parent, axis) : undefined;
961
+ text = { start: unit ? titleStart - unit.start : titleStart, size: this.textSize(parent, axis) };
962
+ whole = text;
963
+ }
964
+ else if (named === parent) {
965
+ const frame = this.frame(parent, axis);
966
+ whole = { start: frame.boxStart, size: frame.boxSize };
967
+ if (target.part === 'text')
968
+ text = { start: frame.textStart, size: frame.textSize };
969
+ }
970
+ else if (named.parent === parent) {
971
+ if (this.framed(named) && !this.framed(node)) {
972
+ throw new SourceError(`"${node.name}" is placed against "${named.name}", which is placed against "${parent.name}" itself. ` +
973
+ `Nothing may be placed against a thing placed against its container's side or corner — place ` +
974
+ `"${node.name}" against another child`, placement.line);
975
+ }
976
+ whole = { start: this.start(named, axis), size: this.size(named, axis) };
977
+ if (target.part === 'text')
978
+ text = this.textOf(named, axis, whole.start);
979
+ }
980
+ else if (this.inOtherPart(node, named)) {
981
+ // A box in another part of the same venn, in the venn's frame: only
982
+ // `vennShift` asks, to find where this part goes.
983
+ whole = { start: this.start(named, axis) + this.vennShift(named.parent, axis), size: this.size(named, axis) };
984
+ if (target.part === 'text')
985
+ text = this.textOf(named, axis, whole.start);
986
+ }
987
+ else if (this.nestedInFrame(named, parent, axis)) {
988
+ // Inside a sibling: measured in this frame, so a container placed on
989
+ // the page needn't be placed before its own children are.
990
+ whole = { start: this.within(named, parent, axis), size: this.size(named, axis) };
991
+ if (target.part === 'text')
992
+ text = this.textOf(named, axis, whole.start);
993
+ }
994
+ else {
995
+ // Outside its container, or inside another (design.org 5.2): found on
996
+ // the page and measured from this frame's origin.
997
+ whole = { start: this.page(named, axis) - this.originFor(parent, axis), size: this.size(named, axis) };
998
+ if (target.part === 'text')
999
+ text = this.textOf(named, axis, whole.start);
1000
+ }
1001
+ if (target.part === 'text') {
1002
+ if (!text || (!this.hasTitle(named) && named !== parent)) {
1003
+ throw new SourceError(`"${node.name}" is placed against "${nameTarget(target)}", and "${named.name}" has no text`, placement.line);
1004
+ }
1005
+ return text;
1006
+ }
1007
+ return narrow(whole, target.part, axis, this.facts.get(named).circle, this.size(named, 'x'));
1008
+ }
1009
+ /** A node's text on one axis, given where the node starts. */
1010
+ textOf(node, axis, start) {
1011
+ const facts = this.facts.get(node);
1012
+ if (node.children.length > 0) {
1013
+ const frame = this.frame(node, axis);
1014
+ return { start: start + frame.textStart - frame.boxStart, size: frame.textSize };
1015
+ }
1016
+ const box = facts.textBox;
1017
+ const natural = axis === 'x' ? facts.natural.width : facts.natural.height;
1018
+ const grown = this.size(node, axis) - natural;
1019
+ return {
1020
+ start: start + (axis === 'x' ? box.x : box.y) + grown / 2,
1021
+ size: axis === 'x' ? box.width : box.height,
1022
+ };
1023
+ }
1024
+ /** A child placed against its own container's box, which it does not grow. */
1025
+ framed(node) {
1026
+ const parent = node.parent;
1027
+ if (!parent || this.facts.get(parent).layout || this.facts.get(parent).venn)
1028
+ return false;
1029
+ return node.placements.some((placement) => placement.targets.some((target) => target.name === parent.name && target.part !== 'text'));
1030
+ }
1031
+ /**
1032
+ * A child placed against its container's text, or against its heading,
1033
+ * which makes the title its frame's anchor.
1034
+ */
1035
+ onTitle(node) {
1036
+ const parent = node.parent;
1037
+ if (!parent)
1038
+ return false;
1039
+ return node.placements.some((placement) => placement.targets.some((target) => {
1040
+ if (target.name === parent.name && target.part === 'text')
1041
+ return true;
1042
+ if (parent.heading !== undefined && target.name === parent.heading.name)
1043
+ return true;
1044
+ const named = this.byName.get(target.name);
1045
+ return named !== undefined && named.parent === parent && joinedToText(named);
1046
+ }));
1047
+ }
1048
+ /** The stretch a container's text and what is joined to it cover, measured from the text's start. */
1049
+ unit(node, axis) {
1050
+ const key = `${node.name}\n${axis}`;
1051
+ const known = this.units.get(key);
1052
+ if (known)
1053
+ return known;
1054
+ const worked = this.guard(`unit\n${key}`, node, axis, undefined, () => {
1055
+ const hasText = this.facts.get(node).hasText;
1056
+ let low = hasText ? 0 : Infinity;
1057
+ let high = hasText ? this.textSize(node, axis) : -Infinity;
1058
+ for (const child of node.children) {
1059
+ if (!joinedToText(child))
1060
+ continue;
1061
+ const start = this.unitStart(child, axis);
1062
+ const reach = this.reachOf(child, axis);
1063
+ low = Math.min(low, start - reach.low);
1064
+ high = Math.max(high, start + this.size(child, axis) + reach.high);
1065
+ }
1066
+ return low > high ? { start: 0, size: 0 } : { start: low, size: high - low };
1067
+ });
1068
+ this.units.set(key, worked);
1069
+ return worked;
1070
+ }
1071
+ /** Whether anything is joined to a container's text. */
1072
+ hasUnit(node) {
1073
+ return node.children.some((child) => joinedToText(child));
1074
+ }
1075
+ /** How big a container's own text is on one axis, without what is joined to it. */
1076
+ textSize(node, axis) {
1077
+ const facts = this.facts.get(node);
1078
+ return facts.hasText ? (axis === 'x' ? facts.textBox.width : facts.textBox.height) : 0;
1079
+ }
1080
+ /**
1081
+ * Whether a container's title anchors its frame, because a child is placed
1082
+ * against it. A title placed against the children is a link between them
1083
+ * instead, and the child it is placed against anchors the frame.
1084
+ */
1085
+ anchoredOnTitle(node) {
1086
+ return !node.titlePlacements?.length && this.contentOf(node).some((child) => this.onTitle(child));
1087
+ }
1088
+ /**
1089
+ * The children a container's box is the outline of, besides its title: not
1090
+ * its heading, not what is joined to its text, and not those placed against
1091
+ * the box.
1092
+ */
1093
+ contentOf(node) {
1094
+ return node.children.filter((child) => child !== node.heading && !joinedToText(child) && !this.framed(child));
1095
+ }
1096
+ /** Whether a node has a title: its text, what is joined to it, or a heading. */
1097
+ hasTitle(node) {
1098
+ return node.heading !== undefined || this.facts.get(node).hasText || this.hasUnit(node);
1099
+ }
1100
+ /** How big a container's title is on one axis: its heading, or its text with what is joined to it. */
1101
+ titleSize(node, axis) {
1102
+ if (node.heading)
1103
+ return this.size(node.heading, axis);
1104
+ if (this.hasUnit(node))
1105
+ return this.unit(node, axis).size;
1106
+ return this.textSize(node, axis);
1107
+ }
1108
+ /**
1109
+ * Where a container's title sits. A heading, or a text with something joined
1110
+ * to it, over nothing else is centered, as a leaf's text is, unless its
1111
+ * bracket says where.
1112
+ */
1113
+ titleOf(node) {
1114
+ const stated = node.textAttrs['place'] !== undefined;
1115
+ if ((node.heading || this.hasUnit(node)) && !stated && this.contentOf(node).length === 0) {
1116
+ return { how: 'center', side: 'center', along: 'center' };
1117
+ }
1118
+ return this.facts.get(node).title;
1119
+ }
1120
+ // --- sizes ---------------------------------------------------------------
1121
+ /**
1122
+ * A node's size: from two pinned edges, from its layout's stretching, or from
1123
+ * what it states or holds. Each axis's size is stated once, so a size key
1124
+ * beside pins or a stretching layout is refused.
1125
+ */
1126
+ workOutSize(node, axis) {
1127
+ if (this.vennPart(node) !== undefined)
1128
+ return this.vennSize(node, axis);
1129
+ const pinned = this.pins(node, axis);
1130
+ if (pinned) {
1131
+ const keys = sizeKeysOn(node, axis);
1132
+ if (keys.length > 0) {
1133
+ const pins = this.statements(node, axis).map((statement) => `"${describePlacement(statement.placement)}"`);
1134
+ throw new SourceError(`"${node.name}" is pinned on both sides (${pins.join(' and ')}), which gives it its ${DIMENSION[axis]}, ` +
1135
+ `and also states ${keys.join(' ')} — keep one`, node.line);
1136
+ }
1137
+ return pinned.size;
1138
+ }
1139
+ const parent = node.parent;
1140
+ if (parent && parent.heading !== node && !joinedToText(node) && this.stretches(parent, axis)) {
1141
+ const laid = this.inLayout(node, axis);
1142
+ if (laid.size !== undefined)
1143
+ return laid.size;
1144
+ }
1145
+ return this.ownSize(node, axis);
1146
+ }
1147
+ /** Whether a laid-out container sets its children's size on this axis. */
1148
+ stretches(parent, axis) {
1149
+ const layout = this.facts.get(parent).layout;
1150
+ return layout !== undefined && (layout.kind === 'deck' || (axis === 'x' && layout.widths !== 'natural'));
1151
+ }
1152
+ /** A node's size from what it states, or else what it holds, within its limits. */
1153
+ ownSize(node, axis) {
1154
+ return this.remember(this.owns, node, axis, () => {
1155
+ const dimension = DIMENSION[axis];
1156
+ const stated = node.appearance[dimension];
1157
+ const value = stated === undefined ? this.naturalSize(node, axis) : this.measure(node, dimension, stated);
1158
+ const least = node.appearance[`min-${dimension}`];
1159
+ const most = node.appearance[`max-${dimension}`];
1160
+ const low = least === undefined ? 0 : this.measure(node, `min-${dimension}`, least);
1161
+ const high = most === undefined ? Infinity : this.measure(node, `max-${dimension}`, most);
1162
+ if (low > high) {
1163
+ throw new SourceError(`"${node.name}" has min-${dimension}: ${least} larger than its max-${dimension}: ${most}, which leaves no ${dimension} it may be`, node.line);
1164
+ }
1165
+ return Math.min(Math.max(value, low), high);
1166
+ });
1167
+ }
1168
+ /** What a node's own contents make it, before anything stretches it. */
1169
+ naturalSize(node, axis) {
1170
+ const facts = this.facts.get(node);
1171
+ if (node.children.length === 0)
1172
+ return axis === 'x' ? facts.natural.width : facts.natural.height;
1173
+ return this.outline(node, axis).natural;
1174
+ }
1175
+ /** Work out one size key's value, in pixels. */
1176
+ measure(node, key, written) {
1177
+ const dimension = SIZE_KEYS[key];
1178
+ const axis = dimension === 'width' ? 'x' : 'y';
1179
+ const size = storedSize(written, key, `"${node.name}"`, node.line);
1180
+ return this.guard(`measure\n${node.name}\n${key}`, node, axis, undefined, () => this.evaluate(node, key, size), `${key}: ${written}`);
1181
+ }
1182
+ evaluate(node, key, size) {
1183
+ const dimension = SIZE_KEYS[key];
1184
+ const facts = this.facts.get(node);
1185
+ switch (size.kind) {
1186
+ case 'number':
1187
+ if (size.unit === 'px')
1188
+ return size.value;
1189
+ if (size.unit === 'unit')
1190
+ return size.value * UNIT;
1191
+ // Room for this much text: the lines or characters, and the padding round them.
1192
+ return size.value * (size.unit === 'lines' ? facts.lineHeight : facts.charWidth) + padBefore(facts, DIMENSION_AXIS[dimension]) + padAfter(facts, DIMENSION_AXIS[dimension]);
1193
+ case 'own':
1194
+ if (size.dimension === dimension) {
1195
+ throw new SourceError(`"${node.name}" ${key}: ${size.dimension} measures itself — measure it from something else`, node.line);
1196
+ }
1197
+ return this.size(node, size.dimension === 'width' ? 'x' : 'y');
1198
+ case 'measure': {
1199
+ const named = this.sized(node, key, size.name);
1200
+ const measured = size.dimension ?? dimension;
1201
+ return this.size(named, measured === 'width' ? 'x' : 'y');
1202
+ }
1203
+ case 'span':
1204
+ return this.span(node, key, size);
1205
+ case 'scale':
1206
+ return size.by * this.evaluate(node, key, size.of);
1207
+ case 'sum':
1208
+ return size.terms.reduce((total, term) => total + this.evaluate(node, key, term), 0);
1209
+ case 'pick': {
1210
+ const first = this.evaluate(node, key, size.first);
1211
+ const second = this.evaluate(node, key, size.second);
1212
+ return size.which === 'larger' ? Math.max(first, second) : Math.min(first, second);
1213
+ }
1214
+ }
1215
+ }
1216
+ /** The box a size names. */
1217
+ sized(node, key, name) {
1218
+ const named = this.byName.get(name);
1219
+ if (named)
1220
+ return named;
1221
+ const dot = name.lastIndexOf('.');
1222
+ const last = name.slice(dot + 1);
1223
+ if (dot !== -1 && (last === 'width' || last === 'height')) {
1224
+ throw new SourceError(`"${node.name}" ${key}: ${name} — a dot names a child, so write the measurement after a space: ${name.slice(0, dot)} ${last}`, node.line);
1225
+ }
1226
+ throw new SourceError(`"${node.name}" ${key}: measures "${name}", which does not exist${partWordHint(name, this.byName.keys())}`, node.line);
1227
+ }
1228
+ /**
1229
+ * `a top to c bottom`: the distance between two edges. Both are found in the
1230
+ * frame of the innermost container holding them both, so a span between two
1231
+ * children never waits on their container's box.
1232
+ */
1233
+ span(node, key, size) {
1234
+ const axisOf = (side) => side === 'top' || side === 'bottom' ? 'y' : side === 'left' || side === 'right' ? 'x' : undefined;
1235
+ const fromAxis = axisOf(size.from.side);
1236
+ const toAxis = axisOf(size.to.side);
1237
+ if (fromAxis && toAxis && fromAxis !== toAxis) {
1238
+ throw new SourceError(`"${node.name}" ${key}: ${size.from.name} ${size.from.side} to ${size.to.name} ${size.to.side} — a span runs between ` +
1239
+ 'two edges on one axis, such as top to bottom or left to right', node.line);
1240
+ }
1241
+ const axis = fromAxis ?? toAxis ?? (SIZE_KEYS[key] === 'width' ? 'x' : 'y');
1242
+ const from = this.sized(node, key, size.from.name);
1243
+ const to = this.sized(node, key, size.to.name);
1244
+ const common = commonFrame(from, to);
1245
+ const edge = (named, side) => {
1246
+ const start = this.within(named, common, axis);
1247
+ const length = named === common ? this.frame(named, axis).boxSize : this.size(named, axis);
1248
+ return start + (side === 'center' ? length / 2 : side === 'bottom' || side === 'right' ? length : 0);
1249
+ };
1250
+ const length = edge(to, size.to.side) - edge(from, size.from.side);
1251
+ if (length < 0) {
1252
+ throw new SourceError(`"${node.name}" ${key}: ${size.from.name} ${size.from.side} to ${size.to.name} ${size.to.side} runs backward — ` +
1253
+ `${size.to.name} ${size.to.side} comes before ${size.from.name} ${size.from.side}, so name them the other way round`, node.line);
1254
+ }
1255
+ return length;
1256
+ }
1257
+ /**
1258
+ * Whether `named` sits inside a sibling within `frame`'s frame, with every
1259
+ * container between them placed in that frame rather than by what it holds.
1260
+ */
1261
+ nestedInFrame(named, frame, axis) {
1262
+ let container = named.parent;
1263
+ if (!container || container === frame)
1264
+ return false;
1265
+ for (; container && container !== frame; container = container.parent) {
1266
+ if (this.frameAnchor(container, axis) || this.vennPart(container) !== undefined || this.facts.get(container).venn)
1267
+ return false;
1268
+ }
1269
+ return container === frame;
1270
+ }
1271
+ /** Where a node starts in the frame of `frame`, one of its containers, or on the page when that is undefined. */
1272
+ within(node, frame, axis) {
1273
+ if (node === frame)
1274
+ return this.frame(node, axis).boxStart;
1275
+ const parent = node.parent;
1276
+ if (parent === frame)
1277
+ return this.start(node, axis);
1278
+ return this.within(parent, frame, axis) - this.frame(parent, axis).boxStart + this.start(node, axis);
1279
+ }
1280
+ // --- containers ----------------------------------------------------------
1281
+ /**
1282
+ * A container on one axis: its box in its own frame, and where its title
1283
+ * sits. The box is the outline of what it holds, plus padding: the children
1284
+ * not placed against the box itself, and the title when it sits inside the
1285
+ * border or on it.
1286
+ */
1287
+ workOutFrame(node, axis) {
1288
+ const facts = this.facts.get(node);
1289
+ const title = this.titleOf(node);
1290
+ const hasTitle = this.hasTitle(node);
1291
+ const titleSize = this.titleSize(node, axis);
1292
+ const content = this.contentOf(node);
1293
+ const anchoredOnTitle = this.anchoredOnTitle(node);
1294
+ let low = Infinity;
1295
+ let high = -Infinity;
1296
+ for (const child of content) {
1297
+ const start = this.start(child, axis);
1298
+ const reach = this.reachOf(child, axis);
1299
+ low = Math.min(low, start - reach.low);
1300
+ high = Math.max(high, start + this.size(child, axis) + reach.high);
1301
+ }
1302
+ // A venn's set wraps the overlap as well as its own children.
1303
+ const shared = this.vennPhantom(node, axis);
1304
+ if (shared) {
1305
+ low = Math.min(low, shared.start);
1306
+ high = Math.max(high, shared.start + shared.size);
1307
+ }
1308
+ const empty = low > high;
1309
+ if (empty) {
1310
+ low = 0;
1311
+ high = 0;
1312
+ }
1313
+ // A laid-out container's content spans the whole band its children are
1314
+ // lined up in.
1315
+ if (!empty && facts.layout) {
1316
+ this.inLayout(content[0], axis);
1317
+ const band = this.bands.get(`${node.name}\n${axis}`);
1318
+ if (band !== undefined) {
1319
+ low = Math.min(low, 0);
1320
+ high = Math.max(high, band);
1321
+ }
1322
+ }
1323
+ // Where the title sits in the frame. When something is placed against it,
1324
+ // it is the frame's anchor and sits at the origin; otherwise it is placed
1325
+ // against the children by its side.
1326
+ const across = title.side === 'top' || title.side === 'bottom' ? 'x' : 'y';
1327
+ const gapToTitle = axis === 'x' ? HEADER_GAP : HEADER_GAP;
1328
+ // With something joined to the text, the title is the whole unit, and the
1329
+ // text sits inside it; a frame the title anchors has the text at its origin.
1330
+ const unit = this.hasUnit(node) ? this.unit(node, axis) : undefined;
1331
+ let titleStart;
1332
+ if (anchoredOnTitle || empty) {
1333
+ titleStart = unit ? unit.start : 0;
1334
+ if (empty && !anchoredOnTitle && hasTitle) {
1335
+ low = titleStart;
1336
+ high = titleStart + titleSize;
1337
+ }
1338
+ }
1339
+ else if (title.how === 'center' || title.side === 'center') {
1340
+ titleStart = (low + high) / 2 - titleSize / 2;
1341
+ }
1342
+ else if (axis === across && titleSize > high - low && title.how !== 'outside') {
1343
+ // A title longer than the children leaves the box room beside them, and
1344
+ // gather-children: says where in it they sit; the title spans it.
1345
+ const where = facts.gather[axis];
1346
+ titleStart = where === 'start' ? low : where === 'end' ? high - titleSize : (low + high) / 2 - titleSize / 2;
1347
+ }
1348
+ else if (axis === across) {
1349
+ titleStart =
1350
+ title.along === 'start' ? low : title.along === 'end' ? high - titleSize : (low + high) / 2 - titleSize / 2;
1351
+ }
1352
+ else if (title.how === 'on') {
1353
+ // The border runs through the title's middle, and the contents clear the
1354
+ // half of it that is inside.
1355
+ const clear = Math.max(facts.padding, titleSize / 2 + gapToTitle);
1356
+ titleStart =
1357
+ title.side === 'top' || title.side === 'left' ? low - clear - titleSize / 2 : high + clear - titleSize / 2;
1358
+ }
1359
+ else if (title.how === 'inside') {
1360
+ titleStart = title.side === 'top' || title.side === 'left' ? low - gapToTitle - titleSize : high + gapToTitle;
1361
+ }
1362
+ else {
1363
+ titleStart = 0;
1364
+ }
1365
+ // A title placed against the box's own children on this axis goes there,
1366
+ // and is content like a child: no row or column is kept for it.
1367
+ const placedTitle = hasTitle ? this.titlePlaced(node, axis) : undefined;
1368
+ if (placedTitle !== undefined)
1369
+ titleStart = placedTitle;
1370
+ const inBox = hasTitle && title.how !== 'outside';
1371
+ let boxLow = low;
1372
+ let boxHigh = high;
1373
+ if (inBox && !empty) {
1374
+ boxLow = Math.min(boxLow, titleStart);
1375
+ boxHigh = Math.max(boxHigh, titleStart + titleSize);
1376
+ }
1377
+ boxLow -= padBefore(facts, axis);
1378
+ boxHigh += padAfter(facts, axis);
1379
+ // A title along a side keeps `padding:` from the box's edge, so a side's
1380
+ // own padding (`padding-right: 4`) widens the room round the children
1381
+ // without walking the title in from the corner.
1382
+ if (inBox && !empty && placedTitle === undefined && axis === across && title.how !== 'center' && title.side !== 'center') {
1383
+ if (title.along === 'start')
1384
+ titleStart -= Math.max(0, padBefore(facts, axis) - facts.padding);
1385
+ if (title.along === 'end')
1386
+ titleStart += Math.max(0, padAfter(facts, axis) - facts.padding);
1387
+ }
1388
+ // On the border: the box's edge on that side runs through the title's middle.
1389
+ if (title.how === 'on' && hasTitle && axis !== across) {
1390
+ const middle = titleStart + titleSize / 2;
1391
+ if (title.side === 'top' || title.side === 'left')
1392
+ boxLow = middle;
1393
+ else
1394
+ boxHigh = middle;
1395
+ }
1396
+ // Outside the border: placed off the finished box, so it never sizes it.
1397
+ if (title.how === 'outside' && hasTitle && !anchoredOnTitle && placedTitle === undefined) {
1398
+ if (axis === across) {
1399
+ titleStart =
1400
+ title.along === 'start'
1401
+ ? boxLow
1402
+ : title.along === 'end'
1403
+ ? boxHigh - titleSize
1404
+ : (boxLow + boxHigh) / 2 - titleSize / 2;
1405
+ }
1406
+ else {
1407
+ titleStart = title.side === 'top' || title.side === 'left' ? boxLow - HEADER_GAP - titleSize : boxHigh + HEADER_GAP;
1408
+ }
1409
+ }
1410
+ // Grown evenly, so a centered title sits in the middle of its box, and a
1411
+ // centered title along a side sits in the middle of that side.
1412
+ if (inBox &&
1413
+ placedTitle === undefined &&
1414
+ (title.how === 'center' || title.side === 'center' || (axis === across && title.along === 'center'))) {
1415
+ const middle = titleStart + titleSize / 2;
1416
+ const half = Math.max(middle - boxLow, boxHigh - middle);
1417
+ boxLow = middle - half;
1418
+ boxHigh = middle + half;
1419
+ }
1420
+ let natural = boxHigh - boxLow;
1421
+ if (facts.circle) {
1422
+ // The circle round the padded-in block, so whatever fits in the
1423
+ // rectangle fits in the circle, clear of it at the corners.
1424
+ const other = axis === 'x' ? 'y' : 'x';
1425
+ const otherInner = this.innerOther(node, other);
1426
+ const side = Math.hypot(natural - facts.padding * 2, otherInner) + facts.padding * 2;
1427
+ boxLow -= (side - natural) / 2;
1428
+ boxHigh = boxLow + side;
1429
+ natural = side;
1430
+ }
1431
+ return {
1432
+ boxStart: boxLow,
1433
+ boxSize: natural,
1434
+ natural,
1435
+ titleStart,
1436
+ titleSize,
1437
+ textStart: unit ? titleStart - unit.start : titleStart,
1438
+ textSize: unit ? this.textSize(node, axis) : titleSize,
1439
+ };
1440
+ }
1441
+ /**
1442
+ * A container's box at the size it ends up, when something states one: its
1443
+ * outline grown or shrunk so what it holds sits where gather-children: says,
1444
+ * the middle unless it says, and its title moved with the side it keeps to. A title something
1445
+ * is placed against is part of what it holds, and stays.
1446
+ */
1447
+ fit(node, axis) {
1448
+ const outline = this.outline(node, axis);
1449
+ const extra = this.size(node, axis) - outline.boxSize;
1450
+ if (extra === 0)
1451
+ return outline;
1452
+ const title = this.titleOf(node);
1453
+ const across = title.side === 'top' || title.side === 'bottom' ? 'x' : 'y';
1454
+ const anchored = this.anchoredOnTitle(node);
1455
+ // The children sit in the room where gather-children: says, the middle
1456
+ // unless it says; the box grows away from them.
1457
+ const share = { start: 0, center: 0.5, end: 1 };
1458
+ const part = this.vennPart(node);
1459
+ // A venn's set whose box is said grows to it, not about its middle.
1460
+ const box = axis === 'y' && part !== undefined && part !== 1 ? this.vennBox(node) : undefined;
1461
+ const grow = box !== undefined
1462
+ ? outline.boxStart + this.vennPlan(node.parent, 'y')[part] - box.start
1463
+ : extra * share[this.facts.get(node).gather[axis]];
1464
+ let shift = 0;
1465
+ if (!anchored && title.how !== 'center' && title.side !== 'center' && !this.titleStates(node, axis)) {
1466
+ const toward = axis === across ? title.along : title.side === 'top' || title.side === 'left' ? 'start' : 'end';
1467
+ shift = extra * share[toward] - grow;
1468
+ }
1469
+ return {
1470
+ ...outline,
1471
+ boxStart: outline.boxStart - grow,
1472
+ boxSize: outline.boxSize + extra,
1473
+ titleStart: outline.titleStart + shift,
1474
+ textStart: outline.textStart + shift,
1475
+ };
1476
+ }
1477
+ /** The title's placements that state one axis. */
1478
+ titleStatementsOn(node, axis) {
1479
+ return (node.titlePlacements ?? []).filter((placement) => placement.kind === 'offset'
1480
+ ? component(placement.direction, axis) !== undefined
1481
+ : placement.kind === 'align'
1482
+ ? placement.axis === axis
1483
+ : true);
1484
+ }
1485
+ /** Whether the title's bracket places it on this axis with a placement. */
1486
+ titleStates(node, axis) {
1487
+ return this.titleStatementsOn(node, axis).length > 0;
1488
+ }
1489
+ /**
1490
+ * Where a title placed against the box's own children starts on one axis,
1491
+ * in the box's frame, or undefined when no placement states the axis and
1492
+ * its side words place it (design.org 7.2). Each axis is stated once.
1493
+ */
1494
+ titlePlaced(node, axis) {
1495
+ const placements = this.titleStatementsOn(node, axis);
1496
+ if (placements.length === 0)
1497
+ return undefined;
1498
+ const subject = `"${node.name}"'s title`;
1499
+ const said = placements.map((placement) => `"${describePlacement(placement)}"`).join(' and ');
1500
+ const line = placements[0].line;
1501
+ // Opposite placements center the title between them, as they center a box (5.3).
1502
+ const ways = placements.map((placement) => (placement.kind === 'offset' ? component(placement.direction, axis) : undefined));
1503
+ const opposite = placements.length === 2 && ways[0] !== undefined && ways[1] !== undefined && ways[0] !== ways[1];
1504
+ if (placements.length > 1 && !opposite) {
1505
+ throw new SourceError(`${subject} says where it goes ${AXIS_SAID[axis]} more than once: ${said}. Each axis is stated once — keep one, ` +
1506
+ 'or place it after one thing and before another to center it between them', line);
1507
+ }
1508
+ // The side words state the axis across their side, and align: the axis
1509
+ // along it; a placement may take whichever of the two they leave unsaid.
1510
+ const place = node.textAttrs['place'];
1511
+ const side = place?.split(' ')[1];
1512
+ const across = side === 'left' || side === 'right' ? 'x' : 'y';
1513
+ const words = place === 'center'
1514
+ ? ['x', 'y']
1515
+ : [...(place !== undefined ? [across] : []), ...(node.textAttrs['align'] !== undefined ? [across === 'x' ? 'y' : 'x'] : [])];
1516
+ if (words.includes(axis)) {
1517
+ const written = [place, node.textAttrs['align'] !== undefined ? `align: ${node.textAttrs['align']}` : undefined]
1518
+ .filter((one) => one !== undefined)
1519
+ .join(', ');
1520
+ throw new SourceError(`${subject} says where it goes ${AXIS_SAID[axis]} twice: "${written}" and ${said}. Each axis is stated once — keep one`, line);
1521
+ }
1522
+ const size = this.titleSize(node, axis);
1523
+ if (opposite) {
1524
+ const after = ways[0] === 'after' ? placements[0] : placements[1];
1525
+ const before = ways[0] === 'after' ? placements[1] : placements[0];
1526
+ const low = this.titleStart(node, axis, after, subject);
1527
+ const high = this.titleStart(node, axis, before, subject) + size;
1528
+ return (low + high) / 2 - size / 2;
1529
+ }
1530
+ return this.titleStart(node, axis, placements[0], subject);
1531
+ }
1532
+ /** Where one placement of a title against the box's own children puts the title's start. */
1533
+ titleStart(node, axis, placement, subject) {
1534
+ // A child may be placed against a title placed against another child; a
1535
+ // title placed against the very child placed against it is caught as a
1536
+ // circle by the guard.
1537
+ return this.guard(`title\n${node.name}\n${axis}\n${placement.line}\n${describePlacement(placement)}`, node, axis, placement, () => {
1538
+ let start = Infinity;
1539
+ let end = -Infinity;
1540
+ for (const target of placement.targets) {
1541
+ const child = node.children.find((one) => one.name === target.name);
1542
+ if (!child) {
1543
+ throw new SourceError(`${subject} is placed against "${target.name}", which is not one of "${node.name}"'s own children — ` +
1544
+ 'a title is placed against the box it names, by its sides, or against its children', placement.line);
1545
+ }
1546
+ const whole = { start: this.start(child, axis), size: this.size(child, axis) };
1547
+ const at = target.part === 'text'
1548
+ ? this.textOf(child, axis, whole.start)
1549
+ : narrow(whole, target.part, axis, this.facts.get(child).circle, this.size(child, 'x'));
1550
+ start = Math.min(start, at.start);
1551
+ end = Math.max(end, at.start + at.size);
1552
+ }
1553
+ const size = this.titleSize(node, axis);
1554
+ if (placement.kind === 'offset') {
1555
+ const gap = placement.gap !== undefined ? gapPixels(placement.gap, subject, placement.line) : GAPS['normal'];
1556
+ return component(placement.direction, axis) === 'after' ? end + gap : start - gap - size;
1557
+ }
1558
+ const along = placement.kind === 'align' ? sideAlong(placement.side) : 'center';
1559
+ if (along === 'start')
1560
+ return start;
1561
+ if (along === 'end')
1562
+ return end - size;
1563
+ return (start + end) / 2 - size / 2;
1564
+ });
1565
+ }
1566
+ /** The padded-in block of a circle on the other axis, which its diameter depends on as well. */
1567
+ innerOther(node, axis) {
1568
+ const key = `inner\n${node.name}\n${axis}`;
1569
+ return this.guard(key, node, axis, undefined, () => {
1570
+ const content = this.contentOf(node);
1571
+ let low = Infinity;
1572
+ let high = -Infinity;
1573
+ for (const child of content) {
1574
+ low = Math.min(low, this.start(child, axis));
1575
+ high = Math.max(high, this.start(child, axis) + this.size(child, axis));
1576
+ }
1577
+ const titleSize = this.titleSize(node, axis);
1578
+ const title = this.titleOf(node);
1579
+ const along = title.side === 'top' || title.side === 'bottom' ? 'x' : 'y';
1580
+ if (low > high)
1581
+ return titleSize;
1582
+ return axis === along ? Math.max(high - low, titleSize) : high - low + (titleSize > 0 ? titleSize + HEADER_GAP : 0);
1583
+ });
1584
+ }
1585
+ /**
1586
+ * A child of a laid-out container: placed by the layout, in written order,
1587
+ * and never by itself.
1588
+ */
1589
+ inLayout(node, axis) {
1590
+ const parent = node.parent;
1591
+ const key = `${node.name}\n${axis}`;
1592
+ const known = this.laid.get(key);
1593
+ if (known)
1594
+ return known;
1595
+ const layout = this.facts.get(parent).layout;
1596
+ const main = layout.kind === 'column' ? 'y' : 'x';
1597
+ const children = parent.children.filter((child) => child !== parent.heading && !joinedToText(child));
1598
+ const natural = (child, on) => this.ownSize(child, on);
1599
+ if (layout.kind === 'deck') {
1600
+ // Every card is as big as the biggest. The first is in front, and each
1601
+ // next one a step up and to the left behind it.
1602
+ const size = Math.max(...children.map((child) => natural(child, axis)));
1603
+ const last = children.length - 1;
1604
+ children.forEach((child, index) => this.laid.set(`${child.name}\n${axis}`, { start: (last - index) * DECK_STEP, size }));
1605
+ return this.laid.get(key);
1606
+ }
1607
+ if (axis !== main && this.stretches(parent, axis)) {
1608
+ for (const child of children) {
1609
+ const keys = sizeKeysOn(child, axis);
1610
+ if (keys.length > 0) {
1611
+ throw new SourceError(`"${child.name}" states ${keys.join(' ')}, but "${parent.name}" has layout: ${layout.kind} ` +
1612
+ `(widths: ${layout.widths}), which sets every child's width — drop one`, child.line);
1613
+ }
1614
+ }
1615
+ }
1616
+ if (axis === main) {
1617
+ let cursor = 0;
1618
+ children.forEach((child, index) => {
1619
+ const size = natural(child, axis);
1620
+ if (layout.centers !== undefined) {
1621
+ this.laid.set(`${child.name}\n${axis}`, { start: index * layout.centers - size / 2 });
1622
+ }
1623
+ else {
1624
+ this.laid.set(`${child.name}\n${axis}`, { start: cursor });
1625
+ cursor += size + layout.gap;
1626
+ }
1627
+ });
1628
+ }
1629
+ else {
1630
+ // The band is the room the children are lined up in: as wide as the
1631
+ // widest of them. With widths: fill it is the title along the top or
1632
+ // bottom if that is wider, so they stretch under it; otherwise a wider
1633
+ // title leaves spare room, which gather-children: says where they sit in.
1634
+ let widest = Math.max(...children.map((child) => natural(child, axis)));
1635
+ const title = this.titleOf(parent);
1636
+ const titleAlong = title.side === 'top' || title.side === 'bottom';
1637
+ const band = layout.widths === 'fill' &&
1638
+ axis === 'x' && titleAlong && this.hasTitle(parent) && title.how !== 'outside' && title.how !== 'center'
1639
+ ? Math.max(widest, this.titleSize(parent, 'x'))
1640
+ : widest;
1641
+ if (layout.widths === 'fill')
1642
+ widest = band;
1643
+ this.bands.set(`${parent.name}\n${axis}`, band);
1644
+ const stretch = axis === 'x' && layout.widths !== 'natural';
1645
+ for (const child of children) {
1646
+ const size = stretch ? widest : natural(child, axis);
1647
+ const start = layout.align === 'start' ? 0 : layout.align === 'end' ? band - size : (band - size) / 2;
1648
+ this.laid.set(`${child.name}\n${axis}`, stretch ? { start, size } : { start });
1649
+ }
1650
+ }
1651
+ return this.laid.get(key);
1652
+ }
1653
+ // --- venn ----------------------------------------------------------------
1654
+ /** Which part of a venn a node is: 0 the left set, 1 the overlap, 2 the right set. */
1655
+ vennPart(node) {
1656
+ const venn = node.parent ? this.facts.get(node.parent).venn : undefined;
1657
+ if (!venn)
1658
+ return undefined;
1659
+ const index = venn.parts.indexOf(node);
1660
+ return index === -1 ? undefined : index;
1661
+ }
1662
+ /** The outline of what a venn's part holds, in its own frame. The overlap's includes its title's width. */
1663
+ partContent(node, axis) {
1664
+ let low = Infinity;
1665
+ let high = -Infinity;
1666
+ for (const child of this.contentOf(node)) {
1667
+ const start = this.start(child, axis);
1668
+ const reach = this.reachOf(child, axis);
1669
+ low = Math.min(low, start - reach.low);
1670
+ high = Math.max(high, start + this.size(child, axis) + reach.high);
1671
+ }
1672
+ if (axis === 'x' && this.vennPart(node) === 1 && this.hasTitle(node)) {
1673
+ const title = this.titleSize(node, 'x');
1674
+ if (title > high - low) {
1675
+ const middle = (low + high) / 2;
1676
+ low = middle - title / 2;
1677
+ high = middle + title / 2;
1678
+ }
1679
+ }
1680
+ return { start: low, size: high - low };
1681
+ }
1682
+ /**
1683
+ * Where each part's own frame sits in the venn's: what to add to a part's
1684
+ * coordinates. Across, the left set's contents, the overlap's and the right
1685
+ * set's in that order. The border between a pair is the crossing set's
1686
+ * padding from what is inside it, and the venn's gap from what is outside it,
1687
+ * as a box beside a container would be. Up and down, see `vennShift`.
1688
+ */
1689
+ vennPlan(venn, axis) {
1690
+ const key = `venn\n${venn.name}\n${axis}`;
1691
+ const known = this.vennPlans.get(key);
1692
+ if (known)
1693
+ return known;
1694
+ const [left, shared, right] = this.facts.get(venn).venn.parts;
1695
+ let plan;
1696
+ if (axis === 'x') {
1697
+ const [l, o, r] = [left, shared, right].map((part) => this.partContent(part, axis));
1698
+ const between = this.facts.get(venn).venn.gap;
1699
+ const dl = -l.start;
1700
+ const dO = dl + l.start + l.size + between + this.facts.get(right).pad.left - o.start;
1701
+ const dr = dO + o.start + o.size + this.facts.get(left).pad.right + between - r.start;
1702
+ plan = [dl, dO, dr];
1703
+ }
1704
+ else {
1705
+ plan = [this.vennShift(left, axis), this.vennShift(shared, axis), this.vennShift(right, axis)];
1706
+ }
1707
+ this.vennPlans.set(key, plan);
1708
+ return plan;
1709
+ }
1710
+ /**
1711
+ * Where a part's frame sits in the venn's up and down. A part whose anchor
1712
+ * is placed against a box in another part goes where that puts the anchor;
1713
+ * the set that moves to line the two sets' tops or bottoms up goes there
1714
+ * (`vennAlign`); otherwise its contents are centered on the line every
1715
+ * unplaced part shares.
1716
+ */
1717
+ vennShift(part, axis) {
1718
+ if (axis === 'x')
1719
+ return this.vennPlan(part.parent, axis)[this.vennPart(part)];
1720
+ const key = `${part.name}\n${axis}`;
1721
+ const known = this.vennShifts.get(key);
1722
+ if (known !== undefined)
1723
+ return known;
1724
+ const worked = this.guard(`venn shift\n${key}`, part, axis, undefined, () => {
1725
+ const anchor = this.contentOf(part).find((child) => this.acrossParts(child));
1726
+ if (anchor) {
1727
+ // The anchor sits at its frame's origin, so where its placement puts
1728
+ // it in the venn's frame is where the frame goes.
1729
+ return this.apply(anchor, axis, this.statements(anchor, axis)[0]);
1730
+ }
1731
+ const venn = part.parent;
1732
+ const [left, shared, right] = this.facts.get(venn).venn.parts;
1733
+ const own = this.partContent(part, axis);
1734
+ const align = this.vennAlign(venn);
1735
+ if (part === shared || align?.mover !== part || align.boxOnly)
1736
+ return -(own.start + own.size / 2);
1737
+ const side = align.side;
1738
+ const otherSet = part === left ? right : left;
1739
+ const other = this.partContent(otherSet, axis);
1740
+ const otherShift = this.vennShift(otherSet, axis);
1741
+ const o = this.partContent(shared, axis);
1742
+ const sharedShift = this.vennShift(shared, axis);
1743
+ // The other set's box holds its own children and the overlap, so its
1744
+ // edge is the further of the two; this set's children go even with that.
1745
+ const low = Math.min(other.start + otherShift, o.start + sharedShift);
1746
+ const high = Math.max(other.start + other.size + otherShift, o.start + o.size + sharedShift);
1747
+ return (side === 'start' ? low - own.start : high - own.start - own.size) + align.by;
1748
+ });
1749
+ this.vennShifts.set(key, worked);
1750
+ return worked;
1751
+ }
1752
+ /**
1753
+ * Which set moves to line up the two sets' tops or bottoms, and by which edge.
1754
+ * `top even with` between the sets is a relation between them, not an order
1755
+ * to move the one it's written on: only where they sit against each other
1756
+ * shows, since the venn wraps them both. So the set it's written on moves,
1757
+ * unless what it would move to meet moves with it (the overlap or the other
1758
+ * set placed against it); then the other set moves, if nothing holds it.
1759
+ * A stagger on a set whose contents are held in place says where its box
1760
+ * goes, not its contents (`boxOnly`): the box starts there and still holds
1761
+ * what it wraps.
1762
+ */
1763
+ vennAlign(venn) {
1764
+ const { parts, align } = this.facts.get(venn).venn;
1765
+ const [left, shared, right] = parts;
1766
+ for (const written of [right, left]) {
1767
+ const [placement] = written.placements;
1768
+ // `layout: venn (align-children: top)` reads as the right set's `top even with` the left.
1769
+ const side = placement?.kind === 'align' ? sideAlong(placement.side) : written === right ? align : 'center';
1770
+ if (side === 'center')
1771
+ continue;
1772
+ const other = written === left ? right : left;
1773
+ // A set can move if nothing holds it and nothing it would be moved to meet moves with it.
1774
+ const free = (set, against) => this.anchorPart(set) === undefined && !this.leansOn(shared, set) && !this.leansOn(against, set);
1775
+ // `top even with 2 below design top`: how much lower the set it's written on sits.
1776
+ const stagger = placement === undefined ? undefined : staggerOf(placement, other);
1777
+ const by = stagger === undefined
1778
+ ? 0
1779
+ : gapPixels(stagger.distance, `"${describePoint(stagger)}"`, placement.line) * (stagger.direction === 'below' ? 1 : -1);
1780
+ if (free(written, other))
1781
+ return { mover: written, side, by, boxOnly: false };
1782
+ if (stagger !== undefined)
1783
+ return { mover: written, side, by, boxOnly: true };
1784
+ if (free(other, written))
1785
+ return { mover: other, side, by: -by, boxOnly: false };
1786
+ const held = [other, written, shared]
1787
+ .flatMap((part) => this.contentOf(part))
1788
+ .find((child) => this.acrossParts(child));
1789
+ const said = placement ? `"${written.name}" has "${describePlacement(placement)}"` : `venn "${venn.name}" has align-children: ${side === 'start' ? 'top' : 'bottom'}`;
1790
+ throw new SourceError(`${said}, and "${held.name}" being placed against another part already decides where the two sets sit up and down — say it once`, placement ? written.line : venn.line);
1791
+ }
1792
+ return undefined;
1793
+ }
1794
+ /** Whether a venn part moves with `part`: its anchor is placed against a box in `part`, directly or through the third part. */
1795
+ leansOn(from, part) {
1796
+ let at = from;
1797
+ for (let step = 0; step < 3 && at !== undefined; step++) {
1798
+ at = this.anchorPart(at);
1799
+ if (at === part)
1800
+ return true;
1801
+ }
1802
+ return false;
1803
+ }
1804
+ /** The part of the same venn that this part's anchor is placed against, if it has one. */
1805
+ anchorPart(part) {
1806
+ const anchor = this.contentOf(part).find((child) => this.acrossParts(child));
1807
+ if (!anchor)
1808
+ return undefined;
1809
+ for (const placement of anchor.placements) {
1810
+ for (const target of placement.targets) {
1811
+ const named = this.byName.get(target.name);
1812
+ if (named !== undefined && this.inOtherPart(anchor, named))
1813
+ return named.parent;
1814
+ }
1815
+ }
1816
+ return undefined;
1817
+ }
1818
+ /** Whether `named` is a child of a different part of the venn `node`'s part belongs to. */
1819
+ inOtherPart(node, named) {
1820
+ const part = node.parent;
1821
+ const other = named.parent;
1822
+ if (!part || !other || part === other || this.vennPart(part) === undefined)
1823
+ return false;
1824
+ return other.parent === part.parent && this.vennPart(other) !== undefined;
1825
+ }
1826
+ /** A child of a venn's part placed against a box in another part, which `checkAnchors` lets only the part's anchor be. */
1827
+ acrossParts(node) {
1828
+ return node.placements.some((placement) => placement.targets.some((target) => {
1829
+ const named = this.byName.get(target.name);
1830
+ return named !== undefined && this.inOtherPart(node, named);
1831
+ }));
1832
+ }
1833
+ /** The overlap's contents in a set's own frame, which the set's box wraps as well as its own. */
1834
+ vennPhantom(node, axis) {
1835
+ const part = this.vennPart(node);
1836
+ if (part === undefined || part === 1)
1837
+ return undefined;
1838
+ const venn = node.parent;
1839
+ const plan = this.vennPlan(venn, axis);
1840
+ const shared = this.partContent(this.facts.get(venn).venn.parts[1], axis);
1841
+ return { start: shared.start + plan[1] - plan[part], size: shared.size };
1842
+ }
1843
+ /** Where a venn's part starts in the venn's frame. */
1844
+ vennStart(node, axis) {
1845
+ const part = this.vennPart(node);
1846
+ if (part === 1)
1847
+ return this.strip(node.parent, axis).start;
1848
+ return this.frame(node, axis).boxStart + this.vennPlan(node.parent, axis)[part];
1849
+ }
1850
+ /** A venn's part's size: a set wraps what it holds, or takes the box `vennBox` gives it; the overlap is where they cross. */
1851
+ vennSize(node, axis) {
1852
+ const venn = node.parent;
1853
+ if (this.vennPart(node) === 1)
1854
+ return this.strip(venn, axis).size;
1855
+ const box = axis === 'y' ? this.vennBox(node) : undefined;
1856
+ return box === undefined ? this.ownSize(node, axis) : box.size;
1857
+ }
1858
+ /**
1859
+ * A set's box up and down in the venn's frame, where it is not just the
1860
+ * outline round what it holds. With `heights: equal`, the span from the
1861
+ * higher set's top to the lower one's bottom, which both boxes then cover,
1862
+ * so they start and end together however their contents are lined up; a set
1863
+ * staggered by `top even with 2 below …` keeps its stagger, its box the span
1864
+ * moved by that much. A stagger that places only the box (`vennAlign`'s
1865
+ * `boxOnly`) moves the side it names, and the box must still hold its contents.
1866
+ */
1867
+ vennBox(set) {
1868
+ const venn = set.parent;
1869
+ const facts = this.facts.get(venn).venn;
1870
+ const plan = this.vennPlan(venn, 'y');
1871
+ const natural = (part) => {
1872
+ const outline = this.outline(part, 'y');
1873
+ const at = outline.boxStart + plan[this.vennPart(part)];
1874
+ return { start: at, end: at + outline.boxSize };
1875
+ };
1876
+ const align = this.vennAlign(venn);
1877
+ const by = align?.mover === set ? align.by : 0;
1878
+ if (facts.heights === 'equal') {
1879
+ const [low, high] = [facts.parts[0], facts.parts[2]].map((part) => {
1880
+ const shift = align?.mover === part ? align.by : 0;
1881
+ const box = natural(part);
1882
+ return { start: box.start - shift, end: box.end - shift };
1883
+ });
1884
+ const start = Math.min(low.start, high.start);
1885
+ return { start: start + by, size: Math.max(low.end, high.end) - start };
1886
+ }
1887
+ if (align?.mover !== set || !align.boxOnly)
1888
+ return undefined;
1889
+ const own = natural(set);
1890
+ const other = natural(set === facts.parts[0] ? facts.parts[2] : facts.parts[0]);
1891
+ const placement = set.placements[0];
1892
+ const start = align.side === 'start' ? other.start + by : own.start;
1893
+ const end = align.side === 'end' ? other.end + by : own.end;
1894
+ const short = Math.max(start - own.start, own.end - end);
1895
+ if (short > 0) {
1896
+ throw new SourceError(`"${set.name}" has "${describePlacement(placement)}", which would cut ${short / UNIT} units into what it holds, ` +
1897
+ `since what it holds is placed against another part — move it ${align.side === 'start' ? 'up' : 'down'} by at least that`, placement.line);
1898
+ }
1899
+ return { start, size: end - start };
1900
+ }
1901
+ /** Where a venn's two sets cross, in the venn's frame. */
1902
+ strip(venn, axis) {
1903
+ const [left, , right] = this.facts.get(venn).venn.parts;
1904
+ const leftStart = this.start(left, axis);
1905
+ const rightStart = this.start(right, axis);
1906
+ const leftEnd = leftStart + this.size(left, axis);
1907
+ const rightEnd = rightStart + this.size(right, axis);
1908
+ if (axis === 'x')
1909
+ return { start: rightStart, size: leftEnd - rightStart };
1910
+ const start = Math.max(leftStart, rightStart);
1911
+ return { start, size: Math.min(leftEnd, rightEnd) - start };
1912
+ }
1913
+ /**
1914
+ * The overlap's frame: the strip where the sets cross, with no border of its
1915
+ * own. Its title is centered across the strip, on the row of the title of
1916
+ * the set whose top is lower, so the strip's contents start below it.
1917
+ */
1918
+ stripFrame(node, axis) {
1919
+ const venn = node.parent;
1920
+ const [left, , right] = this.facts.get(venn).venn.parts;
1921
+ const plan = this.vennPlan(venn, axis);
1922
+ const strip = this.strip(venn, axis);
1923
+ const boxStart = strip.start - plan[1];
1924
+ const titleSize = this.titleSize(node, axis);
1925
+ let titleStart;
1926
+ if (axis === 'x') {
1927
+ titleStart = boxStart + (strip.size - titleSize) / 2;
1928
+ }
1929
+ else {
1930
+ const lower = this.start(left, 'y') >= this.start(right, 'y') ? 0 : 2;
1931
+ const set = lower === 0 ? left : right;
1932
+ titleStart = this.hasTitle(set)
1933
+ ? this.frame(set, 'y').titleStart + plan[lower] - plan[1]
1934
+ : boxStart + this.facts.get(node).padding;
1935
+ }
1936
+ return { boxStart, boxSize: strip.size, natural: strip.size, titleStart, titleSize, textStart: titleStart, textSize: titleSize };
1937
+ }
1938
+ // --- anchors -------------------------------------------------------------
1939
+ /**
1940
+ * Exactly one box in each frame says nothing about where it goes, and every
1941
+ * other is placed against it, directly or through others. In a container a
1942
+ * title something is placed against anchors the frame instead.
1943
+ */
1944
+ checkAnchors(all, parent) {
1945
+ // A heading goes where the title would, and anchors nothing; the tree
1946
+ // pass refused any placement on it. What is joined to the text goes with
1947
+ // the title, placed against the text.
1948
+ const members = all.filter((member) => member !== parent?.heading && !(parent && joinedToText(member)));
1949
+ const venn = parent ? this.facts.get(parent).venn : undefined;
1950
+ if (parent && venn) {
1951
+ // The venn places its three parts. A set may line its top or bottom up
1952
+ // with the other set's instead of being centered on it, or with that
1953
+ // side moved up or down by a distance, and nothing else.
1954
+ const [left, shared, right] = venn.parts;
1955
+ for (const child of members) {
1956
+ const other = child === left ? right : child === right ? left : undefined;
1957
+ for (const placement of child.placements) {
1958
+ const [target] = placement.targets;
1959
+ const lines = other !== undefined && placement.kind === 'align' && placement.axis === 'y' &&
1960
+ placement.targets.length === 1 &&
1961
+ ((target.name === other.name && target.part === undefined && target.fraction === undefined && target.point === undefined) ||
1962
+ staggerOf(placement, other) !== undefined);
1963
+ if (lines && child.placements.length === 1)
1964
+ continue;
1965
+ throw new SourceError(child === shared
1966
+ ? `"${child.name}" places itself ("${describePlacement(placement)}"), but it is the overlap of venn "${parent.name}", ` +
1967
+ 'which goes where the two sets cross — drop the placement'
1968
+ : other === undefined
1969
+ ? `"${child.name}" places itself ("${describePlacement(placement)}"), but "${parent.name}" has layout: venn, ` +
1970
+ 'which places each of its parts — drop the placement'
1971
+ : `"${child.name}" places itself ("${describePlacement(placement)}"), but it is a set of venn "${parent.name}", ` +
1972
+ `which sets it beside the other and centers it — the one thing a set may say is top even with ${other.name} ` +
1973
+ `or bottom even with ${other.name}, or that side moved, such as top even with 2 below ${other.name} top`, placement.line);
1974
+ }
1975
+ }
1976
+ if (left.placements.length > 0 && right.placements.length > 0) {
1977
+ throw new SourceError(`"${left.name}" and "${right.name}" are each lined up with the other — say it on one of them`, right.placements[0].line);
1978
+ }
1979
+ }
1980
+ else if (parent && this.facts.get(parent).layout) {
1981
+ for (const child of members) {
1982
+ const [placement] = child.placements;
1983
+ if (placement) {
1984
+ throw new SourceError(`"${child.name}" places itself ("${describePlacement(placement)}"), but "${parent.name}" has a layout:, ` +
1985
+ 'which places every child — drop the placement, or the layout: to place each child by hand', placement.line);
1986
+ }
1987
+ }
1988
+ }
1989
+ else if (members.length > 0) {
1990
+ if (parent && this.vennPart(parent) !== undefined)
1991
+ this.checkAcrossParts(members, parent);
1992
+ const content = members.filter((member) => !this.framed(member));
1993
+ const unplaced = content.filter((member) => this.unplaced(member));
1994
+ const onTitle = content.find((member) => this.onTitle(member));
1995
+ if (!parent && unplaced.length === 0) {
1996
+ throw new SourceError('every node is placed relative to another, so nothing anchors the diagram', 1);
1997
+ }
1998
+ if (unplaced.length > 1) {
1999
+ const names = unplaced.map((node) => `"${node.name}"`).join(', ');
2000
+ throw new SourceError(parent
2001
+ ? `"${parent.name}" has ${unplaced.length} children that say nothing about where they go (${names}). ` +
2002
+ 'One is the anchor and the rest are placed against it — or give the container a layout: such as ' +
2003
+ 'layout: column to place them in order'
2004
+ : `exactly one node may say nothing about where it goes, but ${unplaced.length} do: ${names}`, unplaced[1].line);
2005
+ }
2006
+ if (parent && unplaced.length === 1 && onTitle && this.anchoredOnTitle(parent)) {
2007
+ throw new SourceError(`"${parent.name}" has two anchors: "${unplaced[0].name}" says nothing about where it goes, and ` +
2008
+ `"${onTitle.name}" is placed against "${parent.name} text" — place "${unplaced[0].name}" against one of them`, unplaced[0].line);
2009
+ }
2010
+ }
2011
+ for (const member of all)
2012
+ this.checkAnchors(member.children, member);
2013
+ }
2014
+ /**
2015
+ * A box in a venn's part may be placed against a box in another part, and
2016
+ * then it says where its whole part goes up and down: so it must be the
2017
+ * part's anchor, say nothing else, and say nothing across, which is the venn's.
2018
+ */
2019
+ checkAcrossParts(members, part) {
2020
+ const across = members.filter((member) => this.acrossParts(member));
2021
+ if (across.length === 0)
2022
+ return;
2023
+ if (across.length > 1) {
2024
+ throw new SourceError(`"${across[0].name}" and "${across[1].name}" are both placed against a box in another part of venn ` +
2025
+ `"${part.parent.name}" — one box places its part; place the other against its siblings`, across[1].line);
2026
+ }
2027
+ const [node] = across;
2028
+ const [placement] = node.placements;
2029
+ const sideways = placement.kind === 'on' ||
2030
+ (placement.kind === 'align' && placement.axis === 'x') ||
2031
+ (placement.kind === 'offset' && component(placement.direction, 'x') !== undefined);
2032
+ const mixed = placement.targets.some((target) => {
2033
+ const named = this.byName.get(target.name);
2034
+ return named !== undefined && !this.inOtherPart(node, named);
2035
+ });
2036
+ if (mixed) {
2037
+ throw new SourceError(`"${node.name}" is placed against boxes in its own part and another part of venn "${part.parent.name}" at once ` +
2038
+ '— place it against the other part only', placement.line);
2039
+ }
2040
+ if (node.placements.length > 1 || sideways) {
2041
+ throw new SourceError(`"${node.name}" is placed against a box in another part of venn "${part.parent.name}", which says where its ` +
2042
+ `part goes up and down, so it can say nothing else: the venn sets the parts side by side, and the rest of ` +
2043
+ `"${part.name}" is placed against "${node.name}" — keep only an up-and-down placement such as even with`, placement.line);
2044
+ }
2045
+ // A stagger on the part then places only its box (`vennAlign`), so the two say different things.
2046
+ const [left, , right] = this.facts.get(part.parent).venn.parts;
2047
+ const otherSet = part === left ? right : left;
2048
+ if (part.placements.length > 0 && staggerOf(part.placements[0], otherSet) === undefined) {
2049
+ throw new SourceError(`"${part.name}" is lined up by "${describePlacement(part.placements[0])}" and also by "${node.name}" ` +
2050
+ `being placed against another part — say it once`, placement.line);
2051
+ }
2052
+ }
2053
+ // --- finishing -----------------------------------------------------------
2054
+ /** The text box, how far the node reaches past its own box, and the flags the renderer reads. */
2055
+ finish(node) {
2056
+ const facts = this.facts.get(node);
2057
+ if (node.children.length === 0) {
2058
+ node.textBox = this.leafText(node);
2059
+ const x = this.reachOf(node, 'x');
2060
+ const y = this.reachOf(node, 'y');
2061
+ node.reach = { left: x.low, top: y.low, right: x.high, bottom: y.high };
2062
+ return;
2063
+ }
2064
+ const x = this.frame(node, 'x');
2065
+ const y = this.frame(node, 'y');
2066
+ node.textBox = facts.hasText
2067
+ ? { x: x.textStart - x.boxStart, y: y.textStart - y.boxStart, width: x.textSize, height: y.textSize }
2068
+ : { x: 0, y: 0, width: 0, height: 0 };
2069
+ node.banded = true;
2070
+ const x2 = this.reachOf(node, 'x');
2071
+ const y2 = this.reachOf(node, 'y');
2072
+ node.reach = { left: x2.low, top: y2.low, right: x2.high, bottom: y2.high };
2073
+ }
2074
+ /**
2075
+ * Where a leaf's text sits, from the leaf's top-left. Centered unless its
2076
+ * bracket says a side, and then where a container's title would be: inside,
2077
+ * on or outside that side, at the start, middle or end along it. A picture
2078
+ * and a circle keep their text where their shape puts it.
2079
+ */
2080
+ leafText(node) {
2081
+ const facts = this.facts.get(node);
2082
+ const box = facts.textBox;
2083
+ const width = this.size(node, 'x');
2084
+ const height = this.size(node, 'y');
2085
+ const title = facts.title;
2086
+ const centered = {
2087
+ x: box.x + (width - facts.natural.width) / 2,
2088
+ y: box.y + (height - facts.natural.height) / 2,
2089
+ width: box.width,
2090
+ height: box.height,
2091
+ };
2092
+ if (node.textAttrs['place'] === undefined || title.how === 'center' || facts.circle || node.body.kind === 'icon') {
2093
+ return centered;
2094
+ }
2095
+ const sideAxis = title.side === 'top' || title.side === 'bottom' ? 'y' : 'x';
2096
+ const low = title.side === 'top' || title.side === 'left';
2097
+ const at = (axis) => {
2098
+ const room = axis === 'x' ? width : height;
2099
+ const text = axis === 'x' ? box.width : box.height;
2100
+ if (axis !== sideAxis) {
2101
+ // Outside, an end of the text is even with the box's edge; otherwise it clears the padding.
2102
+ const inset = title.how === 'outside' ? 0 : facts.padding;
2103
+ return title.along === 'start' ? inset : title.along === 'end' ? room - inset - text : (room - text) / 2;
2104
+ }
2105
+ if (title.how === 'inside')
2106
+ return low ? facts.padding : room - facts.padding - text;
2107
+ if (title.how === 'on')
2108
+ return low ? -text / 2 : room - text / 2;
2109
+ return low ? -HEADER_GAP - text : room + HEADER_GAP;
2110
+ };
2111
+ return { x: at('x'), y: at('y'), width: box.width, height: box.height };
2112
+ }
2113
+ /**
2114
+ * How far a node reaches past its own box on one axis: its title outside the
2115
+ * border, and its children, with whatever they reach in turn. A container's
2116
+ * outline holds what its children reach, so it is worked out as soon as a
2117
+ * container asks, not after everything is placed.
2118
+ */
2119
+ reachOf(node, axis) {
2120
+ if (node.children.length === 0) {
2121
+ // Only a text placed on or outside the border reaches past it; one that
2122
+ // spills from a box too small for it is drawn spilling and reported.
2123
+ const how = this.facts.get(node).title.how;
2124
+ if (node.textAttrs['place'] === undefined || how === 'inside' || how === 'center')
2125
+ return { low: 0, high: 0 };
2126
+ const text = this.leafText(node);
2127
+ const start = axis === 'x' ? text.x : text.y;
2128
+ const size = axis === 'x' ? text.width : text.height;
2129
+ return { low: Math.max(0, -start), high: Math.max(0, start + size - this.size(node, axis)) };
2130
+ }
2131
+ const frame = this.frame(node, axis);
2132
+ const end = frame.boxStart + frame.boxSize;
2133
+ let low = 0;
2134
+ let high = 0;
2135
+ if (this.facts.get(node).hasText) {
2136
+ low = Math.max(low, frame.boxStart - frame.titleStart);
2137
+ high = Math.max(high, frame.titleStart + frame.titleSize - end);
2138
+ }
2139
+ for (const child of node.children) {
2140
+ const start = this.start(child, axis);
2141
+ const reach = this.reachOf(child, axis);
2142
+ low = Math.max(low, frame.boxStart - (start - reach.low));
2143
+ high = Math.max(high, start + this.size(child, axis) + reach.high - end);
2144
+ }
2145
+ return { low, high };
2146
+ }
2147
+ }
2148
+ /**
2149
+ * `top even with 2 below design top` on a venn's set: the other set's same
2150
+ * side, moved up or down. Anything else, such as its bottom or a sideways
2151
+ * move, is not a stagger.
2152
+ */
2153
+ function staggerOf(placement, other) {
2154
+ if (placement.kind !== 'align' || placement.axis !== 'y' || placement.targets.length !== 1)
2155
+ return undefined;
2156
+ const point = placement.targets[0].point;
2157
+ if (point?.kind !== 'moved' || (point.direction !== 'above' && point.direction !== 'below'))
2158
+ return undefined;
2159
+ const from = point.from;
2160
+ const same = from.name === other.name && from.part === placement.side && from.fraction === undefined && from.point === undefined;
2161
+ return same ? point : undefined;
2162
+ }
2163
+ function sideAlong(side) {
2164
+ if (side === 'top' || side === 'left')
2165
+ return 'start';
2166
+ if (side === 'bottom' || side === 'right')
2167
+ return 'end';
2168
+ return 'center';
2169
+ }
2170
+ /** The width or height a size key on an axis states. */
2171
+ const DIMENSION = { x: 'width', y: 'height' };
2172
+ const DIMENSION_AXIS = { width: 'x', height: 'y' };
2173
+ /** The padding on the low side of an axis, the left or the top. */
2174
+ function padBefore(facts, axis) {
2175
+ return axis === 'x' ? facts.pad.left : facts.pad.top;
2176
+ }
2177
+ /** The padding on the high side of an axis, the right or the bottom. */
2178
+ function padAfter(facts, axis) {
2179
+ return axis === 'x' ? facts.pad.right : facts.pad.bottom;
2180
+ }
2181
+ /** The size keys a node states on one axis, as written, for quoting back. */
2182
+ function sizeKeysOn(node, axis) {
2183
+ const dimension = DIMENSION[axis];
2184
+ return [dimension, `min-${dimension}`, `max-${dimension}`]
2185
+ .filter((key) => node.appearance[key] !== undefined)
2186
+ .map((key) => `${key}: ${node.appearance[key]}`);
2187
+ }
2188
+ /**
2189
+ * The container whose frame holds both ends of a span: one of them when it
2190
+ * holds the other, else the innermost container of both, or the page.
2191
+ */
2192
+ function commonFrame(a, b) {
2193
+ if (a === b)
2194
+ return a.parent;
2195
+ if (isWithin(b, a))
2196
+ return a;
2197
+ if (isWithin(a, b))
2198
+ return b;
2199
+ for (let at = a.parent; at; at = at.parent)
2200
+ if (isWithin(b, at))
2201
+ return at;
2202
+ return undefined;
2203
+ }
2204
+ /**
2205
+ * Narrow an extent to the part a placement named. A side leaves the axis it
2206
+ * doesn't name alone (`right` is the whole right edge); a point is a point on
2207
+ * both. On a circle a corner is the point on it halfway between its sides.
2208
+ */
2209
+ function narrow(whole, part, axis, circle, diameter) {
2210
+ if (part === undefined || part === 'text')
2211
+ return whole;
2212
+ const words = part.split('-');
2213
+ const isSide = PART_SIDES.includes(part);
2214
+ const [near, far] = axis === 'x' ? ['left', 'right'] : ['top', 'bottom'];
2215
+ const named = words.includes(near) ? 0 : words.includes(far) ? whole.size : undefined;
2216
+ if (named === undefined && isSide)
2217
+ return whole;
2218
+ let at = whole.start + (named ?? whole.size / 2);
2219
+ const across = words.includes('left') || words.includes('right');
2220
+ const down = words.includes('top') || words.includes('bottom');
2221
+ if (circle && across && down) {
2222
+ const pull = (diameter / 2) * (1 - Math.SQRT1_2);
2223
+ at += named === 0 ? pull : -pull;
2224
+ }
2225
+ return { start: at, size: 0 };
2226
+ }