reladraw 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1012 @@
1
+ import { describePlacement } from './ast.js';
2
+ import { ARROW_LENGTH, CHILD_GAP, DECK_STEP, DEFAULT_FONT_SIZE, DEFAULT_MARGIN, GAPS, HEADER_GAP, ICON_GAP, ICON_LINES, LABEL_CLEARANCE, PAD, SEPARATION_GAP, fontSizeFor, labelExtent, labelStyleFor, } from './constants.js';
3
+ import { fix, reachability, tightest } from './constrain.js';
4
+ import { SourceError } from './errors.js';
5
+ import { iconFor, shapeFor } from './icons.js';
6
+ import { monospaceMeasurer, splitLines } from './measure.js';
7
+ /**
8
+ * Turn a parsed document into solved geometry.
9
+ *
10
+ * Three passes: build the containment tree, size every node bottom-up, then
11
+ * turn each node's placements into minimum distances and solve for the tightest
12
+ * arrangement that satisfies them. That last pass also adds the separations
13
+ * that keep boxes off each other, and solves again until none is left to add.
14
+ *
15
+ * It is a constraint solve, of the kind that computes rather than searches. It
16
+ * works out how far apart things are; nothing about which side of what a node
17
+ * sits on is ever decided here, because the author wrote it down.
18
+ */
19
+ export function resolve(doc, options = {}) {
20
+ const measurer = options.measurer ?? monospaceMeasurer();
21
+ const fontSize = options.fontSize ?? DEFAULT_FONT_SIZE;
22
+ const margin = options.margin ?? DEFAULT_MARGIN;
23
+ const styles = collectStyles(doc.statements);
24
+ const { nodes, byName, roots } = buildTree(doc.statements, styles);
25
+ applyDecks(doc.statements, byName);
26
+ // Links are resolved to nodes before anything is sized, because a labelled
27
+ // link claims room in the gap it crosses and so has to be in hand while the
28
+ // gaps are being worked out. Nothing here reads geometry.
29
+ const links = buildLinks(doc.statements, byName, styles);
30
+ const local = new Map();
31
+ for (const root of roots)
32
+ sizeNode(root, links, measurer, fontSize, local);
33
+ placeRoots(roots, byName, links, measurer, fontSize, local);
34
+ normalize(nodes, margin);
35
+ const extent = bounds(nodes);
36
+ return {
37
+ nodes,
38
+ roots,
39
+ links,
40
+ diagram: collectDiagram(doc.statements),
41
+ width: Math.ceil(extent.maxX + margin),
42
+ height: Math.ceil(extent.maxY + margin),
43
+ margin,
44
+ };
45
+ }
46
+ // --- pass one: the containment tree -----------------------------------------
47
+ function collectStyles(statements) {
48
+ const styles = new Map();
49
+ for (const stmt of statements) {
50
+ if (stmt.kind !== 'style')
51
+ continue;
52
+ if (styles.has(stmt.name)) {
53
+ throw new SourceError(`style "${stmt.name}" is declared twice`, stmt.line);
54
+ }
55
+ styles.set(stmt.name, stmt.attrs);
56
+ }
57
+ return styles;
58
+ }
59
+ /** A file holds one diagram, so a second `diagram` statement is a mistake. */
60
+ function collectDiagram(statements) {
61
+ let found;
62
+ for (const stmt of statements) {
63
+ if (stmt.kind !== 'diagram')
64
+ continue;
65
+ if (found)
66
+ throw new SourceError('the diagram is described twice', stmt.line);
67
+ found = stmt.attrs;
68
+ }
69
+ return found ?? {};
70
+ }
71
+ function buildTree(statements, styles) {
72
+ const nodes = [];
73
+ const byName = new Map();
74
+ const roots = [];
75
+ for (const stmt of statements) {
76
+ if (stmt.kind !== 'box' && stmt.kind !== 'note')
77
+ continue;
78
+ if (byName.has(stmt.name)) {
79
+ throw new SourceError(`"${stmt.name}" is declared twice`, stmt.line);
80
+ }
81
+ const node = {
82
+ name: stmt.name,
83
+ kind: stmt.kind,
84
+ text: stmt.text,
85
+ lines: linesFor(stmt.text, stmt.attrs, stmt.line),
86
+ children: [],
87
+ x: 0,
88
+ y: 0,
89
+ width: 0,
90
+ height: 0,
91
+ inset: 0,
92
+ deckLabels: [],
93
+ headerHeight: 0,
94
+ label: stmt.kind === 'box' ? stmt.label : {},
95
+ attrs: stmt.attrs,
96
+ appearance: appearanceOf(stmt.attrs, styles, stmt.line),
97
+ placements: stmt.placements,
98
+ line: stmt.line,
99
+ };
100
+ const cut = stmt.name.lastIndexOf('.');
101
+ if (cut === -1) {
102
+ roots.push(node);
103
+ }
104
+ else {
105
+ const parentName = stmt.name.slice(0, cut);
106
+ const parent = byName.get(parentName);
107
+ if (!parent) {
108
+ throw new SourceError(`"${stmt.name}" is inside "${parentName}", which is not declared yet`, stmt.line);
109
+ }
110
+ node.parent = parent;
111
+ parent.children.push(node);
112
+ }
113
+ nodes.push(node);
114
+ byName.set(stmt.name, node);
115
+ }
116
+ return { nodes, byName, roots };
117
+ }
118
+ function appearanceOf(attrs, styles, line) {
119
+ const named = attrs['style'];
120
+ if (named === undefined)
121
+ return { ...attrs };
122
+ const base = styles.get(named);
123
+ if (!base)
124
+ throw new SourceError(`no style named "${named}"`, line);
125
+ return { ...base, ...attrs };
126
+ }
127
+ function applyDecks(statements, byName) {
128
+ for (const stmt of statements) {
129
+ if (stmt.kind !== 'deck')
130
+ continue;
131
+ const node = byName.get(stmt.name);
132
+ if (!node)
133
+ throw new SourceError(`deck names "${stmt.name}", which does not exist`, stmt.line);
134
+ node.deckLabels = stmt.labels;
135
+ }
136
+ }
137
+ function buildLinks(statements, byName, styles) {
138
+ const links = [];
139
+ for (const stmt of statements) {
140
+ if (stmt.kind !== 'link')
141
+ continue;
142
+ const from = byName.get(stmt.from);
143
+ const to = byName.get(stmt.to);
144
+ if (!from)
145
+ throw new SourceError(`link from "${stmt.from}", which does not exist`, stmt.line);
146
+ if (!to)
147
+ throw new SourceError(`link to "${stmt.to}", which does not exist`, stmt.line);
148
+ const between = stmt.between && {
149
+ nodes: stmt.between.targets.map((name) => {
150
+ const node = byName.get(name);
151
+ if (!node) {
152
+ throw new SourceError(`link passes between "${name}", which does not exist`, stmt.line);
153
+ }
154
+ return node;
155
+ }),
156
+ ...(stmt.between.axis !== undefined ? { axis: stmt.between.axis } : {}),
157
+ };
158
+ links.push({
159
+ from,
160
+ to,
161
+ both: stmt.both,
162
+ ...(stmt.label !== undefined ? { label: stmt.label } : {}),
163
+ ...(between ? { between } : {}),
164
+ attrs: stmt.attrs,
165
+ appearance: appearanceOf(stmt.attrs, styles, stmt.line),
166
+ line: stmt.line,
167
+ });
168
+ }
169
+ return links;
170
+ }
171
+ /**
172
+ * Give a node a width and height, sizing its children first. Also records each
173
+ * child's offset within this node, which pass three turns into absolute
174
+ * coordinates once this node itself is placed.
175
+ */
176
+ function sizeNode(node, links, measurer, fontSize, local) {
177
+ for (const child of node.children)
178
+ sizeNode(child, links, measurer, fontSize, local);
179
+ // Text is measured at the size it will be drawn at — the size lives in
180
+ // `constants.ts` precisely so the resolver reserving the room and the
181
+ // renderer filling it cannot disagree about how much room there is.
182
+ const textSize = fontSizeFor(node.kind, node.appearance, fontSize, node.line);
183
+ const lineHeight = measurer.lineHeight(textSize);
184
+ // A node with empty text takes no room for it. This is what makes an
185
+ // invisible grouping container size to exactly its contents.
186
+ const hasLabel = node.lines.some((line) => line.length > 0);
187
+ const labelWidth = hasLabel ? widestLine(node.lines, measurer, textSize) : 0;
188
+ const labelHeight = hasLabel ? node.lines.length * lineHeight : 0;
189
+ if (node.kind === 'note') {
190
+ // A note is bare text, so it gets no padding and takes no children.
191
+ // Both are refused rather than ignored, for the reason an unknown diagram
192
+ // key is: a note is bare text with no box to decorate or replace, so either
193
+ // word would silently do nothing and look like the tool being broken.
194
+ for (const key of ['icon', 'shape']) {
195
+ if (node.appearance[key] !== undefined) {
196
+ throw new SourceError(`"${node.name}" is a note and has ${key}: ${node.appearance[key]}. A note is bare text, with no box to ${key === 'icon' ? 'decorate' : 'replace'}`, node.line);
197
+ }
198
+ }
199
+ node.width = labelWidth;
200
+ node.height = labelHeight;
201
+ return;
202
+ }
203
+ const shape = shapeFor(node.appearance, node.line);
204
+ const glyphSide = ICON_LINES * lineHeight;
205
+ if (shape.body !== undefined) {
206
+ // Drawn as a glyph, so there is no box to pad and the node's size is the
207
+ // picture's. A label goes under it rather than inside it, which is the
208
+ // arrangement that makes a row of these read as captioned things.
209
+ if (node.children.length > 0) {
210
+ throw new SourceError(`"${node.name}" is drawn as a glyph and has children. A glyph is not a box, so nothing can go inside it`, node.line);
211
+ }
212
+ node.width = Math.max(glyphSide, labelWidth);
213
+ node.height = glyphSide + (hasLabel ? ICON_GAP + labelHeight : 0);
214
+ return;
215
+ }
216
+ // An icon takes a column of its own on the right of whatever the box holds,
217
+ // so the label never runs underneath it and the box grows to fit both. That
218
+ // is why an icon is not a renderer-only concern: it is content taking room,
219
+ // like a label, and not appearance like `fill:`.
220
+ const icon = iconFor(node.appearance, node.line);
221
+ const iconSide = icon === undefined ? 0 : glyphSide;
222
+ const iconRoom = icon === undefined ? 0 : iconSide + (hasLabel ? ICON_GAP : 0);
223
+ if (node.children.length === 0) {
224
+ // A band only exists because contents have to sit clear of it. A leaf has
225
+ // none, so its label is centred in the box and there is nothing for `at` or
226
+ // `align` to move it relative to. Refused rather than silently dropped.
227
+ const stated = Object.keys(node.label);
228
+ if (stated.length > 0) {
229
+ throw new SourceError(`"${node.name}" holds nothing and its label carries ${stated.join(' and ')}. ` +
230
+ `A label sits at one end of a box so its contents can have the other; with no contents it is centred, and there is nothing to say`, node.line);
231
+ }
232
+ node.width = labelWidth + iconRoom + PAD * 2;
233
+ node.height = Math.max(labelHeight, iconSide) + PAD * 2;
234
+ }
235
+ else {
236
+ applyAlign(node);
237
+ const content = layoutChildren(node, links, measurer, fontSize, local);
238
+ const band = Math.max(labelHeight, iconSide);
239
+ // `headerHeight` is the band the label and icon take, whichever end of the
240
+ // box that band is at. Only the contents' offset depends on the side.
241
+ node.headerHeight = hasLabel || icon !== undefined ? band + HEADER_GAP : 0;
242
+ node.width = Math.max(labelWidth + iconRoom, content.width) + PAD * 2;
243
+ node.height = node.headerHeight + content.height + PAD * 2;
244
+ const above = labelStyleFor(node.label, node.line).at === 'top' ? node.headerHeight : 0;
245
+ for (const child of node.children) {
246
+ const offset = local.get(child);
247
+ offset.x += PAD;
248
+ offset.y += PAD + above;
249
+ }
250
+ }
251
+ if (node.deckLabels.length > 0) {
252
+ // The copies sit behind and above-left, so the whole node grows by the
253
+ // depth of the stack and its own face moves down and right by the same.
254
+ node.inset = node.deckLabels.length * DECK_STEP;
255
+ node.width += node.inset;
256
+ node.height += node.inset;
257
+ for (const child of node.children) {
258
+ const offset = local.get(child);
259
+ offset.x += node.inset;
260
+ offset.y += node.inset;
261
+ }
262
+ }
263
+ }
264
+ /**
265
+ * `align: widths` widens every direct child to the widest one's natural
266
+ * width, before layoutChildren sizes and positions anything from those
267
+ * widths. A container's own children are already sized by this point.
268
+ */
269
+ function applyAlign(node) {
270
+ const value = node.attrs['align'];
271
+ if (value === undefined)
272
+ return;
273
+ if (value !== 'widths') {
274
+ throw new SourceError(`"${node.name}" has align: ${value}, which is not one of widths`, node.line);
275
+ }
276
+ const maxWidth = Math.max(...node.children.map((child) => child.width));
277
+ for (const child of node.children)
278
+ child.width = maxWidth;
279
+ }
280
+ /**
281
+ * Position a container's children relative to each other. Children that make
282
+ * no placement stack vertically in written order; the rest are solved against the
283
+ * siblings they name, by the same constraint pass that positions top-level
284
+ * nodes. A placement may only name a sibling — containment scopes the group.
285
+ */
286
+ function layoutChildren(parent, links, measurer, fontSize, local) {
287
+ const siblings = new Map(parent.children.map((child) => [child.name, child]));
288
+ // Children that say nothing keep the written order, down the page and flush
289
+ // left. Written as constraints rather than a cursor so a placed sibling can
290
+ // push them along like anything else.
291
+ const stack = [];
292
+ const alignment = [];
293
+ const quiet = parent.children.filter((child) => child.placements.length === 0);
294
+ const indexOf = new Map(parent.children.map((child, index) => [child, index]));
295
+ quiet.forEach((child, position) => {
296
+ const previous = quiet[position - 1];
297
+ if (!previous)
298
+ return;
299
+ const before = indexOf.get(previous);
300
+ const after = indexOf.get(child);
301
+ stack.push({ from: before, to: after, weight: previous.height + CHILD_GAP });
302
+ alignment.push(...fix(before, after, 0));
303
+ });
304
+ const positions = positionGroup(parent.children, (placement, owner) => placement.targets.map((name) => {
305
+ const target = siblings.get(name);
306
+ if (!target) {
307
+ throw new SourceError(`"${owner.name}" is placed against "${name}", which is not one of its siblings`, placement.line);
308
+ }
309
+ return {
310
+ name,
311
+ node: target,
312
+ index: indexOf.get(target),
313
+ offset: { x: 0, y: 0 },
314
+ width: target.width,
315
+ height: target.height,
316
+ };
317
+ }), { x: alignment, y: stack }, corridorsIn(links, (node) => liftTo(node, indexOf, local), measurer, fontSize));
318
+ for (const child of parent.children)
319
+ local.set(child, positions.get(child));
320
+ return extentOf(parent.children, local);
321
+ }
322
+ function extentOf(children, local) {
323
+ let minX = Infinity;
324
+ let minY = Infinity;
325
+ let maxX = -Infinity;
326
+ let maxY = -Infinity;
327
+ for (const child of children) {
328
+ const offset = local.get(child);
329
+ minX = Math.min(minX, offset.x);
330
+ minY = Math.min(minY, offset.y);
331
+ maxX = Math.max(maxX, offset.x + child.width);
332
+ maxY = Math.max(maxY, offset.y + child.height);
333
+ }
334
+ for (const child of children) {
335
+ const offset = local.get(child);
336
+ offset.x -= minX;
337
+ offset.y -= minY;
338
+ }
339
+ return { width: maxX - minX, height: maxY - minY };
340
+ }
341
+ // --- pass three: solve for positions -----------------------------------------
342
+ function placeRoots(roots, byName, links, measurer, fontSize, local) {
343
+ const anchors = roots.filter((root) => root.placements.length === 0);
344
+ if (anchors.length === 0) {
345
+ throw new SourceError('every node is placed relative to another, so nothing anchors the diagram', 1);
346
+ }
347
+ if (anchors.length > 1) {
348
+ const names = anchors.map((node) => `"${node.name}"`).join(', ');
349
+ throw new SourceError(`exactly one node may say nothing about where it goes, but ${anchors.length} do: ${names}`, anchors[1].line);
350
+ }
351
+ const indexOf = new Map(roots.map((root, index) => [root, index]));
352
+ // A placement may name something nested — `right of server.docker` places a top-level
353
+ // node against a box inside another. Sizes and offsets within a container are
354
+ // already settled, so a nested target is its root's position plus a constant.
355
+ const positions = positionGroup(roots, (placement, owner) => placement.targets.map((name) => {
356
+ const target = byName.get(name);
357
+ if (!target) {
358
+ throw new SourceError(`"${owner.name}" is placed against "${name}", which does not exist`, placement.line);
359
+ }
360
+ let root = target;
361
+ const offset = { x: 0, y: 0 };
362
+ while (root.parent) {
363
+ const step = local.get(root);
364
+ offset.x += step.x;
365
+ offset.y += step.y;
366
+ root = root.parent;
367
+ }
368
+ return {
369
+ name,
370
+ node: target,
371
+ index: indexOf.get(root),
372
+ offset,
373
+ width: target.width,
374
+ height: target.height,
375
+ };
376
+ }), { x: [], y: [] }, corridorsIn(links, (node) => liftTo(node, indexOf, local), measurer, fontSize));
377
+ for (const root of roots) {
378
+ const position = positions.get(root);
379
+ root.x = position.x;
380
+ root.y = position.y;
381
+ spreadToChildren(root, local);
382
+ }
383
+ }
384
+ /** Once a node has an absolute position, its whole subtree follows from the offsets. */
385
+ function spreadToChildren(node, local) {
386
+ for (const child of node.children) {
387
+ const offset = local.get(child);
388
+ child.x = node.x + offset.x;
389
+ child.y = node.y + offset.y;
390
+ spreadToChildren(child, local);
391
+ }
392
+ }
393
+ const AXES = ['x', 'y'];
394
+ const AXIS_WORD = { x: 'horizontally', y: 'vertically' };
395
+ /**
396
+ * Where a node sits within the group being solved: which member holds it, and
397
+ * where inside that member. A link may name anything at any depth, so its ends
398
+ * are lifted to the members of whichever group is being solved — and a node
399
+ * outside that group has no answer, which is how a link is sorted into the one
400
+ * group where its two ends are different members.
401
+ */
402
+ function liftTo(node, indexOf, local) {
403
+ let member = node;
404
+ const offset = { x: 0, y: 0 };
405
+ while (!indexOf.has(member)) {
406
+ const step = local.get(member);
407
+ if (!step || !member.parent)
408
+ return undefined;
409
+ offset.x += step.x;
410
+ offset.y += step.y;
411
+ member = member.parent;
412
+ }
413
+ return {
414
+ name: node.name,
415
+ node,
416
+ index: indexOf.get(member),
417
+ offset,
418
+ width: node.width,
419
+ height: node.height,
420
+ };
421
+ }
422
+ /** The labelled links whose two ends are different members of this group. */
423
+ function corridorsIn(links, locate, measurer, fontSize) {
424
+ const corridors = [];
425
+ for (const link of links) {
426
+ // An unlabelled link asks for nothing: every gap holds an arrowhead. And a
427
+ // link told to pass between two named things carries its label in *that*
428
+ // corridor rather than in the gap between its own ends, so widening this one
429
+ // would make room where the label never goes.
430
+ if (link.label === undefined || link.between)
431
+ continue;
432
+ const from = locate(link.from);
433
+ const to = locate(link.to);
434
+ if (!from || !to || from.index === to.index)
435
+ continue;
436
+ // The clearance is doubled because the label is drawn at the *midpoint* of
437
+ // the line, so the room it needs is symmetric about that point whatever sits
438
+ // at either end. The arrowhead is charged on both sides for the same reason:
439
+ // it covers `ARROW_LENGTH` of the line it arrives on, and reserving that at
440
+ // one end only would move the midpoint rather than lengthen the run.
441
+ const extent = (axis) => labelExtent(link.label, link.appearance, axis, measurer, fontSize, link.line) +
442
+ (LABEL_CLEARANCE + ARROW_LENGTH) * 2;
443
+ corridors.push({ link, from, to, need: { x: extent('x'), y: extent('y') } });
444
+ }
445
+ return corridors;
446
+ }
447
+ /**
448
+ * Position a set of nodes against each other from their placements.
449
+ *
450
+ * Every placement becomes a minimum distance, and the answer is the arrangement
451
+ * where nothing is further apart than its placements require — which is what an
452
+ * author does by hand when they push two things apart to fit something between
453
+ * them and then pull the slack back out.
454
+ *
455
+ * Because a gap is a floor rather than a fixed distance, a corridor widens to
456
+ * hold whatever is put in it and closes again when that is removed. No number
457
+ * anywhere has to be guessed, and nothing is ever tried and rejected.
458
+ */
459
+ function positionGroup(members, locate, extra, corridors = []) {
460
+ const constraints = { x: [...extra.x], y: [...extra.y] };
461
+ const indexOf = new Map(members.map((member, index) => [member, index]));
462
+ const pending = [];
463
+ for (const node of members) {
464
+ // Checked here as well as in `gapFor`, so a misspelt node-wide gap is caught
465
+ // on a node whose placements all name their own or are alignments — and on
466
+ // one that carries no placements at all, where it now still has an effect.
467
+ namedGap(node, node.attrs['gap'], node.line);
468
+ if (node.placements.length === 0)
469
+ continue;
470
+ const me = indexOf.get(node);
471
+ const size = { x: node.width, y: node.height };
472
+ const located = node.placements.map((placement) => ({ placement, targets: locate(placement, node) }));
473
+ const spokenFor = { x: false, y: false };
474
+ for (const { placement } of located) {
475
+ if (placement.kind === 'align') {
476
+ spokenFor[placement.axis] = true;
477
+ }
478
+ else {
479
+ if (/left|right/.test(placement.direction))
480
+ spokenFor.x = true;
481
+ if (/above|below/.test(placement.direction))
482
+ spokenFor.y = true;
483
+ }
484
+ }
485
+ // Aligning to several targets means aligning to the box that just bounds
486
+ // them. That box is a constant only while its members hold still relative
487
+ // to one another; otherwise the alignment waits for the first solution.
488
+ const alignOn = (axis, edge, targets, placement) => {
489
+ const anchor = sharedMember(targets);
490
+ if (anchor === undefined) {
491
+ pending.push({ node, me, axis, edge, targets, placement });
492
+ return;
493
+ }
494
+ const span = spanOf(targets, axis, () => 0);
495
+ constraints[axis].push(...fix(anchor, me, alignedAt(edge, span, size[axis]), placement));
496
+ };
497
+ for (const { placement, targets } of located) {
498
+ if (placement.kind === 'align') {
499
+ alignOn(placement.axis, placement.edge, targets, placement);
500
+ continue;
501
+ }
502
+ // One constraint per target, so the node clears the furthest of them.
503
+ // Taking that maximum is what longest paths already does, which is why a
504
+ // direction against a whole region needs nothing added to the solver.
505
+ const { direction } = placement;
506
+ // A gap belongs to the relationship rather than to either box in it, so
507
+ // each placement may name its own and the node's `gap:` is only the
508
+ // default. That is what lets a node wedged between two things sit tight
509
+ // against one of them and wide of the other.
510
+ for (const target of targets) {
511
+ const gap = gapFor(node, placement, target.node);
512
+ if (direction.includes('right')) {
513
+ constraints.x.push({
514
+ from: target.index,
515
+ to: me,
516
+ weight: target.offset.x + target.width + gap,
517
+ placement,
518
+ });
519
+ }
520
+ if (direction.includes('left')) {
521
+ constraints.x.push({
522
+ from: me,
523
+ to: target.index,
524
+ weight: node.width + gap - target.offset.x,
525
+ placement,
526
+ });
527
+ }
528
+ if (direction.includes('below')) {
529
+ constraints.y.push({
530
+ from: target.index,
531
+ to: me,
532
+ weight: target.offset.y + target.height + gap,
533
+ placement,
534
+ });
535
+ }
536
+ if (direction.includes('above')) {
537
+ constraints.y.push({
538
+ from: me,
539
+ to: target.index,
540
+ weight: node.height + gap - target.offset.y,
541
+ placement,
542
+ });
543
+ }
544
+ }
545
+ }
546
+ // An axis nobody spoke to falls back to the centre line of whatever the
547
+ // node was placed against, which is why "right of docker" alone is a whole
548
+ // position. Two different targets would decide which row the node shares,
549
+ // so that is refused rather than guessed — but two targets named by one
550
+ // placement are a single region, and centring on it is unambiguous.
551
+ for (const axis of AXES) {
552
+ if (spokenFor[axis])
553
+ continue;
554
+ const offers = located.filter((entry) => entry.placement.kind === 'offset');
555
+ const first = offers[0];
556
+ if (!first) {
557
+ throw new SourceError(`"${node.name}" says nothing about where it sits ${AXIS_WORD[axis]}`, node.placements[0].line);
558
+ }
559
+ const named = (entry) => entry.placement.targets.join('\u0000');
560
+ const other = offers.find((entry) => named(entry) !== named(first));
561
+ if (other) {
562
+ throw new SourceError(`"${node.name}" does not say where it sits ${AXIS_WORD[axis]}: ` +
563
+ `"${describePlacement(first.placement)}" and "${describePlacement(other.placement)}" ` +
564
+ `would put it in different places`, other.placement.line);
565
+ }
566
+ alignOn(axis, 'centre', first.targets, first.placement);
567
+ }
568
+ }
569
+ const solved = { x: [], y: [] };
570
+ const solveAll = () => {
571
+ for (const axis of AXES) {
572
+ const outcome = tightest(members.length, constraints[axis]);
573
+ if ('contradiction' in outcome)
574
+ throw noRoom(outcome.contradiction, axis, members);
575
+ solved[axis] = outcome.positions;
576
+ }
577
+ };
578
+ solveAll();
579
+ room(corridors, constraints, solved, solveAll);
580
+ settle(pending, members, constraints, solved, solveAll);
581
+ snug(members, constraints, solved, solveAll);
582
+ separate(members, constraints, solved, solveAll);
583
+ confirm(pending, solved);
584
+ return new Map(members.map((member, index) => [
585
+ member,
586
+ { x: solved.x[index], y: solved.y[index] },
587
+ ]));
588
+ }
589
+ /** The member every target belongs to, or nothing if they are spread across several. */
590
+ function sharedMember(targets) {
591
+ const first = targets[0].index;
592
+ return targets.every((target) => target.index === first) ? first : undefined;
593
+ }
594
+ /** The stretch of one axis that just covers every target. */
595
+ function spanOf(targets, axis, base) {
596
+ let start = Infinity;
597
+ let end = -Infinity;
598
+ for (const target of targets) {
599
+ const at = base(target) + target.offset[axis];
600
+ start = Math.min(start, at);
601
+ end = Math.max(end, at + (axis === 'x' ? target.width : target.height));
602
+ }
603
+ return { start, size: end - start };
604
+ }
605
+ /** Where a node of this size sits so that the named edge of it meets the span's. */
606
+ function alignedAt(edge, span, own) {
607
+ if (edge === 'centre')
608
+ return span.start + (span.size - own) / 2;
609
+ if (edge === 'top' || edge === 'left')
610
+ return span.start;
611
+ return span.start + span.size - own;
612
+ }
613
+ /**
614
+ * Widen a corridor to hold the label of the link crossing it.
615
+ *
616
+ * This is the one place a link reaches the constraint system, and it is the same
617
+ * measure-then-constrain move `settle` makes rather than links joining the graph
618
+ * outright: the first solution says which gap each label actually falls in, and
619
+ * from there the room it needs is an ordinary minimum distance like any other.
620
+ * Nothing is nudged and no layout is repaired — a constraint the file already
621
+ * implied is derived and the whole system is solved again.
622
+ *
623
+ * Which gap that is, is derived and never chosen. A pair clear of each other on
624
+ * exactly one axis has exactly one corridor between them, and the label is in
625
+ * it. A pair clear on *both* axes sits corner to corner, so the line runs
626
+ * diagonally through open space and there is no corridor to widen; a pair clear
627
+ * on neither overlaps, which is the separation pass's business and not this
628
+ * one's. Both are left alone, which is why this only ever moves boxes that a
629
+ * label is genuinely wedged between.
630
+ *
631
+ * A gap being a minimum does the rest. Where the corridor is already wide enough
632
+ * — because the author said `gap: wide`, or because something else is in there
633
+ * — the constraint is slack and nothing moves; delete the label and the corridor
634
+ * closes back to whatever the file asked for.
635
+ */
636
+ function room(corridors, constraints, solved, solveAll) {
637
+ let added = false;
638
+ for (const { from, to, need } of corridors) {
639
+ const clear = (axis) => {
640
+ const at = (end) => solved[axis][end.index] + end.offset[axis];
641
+ const size = (end) => (axis === 'x' ? end.width : end.height);
642
+ if (at(to) - (at(from) + size(from)) > 1e-9)
643
+ return { before: from, after: to };
644
+ if (at(from) - (at(to) + size(to)) > 1e-9)
645
+ return { before: to, after: from };
646
+ return undefined;
647
+ };
648
+ const open = AXES.filter((axis) => clear(axis));
649
+ if (open.length !== 1)
650
+ continue;
651
+ const axis = open[0];
652
+ const { before, after } = clear(axis);
653
+ constraints[axis].push({
654
+ from: before.index,
655
+ to: after.index,
656
+ weight: before.offset[axis] +
657
+ (axis === 'x' ? before.width : before.height) +
658
+ need[axis] -
659
+ after.offset[axis],
660
+ });
661
+ added = true;
662
+ }
663
+ if (added)
664
+ solveAll();
665
+ }
666
+ /**
667
+ * Fix the alignments that had to wait, by measuring what they align to.
668
+ *
669
+ * The first solution says where every target actually landed, so the region
670
+ * they bound is now a number rather than an expression, and the alignment
671
+ * becomes an ordinary constraint at a fixed distance. Nothing already solved is
672
+ * moved by hand; a distance is read off and added, which is the same shape as
673
+ * the separation pass and not the repair pass this design refuses.
674
+ *
675
+ * That is exact so long as the region does not depend on the node being aligned
676
+ * to it. Where it does, measuring changes the thing measured and there is no
677
+ * order that settles, so the file is refused rather than iterated at. The test
678
+ * is the same reachability the separation pass uses.
679
+ */
680
+ function settle(pending, members, constraints, solved, solveAll) {
681
+ if (pending.length === 0)
682
+ return;
683
+ for (const axis of AXES) {
684
+ const here = pending.filter((entry) => entry.axis === axis);
685
+ if (here.length === 0)
686
+ continue;
687
+ const reach = reachability(members.length, constraints[axis]);
688
+ for (const entry of here) {
689
+ for (const target of entry.targets) {
690
+ if (target.index !== entry.me && !reach[entry.me][target.index])
691
+ continue;
692
+ throw new SourceError(`"${entry.node.name}" is ${describePlacement(entry.placement)}, but "${target.name}" ` +
693
+ `is placed ${AXIS_WORD[axis]} against "${entry.node.name}" in turn, so there is no ` +
694
+ `arrangement where each waits for the other`, entry.placement.line);
695
+ }
696
+ const span = spanOf(entry.targets, axis, (target) => solved[axis][target.index]);
697
+ const own = axis === 'x' ? entry.node.width : entry.node.height;
698
+ const anchor = entry.targets[0].index;
699
+ constraints[axis].push(...fix(anchor, entry.me, alignedAt(entry.edge, span, own) - solved[axis][anchor], entry.placement));
700
+ }
701
+ }
702
+ solveAll();
703
+ }
704
+ /**
705
+ * Check the measured alignments still hold.
706
+ *
707
+ * Separation runs afterwards and only ever adds, so it can push two targets
708
+ * apart and leave a region wider than it was when it was measured. Nothing here
709
+ * repairs that — the picture is reported as unbuildable, because silently
710
+ * drawing a node that is no longer level with what it names is the failure the
711
+ * diagnostics work exists to prevent.
712
+ */
713
+ function confirm(pending, solved) {
714
+ for (const entry of pending) {
715
+ const { axis } = entry;
716
+ const span = spanOf(entry.targets, axis, (target) => solved[axis][target.index]);
717
+ const own = axis === 'x' ? entry.node.width : entry.node.height;
718
+ if (Math.abs(alignedAt(entry.edge, span, own) - solved[axis][entry.me]) <= 0.5)
719
+ continue;
720
+ throw new SourceError(`"${entry.node.name}" cannot be ${describePlacement(entry.placement)}: keeping boxes off ` +
721
+ `each other moved them apart after that region was measured`, entry.placement.line);
722
+ }
723
+ }
724
+ /**
725
+ * Pull in a node that nothing pushes back the other way.
726
+ *
727
+ * Every constraint reads "this one is at least so far along the axis from that
728
+ * one", and the solve puts each member at the smallest position its constraints
729
+ * allow. That is the tightest arrangement for anything with something behind
730
+ * it — but `left of X` and `above X` bound *X*, not the node that wrote them,
731
+ * so a node carrying only those has nothing behind it at all. It settles at the
732
+ * far edge of the drawing while the thing it names is carried away by the rest
733
+ * of the diagram, which is neither what the file says nor what the language
734
+ * promises: as close together as the placements allow.
735
+ *
736
+ * The remedy is to look the other way for exactly those members. A member with
737
+ * no incoming edge cannot be pushed by anything, so moving it along the axis
738
+ * disturbs nothing; the furthest it may travel is set by whichever of its own
739
+ * placements binds first, and pinning that one placement to an exact distance
740
+ * puts it there. Every other placement it wrote had more room to spare and is
741
+ * still satisfied.
742
+ *
743
+ * The pins are worked out against the first solution and applied together,
744
+ * which keeps them independent: a member with no incoming edge is never the
745
+ * target of another member's pin, because being a target is what an incoming
746
+ * edge is.
747
+ */
748
+ function snug(members, constraints, solved, solveAll) {
749
+ const pins = [];
750
+ for (const axis of AXES) {
751
+ const pushed = new Array(members.length).fill(false);
752
+ for (const constraint of constraints[axis])
753
+ pushed[constraint.to] = true;
754
+ for (let index = 0; index < members.length; index += 1) {
755
+ if (pushed[index])
756
+ continue;
757
+ let binding;
758
+ let slack = Infinity;
759
+ for (const constraint of constraints[axis]) {
760
+ if (constraint.from !== index)
761
+ continue;
762
+ const room = solved[axis][constraint.to] - solved[axis][index] - constraint.weight;
763
+ if (room < slack) {
764
+ slack = room;
765
+ binding = constraint;
766
+ }
767
+ }
768
+ // Nothing to travel toward, or already against it.
769
+ if (!binding || slack <= 1e-9)
770
+ continue;
771
+ pins.push({
772
+ axis,
773
+ constraint: {
774
+ from: binding.to,
775
+ to: index,
776
+ weight: -binding.weight,
777
+ ...(binding.placement ? { placement: binding.placement } : {}),
778
+ },
779
+ });
780
+ }
781
+ }
782
+ if (pins.length === 0)
783
+ return;
784
+ for (const pin of pins)
785
+ constraints[pin.axis].push(pin.constraint);
786
+ solveAll();
787
+ }
788
+ /**
789
+ * Push apart any two boxes that landed on top of each other.
790
+ *
791
+ * Two boxes not overlapping is a placement the author never has to write, but on
792
+ * its own it says nothing about *which way* to separate them — left, right,
793
+ * above and below all satisfy it, and choosing among four is the search this
794
+ * whole design refuses. So the direction is never chosen. It is read off the
795
+ * constraints already built from the file: if the file lets one box travel away
796
+ * from the other along an axis and offers no way back, that is the only
797
+ * separation consistent with what was written. Where nothing in the file orders
798
+ * a pair on either axis, this reports the pair instead of guessing.
799
+ *
800
+ * One case is genuinely free. When both axes already imply an order, either
801
+ * would do, and the tie is broken by separating along the axis where the two
802
+ * overlap least — the smallest movement, and the one a person makes by hand.
803
+ * That is the single place the tool decides something nobody wrote.
804
+ *
805
+ * The loop only ever adds constraints and re-solves the whole system, so no
806
+ * arrangement is ever tried and rejected and it settles without backtracking.
807
+ * A solved layout is never nudged in place; that is a different thing and it is
808
+ * the thing the design rules out.
809
+ *
810
+ * Members here are always siblings, or the roots of the diagram, so no member
811
+ * ever contains another and containment needs no exemption of its own.
812
+ */
813
+ function separate(members, constraints, solved, solveAll) {
814
+ const eligible = members.map(allowsOverlap).map((allowed) => !allowed);
815
+ if (eligible.filter(Boolean).length < 2)
816
+ return;
817
+ // Adding only, so the number of separations is bounded; the cap is a
818
+ // backstop against a bug rather than an expected outcome.
819
+ for (let round = 0; round < members.length * members.length + 1; round += 1) {
820
+ let reach;
821
+ let added = false;
822
+ for (let i = 0; i < members.length; i += 1) {
823
+ if (!eligible[i])
824
+ continue;
825
+ for (let j = i + 1; j < members.length; j += 1) {
826
+ if (!eligible[j])
827
+ continue;
828
+ const over = overlapOf(members, solved, i, j);
829
+ if (!over)
830
+ continue;
831
+ reach ??= { x: reachability(members.length, constraints.x), y: reachability(members.length, constraints.y) };
832
+ const orders = {};
833
+ for (const axis of AXES) {
834
+ const order = impliedOrder(reach[axis], i, j);
835
+ if (order)
836
+ orders[axis] = order;
837
+ }
838
+ const axis = pickAxis(orders, over);
839
+ if (!axis)
840
+ throw unordered(members[i], members[j]);
841
+ const { before, after } = orders[axis];
842
+ const span = axis === 'x' ? members[before].width : members[before].height;
843
+ constraints[axis].push({ from: before, to: after, weight: span + SEPARATION_GAP });
844
+ reach = undefined;
845
+ added = true;
846
+ }
847
+ }
848
+ if (!added)
849
+ return;
850
+ solveAll();
851
+ }
852
+ }
853
+ /** How far two members share space on each axis, or nothing if they are clear of each other. */
854
+ function overlapOf(members, solved, i, j) {
855
+ const shared = (axis) => {
856
+ const size = (index) => (axis === 'x' ? members[index].width : members[index].height);
857
+ const startI = solved[axis][i];
858
+ const startJ = solved[axis][j];
859
+ return Math.min(startI + size(i), startJ + size(j)) - Math.max(startI, startJ);
860
+ };
861
+ const x = shared('x');
862
+ const y = shared('y');
863
+ return x > 1e-9 && y > 1e-9 ? { x, y } : undefined;
864
+ }
865
+ /** Which of two members the file lets the other move past, if either. */
866
+ function impliedOrder(reach, i, j) {
867
+ const forward = reach[i][j];
868
+ const backward = reach[j][i];
869
+ if (forward === backward)
870
+ return undefined;
871
+ return forward ? { before: i, after: j } : { before: j, after: i };
872
+ }
873
+ /** Of the axes that can separate a pair, the one where they overlap least. */
874
+ function pickAxis(orders, over) {
875
+ const available = AXES.filter((axis) => orders[axis]);
876
+ if (available.length < 2)
877
+ return available[0];
878
+ return over.x <= over.y ? 'x' : 'y';
879
+ }
880
+ function allowsOverlap(node) {
881
+ const value = node.attrs['overlap'];
882
+ if (value === undefined)
883
+ return false;
884
+ if (value !== 'allow') {
885
+ throw new SourceError(`"${node.name}" has overlap: ${value}, which is not one of allow`, node.line);
886
+ }
887
+ return true;
888
+ }
889
+ function unordered(first, second) {
890
+ const [earlier, later] = first.line <= second.line ? [first, second] : [second, first];
891
+ return new SourceError(`"${earlier.name}" and "${later.name}" overlap, and nothing says which side of the ` +
892
+ `other either one sits on — place one against the other, or say overlap: allow`, later.line);
893
+ }
894
+ /** Report a set of placements that cannot all hold, in the words they were written in. */
895
+ function noRoom(contradiction, axis, members) {
896
+ const { placements } = contradiction;
897
+ if (placements.length === 0) {
898
+ return new SourceError(`these placements run in a circle ${AXIS_WORD[axis]} and cannot all hold`, members[0]?.line ?? 1);
899
+ }
900
+ const quoted = placements.map((placement) => `"${describePlacement(placement)}"`).join(' and ');
901
+ return new SourceError(`${quoted} cannot all hold — they leave no room ${AXIS_WORD[axis]}`, placements[placements.length - 1].line);
902
+ }
903
+ /**
904
+ * How far this placement holds the node off its target.
905
+ *
906
+ * A gap is a property of the relationship, not of either box in it, so the
907
+ * placement's own bracketed gap is the specific statement about this pair and
908
+ * wins outright. Where it says nothing, `gap:` on a node is a default — and both
909
+ * ends of the relationship may offer one. The node doing the placing wrote its
910
+ * gap down; the target had someone else's placement written against it. Neither
911
+ * is more entitled than the other, so the larger applies, which is the only
912
+ * answer consistent with a gap being a minimum in the first place.
913
+ *
914
+ * That is what makes `gap: wide` on a node that carries no placements of its own
915
+ * do the obvious thing rather than nothing at all: an author looking at two boxes
916
+ * pushed too close together has no reason to know which of the two happened to
917
+ * name the other.
918
+ */
919
+ function gapFor(node, placement, target) {
920
+ if (placement.gap !== undefined)
921
+ return namedGap(node, placement.gap, placement.line);
922
+ const mine = node.attrs['gap'];
923
+ const theirs = target.attrs['gap'];
924
+ // Only a gap somebody actually wrote down counts. Reading an absent one as the
925
+ // default would make it a floor rather than a fallback, and every `gap: tight`
926
+ // placed against a silent node would quietly widen back to normal.
927
+ const stated = [];
928
+ if (mine !== undefined)
929
+ stated.push(namedGap(node, mine, node.line));
930
+ if (theirs !== undefined)
931
+ stated.push(namedGap(target, theirs, target.line));
932
+ if (stated.length === 0)
933
+ return namedGap(node, undefined, node.line);
934
+ return Math.max(...stated);
935
+ }
936
+ function namedGap(node, named, line) {
937
+ const gap = GAPS[named ?? 'normal'];
938
+ if (gap === undefined) {
939
+ const known = Object.keys(GAPS).join(', ');
940
+ throw new SourceError(`"${node.name}" asks for gap: ${named}, which is not one of ${known}`, line);
941
+ }
942
+ return gap;
943
+ }
944
+ // --- shared helpers ----------------------------------------------------------
945
+ function widestLine(lines, measurer, fontSize) {
946
+ return lines.reduce((widest, line) => {
947
+ const { width } = measurer.measure(line, fontSize);
948
+ return Math.max(widest, width);
949
+ }, 0);
950
+ }
951
+ /**
952
+ * Split a label into the lines that get drawn. `/` always breaks a line. A
953
+ * `width` attribute additionally folds each of those at word boundaries, which
954
+ * is what stops a long note running across the whole diagram.
955
+ *
956
+ * The width is a character count rather than a distance. It says how much text
957
+ * fits on a line, not where anything sits, so it stays a property of the text
958
+ * and never becomes a coordinate in disguise.
959
+ */
960
+ function linesFor(text, attrs, line) {
961
+ const stated = attrs['width'];
962
+ if (stated === undefined)
963
+ return splitLines(text);
964
+ const columns = Number(stated);
965
+ if (!Number.isInteger(columns) || columns < 1) {
966
+ throw new SourceError(`width must be a whole number of characters, not "${stated}"`, line);
967
+ }
968
+ return splitLines(text).flatMap((part) => wrap(part, columns));
969
+ }
970
+ /** Fold one line onto several at word boundaries, never exceeding `columns`. */
971
+ function wrap(text, columns) {
972
+ const lines = [];
973
+ let current = '';
974
+ for (const word of text.split(/\s+/).filter(Boolean)) {
975
+ if (current.length === 0) {
976
+ current = word;
977
+ }
978
+ else if (current.length + 1 + word.length <= columns) {
979
+ current += ` ${word}`;
980
+ }
981
+ else {
982
+ lines.push(current);
983
+ current = word;
984
+ }
985
+ }
986
+ if (current.length > 0)
987
+ lines.push(current);
988
+ return lines.length > 0 ? lines : [''];
989
+ }
990
+ function bounds(nodes) {
991
+ let minX = Infinity;
992
+ let minY = Infinity;
993
+ let maxX = -Infinity;
994
+ let maxY = -Infinity;
995
+ for (const node of nodes) {
996
+ minX = Math.min(minX, node.x);
997
+ minY = Math.min(minY, node.y);
998
+ maxX = Math.max(maxX, node.x + node.width);
999
+ maxY = Math.max(maxY, node.y + node.height);
1000
+ }
1001
+ return { minX, minY, maxX, maxY };
1002
+ }
1003
+ /** Shift everything so the diagram starts at the margin rather than wherever the anchor fell. */
1004
+ function normalize(nodes, margin) {
1005
+ const { minX, minY } = bounds(nodes);
1006
+ const dx = margin - minX;
1007
+ const dy = margin - minY;
1008
+ for (const node of nodes) {
1009
+ node.x += dx;
1010
+ node.y += dy;
1011
+ }
1012
+ }