reladraw 0.19.1 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/place.js CHANGED
@@ -1,4 +1,4 @@
1
- import { describePlacement, describePoint, fractionAxis, nameTarget, PART_SIDES, partWordHint } from './ast.js';
1
+ import { describeLimit, describePlacement, describePoint, fractionAxis, nameTarget, PART_SIDES, partWordHint } from './ast.js';
2
2
  import { DECK_STEP, GAPS, HEADER_GAP, UNIT } from './constants.js';
3
3
  import { SourceError } from './errors.js';
4
4
  import { isWithin, joinedToText } from './model.js';
@@ -107,6 +107,8 @@ export class Placer {
107
107
  sizes = new Map();
108
108
  frames = new Map();
109
109
  outlines = new Map();
110
+ /** A container's box while a limit tries it out (`roomFor`), in place of its final one. */
111
+ trials = new Map();
110
112
  working = new Set();
111
113
  /** What is being worked out, innermost last, for reporting a circle. */
112
114
  trail = [];
@@ -128,6 +130,8 @@ export class Placer {
128
130
  origins = new Map();
129
131
  /** Containers whose anchor is being placed on the page, so a box outside them is measured on the page rather than in their frame. */
130
132
  onPage = new Set();
133
+ /** Boxes that anchor their frame on one axis only, when no box in it says nothing at all (`splitAnchors`). */
134
+ axisAnchors = new Set();
131
135
  /** Named points and regions being worked out, innermost last, to refuse one defined through itself. */
132
136
  expanding = [];
133
137
  constructor(roots, byName, facts,
@@ -175,6 +179,8 @@ export class Placer {
175
179
  /** Work out everything, and write it onto the nodes. */
176
180
  run() {
177
181
  this.checkAnchors(this.roots, undefined);
182
+ for (const node of this.byName.values())
183
+ this.checkLimitTargets(node);
178
184
  for (const node of this.byName.values()) {
179
185
  for (const axis of BOTH_AXES) {
180
186
  this.size(node, axis);
@@ -189,9 +195,76 @@ export class Placer {
189
195
  }
190
196
  for (const node of this.byName.values())
191
197
  this.checkYielded(node);
198
+ for (const node of this.byName.values())
199
+ this.checkLimitsMet(node);
192
200
  for (const node of this.byName.values())
193
201
  this.finish(node);
194
202
  }
203
+ /**
204
+ * What a limit may relate (design.org 5.6): two things in one box that
205
+ * nothing else ties together, since the box's own spacing between them is
206
+ * the only distance it may lengthen.
207
+ */
208
+ checkLimitTargets(node) {
209
+ for (const limit of node.limits ?? []) {
210
+ const parent = node.parent;
211
+ const said = `"${node.name} ${describeLimit(limit)}"`;
212
+ const named = this.byName.get(limit.target.name);
213
+ if (!named) {
214
+ throw new SourceError(`${said}: "${limit.target.name}" does not exist${partWordHint(limit.target.name, this.byName.keys())}`, limit.line);
215
+ }
216
+ const ownText = parent !== undefined && named === parent && limit.target.part === 'text';
217
+ const sibling = parent !== undefined && named.parent === parent && named !== node && limit.target.part === undefined;
218
+ if (!parent || !(ownText || sibling)) {
219
+ throw new SourceError(`${said}: a limit keeps clear of something in the same box, a sibling or ` +
220
+ `${parent ? `"${parent.name} text"` : 'the box\'s own text'}, since that box is what makes room`, limit.line);
221
+ }
222
+ if (ownText && !this.hasTitle(parent)) {
223
+ throw new SourceError(`${said}: "${parent.name}" has no text`, limit.line);
224
+ }
225
+ const axis = limitAxis(limit);
226
+ const own = this.groupOf(node, parent, axis);
227
+ if (own !== 'frame' && own === this.groupOf(ownText ? 'text' : named, parent, axis)) {
228
+ const how = own === 'title' ? `with "${parent.name}"'s text` : 'against each other';
229
+ throw new SourceError(`${said} could never change anything: "${node.name}" and "${nameTarget(limit.target)}" are placed ${how}, ` +
230
+ 'so the distance between them is already stated — a limit relates two things in one box that nothing else ties together, ' +
231
+ `such as a child placed against "${parent.name}"'s side and one of its other children`, limit.line);
232
+ }
233
+ }
234
+ }
235
+ /**
236
+ * Which of the things a box holds that nothing ties to each other a member
237
+ * belongs to, on one axis: its title, what is placed against the box's
238
+ * sides, or the rest, placed against each other.
239
+ */
240
+ groupOf(member, parent, axis) {
241
+ const titleTied = this.anchoredOnTitle(parent) || (parent.titlePlacements?.length ?? 0) > 0;
242
+ if (member === 'text' || member === parent.heading || joinedToText(member))
243
+ return titleTied ? 'content' : 'title';
244
+ return this.framedOn(member, axis) ? 'frame' : 'content';
245
+ }
246
+ /** A limit the box couldn't make room for: no side helps, or a stated size holds it in. */
247
+ checkLimitsMet(node) {
248
+ for (const limit of node.limits ?? []) {
249
+ const parent = node.parent;
250
+ const axis = limitAxis(limit);
251
+ const frame = this.frame(parent, axis);
252
+ if (this.shortfall(parent, axis, { subject: node, limit }, frame) <= LIMIT_EPSILON)
253
+ continue;
254
+ const said = `"${describeLimit(limit)}"`;
255
+ if (frame.boxSize !== this.outline(parent, axis).boxSize) {
256
+ throw new SourceError(`"${node.name} ${describeLimit(limit)}", but "${parent.name}"'s stated ${axis === 'x' ? 'width' : 'height'} leaves no room for it — ` +
257
+ 'make it bigger, or leave the size unstated so the box can make room', limit.line);
258
+ }
259
+ const named = this.byName.get(limit.target.name);
260
+ const framed = [node, named].find((member) => member !== parent && this.framedOn(member, axis));
261
+ const where = framed
262
+ ? this.statements(framed, axis).map((statement) => `"${framed.name} ${describePlacement(statement.placement)}"`).join(' and ')
263
+ : undefined;
264
+ throw new SourceError(`"${node.name}" can't keep to ${said}${where ? ` with ${where}` : ''}: "${parent.name}" holds both, and growing it ` +
265
+ 'never moves them apart — keep one of the two', limit.line);
266
+ }
267
+ }
195
268
  // --- the quantities ------------------------------------------------------
196
269
  /** Where a node starts on the page. */
197
270
  page(node, axis) {
@@ -215,7 +288,7 @@ export class Placer {
215
288
  if (this.frameAnchor(node, axis))
216
289
  return this.anchorOnPage(node, axis);
217
290
  const above = node.parent ? this.origin(node.parent, axis) : 0;
218
- if (this.floats(node))
291
+ if (this.floats(node, axis))
219
292
  return above;
220
293
  this.refusePlacedTwice(node, axis);
221
294
  return above + this.start(node, axis) - this.frame(node, axis).boxStart;
@@ -275,9 +348,11 @@ export class Placer {
275
348
  * goes, and nothing inside it is placed against a box outside it in its
276
349
  * stead. Its frame's origin is its own container's.
277
350
  */
278
- floats(node) {
351
+ floats(node, axis) {
279
352
  const parent = node.parent;
280
- if (node.placements.length > 0 || node.children.length === 0)
353
+ if (node.children.length === 0)
354
+ return false;
355
+ if (node.placements.length > 0 && !this.axisAnchors.has(`${node.name}\n${axis}`))
281
356
  return false;
282
357
  if (parent && (parent.heading === node || joinedToText(node) || this.facts.get(parent).layout || this.vennPart(node) !== undefined)) {
283
358
  return false;
@@ -363,6 +438,9 @@ export class Placer {
363
438
  /** A container's box and title in its own frame, at the size it ends up. */
364
439
  frame(node, axis) {
365
440
  const key = `${node.name}\n${axis}`;
441
+ const trial = this.trials.get(key);
442
+ if (trial)
443
+ return trial;
366
444
  const known = this.frames.get(key);
367
445
  if (known)
368
446
  return known;
@@ -376,10 +454,91 @@ export class Placer {
376
454
  const known = this.outlines.get(key);
377
455
  if (known)
378
456
  return known;
379
- const worked = this.guard(`outline\n${key}`, node, axis, undefined, () => this.workOutFrame(node, axis));
457
+ const worked = this.guard(`outline\n${key}`, node, axis, undefined, () => this.roomFor(node, axis));
380
458
  this.outlines.set(key, worked);
381
459
  return worked;
382
460
  }
461
+ /**
462
+ * A container's outline with room made for its children's limits
463
+ * (design.org 5.6). The only distance a limit may lengthen is between what
464
+ * the box holds and its sides, which every placement inside leaves unstated.
465
+ * Each side's room moves a limit's two ends apart at a fixed rate (all of
466
+ * it for a corner, half for the middle of a side), so one trial of each
467
+ * side gives the rate, and the room is the shortfall over the rate. A side
468
+ * that doesn't help is never used; with both helping, as for two corner
469
+ * children, the box grows evenly. A limit no side helps is left unmet, and
470
+ * `checkLimits` refuses it.
471
+ */
472
+ roomFor(node, axis) {
473
+ const limits = this.limitsIn(node, axis);
474
+ let room = NO_ROOM;
475
+ let frame = this.workOutFrame(node, axis);
476
+ for (let round = 0; round < limits.length * 2; round++) {
477
+ const short = (at) => Math.max(...limits.map((each) => this.shortfall(node, axis, each, at)));
478
+ const owed = short(frame);
479
+ if (owed <= LIMIT_EPSILON)
480
+ break;
481
+ const rate = (more) => (owed - short(this.workOutFrame(node, axis, more))) / owed;
482
+ const low = rate({ low: room.low + owed, high: room.high });
483
+ const high = rate({ low: room.low, high: room.high + owed });
484
+ if (low > LIMIT_EPSILON && high > LIMIT_EPSILON) {
485
+ const both = rate({ low: room.low + owed, high: room.high + owed });
486
+ room = { low: room.low + owed / both, high: room.high + owed / both };
487
+ }
488
+ else if (low > LIMIT_EPSILON) {
489
+ room = { ...room, low: room.low + owed / low };
490
+ }
491
+ else if (high > LIMIT_EPSILON) {
492
+ room = { ...room, high: room.high + owed / high };
493
+ }
494
+ else {
495
+ break;
496
+ }
497
+ frame = this.workOutFrame(node, axis, room);
498
+ }
499
+ return frame;
500
+ }
501
+ /** The limits a container's children state on one axis. */
502
+ limitsIn(node, axis) {
503
+ return node.children.flatMap((subject) => (subject.limits ?? []).filter((limit) => limitAxis(limit) === axis).map((limit) => ({ subject, limit })));
504
+ }
505
+ /** How much nearer than its gap a limit's two ends are, in a trial frame of their container. */
506
+ shortfall(node, axis, { subject, limit }, at) {
507
+ const own = this.memberAt(subject, node, axis, at);
508
+ const other = limit.target.name === node.name ? { start: at.textStart, size: at.textSize } : this.memberAt(this.byName.get(limit.target.name), node, axis, at);
509
+ const gap = this.limitGap(subject, limit);
510
+ const toward = limit.way === 'above' || limit.way === 'left';
511
+ return toward ? own.start + own.size + gap - other.start : other.start + other.size + gap - own.start;
512
+ }
513
+ /** The distance a limit keeps: its gap, or the usual one; beside a text, as beside any text. */
514
+ limitGap(subject, limit) {
515
+ if (limit.gap !== undefined)
516
+ return gapPixels(limit.gap, `"${subject.name}"`, limit.line);
517
+ const parent = subject.parent;
518
+ if (limit.target.name === parent.name)
519
+ return this.hasTitle(parent) ? UNIT : 0;
520
+ return GAPS['normal'];
521
+ }
522
+ /** Where a child of `node` is on one axis, in a trial frame of `node`. */
523
+ memberAt(member, node, axis, at) {
524
+ const size = this.size(member, axis);
525
+ if (member === node.heading)
526
+ return { start: at.titleStart, size };
527
+ if (joinedToText(member))
528
+ return { start: at.textStart + this.unitStart(member, axis), size };
529
+ if (!this.framedOn(member, axis))
530
+ return { start: this.start(member, axis), size };
531
+ // Placed against the box: where the trial box puts it, worked out
532
+ // without remembering, since the box isn't final.
533
+ const key = `${node.name}\n${axis}`;
534
+ this.trials.set(key, at);
535
+ try {
536
+ return { start: this.placed(member, axis), size };
537
+ }
538
+ finally {
539
+ this.trials.delete(key);
540
+ }
541
+ }
383
542
  remember(memo, node, axis, work) {
384
543
  const key = `${node.name}\n${axis}`;
385
544
  const known = memo.get(key);
@@ -454,7 +613,7 @@ export class Placer {
454
613
  }
455
614
  // A container that anchors its frame shares its container's origin, so
456
615
  // its box starts where its outline does.
457
- if (this.floats(node))
616
+ if (this.floats(node, axis))
458
617
  return this.frame(node, axis).boxStart;
459
618
  if (node.placements.length === 0 || this.acrossParts(node)) {
460
619
  // The anchor of its frame. One placed against a box in another part of
@@ -473,13 +632,14 @@ export class Placer {
473
632
  placed(node, axis) {
474
633
  const statements = this.statements(node, axis);
475
634
  if (statements.length === 0)
476
- return this.lone(node, axis);
635
+ return this.axisAnchors.has(`${node.name}\n${axis}`) ? 0 : this.lone(node, axis);
477
636
  if (statements.length === 1) {
478
637
  const [only] = statements;
479
638
  return this.apply(node, axis, only);
480
639
  }
481
640
  const [first, second] = statements;
482
641
  if (first.kind !== 'align' && second.kind !== 'align' && first.kind !== second.kind) {
642
+ this.refuseBoxAndSibling(node, axis, first.placement, second.placement);
483
643
  // Opposite placements: centered between the two at its own size. Each
484
644
  // target stays where its own statement put it.
485
645
  const after = first.kind === 'after' ? first : second;
@@ -494,6 +654,36 @@ export class Placer {
494
654
  return pins.start;
495
655
  throw this.overStated(node, axis, statements);
496
656
  }
657
+ /**
658
+ * Opposite placements center a box between two others, but not when one of
659
+ * them is its own container's box: `inside c top-right right of c.k` would
660
+ * be neither in the corner nor a gap from `c.k` (design.org 5.3). Refused,
661
+ * naming the two ways out: the corner's other half beside the sibling, or
662
+ * the corner kept and the sibling held off with `stays`.
663
+ */
664
+ refuseBoxAndSibling(node, axis, first, second) {
665
+ const parent = node.parent;
666
+ if (!parent || this.againstBox(first, parent) === this.againstBox(second, parent))
667
+ return;
668
+ const [box, other] = this.againstBox(first, parent) ? [first, second] : [second, first];
669
+ if (other.kind !== 'offset')
670
+ return;
671
+ const ways = [];
672
+ const corner = box.targets[0]?.part;
673
+ if (box.kind === 'offset' && box.written !== undefined && corner !== undefined && corner.includes('-')) {
674
+ const [down, across] = corner.split('-');
675
+ const kept = axis === 'x' ? down : across;
676
+ ways.push(`to sit ${describePlacement(other)} at the ${kept} of ${parent.name}, write "${describePlacement(other)} ${box.written} ${parent.name} ${kept}"`);
677
+ }
678
+ const way = component(other.direction, axis);
679
+ const straight = axis === 'x' ? (way === 'after' ? 'right' : 'left') : way === 'after' ? 'below' : 'above';
680
+ const of = axis === 'x' ? ' of' : '';
681
+ if (other.targets.length === 1) {
682
+ ways.push(`to stay ${describePlacement(box)} and clear of ${nameTarget(other.targets[0])}, write "stays ${straight}${of} ${nameTarget(other.targets[0])}"`);
683
+ }
684
+ throw new SourceError(`"${node.name}" says twice where it goes ${AXIS_SAID[axis]}, with "${describePlacement(box)}" and "${describePlacement(other)}"` +
685
+ (ways.length > 0 ? `; ${ways.join('; ')}` : ' — keep one'), other.line);
686
+ }
497
687
  edgeOf(node, axis, statement) {
498
688
  return this.guard(`edge\n${node.name}\n${axis}\n${statement.placement.line}`, node, axis, statement.placement, () => statement.kind === 'align' ? 0 : statement.edge());
499
689
  }
@@ -991,7 +1181,7 @@ export class Placer {
991
1181
  text = { start: frame.textStart, size: frame.textSize };
992
1182
  }
993
1183
  else if (named.parent === parent) {
994
- if (this.framed(named) && !this.framed(node)) {
1184
+ if (this.framedOn(named, axis) && !this.framedOn(node, axis)) {
995
1185
  throw new SourceError(`"${node.name}" is placed against "${named.name}", which is placed against "${parent.name}" itself. ` +
996
1186
  `Nothing may be placed against a thing placed against its container's side or corner — place ` +
997
1187
  `"${node.name}" against another child`, placement.line);
@@ -1049,7 +1239,27 @@ export class Placer {
1049
1239
  const parent = node.parent;
1050
1240
  if (!parent || this.facts.get(parent).layout || this.facts.get(parent).venn)
1051
1241
  return false;
1052
- return node.placements.some((placement) => placement.targets.some((target) => target.name === parent.name && target.part !== 'text'));
1242
+ return node.placements.some((placement) => this.againstBox(placement, parent));
1243
+ }
1244
+ /**
1245
+ * Framed on one axis: what says where it goes on that axis is against its
1246
+ * container's box. `right of c.k inside c top` is framed down, so its height
1247
+ * is measured from the finished box, and is content across, so the box holds
1248
+ * it there (design.org 5.6).
1249
+ */
1250
+ framedOn(node, axis) {
1251
+ if (!this.framed(node))
1252
+ return false;
1253
+ const parent = node.parent;
1254
+ const statements = this.statements(node, axis);
1255
+ if (statements.length > 0)
1256
+ return statements.some((statement) => this.againstBox(statement.placement, parent));
1257
+ // Nothing states this axis, so a lone direction on the other one centers it on its target.
1258
+ return node.placements.some((placement) => placement.kind === 'offset' && component(placement.direction, axis) === undefined && this.againstBox(placement, parent));
1259
+ }
1260
+ /** Whether a placement names its container's box, any part but the text. */
1261
+ againstBox(placement, parent) {
1262
+ return placement.targets.some((target) => target.name === parent.name && target.part !== 'text');
1053
1263
  }
1054
1264
  /**
1055
1265
  * A child placed against its container's text, or against its heading,
@@ -1111,10 +1321,13 @@ export class Placer {
1111
1321
  /**
1112
1322
  * The children a container's box is the outline of, besides its title: not
1113
1323
  * its heading, not what is joined to its text, and not those placed against
1114
- * the box.
1324
+ * the box. Given an axis, a child placed against the box only on the other
1325
+ * axis is content on this one.
1115
1326
  */
1116
- contentOf(node) {
1117
- return node.children.filter((child) => child !== node.heading && !joinedToText(child) && !this.framed(child));
1327
+ contentOf(node, axis) {
1328
+ return node.children.filter((child) => child !== node.heading &&
1329
+ !joinedToText(child) &&
1330
+ !(axis === undefined ? this.framed(child) : this.framedOn(child, axis)));
1118
1331
  }
1119
1332
  /** Whether a node has a title: its text, what is joined to it, or a heading. */
1120
1333
  hasTitle(node) {
@@ -1307,12 +1520,12 @@ export class Placer {
1307
1520
  * not placed against the box itself, and the title when it sits inside the
1308
1521
  * border or on it.
1309
1522
  */
1310
- workOutFrame(node, axis) {
1523
+ workOutFrame(node, axis, room = NO_ROOM) {
1311
1524
  const facts = this.facts.get(node);
1312
1525
  const title = this.titleOf(node);
1313
1526
  const hasTitle = this.hasTitle(node);
1314
1527
  const titleSize = this.titleSize(node, axis);
1315
- const content = this.contentOf(node);
1528
+ const content = this.contentOf(node, axis);
1316
1529
  const anchoredOnTitle = this.anchoredOnTitle(node);
1317
1530
  let low = Infinity;
1318
1531
  let high = -Infinity;
@@ -1343,6 +1556,12 @@ export class Placer {
1343
1556
  high = Math.max(high, band);
1344
1557
  }
1345
1558
  }
1559
+ // Room a child's limit asks for (design.org 5.6), between what the box
1560
+ // holds and its sides, so a title against a side moves out with it.
1561
+ if (!empty) {
1562
+ low -= room.low;
1563
+ high += room.high;
1564
+ }
1346
1565
  // Where the title sits in the frame. When something is placed against it,
1347
1566
  // it is the frame's anchor and sits at the origin; otherwise it is placed
1348
1567
  // against the children by its side.
@@ -1358,6 +1577,10 @@ export class Placer {
1358
1577
  low = titleStart;
1359
1578
  high = titleStart + titleSize;
1360
1579
  }
1580
+ if (empty) {
1581
+ low -= room.low;
1582
+ high += room.high;
1583
+ }
1361
1584
  }
1362
1585
  else if (title.how === 'center' || title.side === 'center') {
1363
1586
  titleStart = (low + high) / 2 - titleSize / 2;
@@ -1590,7 +1813,7 @@ export class Placer {
1590
1813
  innerOther(node, axis) {
1591
1814
  const key = `inner\n${node.name}\n${axis}`;
1592
1815
  return this.guard(key, node, axis, undefined, () => {
1593
- const content = this.contentOf(node);
1816
+ const content = this.contentOf(node, axis);
1594
1817
  let low = Infinity;
1595
1818
  let high = -Infinity;
1596
1819
  for (const child of content) {
@@ -2015,9 +2238,8 @@ export class Placer {
2015
2238
  const content = members.filter((member) => !this.framed(member));
2016
2239
  const unplaced = content.filter((member) => this.unplaced(member));
2017
2240
  const onTitle = content.find((member) => this.onTitle(member));
2018
- if (!parent && unplaced.length === 0) {
2019
- throw new SourceError('every node is placed relative to another, so nothing anchors the diagram', 1);
2020
- }
2241
+ if (unplaced.length === 0 && !(parent && this.anchoredOnTitle(parent)))
2242
+ this.splitAnchors(content, parent);
2021
2243
  if (unplaced.length > 1) {
2022
2244
  const names = unplaced.map((node) => `"${node.name}"`).join(', ');
2023
2245
  throw new SourceError(parent
@@ -2034,6 +2256,62 @@ export class Placer {
2034
2256
  for (const member of all)
2035
2257
  this.checkAnchors(member.children, member);
2036
2258
  }
2259
+ /**
2260
+ * Every box in a frame says something about where it goes, so the frame is
2261
+ * anchored one axis at a time (design.org 5.3): across by the one box that
2262
+ * says nothing across, up and down by the one that says nothing up and
2263
+ * down. A lone direction's centering counts as saying nothing when its
2264
+ * target's place on that axis is worked out from the box itself, as with
2265
+ * `b right of a` and `a even with b.1`: the centering gives way to what was
2266
+ * written. An axis nobody leaves free is a circle, reported when placing,
2267
+ * and an axis a container is placed on by what it holds needs no anchor.
2268
+ */
2269
+ splitAnchors(members, parent) {
2270
+ const free = BOTH_AXES.map((axis) => parent && this.frameAnchor(parent, axis)
2271
+ ? []
2272
+ : members.filter((member) => this.statements(member, axis).length === 0 && this.loneGivesWay(member, axis)));
2273
+ if (!parent && free.every((on) => on.length === 0)) {
2274
+ throw new SourceError('every node is placed relative to another, so nothing anchors the diagram', 1);
2275
+ }
2276
+ BOTH_AXES.forEach((axis, index) => {
2277
+ const on = free[index];
2278
+ // With the other axis bound all round there is a circle, which placing reports better.
2279
+ if (on.length > 1 && free.every((other) => other.length > 0)) {
2280
+ const names = on.map((node) => `"${node.name}"`).join(', ');
2281
+ throw new SourceError(`exactly one node may say nothing about where it goes ${AXIS_SAID[axis]}, but ${on.length} do: ${names}`, on[1].line);
2282
+ }
2283
+ if (on.length === 1)
2284
+ this.axisAnchors.add(`${on[0].name}\n${axis}`);
2285
+ });
2286
+ }
2287
+ /** Whether a box with nothing stated on this axis is left free there: no lone direction centers it, or the one that would is placed from the box itself. */
2288
+ loneGivesWay(node, axis) {
2289
+ const offsets = node.placements.filter((placement) => placement.kind === 'offset' && component(placement.direction, axis) === undefined);
2290
+ if (offsets.length === 0)
2291
+ return true;
2292
+ if (offsets.length > 1)
2293
+ return false;
2294
+ return this.namedFrom(offsets[0]).some((named) => this.placedFrom(named, axis, node, new Set()));
2295
+ }
2296
+ /** The boxes a placement names, through points and regions. */
2297
+ namedFrom(placement) {
2298
+ return placement.targets
2299
+ .flatMap((target) => this.boxesNamed(target, []))
2300
+ .flatMap((name) => this.byName.get(name) ?? []);
2301
+ }
2302
+ /** Whether a box's place on one axis is worked out, however indirectly, from `from` or something inside it. */
2303
+ placedFrom(node, axis, from, seen) {
2304
+ if (node === from || isWithin(node, from))
2305
+ return true;
2306
+ if (seen.has(node))
2307
+ return false;
2308
+ seen.add(node);
2309
+ const stated = this.statements(node, axis).map((statement) => statement.placement);
2310
+ const placements = stated.length > 0
2311
+ ? stated
2312
+ : node.placements.filter((placement) => placement.kind === 'offset' && component(placement.direction, axis) === undefined);
2313
+ return placements.some((placement) => this.namedFrom(placement).some((named) => this.placedFrom(named, axis, from, seen)));
2314
+ }
2037
2315
  /**
2038
2316
  * A box in a venn's part may be placed against a box in another part, and
2039
2317
  * then it says where its whole part goes up and down: so it must be the
@@ -2173,6 +2451,13 @@ export class Placer {
2173
2451
  return { low, high };
2174
2452
  }
2175
2453
  }
2454
+ const NO_ROOM = { low: 0, high: 0 };
2455
+ /** Less than this is no distance at all, for a limit worked out in floating point. */
2456
+ const LIMIT_EPSILON = 0.01;
2457
+ /** The axis a limit keeps to: up and down for above and below. */
2458
+ function limitAxis(limit) {
2459
+ return limit.way === 'above' || limit.way === 'below' ? 'y' : 'x';
2460
+ }
2176
2461
  /**
2177
2462
  * `top even with 2 below design top` on a venn's set: the other set's same
2178
2463
  * side, moved up or down. Anything else, such as its bottom or a sideways
package/dist/render.d.ts CHANGED
@@ -10,3 +10,20 @@ export interface RenderOptions {
10
10
  }
11
11
  /** Turn solved geometry into a standalone SVG document. */
12
12
  export declare function render(layout: Layout, options?: RenderOptions): string;
13
+ /**
14
+ * The SVG as `render` gives it, and the same drawing with each thing wrapped in
15
+ * a group naming it and its statement's line, for a page that points at things
16
+ * (`--page`). Drawn once, so the two can't disagree.
17
+ */
18
+ export declare function renderTagged(layout: Layout, options?: RenderOptions): {
19
+ svg: string;
20
+ tagged: string;
21
+ };
22
+ /**
23
+ * The colors a drawing is made in. A theme passed in — the command line's
24
+ * `--theme` — beats the one the file names, so one source renders in either.
25
+ * `diagram background:` and `text:` are the author overruling a color of
26
+ * whichever theme that is, and a color written by hand wins over any theme, so
27
+ * they are folded in afterwards and everything downstream sees one theme.
28
+ */
29
+ export declare function themeOf(layout: Layout, options?: RenderOptions): Theme;