reladraw 0.9.0 → 0.11.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/render.js CHANGED
@@ -4,6 +4,7 @@ import { SourceError } from './errors.js';
4
4
  import { ICON_STROKE } from './icons.js';
5
5
  import { monospaceMeasurer } from './measure.js';
6
6
  import { DARK_THEME, THEMES, textOnFill, themeColor } from './themes.js';
7
+ import { searchRoute } from './search.js';
7
8
  import { plain } from './text.js';
8
9
  const CORNER = 8;
9
10
  /** Turn solved geometry into a standalone SVG document. */
@@ -37,9 +38,10 @@ export function render(layout, options = {}) {
37
38
  const ends = planEndpoints(layout.edges, measurer, fontSize);
38
39
  const corridors = planCorridors(layout.edges, ends, measurer, fontSize);
39
40
  const routes = planRoutes(layout.edges, layout.nodes, ends, measurer, fontSize);
40
- planLoops(layout.edges, layout.nodes, ends, corridors, routes, measurer, fontSize);
41
+ planWays(layout.edges, layout.nodes, ends, corridors, routes, measurer, fontSize);
41
42
  aimFreeEnds(layout.edges, ends, corridors);
42
43
  const drawn = layout.edges.map((edge) => drawEdge(edge, ends.get(edge), corridors.get(edge), routes.get(edge), layout.nodes, theme, measurer, fontSize, layout.markup));
44
+ placeTexts(drawn, layout.nodes);
43
45
  // A line with a `crossing:` style is cut where it crosses an earlier one, and
44
46
  // the jump drawn over the cut. The cut is a mask rather than a break in the
45
47
  // path, so it works the same on a curve as on a corner, and leaves whatever
@@ -349,21 +351,14 @@ function drawEdge(edge, ends, corridor, route, nodes, theme, measurer, fontSize,
349
351
  midY = path.mid.y;
350
352
  }
351
353
  else if (curved) {
352
- const reach = controlReach(start, end);
353
- // A bundle whose sides were too short to spread it takes the rest of the
354
- // room in the middle, exactly as a straight group does — see `bowBundles`.
355
- // Displacing both control points equally moves the curve's middle by three
356
- // quarters as much, so the bow is scaled up by the inverse of that.
357
- const lift = 4 / 3;
358
- const bx = (ends.bow?.x ?? 0) * lift;
359
- const by = (ends.bow?.y ?? 0) * lift;
360
- const c1 = { x: start.x + start.tx * reach + bx, y: start.y + start.ty * reach + by };
361
- const c2 = { x: end.x + end.tx * reach + bx, y: end.y + end.ty * reach + by };
362
- parts.push(` <path d="M ${round(start.x)} ${round(start.y)} C ${round(c1.x)} ${round(c1.y)}, ${round(c2.x)} ${round(c2.y)}, ${round(end.x)} ${round(end.y)}" fill="none"${stroke}${markerEnd}${markerStart}/>`);
363
- ink = union(ink, cubicExtent(start, c1, c2, end));
364
- // The point halfway along a cubic, which is where the text belongs.
365
- midX = (start.x + 3 * c1.x + 3 * c2.x + end.x) / 8;
366
- midY = (start.y + 3 * c1.y + 3 * c2.y + end.y) / 8;
354
+ const { p0, c1, c2, p3 } = sideCurve(edge, ends);
355
+ const lead = p0.x !== start.x || p0.y !== start.y ? `M ${round(start.x)} ${round(start.y)} L` : 'M';
356
+ const tail = p3.x !== end.x || p3.y !== end.y ? ` L ${round(end.x)} ${round(end.y)}` : '';
357
+ parts.push(` <path d="${lead} ${round(p0.x)} ${round(p0.y)} C ${round(c1.x)} ${round(c1.y)}, ${round(c2.x)} ${round(c2.y)}, ${round(p3.x)} ${round(p3.y)}${tail}" fill="none"${stroke}${markerEnd}${markerStart}/>`);
358
+ ink = union(ink, cubicExtent(p0, c1, c2, p3));
359
+ // The point halfway along the cubic, which is where the text belongs.
360
+ midX = (p0.x + 3 * c1.x + 3 * c2.x + p3.x) / 8;
361
+ midY = (p0.y + 3 * c1.y + 3 * c2.y + p3.y) / 8;
367
362
  }
368
363
  else if (ends.bow && bowed) {
369
364
  // A straight line that could not get the room it needed at its ends, so it
@@ -390,6 +385,7 @@ function drawEdge(edge, ends, corridor, route, nodes, theme, measurer, fontSize,
390
385
  midX = (start.x + end.x) / 2;
391
386
  midY = (start.y + end.y) / 2;
392
387
  }
388
+ let text;
393
389
  if (edge.text !== undefined) {
394
390
  // An edge text breaks on ` / ` exactly as a box text does, so a two-line
395
391
  // caption on an arrow needs no vocabulary of its own. The block is centered
@@ -399,37 +395,132 @@ function drawEdge(edge, ends, corridor, route, nodes, theme, measurer, fontSize,
399
395
  const lines = edge.lines;
400
396
  const width = widestLine(lines, measurer, size);
401
397
  const height = lines.length * textHeight;
402
- const top = midY - height / 2;
403
- // The text knocks a hole in whatever it lands on rather than sitting in a
404
- // chip of its own: an outlined box reads as a node, which is the one thing
405
- // a text on a line is not.
406
- parts.push(` <rect x="${round(midX - width / 2 - 5)}" y="${round(top)}" width="${round(width + 10)}" height="${round(height)}" fill="${theme.background}"/>`);
407
- ink = union(ink, {
408
- minX: midX - width / 2 - 5,
409
- minY: top,
410
- maxX: midX + width / 2 + 5,
411
- maxY: top + height,
412
- });
413
- parts.push(sized(textBlock(lines, midX - width / 2, top, textHeight, size, { x: 0, y: 0, width, height }, {
414
- // A colored edge carries its meaning into its text; an uncolored
415
- // one leaves the words to read as ordinary text.
416
- color: textColorOf(edge.textAttrs, theme, lineOf(edge.appearance, theme, theme.text)),
417
- align: 'middle',
418
- ink: (run, own) => runInk(run, own, markup, theme),
419
- }), size, fontSize));
398
+ text = {
399
+ at: { x: midX, y: midY },
400
+ width: width + 10,
401
+ height,
402
+ draw: ({ x, y }) => {
403
+ const top = y - height / 2;
404
+ return [
405
+ // The text knocks a hole in whatever it lands on rather than sitting
406
+ // in a chip of its own: an outlined box reads as a node, which is the
407
+ // one thing a text on a line is not.
408
+ ` <rect x="${round(x - width / 2 - 5)}" y="${round(top)}" width="${round(width + 10)}" height="${round(height)}" fill="${theme.background}"/>`,
409
+ sized(textBlock(lines, x - width / 2, top, textHeight, size, { x: 0, y: 0, width, height }, {
410
+ // A colored edge carries its meaning into its text; an uncolored
411
+ // one leaves the words to read as ordinary text.
412
+ color: textColorOf(edge.textAttrs, theme, lineOf(edge.appearance, theme, theme.text)),
413
+ align: 'middle',
414
+ ink: (run, own) => runInk(run, own, markup, theme),
415
+ }), size, fontSize),
416
+ ];
417
+ },
418
+ };
420
419
  }
421
420
  // The stroke straddles the path, so half of it lies outside the geometry.
422
- const [line, ...rest] = parts;
421
+ const [line] = parts;
423
422
  return {
424
423
  edge,
425
424
  line: line,
426
- rest,
425
+ rest: [],
427
426
  ink: grow(ink, edge.look.thickness / 2),
428
427
  trace: traceOf(line),
429
428
  color,
430
429
  stroke,
430
+ text,
431
431
  };
432
432
  }
433
+ /** The space a text moved off another keeps from it. */
434
+ const TEXT_AIR = 6;
435
+ /**
436
+ * Put every edge's text where it goes. A text sits where its line put it unless
437
+ * that lands on a text placed before it; then it slides along its own line to
438
+ * the nearest spot clear of the other texts and of every box. The line never
439
+ * moves for it. With no clear spot anywhere along the line, it stays put.
440
+ */
441
+ function placeTexts(drawn, nodes) {
442
+ const placed = [];
443
+ const boxAt = (at, text) => ({
444
+ minX: at.x - text.width / 2,
445
+ minY: at.y - text.height / 2,
446
+ maxX: at.x + text.width / 2,
447
+ maxY: at.y + text.height / 2,
448
+ });
449
+ const overlaps = (a, b) => Math.min(a.maxX, b.maxX) - Math.max(a.minX, b.minX) > 0.5 &&
450
+ Math.min(a.maxY, b.maxY) - Math.max(a.minY, b.minY) > 0.5;
451
+ // A box the text is inside, drawn round it, is a container it rides across,
452
+ // not something it lands on.
453
+ const onBox = (area, at) => nodes.some((node) => {
454
+ const box = extentOfBox(faceOf(node));
455
+ const holds = node.children.length > 0 &&
456
+ box.minX < at.x && at.x < box.maxX && box.minY < at.y && at.y < box.maxY;
457
+ return !holds && overlaps(area, box);
458
+ });
459
+ for (const edge of drawn) {
460
+ const text = edge.text;
461
+ if (!text)
462
+ continue;
463
+ let at = text.at;
464
+ if (placed.some((other) => overlaps(other, boxAt(at, text)))) {
465
+ // Moved, it keeps a little air from the others rather than touching them.
466
+ const free = (point) => {
467
+ const area = boxAt(point, text);
468
+ return !placed.some((other) => overlaps(grow(other, TEXT_AIR), area)) && !onBox(area, point);
469
+ };
470
+ at = slideAlong(edge.trace, text.at, free) ?? at;
471
+ }
472
+ const area = boxAt(at, text);
473
+ placed.push(area);
474
+ edge.rest = text.draw(at);
475
+ // Grown by half the stroke as the line's own ink is, so a text at the edge
476
+ // of the page sizes it exactly as it always did.
477
+ edge.ink = union(edge.ink, grow(area, edge.edge.look.thickness / 2));
478
+ }
479
+ }
480
+ /**
481
+ * The point on a polyline nearest `from`, measured along the line, that `free`
482
+ * accepts: tried a few pixels at a time in both directions, nearer first.
483
+ * Undefined when no point along it is accepted.
484
+ */
485
+ function slideAlong(trace, from, free) {
486
+ const lengths = [0];
487
+ for (let index = 1; index < trace.length; index += 1) {
488
+ const [a, b] = [trace[index - 1], trace[index]];
489
+ lengths.push(lengths[index - 1] + Math.hypot(b.x - a.x, b.y - a.y));
490
+ }
491
+ const total = lengths[lengths.length - 1];
492
+ const pointAt = (s) => {
493
+ let index = 1;
494
+ while (index < trace.length - 1 && lengths[index] < s)
495
+ index += 1;
496
+ const [a, b] = [trace[index - 1], trace[index]];
497
+ const piece = lengths[index] - lengths[index - 1];
498
+ const t = piece === 0 ? 0 : (s - lengths[index - 1]) / piece;
499
+ return { x: a.x + (b.x - a.x) * t, y: a.y + (b.y - a.y) * t };
500
+ };
501
+ // Where along the line the text started.
502
+ let start = 0;
503
+ let nearest = Infinity;
504
+ for (let s = 0; s <= total; s += 1) {
505
+ const p = pointAt(s);
506
+ const off = Math.hypot(p.x - from.x, p.y - from.y);
507
+ if (off < nearest) {
508
+ nearest = off;
509
+ start = s;
510
+ }
511
+ }
512
+ const step = 4;
513
+ for (let offset = step; offset <= total; offset += step) {
514
+ for (const s of [start + offset, start - offset]) {
515
+ if (s < 0 || s > total)
516
+ continue;
517
+ const p = pointAt(s);
518
+ if (free(p))
519
+ return p;
520
+ }
521
+ }
522
+ return undefined;
523
+ }
433
524
  /**
434
525
  * The stroke of a line: its color, its thickness and its pattern. The dashes
435
526
  * are measured in thicknesses, so a pattern keeps its proportions on a thick
@@ -1537,250 +1628,295 @@ function planCorridors(edges, ends, measurer, fontSize) {
1537
1628
  return plans;
1538
1629
  }
1539
1630
  /**
1540
- * Route every edge that has to go around its own two boxes.
1631
+ * Every line takes the shortest sensible way between its ends, and a way
1632
+ * through a box is not sensible. Where the direct line — the fewest turns that
1633
+ * leave and arrive head-on, or a plain straight line when no side is named — is
1634
+ * clear of every box, that is the shortest way and it is kept as it is drawn
1635
+ * today. Where it is not, the way is searched for through the open space (see
1636
+ * `search.ts`), and the line is drawn along what was found.
1541
1637
  *
1542
- * An edge leaving the left of one box for the right of another, with the second
1543
- * box further right, has to turn back on itself, and a single curve can only
1544
- * do that by crossing its own boxes. In a row it flattened into a straight line
1545
- * through both; stepped down, even by a `normal` gap, it still doubled back
1546
- * across the first box. So such an edge runs along a channel instead, turning
1547
- * back at each end: in the gap between its two boxes if that holds the line
1548
- * and its text, and otherwise over the top of everything between its ends,
1549
- * with its text on the top, where it cannot land on a box.
1638
+ * A `between` edge and one with clauses are left to their own planning: the
1639
+ * author said which way they go.
1550
1640
  *
1551
- * Over the top, always, and round the right for a column. Nothing here weighs
1552
- * one way round against the other: the shorter way ties in the case that
1553
- * seemed to argue for it, and a default that flips on one box's height is
1554
- * harder to predict than one that never does. The run is placed the way a
1555
- * `between` channel is, measured off where the boxes landed.
1641
+ * Lines searched for are placed shortest first, and each later one keeps a lane
1642
+ * clear of those already placed where they share a stretch, so a longer line
1643
+ * nests outside a shorter one rather than drawing on top of it.
1556
1644
  */
1557
- function planLoops(edges, nodes, ends, corridors, routes, measurer, fontSize) {
1558
- // Loops over the top, gathered so that two sharing a stretch can take a lane
1559
- // each rather than drawing on top of one another.
1560
- const tops = [];
1645
+ function planWays(edges, nodes, ends, corridors, routes, measurer, fontSize) {
1646
+ const wanted = [];
1647
+ // Direct lines that turn twice, across a gap in its middle.
1648
+ const turning = [];
1561
1649
  for (const edge of edges) {
1562
- if (corridors.has(edge) || edge.passes)
1650
+ if (corridors.has(edge) || edge.passes || routes.has(edge))
1563
1651
  continue;
1564
- const { start, end } = ends.get(edge);
1565
- if (start.side === undefined || end.side === undefined)
1652
+ // A line from a container to something inside it lives inside the box it
1653
+ // leaves, and has no way round anything.
1654
+ if (edge.from !== edge.to && (contains(edge.from, edge.to) || contains(edge.to, edge.from)))
1566
1655
  continue;
1567
- // Sides at right angles, one facing away from the other end. Going out
1568
- // past both boxes and coming straight in is right when the boxes are side
1569
- // by side. When the other end lies within the span of the box facing away
1570
- // — stacked boxes — coming straight in would pass through that box, so
1571
- // the curve is kept, which has room to bend round the corner; and if even
1572
- // the curve hits a box, the line goes round the facing-away box on the
1573
- // side it names.
1574
- if (start.tx * end.tx + start.ty * end.ty === 0) {
1575
- const away = awayEnd(edge, start, end);
1576
- if (away && within(faceOf(away.node), away.anchor, away.other)) {
1577
- if (curveHits(edge, ends.get(edge), nodes)) {
1578
- implyRoute(edge, away.node, sidePass(away.anchor.side), nodes, ends, routes, measurer, fontSize);
1656
+ const plan = ends.get(edge);
1657
+ const boxes = obstaclesFor(edge, nodes);
1658
+ const { start, end } = plan;
1659
+ if (start.side !== undefined && end.side !== undefined) {
1660
+ const direct = directWay(start, end);
1661
+ if (direct && wayClear(direct, boxes)) {
1662
+ // The curve is the direct way with its turns swept, and is kept — unless
1663
+ // it bulges into a box the direct way clears, when the way is drawn.
1664
+ if (edge.look.path === 'curved' && curveHits(edge, plan, nodes)) {
1665
+ routes.set(edge, { points: direct, mid: textOnWay(direct, edge, nodes, measurer, fontSize) });
1579
1666
  }
1667
+ if (direct.length === 4)
1668
+ turning.push({ edge, points: direct });
1580
1669
  continue;
1581
1670
  }
1582
- const inWay = nodes.filter((node) => !(contains(node, edge.from) && contains(node, edge.to)));
1583
- const plan = turnBack(edge, start, end, inWay, routes, measurer, fontSize);
1584
- if (plan)
1585
- tops.push(plan);
1586
- continue;
1587
1671
  }
1588
- // Both sides facing the same way, one box behind the other: the line has
1589
- // to get round that box to reach the far side, which a curve cannot. It
1590
- // goes over the top, or round the right for a column, as a loop does.
1591
- if (start.tx === end.tx && start.ty === end.ty) {
1592
- const away = awayEnd(edge, start, end);
1593
- if (away && curveHits(edge, ends.get(edge), nodes)) {
1594
- implyRoute(edge, away.node, start.tx !== 0 ? 'above' : 'right', nodes, ends, routes, measurer, fontSize);
1595
- }
1596
- continue;
1672
+ else if (start.side === undefined && end.side === undefined) {
1673
+ if (wayClear([start, end], boxes))
1674
+ continue;
1597
1675
  }
1598
- // The two ends point opposite ways along one axis, each away from the other.
1599
- if (start.tx !== -end.tx || start.ty !== -end.ty)
1600
- continue;
1601
- const run = start.tx !== 0 ? 'x' : 'y';
1602
- const across = run === 'x' ? 'y' : 'x';
1603
- const outward = run === 'x' ? start.tx : start.ty;
1604
- if (outward * (end[run] - start[run]) >= 0)
1605
- continue;
1606
- const a = faceOf(edge.from);
1607
- const b = faceOf(edge.to);
1608
- // Far enough out that the line reads as passing, and that half the text,
1609
- // centered on the line, still clears the box beside it.
1610
- const clear = Math.max(SEPARATION_GAP, laneExtent(edge, across, measurer, fontSize) / 2 + ATTACH_MARGIN);
1611
- const from = Math.min(lo(a, run), lo(b, run));
1612
- const to = Math.max(hi(a, run), hi(b, run));
1613
- const inWay = nodes.filter((node) => !(contains(node, edge.from) && contains(node, edge.to)));
1614
- const blocked = (low, high) => inWay.some((node) => {
1615
- const face = faceOf(node);
1616
- return (lo(face, run) < to && hi(face, run) > from &&
1617
- lo(face, across) < high && hi(face, across) > low);
1618
- });
1619
- // Boxes apart across the axis have a gap between them, and a line that
1620
- // fits in it turns back through that — the shortest way, and the one a
1621
- // single curve was reaching for.
1622
- const [upper, lower] = lo(a, across) <= lo(b, across) ? [a, b] : [b, a];
1623
- const gap = { lo: hi(upper, across), hi: lo(lower, across) };
1624
- const middle = (gap.lo + gap.hi) / 2;
1625
- if (gap.hi - gap.lo >= clear * 2 && !blocked(middle - clear, middle + clear)) {
1626
- corridors.set(edge, {
1627
- axis: across,
1628
- lane: middle,
1629
- enter: start[run],
1630
- leave: end[run],
1631
- loop: true,
1632
- });
1676
+ else if (!curveHits(edge, plan, nodes)) {
1633
1677
  continue;
1634
1678
  }
1635
- // Otherwise over the top. Measured as distance outward — up for a row,
1636
- // right for a column — so one loop serves both.
1637
- const sign = across === 'y' ? -1 : 1;
1638
- const out = (box) => {
1639
- const [p, q] = [sign * lo(box, across), sign * hi(box, across)];
1640
- return [Math.min(p, q), Math.max(p, q)];
1641
- };
1642
- const inner = Math.min(sign * start[across], sign * end[across]);
1643
- // A box that shares the stretch and reaches into the band the line needs
1644
- // pushes the line out past it, which can bring another into the band.
1645
- const pushOut = (start) => {
1646
- let lane = start;
1647
- for (let moved = true; moved;) {
1648
- moved = false;
1649
- for (const node of inWay) {
1650
- const face = faceOf(node);
1651
- if (lo(face, run) >= to || hi(face, run) <= from)
1652
- continue;
1653
- const [near, far] = out(face);
1654
- if (far + clear > lane && near < lane + clear && far > inner) {
1655
- lane = far + clear;
1656
- moved = true;
1657
- }
1658
- }
1659
- }
1660
- return lane;
1661
- };
1662
- tops.push({
1663
- edge,
1664
- across,
1665
- sign,
1666
- lane: pushOut(Math.max(out(a)[1], out(b)[1]) + clear),
1667
- loop: true,
1668
- from,
1669
- to,
1670
- height: sign * start[across] + sign * end[across],
1671
- pushOut,
1672
- place: (lane) => corridors.set(edge, { axis: across, lane: sign * lane, enter: start[run], leave: end[run], loop: true }),
1673
- });
1679
+ wanted.push(edge);
1674
1680
  }
1675
- // Lines over one stretch nest so they do not cross. A turn-back goes inside
1676
- // any loop, because it comes in to a box from the side the loops pass over;
1677
- // otherwise the shorter goes inside, and of two the same length, the one
1678
- // whose ends sit further out. Each lane is as far from the one inside it as
1679
- // the lanes of a named gap are.
1680
- tops.sort((p, q) => Number(p.loop) - Number(q.loop) || p.to - p.from - (q.to - q.from) || q.height - p.height);
1681
- const placed = [];
1682
- for (const top of tops) {
1683
- for (let moved = true; moved;) {
1684
- moved = false;
1685
- for (const other of placed) {
1686
- if (other.across !== top.across || other.sign !== top.sign)
1687
- continue;
1688
- if (other.from >= top.to || top.from >= other.to)
1689
- continue;
1690
- const step = Math.max(ATTACH_STEP, laneExtent(top.edge, top.across, measurer, fontSize), laneExtent(other.edge, other.across, measurer, fontSize));
1691
- if (Math.abs(top.lane - other.lane) < step - 0.5) {
1692
- top.lane = top.pushOut(other.lane + step);
1693
- moved = true;
1694
- }
1695
- }
1681
+ shareGaps(turning, routes, nodes, measurer, fontSize);
1682
+ const reach = (edge) => {
1683
+ const { start, end } = ends.get(edge);
1684
+ return Math.abs(end.x - start.x) + Math.abs(end.y - start.y);
1685
+ };
1686
+ wanted.sort((p, q) => reach(p) - reach(q));
1687
+ const taken = [];
1688
+ for (const edge of wanted) {
1689
+ const way = searchWay(edge, nodes, ends.get(edge), taken);
1690
+ if (!way) {
1691
+ throw new SourceError(blockedMessage(edge, ends.get(edge), nodes), edge.line);
1696
1692
  }
1697
- placed.push(top);
1698
- top.place(top.lane);
1693
+ ends.set(edge, { start: way.start, end: way.end });
1694
+ routes.set(edge, { points: way.points, mid: textOnWay(way.points, edge, nodes, measurer, fontSize) });
1695
+ taken.push(way.points);
1699
1696
  }
1700
1697
  }
1701
1698
  /**
1702
- * A route for an edge whose named sides are at right angles and one of which
1703
- * faces away from the other end: `from: left to: top` with the far node to
1704
- * the right. A single curve leaving that side can only turn back across its
1705
- * own box. So the line steps out of the side facing away, goes out past both
1706
- * boxes the way the other side faces, runs along there, and comes straight in
1707
- * to the other side. Undefined when neither side faces away, where the curve
1708
- * already reads right. The route is recorded in `routes` once its lane is
1709
- * settled against any other line running outside the same boxes.
1699
+ * Direct lines that turn in the middle of the same gap and share some of it
1700
+ * take a lane each, side by side, in the order that keeps them from crossing:
1701
+ * of lines heading down a gap, the one whose ends are both higher goes further
1702
+ * across, so it turns in above the other's arrival and the other turns down
1703
+ * before reaching it. Each is then drawn along its lane, its turns swept.
1710
1704
  */
1711
- function turnBack(edge, start, end, inWay, routes, measurer, fontSize) {
1712
- const facesAway = (from, to) => from.tx * (to.x - from.x) + from.ty * (to.y - from.y) < 0;
1713
- const startAway = facesAway(start, end);
1714
- if (!startAway && !facesAway(end, start))
1715
- return undefined;
1716
- // Planned from the end facing away, and turned round if that is the far end.
1717
- const [away, other] = startAway ? [start, end] : [end, start];
1718
- const [awayBox, otherBox] = (startAway ? [edge.from, edge.to] : [edge.to, edge.from]).map(faceOf);
1719
- const run = away.tx !== 0 ? 'x' : 'y';
1720
- const across = run === 'x' ? 'y' : 'x';
1721
- const make = axesAcross(across).make;
1722
- // The way the other side faces, which is the way the line goes out.
1723
- const sign = across === 'y' ? other.ty : other.tx;
1724
- const clear = Math.max(SEPARATION_GAP, laneExtent(edge, across, measurer, fontSize) / 2 + ATTACH_MARGIN);
1725
- const stub = away[run] + (run === 'x' ? away.tx : away.ty) * ROUTE_STUB;
1726
- const from = Math.min(stub, other[run], lo(awayBox, run), lo(otherBox, run));
1727
- const to = Math.max(stub, other[run], hi(awayBox, run), hi(otherBox, run));
1728
- // Measured as distance outward, the way the line goes.
1729
- const out = (box) => {
1730
- const [p, q] = [sign * lo(box, across), sign * hi(box, across)];
1731
- return [Math.min(p, q), Math.max(p, q)];
1705
+ function shareGaps(turning, routes, nodes, measurer, fontSize) {
1706
+ // The middle piece runs down (x fixed) when the first piece runs across.
1707
+ const upright = (points) => Math.abs(points[0].y - points[1].y) < 0.5;
1708
+ const level = (points) => (upright(points) ? points[1].x : points[1].y);
1709
+ const span = (points) => {
1710
+ const [a, b] = upright(points) ? [points[1].y, points[2].y] : [points[1].x, points[2].x];
1711
+ return [Math.min(a, b), Math.max(a, b)];
1732
1712
  };
1733
- const inner = Math.min(sign * away[across], sign * other[across]);
1734
- const pushOut = (start) => {
1735
- let level = start;
1736
- for (let moved = true; moved;) {
1737
- moved = false;
1738
- for (const node of inWay) {
1739
- const face = faceOf(node);
1740
- if (lo(face, run) >= to || hi(face, run) <= from)
1741
- continue;
1742
- const [near, far] = out(face);
1743
- if (far + clear > level && near < level + clear && far > inner) {
1744
- level = far + clear;
1745
- moved = true;
1746
- }
1747
- }
1713
+ const groups = [];
1714
+ for (const line of turning) {
1715
+ const group = groups.find((members) => members.some((other) => {
1716
+ if (upright(other.points) !== upright(line.points))
1717
+ return false;
1718
+ if (Math.abs(level(other.points) - level(line.points)) > 0.5)
1719
+ return false;
1720
+ const [a, b] = span(other.points);
1721
+ const [c, d] = span(line.points);
1722
+ return Math.min(b, d) - Math.max(a, c) > 0.5;
1723
+ }));
1724
+ if (group)
1725
+ group.push(line);
1726
+ else
1727
+ groups.push([line]);
1728
+ }
1729
+ for (const group of groups) {
1730
+ if (group.length < 2)
1731
+ continue;
1732
+ const first = group[0].points;
1733
+ const down = upright(first);
1734
+ // Which way the lines head across the gap, and how wide the gap is.
1735
+ const heading = Math.sign(down ? first[1].x - first[0].x : first[1].y - first[0].y);
1736
+ const width = Math.abs(down ? first[3].x - first[0].x : first[3].y - first[0].y);
1737
+ const along = (p) => (down ? p.y : p.x);
1738
+ const key = ({ points }) => {
1739
+ const travel = Math.sign(along(points[3]) - along(points[0]));
1740
+ return travel * (along(points[0]) + along(points[3]));
1741
+ };
1742
+ // Outermost — furthest the way the lines head — first.
1743
+ const ordered = [...group].sort((p, q) => key(p) - key(q));
1744
+ const step = Math.min(ATTACH_STEP, width / (group.length + 1));
1745
+ const middle = level(first);
1746
+ ordered.forEach(({ edge, points }, index) => {
1747
+ const lane = middle + heading * step * ((group.length - 1) / 2 - index);
1748
+ const [s, , , e] = points;
1749
+ const laned = down
1750
+ ? [s, { x: lane, y: s.y }, { x: lane, y: e.y }, e]
1751
+ : [s, { x: s.x, y: lane }, { x: e.x, y: lane }, e];
1752
+ routes.set(edge, { points: laned, mid: textOnWay(laned, edge, nodes, measurer, fontSize) });
1753
+ });
1754
+ }
1755
+ }
1756
+ /**
1757
+ * The boxes a line must stay out of, as extents: every node that holds neither
1758
+ * end, and the line's own two, shrunk a pixel so it may start and end on their
1759
+ * sides. A container holding one end is not among them — the line crosses its
1760
+ * border on the way out.
1761
+ */
1762
+ function obstaclesFor(edge, nodes) {
1763
+ return [
1764
+ // A circle's ends sit inside its square extent, so the square cannot stand
1765
+ // for it here; a line meeting a circle square on does not cross it anyway.
1766
+ ...[edge.from, edge.to]
1767
+ .filter((node) => !faceOf(node).round)
1768
+ .map((node) => grow(extentOfBox(faceOf(node)), -1)),
1769
+ ...nodes
1770
+ .filter((node) => !contains(node, edge.from) && !contains(node, edge.to))
1771
+ .map((node) => extentOfBox(faceOf(node))),
1772
+ ];
1773
+ }
1774
+ /** Whether a line through these points passes through none of the boxes. */
1775
+ function wayClear(points, boxes) {
1776
+ for (let index = 0; index + 1 < points.length; index += 1) {
1777
+ if (boxes.some((box) => segmentHits(points[index], points[index + 1], box)))
1778
+ return false;
1779
+ }
1780
+ return true;
1781
+ }
1782
+ /**
1783
+ * The direct way between two named sides: straight across when they face each
1784
+ * other in line, two turns in the middle when they face each other offset, one
1785
+ * turn when they are at right angles and the corner lies ahead of both.
1786
+ * Undefined when there is no such way — the far end is behind a side, or both
1787
+ * sides face the same way — and the line has to go round something.
1788
+ */
1789
+ function directWay(start, end) {
1790
+ // Which way each side faces, square to the side even where a circle's point
1791
+ // leans off its compass point.
1792
+ const s = endOf(start);
1793
+ const e = endOf(end);
1794
+ const out = { x: s.dx, y: s.dy };
1795
+ const back = { x: e.dx, y: e.dy };
1796
+ const across = out.x !== 0;
1797
+ const ahead = (p, q, d) => (q.x - p.x) * d.x + (q.y - p.y) * d.y;
1798
+ if (across === (back.x !== 0)) {
1799
+ const facing = out.x * back.x + out.y * back.y < 0;
1800
+ if (!facing || ahead(start, end, out) <= 0)
1801
+ return undefined;
1802
+ if (Math.abs(across ? end.y - start.y : end.x - start.x) < 0.5)
1803
+ return [start, end];
1804
+ const m = across ? (start.x + end.x) / 2 : (start.y + end.y) / 2;
1805
+ return across
1806
+ ? [start, { x: m, y: start.y }, { x: m, y: end.y }, end]
1807
+ : [start, { x: start.x, y: m }, { x: end.x, y: m }, end];
1808
+ }
1809
+ const corner = across ? { x: end.x, y: start.y } : { x: start.x, y: end.y };
1810
+ if (ahead(start, corner, out) > 0 && ahead(end, corner, back) > 0)
1811
+ return [start, corner, end];
1812
+ return undefined;
1813
+ }
1814
+ /**
1815
+ * Where a line's text rides: the middle of its longest piece on which the text
1816
+ * touches no box, or of its longest piece if it touches one on every piece. The
1817
+ * text never moves the line — it fits itself onto the line it is given.
1818
+ */
1819
+ function textOnWay(points, edge, nodes, measurer, fontSize) {
1820
+ const pieces = [];
1821
+ for (let index = 0; index + 1 < points.length; index += 1) {
1822
+ const [p, q] = [points[index], points[index + 1]];
1823
+ pieces.push({ mid: { x: (p.x + q.x) / 2, y: (p.y + q.y) / 2 }, length: Math.hypot(q.x - p.x, q.y - p.y) });
1824
+ }
1825
+ // Longest first, and of two the same length, the earlier.
1826
+ pieces.sort((p, q) => q.length - p.length > 0.5 ? 1 : p.length - q.length > 0.5 ? -1 : 0);
1827
+ if (edge.lines === undefined)
1828
+ return pieces[0].mid;
1829
+ const half = { x: laneExtent(edge, 'x', measurer, fontSize) / 2, y: laneExtent(edge, 'y', measurer, fontSize) / 2 };
1830
+ const clear = pieces.find(({ mid }) => nodes.every((node) => {
1831
+ const box = extentOfBox(faceOf(node));
1832
+ // A container the text sits inside is not something it lands on.
1833
+ const holds = box.minX < mid.x && mid.x < box.maxX && box.minY < mid.y && mid.y < box.maxY &&
1834
+ node.children.length > 0;
1835
+ return holds ||
1836
+ mid.x + half.x <= box.minX || mid.x - half.x >= box.maxX ||
1837
+ mid.y + half.y <= box.minY || mid.y - half.y >= box.maxY;
1838
+ }));
1839
+ return (clear ?? pieces[0]).mid;
1840
+ }
1841
+ /** How much a turn costs the search, in the same units as length. */
1842
+ const SEARCH_TURN = SEPARATION_GAP * 2;
1843
+ /**
1844
+ * The searched way for one edge. A named side is fixed; an end with no side
1845
+ * named may leave or arrive by any of its box's four, at that side's middle,
1846
+ * and takes whichever makes the way cheapest.
1847
+ */
1848
+ function searchWay(edge, nodes, { start, end }, taken) {
1849
+ const holders = (node) => nodes.filter((other) => contains(other, node));
1850
+ const walls = new Map();
1851
+ for (const node of nodes) {
1852
+ // A container holding an end — one or both — is crossed, or lived inside,
1853
+ // not gone round. The line's own boxes are walls, a loop back to one box
1854
+ // included.
1855
+ if (node !== edge.from && node !== edge.to && (contains(node, edge.from) || contains(node, edge.to)))
1856
+ continue;
1857
+ walls.set(node, wallOf(grow(extentOfBox(faceOf(node)), ATTACH_MARGIN)));
1858
+ }
1859
+ const ownOf = (node) => holders(node).map((holder) => walls.get(holder)).filter((wall) => wall !== undefined);
1860
+ const choices = (anchor, node) => anchor.side !== undefined
1861
+ ? [anchor]
1862
+ : ATTACH_SIDES.map((side) => {
1863
+ const center = centerOf(faceOf(node));
1864
+ return anchorOn(faceOf(node), side, side === 'top' || side === 'bottom' ? center.x : center.y);
1865
+ });
1866
+ const stub = ROUTE_STUB;
1867
+ let best;
1868
+ for (const from of choices(start, edge.from)) {
1869
+ for (const to of choices(end, edge.to)) {
1870
+ const found = searchRoute(endOf(from), endOf(to), [...walls.values()], {
1871
+ stub,
1872
+ clear: SEPARATION_GAP - ATTACH_MARGIN,
1873
+ turn: SEARCH_TURN,
1874
+ ownStart: ownOf(edge.from),
1875
+ ownEnd: ownOf(edge.to),
1876
+ taken,
1877
+ lane: ATTACH_STEP,
1878
+ });
1879
+ if (found && (!best || found.cost < best.cost - 1e-6))
1880
+ best = { start: from, end: to, ...found };
1748
1881
  }
1749
- return level;
1882
+ }
1883
+ return best;
1884
+ }
1885
+ /** An end for the search, heading straight out of its side: a circle's point off its compass point still leaves square to the side. */
1886
+ function endOf(anchor) {
1887
+ const outward = {
1888
+ top: { x: 0, y: -1 },
1889
+ bottom: { x: 0, y: 1 },
1890
+ left: { x: -1, y: 0 },
1891
+ right: { x: 1, y: 0 },
1750
1892
  };
1751
- return {
1752
- edge,
1753
- across,
1754
- sign,
1755
- lane: pushOut(Math.max(out(awayBox)[1], out(otherBox)[1]) + clear),
1756
- loop: false,
1757
- from,
1758
- to,
1759
- height: sign * away[across] + sign * other[across],
1760
- pushOut,
1761
- place: (lane) => {
1762
- const at = sign * lane;
1763
- // A shallow turn steps out no further than half its depth, the half
1764
- // circle a loop's turn makes, so it stays inside any loop turning
1765
- // round the same box.
1766
- const outward = run === 'x' ? away.tx : away.ty;
1767
- const step = away[run] + outward * Math.min(ROUTE_STUB, Math.abs(at - away[across]) / 2);
1768
- const points = tidyRoute([away, make(step, away[across]), make(step, at), make(other[run], at), other]);
1769
- // The text rides on the longest piece, which is the run outside the
1770
- // boxes unless the two ends are nearly level.
1771
- let mid = make((stub + other[run]) / 2, at);
1772
- let best = -1;
1773
- for (let index = 0; index + 1 < points.length; index += 1) {
1774
- const [p, q] = [points[index], points[index + 1]];
1775
- const length = Math.hypot(q.x - p.x, q.y - p.y);
1776
- if (length > best) {
1777
- best = length;
1778
- mid = { x: (p.x + q.x) / 2, y: (p.y + q.y) / 2 };
1779
- }
1780
- }
1781
- routes.set(edge, { points: startAway ? points : points.reverse(), mid });
1782
- },
1893
+ const t = anchor.side !== undefined ? outward[anchor.side] : { x: Math.sign(anchor.tx), y: Math.sign(anchor.ty) };
1894
+ return { x: anchor.x, y: anchor.y, dx: t.x, dy: t.y };
1895
+ }
1896
+ function wallOf(extent) {
1897
+ return { minX: extent.minX, minY: extent.minY, maxX: extent.maxX, maxY: extent.maxY };
1898
+ }
1899
+ /**
1900
+ * Why an edge has no way through, naming the box in the way where one box
1901
+ * explains it: a box against the side a line has to leave or arrive by.
1902
+ */
1903
+ function blockedMessage(edge, { start, end }, nodes) {
1904
+ const subject = `edge ${edge.from.name} -> ${edge.to.name}`;
1905
+ const against = (anchor, own) => {
1906
+ if (anchor.side === undefined)
1907
+ return undefined;
1908
+ // Room to get out is room for both boxes' margins; anything closer is against it.
1909
+ const room = ATTACH_MARGIN * 2;
1910
+ const tip = { x: anchor.x + anchor.tx * room, y: anchor.y + anchor.ty * room };
1911
+ return nodes.find((node) => !contains(node, own) && segmentHits(anchor, tip, extentOfBox(faceOf(node))));
1783
1912
  };
1913
+ const first = against(start, edge.from);
1914
+ if (first)
1915
+ return `${subject}: cannot leave ${edge.from.name}'s ${start.side} side, ${first.name} is against it`;
1916
+ const last = against(end, edge.to);
1917
+ if (last)
1918
+ return `${subject}: cannot arrive at ${edge.to.name}'s ${end.side} side, ${last.name} is against it`;
1919
+ return `${subject}: every way from ${edge.from.name} to ${edge.to.name} passes through another box. Give them room, or name other sides`;
1784
1920
  }
1785
1921
  /**
1786
1922
  * Whether the single curve an edge would otherwise be drawn as passes through
@@ -1793,78 +1929,53 @@ function turnBack(edge, start, end, inWay, routes, measurer, fontSize) {
1793
1929
  * such edge the long way round with square corners, which the user rejected
1794
1930
  * outright.
1795
1931
  */
1796
- function curveHits(edge, { start, end, bow }, nodes) {
1797
- const reach = controlReach(start, end);
1798
- const lift = 4 / 3;
1799
- const bx = (bow?.x ?? 0) * lift;
1800
- const by = (bow?.y ?? 0) * lift;
1801
- const c1 = { x: start.x + start.tx * reach + bx, y: start.y + start.ty * reach + by };
1802
- const c2 = { x: end.x + end.tx * reach + bx, y: end.y + end.ty * reach + by };
1932
+ function curveHits(edge, ends, nodes) {
1933
+ const curve = sideCurve(edge, ends);
1934
+ const { p0, c1, c2, p3 } = curve;
1803
1935
  const at = (t) => {
1804
1936
  const u = 1 - t;
1805
1937
  return {
1806
- x: u * u * u * start.x + 3 * u * u * t * c1.x + 3 * u * t * t * c2.x + t * t * t * end.x,
1807
- y: u * u * u * start.y + 3 * u * u * t * c1.y + 3 * u * t * t * c2.y + t * t * t * end.y,
1938
+ x: u * u * u * p0.x + 3 * u * u * t * c1.x + 3 * u * t * t * c2.x + t * t * t * p3.x,
1939
+ y: u * u * u * p0.y + 3 * u * u * t * c1.y + 3 * u * t * t * c2.y + t * t * t * p3.y,
1808
1940
  };
1809
1941
  };
1810
1942
  // The line starts and ends on its own boxes' sides, so those are shrunk a
1811
1943
  // pixel to let it touch them there.
1812
- const boxes = [
1813
- ...[edge.from, edge.to].map((node) => grow(extentOfBox(faceOf(node)), -1)),
1814
- ...nodes
1815
- .filter((node) => !contains(node, edge.from) && !contains(node, edge.to))
1816
- .map((node) => extentOfBox(faceOf(node))),
1817
- ];
1944
+ const boxes = obstaclesFor(edge, nodes);
1818
1945
  const steps = 32;
1819
- let previous = start;
1820
- for (let step = 1; step <= steps; step += 1) {
1821
- const point = at(step / steps);
1822
- if (boxes.some((box) => segmentHits(previous, point, box)))
1823
- return true;
1824
- previous = point;
1825
- }
1826
- return false;
1946
+ const points = [curve.start, ...Array.from({ length: steps + 1 }, (_, step) => at(step / steps)), curve.end];
1947
+ return !wayClear(points, boxes);
1827
1948
  }
1828
1949
  /**
1829
- * The end of an edge whose side faces away from the other end, with its node
1830
- * and the other end's anchor; the start if both do.
1950
+ * The curve a line with a named side is drawn as. Its handles leave along each
1951
+ * side's outward normal. At an end carrying an arrowhead the curve stops an
1952
+ * arrowhead's length out and the line finishes straight, so the head sits on a
1953
+ * straight piece pointing square into the side rather than on a line still
1954
+ * bending.
1831
1955
  */
1832
- function awayEnd(edge, start, end) {
1833
- const facesAway = (from, to) => from.tx * (to.x - from.x) + from.ty * (to.y - from.y) < 0;
1834
- if (facesAway(start, end))
1835
- return { node: edge.from, anchor: start, other: end };
1836
- if (facesAway(end, start))
1837
- return { node: edge.to, anchor: end, other: start };
1838
- return undefined;
1839
- }
1840
- /**
1841
- * Whether a line coming straight in to `other`, across the way `anchor`'s side
1842
- * faces, would pass through `box` — that is, `other` lies within the box's
1843
- * span along that way.
1844
- */
1845
- function within(box, anchor, other) {
1846
- const run = anchor.tx !== 0 ? 'x' : 'y';
1847
- return lo(box, run) < other[run] && other[run] < hi(box, run);
1848
- }
1849
- /** The clause that passes a node on the side named. */
1850
- function sidePass(side) {
1851
- return side === 'top' ? 'above' : side === 'bottom' ? 'below' : side;
1852
- }
1853
- /**
1854
- * Route an edge as if it carried one clause the author did not write, passing
1855
- * `node` on that side. If that cannot be drawn, the edge keeps its curve: a
1856
- * refusal would name a clause that is not in the file, and would refuse a file
1857
- * earlier versions drew.
1858
- */
1859
- function implyRoute(edge, node, direction, nodes, ends, routes, measurer, fontSize) {
1860
- const written = `${direction === 'left' || direction === 'right' ? `${direction} of` : direction} ${node.name}`;
1861
- try {
1862
- routes.set(edge, planRoute(edge, [{ direction, nodes: [node], written }], nodes, ends.get(edge), measurer, fontSize));
1863
- }
1864
- catch (error) {
1865
- if (!(error instanceof SourceError))
1866
- throw error;
1867
- }
1956
+ function sideCurve(edge, { start, end, bow }) {
1957
+ const head = arrowLength(edge.look.thickness);
1958
+ const tip = (anchor, arrowed) => arrowed && anchor.side !== undefined
1959
+ ? { x: anchor.x + anchor.tx * head, y: anchor.y + anchor.ty * head }
1960
+ : { x: anchor.x, y: anchor.y };
1961
+ const p0 = tip(start, edge.both);
1962
+ const p3 = tip(end, true);
1963
+ const reach = controlReach(start, end);
1964
+ // A bundle whose sides were too short to spread it takes the rest of the
1965
+ // room in the middle — see `bowBundles`. Displacing both control points
1966
+ // equally moves the curve's middle by three quarters as much, so the bow is
1967
+ // scaled up by the inverse of that.
1968
+ const lift = 4 / 3;
1969
+ const bx = (bow?.x ?? 0) * lift;
1970
+ const by = (bow?.y ?? 0) * lift;
1971
+ return {
1972
+ start,
1973
+ p0,
1974
+ c1: { x: start.x + start.tx * reach + bx, y: start.y + start.ty * reach + by },
1975
+ c2: { x: end.x + end.tx * reach + bx, y: end.y + end.ty * reach + by },
1976
+ p3,
1977
+ end,
1978
+ };
1868
1979
  }
1869
1980
  /** The most a route's corner is rounded by. */
1870
1981
  const ROUTE_RADIUS = 20;
@@ -2464,11 +2575,10 @@ function corridorPath(start, end, plan) {
2464
2575
  // Along the run the line travels one way, so that is its tangent at both ends
2465
2576
  // of the straight stretch — it enters the gap already going where the gap goes.
2466
2577
  const rt = plan.axis === 'y' ? { tx: forward, ty: 0 } : { tx: 0, ty: forward };
2467
- const reach = plan.loop ? loopReach : corridorReach;
2468
- const r1 = reach(start, p1, plan.axis);
2578
+ const r1 = corridorReach(start, p1, plan.axis);
2469
2579
  const c1 = { x: start.x + start.tx * r1, y: start.y + start.ty * r1 };
2470
2580
  const c2 = { x: p1.x - rt.tx * r1, y: p1.y - rt.ty * r1 };
2471
- const r2 = reach(p2, end, plan.axis);
2581
+ const r2 = corridorReach(p2, end, plan.axis);
2472
2582
  const c3 = { x: p2.x + rt.tx * r2, y: p2.y + rt.ty * r2 };
2473
2583
  const c4 = { x: end.x + end.tx * r2, y: end.y + end.ty * r2 };
2474
2584
  const d = [
@@ -2491,15 +2601,6 @@ function corridorReach(from, to, axis) {
2491
2601
  const distance = Math.hypot(to.x - from.x, to.y - from.y);
2492
2602
  return Math.min(140, Math.max(8, Math.min(distance * 0.4, run / 2)));
2493
2603
  }
2494
- /**
2495
- * How far the handles reach on the turn at either end of a loop. The turn
2496
- * leaves heading one way and joins the run heading the other, over the depth
2497
- * between the side and the run, so it is a half circle on that depth — and a
2498
- * cubic comes closest to a half circle with handles two thirds of its diameter.
2499
- */
2500
- function loopReach(from, to, axis) {
2501
- return (Math.abs(axis === 'y' ? to.y - from.y : to.x - from.x) * 2) / 3;
2502
- }
2503
2604
  function sideAttr(edge, key) {
2504
2605
  const value = edge.attrs[key];
2505
2606
  if (value === undefined)