reladraw 0.11.1 → 0.13.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
@@ -36,11 +36,25 @@ export function render(layout, options = {}) {
36
36
  // the same reason in the other direction: which lane of a gap an edge takes
37
37
  // is ordered by where its ends turned out to be.
38
38
  const ends = planEndpoints(layout.edges, measurer, fontSize);
39
- const corridors = planCorridors(layout.edges, ends, measurer, fontSize);
40
- const routes = planRoutes(layout.edges, layout.nodes, ends, measurer, fontSize);
41
- planWays(layout.edges, layout.nodes, ends, corridors, routes, measurer, fontSize);
42
- aimFreeEnds(layout.edges, ends, corridors);
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));
39
+ const corridors = planCorridors(layout.edges, ends);
40
+ const routes = planWays(layout.edges, layout.nodes, ends, corridors, measurer, fontSize);
41
+ // A curved line sweeps along its way, kept off every other line: off the
42
+ // sweeps already drawn, and off the ways of those still to come. Routes are
43
+ // in the order they were placed, inside lanes first, so a line outside
44
+ // another follows the curve of the one inside it rather than its corners.
45
+ const sweeps = new Map();
46
+ const swept = [];
47
+ for (const [edge, route] of routes) {
48
+ if (edge.look.path !== 'curved')
49
+ continue;
50
+ const coming = [...routes]
51
+ .filter(([other]) => other !== edge && !sweeps.has(other))
52
+ .map(([, way]) => way.points);
53
+ const d = sweptPath(route.points, sweepObstacles(edge, layout.nodes), [...swept, ...coming], arrowLength(edge.look.thickness), edge.both);
54
+ sweeps.set(edge, d);
55
+ swept.push(traceOf(`d="${d}"`));
56
+ }
57
+ const drawn = layout.edges.map((edge) => drawEdge(edge, ends.get(edge), routes.get(edge), sweeps.get(edge), layout.nodes, theme, measurer, fontSize, layout.markup));
44
58
  placeTexts(drawn, layout.nodes);
45
59
  // A line with a `crossing:` style is cut where it crosses an earlier one, and
46
60
  // the jump drawn over the cut. The cut is a mask rather than a break in the
@@ -328,7 +342,7 @@ function drawIcon(icon, x, y, side, theme) {
328
342
  ' </g>',
329
343
  ].join('\n');
330
344
  }
331
- function drawEdge(edge, ends, corridor, route, nodes, theme, measurer, fontSize, markup) {
345
+ function drawEdge(edge, ends, route, sweep, nodes, theme, measurer, fontSize, markup) {
332
346
  const { start, end } = ends;
333
347
  const color = lineOf(edge.appearance, theme, theme.edge);
334
348
  const stroke = strokeOf(color, edge.look);
@@ -346,7 +360,7 @@ function drawEdge(edge, ends, corridor, route, nodes, theme, measurer, fontSize,
346
360
  let ink = extentOfPoints([start, end]);
347
361
  let midX;
348
362
  let midY;
349
- const jointed = edge.look.path !== 'curved' && (route || corridor || curved || bowed);
363
+ const jointed = edge.look.path !== 'curved' && (route || curved || bowed);
350
364
  if (jointed) {
351
365
  // `path: square` and `path: straight` are the same fixed points — the ends,
352
366
  // which way each faces, the run a clause asked for — joined by a different
@@ -354,7 +368,7 @@ function drawEdge(edge, ends, corridor, route, nodes, theme, measurer, fontSize,
354
368
  const textWidth = edge.lines === undefined
355
369
  ? 0
356
370
  : widestLine(edge.lines, measurer, fontSizeFor('edge', edge.textAttrs, fontSize, edge.line));
357
- const plan = jointedLine(edge, ends, corridor, route, nodes, textWidth);
371
+ const plan = jointedLine(edge, ends, route, nodes, textWidth);
358
372
  const d = edge.look.corners === 'rounded'
359
373
  ? roundedPath(plan.points, arrowLength(edge.look.thickness))
360
374
  : sharpPath(plan.points);
@@ -364,20 +378,15 @@ function drawEdge(edge, ends, corridor, route, nodes, theme, measurer, fontSize,
364
378
  midY = plan.mid.y;
365
379
  }
366
380
  else if (route) {
367
- parts.push(` <path d="${roundedPath(route.points)}" fill="none"${stroke}${markerEnd}${markerStart}/>`);
381
+ // A curved line along a found way sweeps through each turn, as widely as
382
+ // the room around the turn allows — worked out for every line at once, in
383
+ // `render`, since each sweep keeps off the others.
384
+ parts.push(` <path d="${sweep}" fill="none"${stroke}${markerEnd}${markerStart}/>`);
385
+ // A sweep cuts inside its corners, so the way's own points bound it.
368
386
  ink = union(ink, extentOfPoints(route.points));
369
387
  midX = route.mid.x;
370
388
  midY = route.mid.y;
371
389
  }
372
- else if (corridor) {
373
- const path = corridorPath(start, end, corridor);
374
- ink = union(ink, path.ink);
375
- parts.push(` <path d="${path.d}" fill="none"${stroke}${markerEnd}${markerStart}/>`);
376
- // The text goes on the straight run rather than at the midpoint of the
377
- // whole path, so it sits in the gap the author asked the line to travel.
378
- midX = path.mid.x;
379
- midY = path.mid.y;
380
- }
381
390
  else if (curved) {
382
391
  const { p0, c1, c2, p3 } = sideCurve(edge, ends);
383
392
  const lead = p0.x !== start.x || p0.y !== start.y ? `M ${round(start.x)} ${round(start.y)} L` : 'M';
@@ -574,7 +583,7 @@ function strokeOf(color, look) {
574
583
  * point, and bends only where a clause — `below c`, `between a and b` — puts a
575
584
  * point it has to pass through.
576
585
  */
577
- function jointedLine(edge, ends, corridor, route, nodes, textWidth) {
586
+ function jointedLine(edge, ends, route, nodes, textWidth) {
578
587
  const { start, end } = ends;
579
588
  const square = edge.look.path === 'square';
580
589
  const stub = ROUTE_RADIUS + arrowLength(edge.look.thickness);
@@ -598,20 +607,6 @@ function jointedLine(edge, ends, corridor, route, nodes, textWidth) {
598
607
  ]);
599
608
  return { points, mid: route.mid };
600
609
  }
601
- if (corridor) {
602
- const p1 = corridorPoint(corridor, corridor.enter);
603
- const p2 = corridorPoint(corridor, corridor.leave);
604
- const mid = { x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2 };
605
- if (!square)
606
- return { points: tidyRoute([start, p1, p2, end]), mid };
607
- const forward = corridor.leave >= corridor.enter ? 1 : -1;
608
- const run = corridor.axis === 'y' ? { x: forward, y: 0 } : { x: 0, y: forward };
609
- const points = tidyRoute([
610
- ...rightAngles(start, headingOf(start, fromFace), p1, { x: -run.x, y: -run.y }, stub),
611
- ...rightAngles(p2, run, end, headingOf(end, toFace), stub),
612
- ]);
613
- return { points, mid };
614
- }
615
610
  const bow = ends.bow ?? { x: 0, y: 0 };
616
611
  const bowed = bow.x !== 0 || bow.y !== 0;
617
612
  if (!square) {
@@ -1239,9 +1234,9 @@ function planBundles(edges, measurer, fontSize) {
1239
1234
  * therefore derived rather than styled, and it is zero whenever the edge was
1240
1235
  * long enough, which is why the ordinary case is still a straight line.
1241
1236
  *
1242
- * A `between` edge is left out. Its route is the corridor it named, its lane
1243
- * inside that corridor is `planCorridors`' business, and `aimFreeEnds` will
1244
- * re-aim these ends at the corridor afterwards regardless.
1237
+ * A `between` edge is left out. Its way through the gap it named, lane
1238
+ * included, is searched for in `planWays`, which picks a side for any end
1239
+ * naming none.
1245
1240
  */
1246
1241
  function planSpreads(edges, measurer, fontSize) {
1247
1242
  const ids = new Map();
@@ -1591,67 +1586,31 @@ function gapBetween(a, b, aName, bName, wanted, line) {
1591
1586
  };
1592
1587
  }
1593
1588
  /**
1594
- * Route every edge that named a gap.
1589
+ * The gap each `between` edge named, and where along it the line must cross.
1595
1590
  *
1596
- * Edges sharing one gap share its lanes, spread like attachments on a side and
1597
- * ordered the same derived way — by where their ends actually sit, so the two
1598
- * arriving at the hub's left edge in one order run through the corridor in that
1599
- * same order and never cross.
1591
+ * Only the gap: which lane a line takes through it, and how far it keeps from
1592
+ * the others there, is the search's, as it is for every line (see `planWays`).
1600
1593
  */
1601
- function planCorridors(edges, ends, measurer, fontSize) {
1594
+ function planCorridors(edges, ends) {
1602
1595
  const plans = new Map();
1603
- const groups = new Map();
1604
1596
  for (const edge of edges) {
1605
1597
  if (!edge.between)
1606
1598
  continue;
1607
1599
  const [first, second] = edge.between.nodes;
1608
- const gap = gapBetween(faceOf(first), faceOf(second), first.name, second.name, edge.between.axis, edge.line);
1609
- // The pair names one gap however the author ordered them. The axis is in
1610
- // the key because a diagonal pair really does have two, and two edges may
1611
- // legitimately name the same pair and take different ones.
1612
- const key = [gap.axis, ...[first.name, second.name].sort()].join(' ');
1613
- const group = groups.get(key);
1614
- if (group)
1615
- group.members.push(edge);
1616
- else
1617
- groups.set(key, { ...gap, members: [edge] });
1618
- }
1619
- for (const group of groups.values()) {
1620
- const along = (edge) => {
1621
- const { start, end } = ends.get(edge);
1622
- return (start[group.axis] + end[group.axis]) / 2;
1623
- };
1624
- const ordered = [...group.members].sort((a, b) => along(a) - along(b));
1625
- // Lanes are spread as attachments on a side are, including the squeeze when
1626
- // there is not enough room — see `planEndpoints`. The step is wider here,
1627
- // because a lane carries a whole text rather than the point of an arrow,
1628
- // and two lanes closer together than a text is deep would draw the texts
1629
- // over each other. Still derived, not chosen: it is the size of what is
1630
- // actually running along the corridor.
1631
- const span = group.hi - group.lo;
1632
- const usable = Math.max(0, span - ATTACH_MARGIN * 2);
1633
- const want = Math.max(ATTACH_STEP, ...ordered.map((edge) => laneExtent(edge, group.axis, measurer, fontSize)));
1634
- const step = ordered.length > 1 ? Math.min(want, usable / (ordered.length - 1)) : 0;
1635
- const firstLane = group.lo + span / 2 - (step * (ordered.length - 1)) / 2;
1636
- ordered.forEach((edge, index) => {
1637
- const { start, end } = ends.get(edge);
1638
- const run = group.axis === 'y' ? 'x' : 'y';
1639
- // The corridor binds only where the edge is actually passing the pair, so
1640
- // its reach is the overlap of the pair's extent with the edge's own.
1641
- const enterAt = Math.max(group.across[0], Math.min(start[run], end[run]));
1642
- const leaveAt = Math.min(group.across[1], Math.max(start[run], end[run]));
1643
- if (leaveAt <= enterAt) {
1644
- const [a, b] = edge.between.nodes;
1645
- throw new SourceError(`this edge never passes between "${a.name}" and "${b.name}"`, edge.line);
1646
- }
1647
- const forward = end[run] >= start[run];
1648
- plans.set(edge, {
1649
- axis: group.axis,
1650
- lane: firstLane + index * step,
1651
- enter: forward ? enterAt : leaveAt,
1652
- leave: forward ? leaveAt : enterAt,
1653
- });
1654
- });
1600
+ const a = faceOf(first);
1601
+ const b = faceOf(second);
1602
+ const gap = gapBetween(a, b, first.name, second.name, edge.between.axis, edge.line);
1603
+ const run = gap.axis === 'y' ? 'x' : 'y';
1604
+ // The line passes the pair only if its own ends reach past their extent.
1605
+ const { start, end } = ends.get(edge);
1606
+ if (Math.min(gap.across[1], Math.max(start[run], end[run])) <= Math.max(gap.across[0], Math.min(start[run], end[run]))) {
1607
+ throw new SourceError(`this edge never passes between "${first.name}" and "${second.name}"`, edge.line);
1608
+ }
1609
+ // Where the two face each other, the middle of that; where they sit
1610
+ // diagonally, the middle of the space between them.
1611
+ const near = Math.max(lo(a, run), lo(b, run));
1612
+ const far = Math.min(hi(a, run), hi(b, run));
1613
+ plans.set(edge, { axis: gap.axis, lo: gap.lo, hi: gap.hi, at: (near + far) / 2 });
1655
1614
  }
1656
1615
  return plans;
1657
1616
  }
@@ -1663,33 +1622,46 @@ function planCorridors(edges, ends, measurer, fontSize) {
1663
1622
  * today. Where it is not, the way is searched for through the open space (see
1664
1623
  * `search.ts`), and the line is drawn along what was found.
1665
1624
  *
1666
- * A `between` edge and one with clauses are left to their own planning: the
1667
- * author said which way they go.
1625
+ * A line with clauses is the same line with rules added. `below m` fences off
1626
+ * everything above M within M's width (see `fenceOf`), and the direct line is
1627
+ * kept only if it keeps clear of the fences as well as the boxes; otherwise
1628
+ * the search goes round them as it goes round a box. A `between` line must pass
1629
+ * through the gap it names: the search carries the gap as a gate, and finds
1630
+ * its lane there with the rest of its way. A clause line whose shortest way
1631
+ * never reaches the side a clause names is sent through a point on that side,
1632
+ * searched for in legs into and out of it (see `clauseGate`).
1668
1633
  *
1669
1634
  * Lines searched for are placed shortest first, and each later one keeps a lane
1670
1635
  * clear of those already placed where they share a stretch, so a longer line
1671
- * nests outside a shorter one rather than drawing on top of it.
1636
+ * nests outside a shorter one rather than drawing on top of it. Two lines that
1637
+ * cross are then placed again the other way round, and kept so if they cross
1638
+ * less.
1672
1639
  */
1673
- function planWays(edges, nodes, ends, corridors, routes, measurer, fontSize) {
1640
+ function planWays(edges, nodes, ends, corridors, measurer, fontSize) {
1641
+ const routes = new Map();
1674
1642
  const wanted = [];
1675
1643
  // Direct lines that turn twice, across a gap in its middle.
1676
1644
  const turning = [];
1677
1645
  for (const edge of edges) {
1678
- if (corridors.has(edge) || edge.passes || routes.has(edge))
1646
+ if (corridors.has(edge)) {
1647
+ wanted.push(edge);
1679
1648
  continue;
1649
+ }
1680
1650
  // A line from a container to something inside it lives inside the box it
1681
1651
  // leaves, and has no way round anything.
1682
- if (edge.from !== edge.to && (contains(edge.from, edge.to) || contains(edge.to, edge.from)))
1652
+ if (!edge.passes && edge.from !== edge.to && (contains(edge.from, edge.to) || contains(edge.to, edge.from)))
1683
1653
  continue;
1684
1654
  const plan = ends.get(edge);
1685
- const boxes = obstaclesFor(edge, nodes);
1655
+ // A fence is checked at the box's own edge here, not grown: a line leaving
1656
+ // M's bottom with `below m` starts on the fence's border, not inside it.
1657
+ const boxes = [...obstaclesFor(edge, nodes), ...(edge.passes ?? []).map((pass) => fenceOf(pass, 0))];
1686
1658
  const { start, end } = plan;
1687
1659
  if (start.side !== undefined && end.side !== undefined) {
1688
1660
  const direct = directWay(start, end);
1689
- if (direct && wayClear(direct, boxes)) {
1661
+ if (direct && wayClear(direct, boxes) && passesAll(edge, direct)) {
1690
1662
  // The curve is the direct way with its turns swept, and is kept — unless
1691
1663
  // it bulges into a box the direct way clears, when the way is drawn.
1692
- if (edge.look.path === 'curved' && curveHits(edge, plan, nodes)) {
1664
+ if (edge.passes || (edge.look.path === 'curved' && curveHits(edge, plan, boxes))) {
1693
1665
  routes.set(edge, { points: direct, mid: textOnWay(direct, edge, nodes, measurer, fontSize) });
1694
1666
  }
1695
1667
  if (direct.length === 4)
@@ -1698,10 +1670,10 @@ function planWays(edges, nodes, ends, corridors, routes, measurer, fontSize) {
1698
1670
  }
1699
1671
  }
1700
1672
  else if (start.side === undefined && end.side === undefined) {
1701
- if (wayClear([start, end], boxes))
1673
+ if (wayClear([start, end], boxes) && passesAll(edge, [start, end]))
1702
1674
  continue;
1703
1675
  }
1704
- else if (!curveHits(edge, plan, nodes)) {
1676
+ else if (!curveHits(edge, plan, boxes) && passesAll(edge, curveTrace(edge, plan))) {
1705
1677
  continue;
1706
1678
  }
1707
1679
  wanted.push(edge);
@@ -1712,16 +1684,364 @@ function planWays(edges, nodes, ends, corridors, routes, measurer, fontSize) {
1712
1684
  return Math.abs(end.x - start.x) + Math.abs(end.y - start.y);
1713
1685
  };
1714
1686
  wanted.sort((p, q) => reach(p) - reach(q));
1715
- const taken = [];
1716
- for (const edge of wanted) {
1717
- const way = searchWay(edge, nodes, ends.get(edge), taken);
1718
- if (!way) {
1719
- throw new SourceError(blockedMessage(edge, ends.get(edge), nodes), edge.line);
1687
+ // Lines between the same two sides, told the same things, go the same way
1688
+ // round, and the one placed first takes the inside lane. So the one whose end
1689
+ // sits furthest the way the line turns goes first: out of a left side and
1690
+ // down, the lowest end takes the inside, and none of them crosses another.
1691
+ const sameWay = (edge) => {
1692
+ const { start, end } = ends.get(edge);
1693
+ return [
1694
+ edge.from.name, edge.to.name, start.side, end.side, start.tx, start.ty, end.tx, end.ty,
1695
+ corridors.has(edge) ? 'between' : '',
1696
+ ...(edge.passes ?? []).map((pass) => pass.written),
1697
+ ].join(' ');
1698
+ };
1699
+ for (let index = 0; index < wanted.length; index += 1) {
1700
+ const key = sameWay(wanted[index]);
1701
+ const group = wanted.filter((edge) => sameWay(edge) === key);
1702
+ if (group.length < 2 || group[0] !== wanted[index] || corridors.has(group[0]))
1703
+ continue;
1704
+ const trial = searchWay(group[0], nodes, ends.get(group[0]).start, group[0].from, ends.get(group[0]).end, group[0].to, [], ATTACH_STEP);
1705
+ if (!trial || trial.points.length < 3)
1706
+ continue;
1707
+ const [, bend, after] = trial.points;
1708
+ const turn = { x: Math.sign(after.x - bend.x), y: Math.sign(after.y - bend.y) };
1709
+ const ordered = [...group].sort((p, q) => {
1710
+ const a = ends.get(p).start;
1711
+ const b = ends.get(q).start;
1712
+ return (b.x - a.x) * turn.x + (b.y - a.y) * turn.y;
1713
+ });
1714
+ const rest = wanted.filter((edge) => !group.includes(edge));
1715
+ rest.splice(index, 0, ...ordered);
1716
+ wanted.splice(0, wanted.length, ...rest);
1717
+ index += group.length - 1;
1718
+ }
1719
+ // Where several lines leave one side, their ends may be squeezed closer than
1720
+ // a lane; the lines then run out of them that close together, and the lanes
1721
+ // beyond keep the same spacing, so they nest rather than finding no room.
1722
+ const attached = [];
1723
+ for (const [edge, { start, end }] of ends) {
1724
+ attached.push({ node: edge.from, edge, anchor: start }, { node: edge.to, edge, anchor: end });
1725
+ }
1726
+ const laneFor = (edge) => {
1727
+ const { start, end } = ends.get(edge);
1728
+ let lane = ATTACH_STEP;
1729
+ for (const [node, anchor] of [[edge.from, start], [edge.to, end]]) {
1730
+ for (const other of attached) {
1731
+ if (other.edge === edge || other.node !== node)
1732
+ continue;
1733
+ if (other.anchor.tx !== anchor.tx || other.anchor.ty !== anchor.ty)
1734
+ continue;
1735
+ const apart = Math.hypot(other.anchor.x - anchor.x, other.anchor.y - anchor.y);
1736
+ if (apart > 0.5)
1737
+ lane = Math.min(lane, apart);
1738
+ }
1739
+ }
1740
+ return lane;
1741
+ };
1742
+ // The ends as planned, before a search chose a side for any end naming none,
1743
+ // so a line placed again chooses afresh.
1744
+ const planned = new Map(ends);
1745
+ const found = new Map();
1746
+ // For a placed line, per stretch, half the depth across it of its text where
1747
+ // the text rides, so a line beside that stretch keeps clear of the words.
1748
+ const halvesOf = (other) => {
1749
+ const { points, mid } = found.get(other);
1750
+ return points.slice(1).map((q, index) => {
1751
+ const p = points[index];
1752
+ if (other.lines === undefined)
1753
+ return 0;
1754
+ const level = Math.abs(p.y - q.y) < 0.5;
1755
+ const on = level
1756
+ ? Math.abs(mid.y - p.y) < 0.5 && mid.x >= Math.min(p.x, q.x) - 0.5 && mid.x <= Math.max(p.x, q.x) + 0.5
1757
+ : Math.abs(mid.x - p.x) < 0.5 && mid.y >= Math.min(p.y, q.y) - 0.5 && mid.y <= Math.max(p.y, q.y) + 0.5;
1758
+ return on ? laneExtent(other, level ? 'y' : 'x', measurer, fontSize) / 2 : 0;
1759
+ });
1760
+ };
1761
+ const place = (edge, placed) => {
1762
+ const plan = planned.get(edge);
1763
+ const lane = laneFor(edge);
1764
+ const taken = placed.map((other) => found.get(other).points);
1765
+ const takenHalves = placed.map(halvesOf);
1766
+ const used = [...ends].flatMap(([other, { start, end }]) => other === edge ? [] : [{ node: other.from, anchor: start }, { node: other.to, anchor: end }]);
1767
+ // The way through a run of stops in order, each a straight stretch entered
1768
+ // heading along it and left the same way; each leg between them is
1769
+ // searched on its own, with a straight stretch into and out of every stop.
1770
+ const through = (stops) => {
1771
+ let from = plan.start;
1772
+ let fromNode = edge.from;
1773
+ let first;
1774
+ const points = [];
1775
+ for (const { enter, leave, run } of stops) {
1776
+ const leg = searchWay(edge, nodes, from, fromNode, { x: enter.x, y: enter.y, tx: -run.x, ty: -run.y }, undefined, taken, lane, used, { takenHalves });
1777
+ if (!leg)
1778
+ return undefined;
1779
+ first ??= leg.start;
1780
+ points.push(...leg.points);
1781
+ from = { x: leave.x, y: leave.y, tx: run.x, ty: run.y };
1782
+ fromNode = undefined;
1783
+ }
1784
+ const last = searchWay(edge, nodes, from, fromNode, plan.end, edge.to, taken, lane, used, { takenHalves });
1785
+ if (!last)
1786
+ return undefined;
1787
+ return { start: first ?? last.start, end: last.end, points: tidyRoute([...points, ...last.points]) };
1788
+ };
1789
+ const corridor = corridors.get(edge);
1790
+ let way;
1791
+ let mid;
1792
+ if (corridor) {
1793
+ // Somewhere along its way the line crosses the gap, travelling along it;
1794
+ // which lane it takes there is found with the rest of the way, and its
1795
+ // text, which rides in the gap, keeps the lines beside it clear.
1796
+ const run = corridor.axis === 'y' ? 'x' : 'y';
1797
+ const gate = { run, at: corridor.at, lo: corridor.lo, hi: corridor.hi };
1798
+ const ownHalf = laneExtent(edge, corridor.axis, measurer, fontSize) / 2;
1799
+ way = searchWay(edge, nodes, plan.start, edge.from, plan.end, edge.to, taken, lane, used, { gate, ownHalf, takenHalves });
1800
+ if (!way)
1801
+ throw new SourceError(gateMessage(edge, corridor, nodes), edge.line);
1802
+ mid = gateCrossing(way.points, gate);
1803
+ }
1804
+ else {
1805
+ way = searchWay(edge, nodes, plan.start, edge.from, plan.end, edge.to, taken, lane, used, { takenHalves });
1806
+ if (!way)
1807
+ throw new SourceError(edge.passes ? clauseMessage(edge, edge.passes) : blockedMessage(edge, plan, nodes), edge.line);
1808
+ if (!passesAll(edge, way.points)) {
1809
+ // The shortest way keeps clear of every fence without ever coming round
1810
+ // to the side a clause names — a line leaving a node that sits over M,
1811
+ // told to pass below M, can simply leave sideways. It is sent through a
1812
+ // point on that side of each clause it missed, in the order they lie
1813
+ // along its way.
1814
+ const missed = edge.passes.filter((pass) => !alongside(way.points, pass));
1815
+ const gates = missed.map((pass) => clauseGate(pass, plan.start, plan.end));
1816
+ if (gates.some((gate) => gate === undefined)) {
1817
+ throw new SourceError(neverPassesMessage(edge, way.points), edge.line);
1818
+ }
1819
+ const travel = { x: plan.end.x - plan.start.x, y: plan.end.y - plan.start.y };
1820
+ const stops = gates
1821
+ .map((gate) => gate)
1822
+ .sort((p, q) => (p.at.x - q.at.x) * travel.x + (p.at.y - q.at.y) * travel.y)
1823
+ .map(({ at, run }) => ({ enter: at, leave: at, run }));
1824
+ way = through(stops);
1825
+ if (!way)
1826
+ throw new SourceError(clauseMessage(edge, edge.passes), edge.line);
1827
+ if (!passesAll(edge, way.points))
1828
+ throw new SourceError(neverPassesMessage(edge, way.points), edge.line);
1829
+ }
1720
1830
  }
1831
+ return { ...way, mid: mid ?? textOnWay(way.points, edge, nodes, measurer, fontSize) };
1832
+ };
1833
+ const order = [];
1834
+ const settle = (edge, way) => {
1835
+ found.set(edge, way);
1721
1836
  ends.set(edge, { start: way.start, end: way.end });
1722
- routes.set(edge, { points: way.points, mid: textOnWay(way.points, edge, nodes, measurer, fontSize) });
1723
- taken.push(way.points);
1837
+ };
1838
+ for (const edge of wanted) {
1839
+ settle(edge, place(edge, [...order]));
1840
+ order.push(edge);
1841
+ }
1842
+ // Which line takes the inside of a shared stretch depends on which was placed
1843
+ // first, and the wrong order makes two lines cross where they need not. So a
1844
+ // pair that crosses is placed again the other way round, and kept that way if
1845
+ // it crosses less.
1846
+ const crossingsOf = (edge) => order.reduce((sum, other) => (other === edge ? sum : sum + linesCross(found.get(edge).points, found.get(other).points)), 0);
1847
+ for (let pass = 0; pass < 3; pass += 1) {
1848
+ let better = false;
1849
+ for (let i = 0; i < order.length; i += 1) {
1850
+ for (let j = i + 1; j < order.length; j += 1) {
1851
+ const [first, second] = [order[i], order[j]];
1852
+ if (linesCross(found.get(first).points, found.get(second).points) === 0)
1853
+ continue;
1854
+ const before = crossingsOf(first) + crossingsOf(second);
1855
+ const kept = [found.get(first), found.get(second)];
1856
+ const rest = order.filter((edge) => edge !== first && edge !== second);
1857
+ try {
1858
+ settle(second, place(second, rest));
1859
+ settle(first, place(first, [...rest, second]));
1860
+ }
1861
+ catch (error) {
1862
+ if (!(error instanceof SourceError))
1863
+ throw error;
1864
+ settle(first, kept[0]);
1865
+ settle(second, kept[1]);
1866
+ continue;
1867
+ }
1868
+ if (crossingsOf(first) + crossingsOf(second) < before) {
1869
+ order.splice(j, 1);
1870
+ order.splice(i, 0, second);
1871
+ better = true;
1872
+ }
1873
+ else {
1874
+ settle(first, kept[0]);
1875
+ settle(second, kept[1]);
1876
+ }
1877
+ }
1878
+ }
1879
+ if (!better)
1880
+ break;
1724
1881
  }
1882
+ // In the order placed, inside lanes first, which is the order sweeps are drawn in.
1883
+ for (const edge of order) {
1884
+ const { points, mid } = found.get(edge);
1885
+ routes.set(edge, { points, mid });
1886
+ }
1887
+ return routes;
1888
+ }
1889
+ /** How many times two lines cross, each drawn as straight pieces through its points. */
1890
+ function linesCross(a, b) {
1891
+ let count = 0;
1892
+ for (let i = 0; i + 1 < a.length; i += 1) {
1893
+ for (let j = 0; j + 1 < b.length; j += 1) {
1894
+ if (intersect(a[i], a[i + 1], b[j], b[j + 1]))
1895
+ count += 1;
1896
+ }
1897
+ }
1898
+ return count;
1899
+ }
1900
+ /** Far enough to stand for the edge of the page on any side. */
1901
+ const FAR = 1e5;
1902
+ /**
1903
+ * The region a clause fences a line out of: everything from its nodes to the
1904
+ * edge of the page on the far side, within their width or height. `below m` is
1905
+ * M's whole column above M's bottom; "wherever the line is within M's width, it
1906
+ * is below M" is exactly "the line never enters this". Grown by `by`, so the
1907
+ * search keeps the margin a box gets.
1908
+ */
1909
+ function fenceOf(pass, by) {
1910
+ const box = grow(extentOfBox(boundingBox(pass.nodes.map(faceOf))), by);
1911
+ switch (pass.direction) {
1912
+ case 'below':
1913
+ return { ...box, minY: -FAR };
1914
+ case 'above':
1915
+ return { ...box, maxY: FAR };
1916
+ case 'left':
1917
+ return { ...box, maxX: FAR };
1918
+ default:
1919
+ return { ...box, minX: -FAR };
1920
+ }
1921
+ }
1922
+ /** Whether a line along these points is alongside every clause's nodes somewhere. */
1923
+ function passesAll(edge, points) {
1924
+ return (edge.passes ?? []).every((pass) => alongside(points, pass));
1925
+ }
1926
+ /**
1927
+ * Whether the line passes a clause's nodes on the side it names somewhere:
1928
+ * some of it lies within their width (or height) and beyond their named side.
1929
+ * A line that is within their width only on the wrong side — leaving a node
1930
+ * that sits over M, say — has not passed M at all.
1931
+ */
1932
+ function alongside(points, pass) {
1933
+ const box = extentOfBox(boundingBox(pass.nodes.map(faceOf)));
1934
+ const across = sideAxis(pass) === 'y';
1935
+ const [lo, hi] = across ? [box.minX, box.maxX] : [box.minY, box.maxY];
1936
+ // The side named, as a test on the other coordinate.
1937
+ const beyond = (level) => pass.direction === 'below' ? level >= box.maxY - 0.5
1938
+ : pass.direction === 'above' ? level <= box.minY + 0.5
1939
+ : pass.direction === 'left' ? level <= box.minX + 0.5
1940
+ : level >= box.maxX - 0.5;
1941
+ for (let index = 0; index + 1 < points.length; index += 1) {
1942
+ const [p, q] = [points[index], points[index + 1]];
1943
+ const [a, b] = across ? [p.x, q.x] : [p.y, q.y];
1944
+ const [u, v] = across ? [p.y, q.y] : [p.x, q.x];
1945
+ const overlap = Math.min(hi, Math.max(a, b)) - Math.max(lo, Math.min(a, b));
1946
+ if (Math.abs(a - b) > 0.5) {
1947
+ // Travelling along the run: within the range, at one level.
1948
+ if (overlap > 0.5 && beyond(u))
1949
+ return true;
1950
+ }
1951
+ else if (a > lo && a < hi && (beyond(u) || beyond(v))) {
1952
+ // Travelling across the run, inside the range: reaching the named side.
1953
+ return true;
1954
+ }
1955
+ }
1956
+ return false;
1957
+ }
1958
+ /**
1959
+ * A point the line must pass through to be on a clause's named side of its
1960
+ * nodes: under the middle of them for `below`, a clear gap out, heading the way
1961
+ * the line travels. Undefined when the line's ends do not reach past the
1962
+ * nodes' width at all, so the clause has nothing to say about it.
1963
+ */
1964
+ function clauseGate(pass, start, end) {
1965
+ const box = extentOfBox(boundingBox(pass.nodes.map(faceOf)));
1966
+ const across = sideAxis(pass) === 'y';
1967
+ const [lo, hi] = across ? [box.minX, box.maxX] : [box.minY, box.maxY];
1968
+ const [a, b] = across ? [start.x, end.x] : [start.y, end.y];
1969
+ const from = Math.max(lo, Math.min(a, b));
1970
+ const to = Math.min(hi, Math.max(a, b));
1971
+ if (to - from < -0.5)
1972
+ return undefined;
1973
+ // The middle of the nodes, or as near it as the line's own span reaches.
1974
+ const middle = Math.max(Math.min(a, b), Math.min(Math.max(a, b), (lo + hi) / 2));
1975
+ const travel = Math.sign(b - a) || 1;
1976
+ const level = pass.direction === 'below' ? box.maxY + SEPARATION_GAP
1977
+ : pass.direction === 'above' ? box.minY - SEPARATION_GAP
1978
+ : pass.direction === 'left' ? box.minX - SEPARATION_GAP
1979
+ : box.maxX + SEPARATION_GAP;
1980
+ return across
1981
+ ? { at: { x: middle, y: level }, run: { x: travel, y: 0 } }
1982
+ : { at: { x: level, y: middle }, run: { x: 0, y: travel } };
1983
+ }
1984
+ function neverPassesMessage(edge, points) {
1985
+ const pass = edge.passes.find((one) => !alongside(points, one));
1986
+ return `edge ${edge.from.name} -> ${edge.to.name}: the line never passes ${quoteNames(pass.nodes)}, so ` +
1987
+ `"${pass.written}" says nothing about it`;
1988
+ }
1989
+ /**
1990
+ * Why no line keeps an edge's clauses. Two clauses that pull opposite ways over
1991
+ * the same stretch cannot both hold; two that pull opposite ways one after the
1992
+ * other need room between their nodes to cross over in. Otherwise the clauses
1993
+ * and the boxes between them leave no way through.
1994
+ */
1995
+ function clauseMessage(edge, passes) {
1996
+ const subject = `edge ${edge.from.name} -> ${edge.to.name}`;
1997
+ const opposite = { below: 'above', above: 'below', left: 'right', right: 'left' };
1998
+ for (const floor of passes) {
1999
+ for (const ceiling of passes) {
2000
+ if (opposite[floor.direction] !== ceiling.direction)
2001
+ continue;
2002
+ if (floor.direction !== 'below' && floor.direction !== 'right')
2003
+ continue;
2004
+ const f = extentOfBox(boundingBox(floor.nodes.map(faceOf)));
2005
+ const c = extentOfBox(boundingBox(ceiling.nodes.map(faceOf)));
2006
+ const across = sideAxis(floor) === 'y';
2007
+ // The floor's far edge and the ceiling's near edge, on the axis the clauses bound.
2008
+ const band = across ? c.minY - f.maxY : c.minX - f.maxX;
2009
+ if (band >= CROSSING_ROOM)
2010
+ continue;
2011
+ const [flo, fhi, clo, chi] = across ? [f.minX, f.maxX, c.minX, c.maxX] : [f.minY, f.maxY, c.minY, c.maxY];
2012
+ const apart = Math.max(clo - fhi, flo - chi);
2013
+ if (apart < 0) {
2014
+ return `${subject}: "${floor.written}" and "${ceiling.written}" cannot both hold — there is a stretch ` +
2015
+ `where the line is alongside both, and it cannot be ${sideWord(floor)} ${quoteNames(floor.nodes)} and ` +
2016
+ `${sideWord(ceiling)} ${quoteNames(ceiling.nodes)} at the same point. Drop one`;
2017
+ }
2018
+ if (apart < CROSSING_ROOM) {
2019
+ return `${subject}: to pass "${floor.written}" and "${ceiling.written}" the line has to cross over ` +
2020
+ `between ${quoteNames(floor.nodes)} and ${quoteNames(ceiling.nodes)}, and there is no room between ` +
2021
+ 'them — give the placement between them a gap, or drop one of the two';
2022
+ }
2023
+ }
2024
+ }
2025
+ const written = passes.map((pass) => `"${pass.written}"`);
2026
+ const listed = written.length === 1 ? written[0] : `${written.slice(0, -1).join(', ')} and ${written[written.length - 1]}`;
2027
+ return `${subject}: no line from ${edge.from.name} to ${edge.to.name} keeps ${listed} without passing through ` +
2028
+ 'another box. Give the nodes more room, or drop a clause';
2029
+ }
2030
+ /** Why a `between` line has no way into or out of its gap. */
2031
+ function gateMessage(edge, corridor, nodes) {
2032
+ const [a, b] = edge.between.nodes;
2033
+ // Straight across the gap where the line has to cross it.
2034
+ const [p1, p2] = corridor.axis === 'y'
2035
+ ? [{ x: corridor.at, y: corridor.lo }, { x: corridor.at, y: corridor.hi }]
2036
+ : [{ x: corridor.lo, y: corridor.at }, { x: corridor.hi, y: corridor.at }];
2037
+ const blocker = nodes.find((node) => node !== a && node !== b && !contains(node, a) && !contains(node, b) &&
2038
+ segmentHits(p1, p2, extentOfBox(faceOf(node))));
2039
+ if (blocker) {
2040
+ return `edge ${edge.from.name} -> ${edge.to.name}: the gap between "${a.name}" and "${b.name}" is blocked ` +
2041
+ `by ${blocker.name}, so the line cannot pass through it`;
2042
+ }
2043
+ return `edge ${edge.from.name} -> ${edge.to.name}: no way from ${edge.from.name} through the gap between ` +
2044
+ `"${a.name}" and "${b.name}" to ${edge.to.name} passes clear of the other boxes. Give them room, or name other sides`;
1725
2045
  }
1726
2046
  /**
1727
2047
  * Direct lines that turn in the middle of the same gap and share some of it
@@ -1869,12 +2189,20 @@ function textOnWay(points, edge, nodes, measurer, fontSize) {
1869
2189
  /** How much a turn costs the search, in the same units as length. */
1870
2190
  const SEARCH_TURN = SEPARATION_GAP * 2;
1871
2191
  /**
1872
- * The searched way for one edge. A named side is fixed; an end with no side
1873
- * named may leave or arrive by any of its box's four, at that side's middle,
1874
- * and takes whichever makes the way cheapest.
2192
+ * The searched way for one edge, or one half of a `between` edge. A named side
2193
+ * is fixed; an end with no side named may leave or arrive by any of its box's
2194
+ * four, at that side's middle, and takes whichever makes the way cheapest. An
2195
+ * end with no node is a point in a gap, heading along it, and is fixed too.
2196
+ *
2197
+ * The edge's clauses are walls like the boxes: each fences off the region
2198
+ * `fenceOf` gives. An end starting inside a fence — a line leaving M's left
2199
+ * side with `below m` — crosses its margin on the way out, as it crosses its
2200
+ * own box's. An end heading into the fenced side — M's top with `below m` —
2201
+ * gets a stub's room before the fence begins, so it leaves, steps round, and
2202
+ * comes back below.
1875
2203
  */
1876
- function searchWay(edge, nodes, { start, end }, taken) {
1877
- const holders = (node) => nodes.filter((other) => contains(other, node));
2204
+ function searchWay(edge, nodes, start, startNode, end, endNode, taken, lane, used = [], more = {}) {
2205
+ const holders = (node) => node ? nodes.filter((other) => contains(other, node)) : [];
1878
2206
  const walls = new Map();
1879
2207
  for (const node of nodes) {
1880
2208
  // A container holding an end — one or both — is crossed, or lived inside,
@@ -1885,24 +2213,88 @@ function searchWay(edge, nodes, { start, end }, taken) {
1885
2213
  walls.set(node, wallOf(grow(extentOfBox(faceOf(node)), ATTACH_MARGIN)));
1886
2214
  }
1887
2215
  const ownOf = (node) => holders(node).map((holder) => walls.get(holder)).filter((wall) => wall !== undefined);
1888
- const choices = (anchor, node) => anchor.side !== undefined
2216
+ // An end with no side named takes a side's middle, or the nearest point
2217
+ // along it a step clear of where another line already meets that side.
2218
+ const choices = (anchor, node) => anchor.side !== undefined || !node
1889
2219
  ? [anchor]
1890
2220
  : ATTACH_SIDES.map((side) => {
1891
- const center = centerOf(faceOf(node));
1892
- return anchorOn(faceOf(node), side, side === 'top' || side === 'bottom' ? center.x : center.y);
2221
+ const face = faceOf(node);
2222
+ const center = centerOf(face);
2223
+ const level = side === 'top' || side === 'bottom';
2224
+ const middle = level ? center.x : center.y;
2225
+ const out = endOf(anchorOn(face, side, middle));
2226
+ const others = used
2227
+ .filter((one) => one.node === node)
2228
+ .map((one) => ({ at: one.anchor, heading: endOf(one.anchor) }))
2229
+ .filter(({ heading }) => heading.dx === out.dx && heading.dy === out.dy)
2230
+ .map(({ at }) => (level ? at.x : at.y));
2231
+ const [lo, hi] = level
2232
+ ? [face.x + ATTACH_MARGIN, face.x + face.width - ATTACH_MARGIN]
2233
+ : [face.y + ATTACH_MARGIN, face.y + face.height - ATTACH_MARGIN];
2234
+ const unused = (at) => others.every((other) => Math.abs(other - at) >= ATTACH_STEP - 0.5);
2235
+ let at = middle;
2236
+ for (let step = 1; !unused(at) && step <= 8; step += 1) {
2237
+ const candidates = [middle + step * ATTACH_STEP, middle - step * ATTACH_STEP]
2238
+ .filter((one) => one >= lo && one <= hi && unused(one));
2239
+ if (candidates[0] !== undefined)
2240
+ at = candidates[0];
2241
+ }
2242
+ return anchorOn(face, side, at);
1893
2243
  });
1894
2244
  const stub = ROUTE_STUB;
2245
+ const clear = SEPARATION_GAP - ATTACH_MARGIN;
2246
+ // Which way each fence runs off to the edge of the page.
2247
+ const far = { below: { x: 0, y: -1 }, above: { x: 0, y: 1 }, left: { x: 1, y: 0 }, right: { x: -1, y: 0 } };
1895
2248
  let best;
1896
- for (const from of choices(start, edge.from)) {
1897
- for (const to of choices(end, edge.to)) {
1898
- const found = searchRoute(endOf(from), endOf(to), [...walls.values()], {
2249
+ for (const from of choices(start, startNode)) {
2250
+ for (const to of choices(end, endNode)) {
2251
+ const s = endOf(from);
2252
+ const e = endOf(to);
2253
+ const ownStart = ownOf(startNode);
2254
+ const ownEnd = ownOf(endNode);
2255
+ const fences = [];
2256
+ for (const pass of edge.passes ?? []) {
2257
+ const fence = wallOf(fenceOf(pass, ATTACH_MARGIN));
2258
+ const away = far[pass.direction];
2259
+ let inStart = false;
2260
+ let inEnd = false;
2261
+ for (const [point, own] of [[s, ownStart], [e, ownEnd]]) {
2262
+ if (!within(point, fence))
2263
+ continue;
2264
+ if (point.dx === away.x && point.dy === away.y) {
2265
+ const room = stub + clear;
2266
+ if (away.y < 0)
2267
+ fence.maxY = Math.min(fence.maxY, point.y - room);
2268
+ if (away.y > 0)
2269
+ fence.minY = Math.max(fence.minY, point.y + room);
2270
+ if (away.x > 0)
2271
+ fence.minX = Math.max(fence.minX, point.x + room);
2272
+ if (away.x < 0)
2273
+ fence.maxX = Math.min(fence.maxX, point.x - room);
2274
+ }
2275
+ else if (own === ownStart) {
2276
+ inStart = true;
2277
+ }
2278
+ else {
2279
+ inEnd = true;
2280
+ }
2281
+ }
2282
+ fences.push(fence);
2283
+ if (inStart)
2284
+ ownStart.push(fence);
2285
+ if (inEnd)
2286
+ ownEnd.push(fence);
2287
+ }
2288
+ const found = searchRoute(s, e, [...walls.values(), ...fences], {
1899
2289
  stub,
1900
- clear: SEPARATION_GAP - ATTACH_MARGIN,
2290
+ clear,
1901
2291
  turn: SEARCH_TURN,
1902
- ownStart: ownOf(edge.from),
1903
- ownEnd: ownOf(edge.to),
2292
+ ownStart,
2293
+ ownEnd,
1904
2294
  taken,
1905
- lane: ATTACH_STEP,
2295
+ lane,
2296
+ ...more,
2297
+ bounds: wallOf(grow(nodes.map((node) => extentOfBox(faceOf(node))).reduce(union), ROUTE_STUB * 4)),
1906
2298
  });
1907
2299
  if (found && (!best || found.cost < best.cost - 1e-6))
1908
2300
  best = { start: from, end: to, ...found };
@@ -1957,7 +2349,11 @@ function blockedMessage(edge, { start, end }, nodes) {
1957
2349
  * such edge the long way round with square corners, which the user rejected
1958
2350
  * outright.
1959
2351
  */
1960
- function curveHits(edge, ends, nodes) {
2352
+ function curveHits(edge, ends, boxes) {
2353
+ return !wayClear(curveTrace(edge, ends), boxes);
2354
+ }
2355
+ /** The curve an edge with a named side is drawn as, as points close enough together to test against boxes. */
2356
+ function curveTrace(edge, ends) {
1961
2357
  const curve = sideCurve(edge, ends);
1962
2358
  const { p0, c1, c2, p3 } = curve;
1963
2359
  const at = (t) => {
@@ -1967,12 +2363,12 @@ function curveHits(edge, ends, nodes) {
1967
2363
  y: u * u * u * p0.y + 3 * u * u * t * c1.y + 3 * u * t * t * c2.y + t * t * t * p3.y,
1968
2364
  };
1969
2365
  };
1970
- // The line starts and ends on its own boxes' sides, so those are shrunk a
1971
- // pixel to let it touch them there.
1972
- const boxes = obstaclesFor(edge, nodes);
1973
2366
  const steps = 32;
1974
- const points = [curve.start, ...Array.from({ length: steps + 1 }, (_, step) => at(step / steps)), curve.end];
1975
- return !wayClear(points, boxes);
2367
+ return [curve.start, ...Array.from({ length: steps + 1 }, (_, step) => at(step / steps)), curve.end];
2368
+ }
2369
+ /** Whether a point lies strictly inside a wall. */
2370
+ function within(point, wall) {
2371
+ return point.x > wall.minX && point.x < wall.maxX && point.y > wall.minY && point.y < wall.maxY;
1976
2372
  }
1977
2373
  /**
1978
2374
  * The curve a line with a named side is drawn as. Its handles leave along each
@@ -2014,457 +2410,6 @@ const ROUTE_RADIUS = 20;
2014
2410
  const ROUTE_STUB = ROUTE_RADIUS + ARROW_LENGTH;
2015
2411
  /** The narrowest gap a route will cross over in, between two nodes it passes on opposite sides. */
2016
2412
  const CROSSING_ROOM = ATTACH_MARGIN * 2;
2017
- function axesAcross(across) {
2018
- return across === 'y'
2019
- ? { across, run: 'x', make: (along, level) => ({ x: along, y: level }) }
2020
- : { across, run: 'y', make: (along, level) => ({ x: level, y: along }) };
2021
- }
2022
- /**
2023
- * Route every edge that says which side of something it passes.
2024
- *
2025
- * `below resolver` means that where the line passes Resolver it is below it —
2026
- * not that the whole line is. So the line is a run of straight stretches along
2027
- * the way it travels, and each clause binds only the stretch lying alongside
2028
- * its nodes. A clause naming several nodes binds the stretch alongside all of
2029
- * them, which is how `below a and b` says "with no rising in between" and two
2030
- * separate clauses do not. Consecutive stretches that can share one level do;
2031
- * where they cannot, the line crosses over in the gap between the two sets of
2032
- * nodes, and if there is no gap, the file is refused rather than drawn through
2033
- * a box. No clause says an order: the line meets the nodes in the order they
2034
- * sit along its way.
2035
- *
2036
- * Above and below are passed travelling across the page, left and right
2037
- * travelling up or down it, so an edge naming both kinds turns between such
2038
- * runs, as few times as keeps every clause. Which comes first is the named
2039
- * side's to say, and across when no end names one.
2040
- *
2041
- * Nothing here moves a node or looks for a path. Every level is read off where
2042
- * the named nodes landed, as a `between` channel is, and the only other nodes
2043
- * consulted are ones sitting on a stretch, which push it further the way its
2044
- * clause already points.
2045
- */
2046
- function planRoutes(edges, nodes, ends, measurer, fontSize) {
2047
- const routes = new Map();
2048
- for (const edge of edges) {
2049
- if (edge.passes)
2050
- routes.set(edge, planRoute(edge, edge.passes, nodes, ends.get(edge), measurer, fontSize));
2051
- }
2052
- return routes;
2053
- }
2054
- function planRoute(edge, passes, nodes, { start, end }, measurer, fontSize) {
2055
- const subject = `edge ${edge.from.name} -> ${edge.to.name}`;
2056
- const a = faceOf(edge.from);
2057
- const b = faceOf(edge.to);
2058
- const named = (anchor, face) => anchor.side === undefined ? centerOf(face) : anchor;
2059
- // Far enough out that the line reads as passing. The one stretch carrying
2060
- // the text is held further out, so that half the text, centered on the line,
2061
- // still clears the box beside it.
2062
- const clear = SEPARATION_GAP;
2063
- const inWay = nodes.filter((node) => !(contains(node, edge.from) && contains(node, edge.to)));
2064
- // Above and below are passed travelling across the page, left and right
2065
- // travelling up or down it. An edge naming only one kind is one section.
2066
- const acrossPasses = passes.filter((pass) => sideAxis(pass) === 'y');
2067
- const downPasses = passes.filter((pass) => sideAxis(pass) === 'x');
2068
- if (acrossPasses.length === 0 || downPasses.length === 0) {
2069
- const axes = axesAcross(sideAxis(passes[0]));
2070
- const travel = Math.sign(named(end, b)[axes.run] - named(start, a)[axes.run]) || 1;
2071
- const section = cut(axes, passes, reach(start, a, axes), reach(end, b, axes), travel);
2072
- const carrier = longest(section.stretches);
2073
- settle(section, (centerOf(a)[axes.across] + centerOf(b)[axes.across]) / 2, carrier);
2074
- return finish([
2075
- ...approach(start, a, axes, section.stretches[0].level, travel),
2076
- ...crossings(section),
2077
- ...approach(end, b, axes, section.stretches[section.stretches.length - 1].level, -travel).reverse(),
2078
- ], carrier, axes);
2079
- }
2080
- // Both kinds: the line turns between travelling across the page and
2081
- // travelling down it, as often as its clauses need. A side named at an end
2082
- // says which it does first; with none named, it goes across first. The
2083
- // fewest turns that keep every clause win, and at each count the other
2084
- // order is tried before adding a turn. If every attempt fails the same way,
2085
- // that is the error; if they fail differently, no one of them is the reason
2086
- // and the error names every clause.
2087
- const sideways = (anchor) => anchor.side === undefined ? undefined : anchor.side === 'left' || anchor.side === 'right';
2088
- const acrossFirst = sideways(start) ?? !(sideways(end) ?? false);
2089
- const refusals = [];
2090
- for (const count of [2, 3, 4]) {
2091
- for (const order of [acrossFirst, !acrossFirst]) {
2092
- try {
2093
- return turned(order, count);
2094
- }
2095
- catch (error) {
2096
- if (!(error instanceof SourceError))
2097
- throw error;
2098
- refusals.push(error);
2099
- }
2100
- }
2101
- }
2102
- if (refusals.every((refusal) => refusal.message === refusals[0].message))
2103
- throw refusals[0];
2104
- const written = passes.map((pass) => `"${pass.written}"`);
2105
- throw new SourceError(`${subject}: no line keeps ${written.slice(0, -1).join(', ')} and ${written[written.length - 1]} ` +
2106
- 'all at once, whichever way it turns — each way misses one of those nodes or runs into one. ' +
2107
- 'Give the nodes more room, or drop a clause', edge.line);
2108
- /**
2109
- * A route in `count` sections, alternating across and down, joined at
2110
- * corners. Each section is planned as an edge naming one kind is, running
2111
- * from the level of the section before it to the level of the one after,
2112
- * and each clause binds every section of its kind that passes its node.
2113
- * The first and last keep as near their own ends as their clauses allow,
2114
- * so with nothing in the way two sections make an L through the corner
2115
- * level with both ends; a section between hugs its own clauses. Where the
2116
- * corners land depends on every section, so they are planned in turn
2117
- * until none moves.
2118
- */
2119
- function turned(acrossFirst, count) {
2120
- const axes = Array.from({ length: count }, (_, index) => axesAcross((index % 2 === 0) === acrossFirst ? 'y' : 'x'));
2121
- const last = count - 1;
2122
- const targets = axes.map(({ across }, index) => index === 0
2123
- ? centerOf(a)[across]
2124
- : index === last
2125
- ? centerOf(b)[across]
2126
- : (centerOf(a)[across] + centerOf(b)[across]) / 2);
2127
- // Each section's first and last level, which bound its neighbours' runs.
2128
- const firstLevels = [...targets];
2129
- const lastLevels = [...targets];
2130
- const startAt = reach(start, a, axes[0]);
2131
- const endAt = reach(end, b, axes[last]);
2132
- // A section starts or ends at the middle of its end's own node, which
2133
- // must not push it: the line leaves that node from the side facing it.
2134
- const obstacles = inWay.filter((node) => !contains(node, edge.from) && !contains(node, edge.to));
2135
- const near = new Set(passes.flatMap((pass) => pass.nodes));
2136
- // Which stretch carries the text: its section and index, from the last round.
2137
- let carries;
2138
- let sections = [];
2139
- let travels = [];
2140
- let bound = new Set();
2141
- for (let round = 0; round < 8; round += 1) {
2142
- let moved = false;
2143
- sections = [];
2144
- travels = [];
2145
- bound = new Set();
2146
- axes.forEach((section, index) => {
2147
- const from = index === 0 ? startAt : lastLevels[index - 1];
2148
- const to = index === last ? endAt : firstLevels[index + 1];
2149
- const travel = Math.sign((index === last ? named(end, b)[section.run] : to) -
2150
- (index === 0 ? named(start, a)[section.run] : from)) || 1;
2151
- const low = Math.min(from, to);
2152
- const high = Math.max(from, to);
2153
- const mine = passes.filter((pass) => {
2154
- const box = boundingBox(pass.nodes.map(faceOf));
2155
- return sideAxis(pass) === section.across && hi(box, section.run) > low && lo(box, section.run) < high;
2156
- });
2157
- mine.forEach((pass) => bound.add(pass));
2158
- const planned = cut(section, mine, from, to, travel);
2159
- const carrier = carries?.[0] === index ? planned.stretches[carries[1]] : undefined;
2160
- settle(planned, targets[index], carrier, obstacles, index > 0 && index < last, near);
2161
- const first = planned.stretches[0].level;
2162
- const final = planned.stretches[planned.stretches.length - 1].level;
2163
- if (Math.abs(first - firstLevels[index]) >= 0.5 || Math.abs(final - lastLevels[index]) >= 0.5) {
2164
- moved = true;
2165
- }
2166
- firstLevels[index] = first;
2167
- lastLevels[index] = final;
2168
- sections.push(planned);
2169
- travels.push(travel);
2170
- });
2171
- let wanted;
2172
- if (edge.lines !== undefined) {
2173
- sections.forEach((section, index) => {
2174
- const best = longest(section.stretches);
2175
- if (!wanted || length(best) > length(sections[wanted[0]].stretches[wanted[1]])) {
2176
- wanted = [index, section.stretches.indexOf(best)];
2177
- }
2178
- });
2179
- }
2180
- const settled = round > 0 && !moved && wanted?.[0] === carries?.[0] && wanted?.[1] === carries?.[1];
2181
- carries = wanted;
2182
- if (settled)
2183
- break;
2184
- }
2185
- const unpassed = passes.find((pass) => !bound.has(pass));
2186
- if (unpassed) {
2187
- throw new SourceError(`${subject}: the line never passes ${quoteNames(unpassed.nodes)}, so "${unpassed.written}" says ` +
2188
- 'nothing about it', edge.line);
2189
- }
2190
- const points = [...approach(start, a, axes[0], firstLevels[0], travels[0])];
2191
- sections.forEach((section, index) => {
2192
- points.push(...crossings(section));
2193
- if (index < last)
2194
- points.push(section.axes.make(firstLevels[index + 1], lastLevels[index]));
2195
- });
2196
- points.push(...approach(end, b, axes[last], lastLevels[last], -travels[last]).reverse());
2197
- const broken = breaks(points);
2198
- if (broken) {
2199
- throw new SourceError(`${subject}: a line passing things both above or below and left or right has to turn, and ` +
2200
- `no way it can turn keeps "${broken.written}" — drop that clause, or one of the others`, edge.line);
2201
- }
2202
- const [which, index] = carries ?? [0, 0];
2203
- const carrier = edge.lines === undefined ? undefined : sections[which].stretches[index];
2204
- return finish(points, carrier, axes[which]);
2205
- }
2206
- /** The first clause the drawn line breaks, wherever it lies alongside that clause's nodes. */
2207
- function breaks(points) {
2208
- for (const pass of passes) {
2209
- const box = boundingBox(pass.nodes.map(faceOf));
2210
- const across = sideAxis(pass);
2211
- const run = across === 'y' ? 'x' : 'y';
2212
- const further = pass.direction === 'below' || pass.direction === 'right';
2213
- for (let index = 0; index + 1 < points.length; index += 1) {
2214
- const [p, q] = [points[index], points[index + 1]];
2215
- const alongside = Math.abs(p[run] - q[run]) < 0.5
2216
- ? p[run] > lo(box, run) + 0.5 && p[run] < hi(box, run) - 0.5
2217
- : Math.min(Math.max(p[run], q[run]), hi(box, run)) -
2218
- Math.max(Math.min(p[run], q[run]), lo(box, run)) > 0.5;
2219
- if (!alongside)
2220
- continue;
2221
- const kept = further
2222
- ? Math.min(p[across], q[across]) >= hi(box, across) - 0.5
2223
- : Math.max(p[across], q[across]) <= lo(box, across) + 0.5;
2224
- if (!kept)
2225
- return pass;
2226
- }
2227
- }
2228
- return undefined;
2229
- }
2230
- /**
2231
- * Where the line reaches along a run at one end, before it turns onto a
2232
- * stretch. Enough to tell which nodes it passes.
2233
- */
2234
- function reach(anchor, face, { run }) {
2235
- const along = run === 'x' ? anchor.tx : anchor.ty;
2236
- return anchor.side !== undefined && along !== 0
2237
- ? anchor[run] + along * ROUTE_STUB
2238
- : named(anchor, face)[run];
2239
- }
2240
- function length(stretch) {
2241
- return Math.abs(stretch.to - stretch.from);
2242
- }
2243
- function longest(stretches) {
2244
- return stretches.reduce((best, stretch) => (length(stretch) > length(best) ? stretch : best));
2245
- }
2246
- /**
2247
- * The way from `first` to `last` along a run, cut wherever a clause starts
2248
- * or stops binding, with each piece given the band its clauses leave the
2249
- * line, and consecutive pieces grouped into stretches that can share one
2250
- * level. Levels are left for `settle`.
2251
- */
2252
- function cut(axes, passes, first, last, travel) {
2253
- const { across, run } = axes;
2254
- const low = Math.min(first, last);
2255
- const high = Math.max(first, last);
2256
- const clauses = passes.map((pass) => {
2257
- const box = boundingBox(pass.nodes.map(faceOf));
2258
- if (hi(box, run) <= low || lo(box, run) >= high) {
2259
- throw new SourceError(`${subject}: the line never passes ${quoteNames(pass.nodes)}, so "${pass.written}" says nothing ` +
2260
- 'about it', edge.line);
2261
- }
2262
- const further = pass.direction === 'below' || pass.direction === 'right';
2263
- return {
2264
- pass,
2265
- from: lo(box, run),
2266
- to: hi(box, run),
2267
- bound: further ? hi(box, across) + clear : lo(box, across) - clear,
2268
- further,
2269
- };
2270
- });
2271
- // Cut the way into pieces at every place a clause starts or stops binding,
2272
- // and give each piece the band its clauses leave the line.
2273
- const cuts = [...new Set([low, high, ...clauses.flatMap((c) => [c.from, c.to])])]
2274
- .filter((at) => at >= low && at <= high)
2275
- .sort((p, q) => (p - q) * travel);
2276
- const pieces = cuts.slice(0, -1).map((from, index) => {
2277
- const to = cuts[index + 1];
2278
- const middle = (from + to) / 2;
2279
- const binding = clauses.filter((c) => c.from < middle && c.to > middle);
2280
- const floor = binding.filter((c) => c.further).sort((p, q) => q.bound - p.bound)[0];
2281
- const ceiling = binding.filter((c) => !c.further).sort((p, q) => p.bound - q.bound)[0];
2282
- if (floor && ceiling && floor.bound > ceiling.bound) {
2283
- const grouped = [floor, ceiling].some((c) => c.pass.nodes.length > 1);
2284
- throw new SourceError(`${subject}: "${floor.pass.written}" and "${ceiling.pass.written}" cannot both hold — there ` +
2285
- 'is a stretch where the line is alongside both, and it cannot be ' +
2286
- `${sideWord(floor.pass)} ${quoteNames(floor.pass.nodes)} and ${sideWord(ceiling.pass)} ` +
2287
- `${quoteNames(ceiling.pass.nodes)} at the same point. Drop one` +
2288
- (grouped
2289
- ? ', or name the nodes in separate clauses so the line may cross over between them'
2290
- : ''), edge.line);
2291
- }
2292
- return {
2293
- from,
2294
- to,
2295
- lo: floor?.bound ?? -Infinity,
2296
- hi: ceiling?.bound ?? Infinity,
2297
- floor,
2298
- ceiling,
2299
- };
2300
- });
2301
- // A way of no length, as the first section of a turned route can be when
2302
- // the corner is level with its start, is one piece binding nothing.
2303
- if (pieces.length === 0) {
2304
- pieces.push({ from: low, to: high, lo: -Infinity, hi: Infinity, floor: undefined, ceiling: undefined });
2305
- }
2306
- // Consecutive pieces share one level for as long as their bands overlap.
2307
- const stretches = [];
2308
- let current = { lo: -Infinity, hi: Infinity, first: 0, last: 0, from: 0, to: 0, level: 0 };
2309
- pieces.forEach((piece, index) => {
2310
- const lower = Math.max(current.lo, piece.lo);
2311
- const upper = Math.min(current.hi, piece.hi);
2312
- if (lower <= upper) {
2313
- Object.assign(current, { lo: lower, hi: upper, last: index });
2314
- }
2315
- else {
2316
- stretches.push(current);
2317
- current = { lo: piece.lo, hi: piece.hi, first: index, last: index, from: 0, to: 0, level: 0 };
2318
- }
2319
- });
2320
- stretches.push(current);
2321
- for (const stretch of stretches) {
2322
- stretch.from = pieces[stretch.first].from;
2323
- stretch.to = pieces[stretch.last].to;
2324
- }
2325
- return { axes, pieces, stretches };
2326
- }
2327
- /**
2328
- * Each stretch sits as near `target` as its band allows, and a node lying
2329
- * on it pushes it on the way its clause already points. `carrier` is the
2330
- * stretch held clear for the text, if this section has it.
2331
- */
2332
- function settle({ axes: { across, run }, stretches }, target, carrier, obstacles = inWay, hug = false, near = new Set()) {
2333
- const textClear = Math.max(clear, laneExtent(edge, across, measurer, fontSize) / 2 + ATTACH_MARGIN);
2334
- for (const stretch of stretches) {
2335
- const room = stretch === carrier && edge.lines !== undefined ? textClear : clear;
2336
- const extra = room - clear;
2337
- const lower = stretch.lo + extra;
2338
- const upper = stretch.hi - extra;
2339
- // A stretch hugging its clauses sits as close as its one bound allows.
2340
- const wanted = !hug
2341
- ? target
2342
- : stretch.hi === Infinity && stretch.lo !== -Infinity
2343
- ? lower
2344
- : stretch.lo === -Infinity && stretch.hi !== Infinity
2345
- ? upper
2346
- : target;
2347
- stretch.level = lower <= upper
2348
- ? Math.min(Math.max(wanted, lower), upper)
2349
- : (stretch.lo + stretch.hi) / 2;
2350
- const push = stretch.hi === Infinity ? 1 : stretch.lo === -Infinity ? -1 : 0;
2351
- if (push === 0)
2352
- continue;
2353
- const from = Math.min(stretch.from, stretch.to);
2354
- const to = Math.max(stretch.from, stretch.to);
2355
- for (let moved = true; moved;) {
2356
- moved = false;
2357
- for (const node of obstacles) {
2358
- const face = faceOf(node);
2359
- if (lo(face, run) >= to || hi(face, run) <= from)
2360
- continue;
2361
- // A node the edge names is one it is meant to go close by, so it
2362
- // keeps the line only as far off as a crossing does.
2363
- const off = near.has(node) && room === clear ? ATTACH_MARGIN : room;
2364
- if (lo(face, across) >= stretch.level + off || hi(face, across) <= stretch.level - off) {
2365
- continue;
2366
- }
2367
- stretch.level = push > 0 ? hi(face, across) + off : lo(face, across) - off;
2368
- moved = true;
2369
- }
2370
- }
2371
- }
2372
- }
2373
- /**
2374
- * Between two stretches the line crosses over, in whatever run of pieces at
2375
- * the end of the first leaves room for both levels.
2376
- */
2377
- function crossings({ axes: { make }, pieces, stretches }) {
2378
- const points = [];
2379
- for (let index = 0; index + 1 < stretches.length; index += 1) {
2380
- const here = stretches[index];
2381
- const next = stretches[index + 1];
2382
- let open = here.last + 1;
2383
- while (open > here.first &&
2384
- pieces[open - 1].lo <= next.level &&
2385
- pieces[open - 1].hi >= next.level) {
2386
- open -= 1;
2387
- }
2388
- const from = open <= here.last ? pieces[open].from : pieces[here.last].to;
2389
- const to = pieces[here.last].to;
2390
- if (Math.abs(to - from) < CROSSING_ROOM) {
2391
- const behind = [...pieces.slice(here.first, here.last + 1)]
2392
- .reverse()
2393
- .map((piece) => piece.floor ?? piece.ceiling)
2394
- .find((clause) => clause !== undefined);
2395
- const entering = pieces[next.first];
2396
- const ahead = (next.level > here.level ? entering.floor : entering.ceiling) ??
2397
- entering.floor ?? entering.ceiling;
2398
- throw new SourceError(`${subject}: to pass "${behind.pass.written}" and "${ahead.pass.written}" the line has to ` +
2399
- `cross over between ${quoteNames(behind.pass.nodes)} and ${quoteNames(ahead.pass.nodes)}, and there ` +
2400
- 'is no room between them — give the placement between them a gap, or drop one of the two', edge.line);
2401
- }
2402
- const at = (from + to) / 2;
2403
- points.push(make(at, here.level), make(at, next.level));
2404
- }
2405
- return points;
2406
- }
2407
- /**
2408
- * The route drawn through `points`, with its text at the middle of the
2409
- * piece that lies on the stretch held clear for it.
2410
- */
2411
- function finish(points, carrier, { across, run }) {
2412
- const drawn = tidyRoute(points);
2413
- let mid = centerOf(boundingBox([a, b]));
2414
- let best = -1;
2415
- for (let index = 0; index + 1 < drawn.length; index += 1) {
2416
- const [p, q] = [drawn[index], drawn[index + 1]];
2417
- if (!carrier)
2418
- break;
2419
- if (Math.abs(p[across] - carrier.level) > 0.5 || Math.abs(q[across] - carrier.level) > 0.5) {
2420
- continue;
2421
- }
2422
- const length = Math.abs(q[run] - p[run]);
2423
- if (length > best) {
2424
- best = length;
2425
- mid = { x: (p.x + q.x) / 2, y: (p.y + q.y) / 2 };
2426
- }
2427
- }
2428
- return { points: drawn, mid };
2429
- }
2430
- /**
2431
- * The points from one end of the line to the level of its nearest stretch.
2432
- * `toward` is the way the line heads along its run from this end.
2433
- */
2434
- function approach(given, face, axes, level, toward) {
2435
- const { across, run, make } = axes;
2436
- const anchor = given.side !== undefined ? given : leaving(face, axes, level, toward);
2437
- const along = run === 'x' ? anchor.tx : anchor.ty;
2438
- const out = across === 'x' ? anchor.tx : anchor.ty;
2439
- if (along !== 0) {
2440
- // Out along the run, one way or the other, then across to the level.
2441
- const turn = make(anchor[run] + along * ROUTE_STUB, anchor[across]);
2442
- return [anchor, turn, make(turn[run], level)];
2443
- }
2444
- if ((level - anchor[across]) * out >= 0)
2445
- return [anchor, make(anchor[run], level)];
2446
- // The side faces away from the level, so the line steps round the back of
2447
- // its own node — the end the run is heading away from — to get there.
2448
- const stub = make(anchor[run], anchor[across] + out * ROUTE_STUB);
2449
- const back = toward > 0 ? lo(face, run) - ROUTE_STUB : hi(face, run) + ROUTE_STUB;
2450
- return [anchor, stub, make(back, stub[across]), make(back, level)];
2451
- }
2452
- /**
2453
- * An end naming no side leaves from the one facing the level it is going
2454
- * to, or, when the level is alongside the node, the one facing its way.
2455
- */
2456
- function leaving(face, { across, run }, level, toward) {
2457
- const center = centerOf(face);
2458
- const side = level > hi(face, across)
2459
- ? across === 'y' ? 'bottom' : 'right'
2460
- : level < lo(face, across)
2461
- ? across === 'y' ? 'top' : 'left'
2462
- : run === 'x'
2463
- ? toward > 0 ? 'right' : 'left'
2464
- : toward > 0 ? 'bottom' : 'top';
2465
- return anchorOn(face, side, side === 'top' || side === 'bottom' ? center.x : center.y);
2466
- }
2467
- }
2468
2413
  /** Which axis a clause's side sits on: above and below are a matter of y. */
2469
2414
  function sideAxis(pass) {
2470
2415
  return pass.direction === 'above' || pass.direction === 'below' ? 'y' : 'x';
@@ -2537,6 +2482,162 @@ function roundedPath(points, arrow = ARROW_LENGTH) {
2537
2482
  parts.push(`L ${round(last.x)} ${round(last.y)}`);
2538
2483
  return parts.join(' ');
2539
2484
  }
2485
+ /** The furthest a sweep reaches along a piece from the turn it rounds. */
2486
+ const SWEEP_REACH = 120;
2487
+ /** The nearest a sweep comes to another line, except where the two cross. */
2488
+ const SWEEP_AIR = 5;
2489
+ /**
2490
+ * A line through `points` that sweeps through its turns rather than cornering.
2491
+ *
2492
+ * A jog — a turn one way and straight back the other, as a line stepping into
2493
+ * a gap does — is one S from well back along the piece before it to well along
2494
+ * the piece after, the middle piece taken up entirely. Any other turn is a
2495
+ * quarter sweep, starting as far back along the piece coming in and ending as
2496
+ * far along the piece going out as the room allows. A turn may take all of an
2497
+ * end piece short of the arrowhead, and half of a piece it shares with the next
2498
+ * turn, so a U under a row is two sweeps meeting in its middle.
2499
+ *
2500
+ * Neither may cut into a box or come near another line; each is tried at its
2501
+ * widest and shrunk until clear. A jog that cannot clear as one S is swept as
2502
+ * two turns, and a turn at its smallest is the rounded corner a square line
2503
+ * has. So where the room is tight the line looks nearly square, and where there
2504
+ * is room it flows.
2505
+ */
2506
+ function sweptPath(points, boxes, neighbors, arrow, both) {
2507
+ const kappa = 0.5523;
2508
+ const count = points.length;
2509
+ const legLength = (leg) => Math.hypot(points[leg + 1].x - points[leg].x, points[leg + 1].y - points[leg].y);
2510
+ const heading = (leg) => {
2511
+ const length = legLength(leg);
2512
+ return { x: (points[leg + 1].x - points[leg].x) / length, y: (points[leg + 1].y - points[leg].y) / length };
2513
+ };
2514
+ // How much of a piece one of its turns may take.
2515
+ const room = (leg) => {
2516
+ const length = legLength(leg);
2517
+ if (leg === 0)
2518
+ return Math.max(0, length - (both ? arrow : 0));
2519
+ if (leg === count - 2)
2520
+ return Math.max(0, length - arrow);
2521
+ return length / 2;
2522
+ };
2523
+ const cubicAt = (p0, c1, c2, p3, t) => {
2524
+ const u = 1 - t;
2525
+ return {
2526
+ x: u * u * u * p0.x + 3 * u * u * t * c1.x + 3 * u * t * t * c2.x + t * t * t * p3.x,
2527
+ y: u * u * u * p0.y + 3 * u * u * t * c1.y + 3 * u * t * t * c2.y + t * t * t * p3.y,
2528
+ };
2529
+ };
2530
+ const distance = (p, a, b) => {
2531
+ const dx = b.x - a.x;
2532
+ const dy = b.y - a.y;
2533
+ const squared = dx * dx + dy * dy;
2534
+ const t = squared === 0 ? 0 : Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / squared));
2535
+ return Math.hypot(p.x - (a.x + dx * t), p.y - (a.y + dy * t));
2536
+ };
2537
+ // Whether a curve keeps out of every box and clear of every other line.
2538
+ const clear = (p0, c1, c2, p3) => {
2539
+ const steps = 24;
2540
+ const trace = Array.from({ length: steps + 1 }, (_, step) => cubicAt(p0, c1, c2, p3, step / steps));
2541
+ for (const point of trace) {
2542
+ if (boxes.some((box) => point.x > box.minX && point.x < box.maxX && point.y > box.minY && point.y < box.maxY)) {
2543
+ return false;
2544
+ }
2545
+ }
2546
+ for (const line of neighbors) {
2547
+ for (let index = 0; index + 1 < line.length; index += 1) {
2548
+ const [p, q] = [line[index], line[index + 1]];
2549
+ for (let step = 0; step < steps; step += 1) {
2550
+ if (intersect(trace[step], trace[step + 1], p, q))
2551
+ return false;
2552
+ }
2553
+ // The ends of the sweep sit on the line's own way, which already keeps
2554
+ // its lane; only the part cut inside the turn is held off.
2555
+ if (trace.slice(1, -1).some((point) => distance(point, p, q) < SWEEP_AIR))
2556
+ return false;
2557
+ }
2558
+ }
2559
+ return true;
2560
+ };
2561
+ const along = (from, direction, by) => ({
2562
+ x: from.x + direction.x * by,
2563
+ y: from.y + direction.y * by,
2564
+ });
2565
+ const parts = [`M ${round(points[0].x)} ${round(points[0].y)}`];
2566
+ const curve = (p0, c1, c2, p3) => {
2567
+ parts.push(`L ${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)}`);
2568
+ };
2569
+ for (let index = 1; index + 1 < count; index += 1) {
2570
+ const din = heading(index - 1);
2571
+ const dout = heading(index);
2572
+ const wantIn = Math.min(SWEEP_REACH, room(index - 1));
2573
+ // A jog: this turn and the next go opposite ways, so the line comes out
2574
+ // heading the way it went in.
2575
+ if (index + 2 < count) {
2576
+ const dafter = heading(index + 1);
2577
+ if (Math.abs(dafter.x - din.x) < 1e-6 && Math.abs(dafter.y - din.y) < 1e-6) {
2578
+ const wantOut = Math.min(SWEEP_REACH, room(index + 1));
2579
+ // The two ends shrink separately, since a neighbor often crowds only
2580
+ // one of them, and the widest S that clears wins.
2581
+ const sizes = (want) => {
2582
+ const found = [];
2583
+ for (let size = want; size >= ROUTE_RADIUS; size *= 0.8)
2584
+ found.push(size);
2585
+ return found;
2586
+ };
2587
+ const tries = sizes(wantIn)
2588
+ .flatMap((a) => sizes(wantOut).map((b) => [a, b]))
2589
+ .sort((p, q) => q[0] + q[1] - (p[0] + p[1]));
2590
+ const jog = tries
2591
+ .map(([a, b]) => {
2592
+ const p0 = along(points[index], din, -a);
2593
+ const p3 = along(points[index + 1], din, b);
2594
+ const run = (p3.x - p0.x) * din.x + (p3.y - p0.y) * din.y;
2595
+ return [p0, along(p0, din, run / 2), along(p3, din, -run / 2), p3];
2596
+ })
2597
+ .find((piece) => clear(...piece));
2598
+ if (jog) {
2599
+ curve(...jog);
2600
+ index += 1;
2601
+ continue;
2602
+ }
2603
+ }
2604
+ }
2605
+ const wantOut = Math.min(SWEEP_REACH, room(index));
2606
+ // The smallest a sweep gets: a square line's rounded corner.
2607
+ const least = Math.max(0, Math.min(ROUTE_RADIUS, wantIn, wantOut));
2608
+ let a = Math.max(0, wantIn);
2609
+ let b = Math.max(0, wantOut);
2610
+ const corner = points[index];
2611
+ const quarter = (a, b) => {
2612
+ const p0 = along(corner, din, -a);
2613
+ const p3 = along(corner, dout, b);
2614
+ return [p0, along(p0, din, a * kappa), along(p3, dout, -b * kappa), p3];
2615
+ };
2616
+ while ((a > least || b > least) && !clear(...quarter(a, b))) {
2617
+ a = Math.max(least, a * 0.85);
2618
+ b = Math.max(least, b * 0.85);
2619
+ }
2620
+ curve(...quarter(a, b));
2621
+ }
2622
+ const last = points[count - 1];
2623
+ parts.push(`L ${round(last.x)} ${round(last.y)}`);
2624
+ return parts.join(' ');
2625
+ }
2626
+ /**
2627
+ * What a sweep may not cut into: every box that holds neither end, with the
2628
+ * margin a line keeps from it, and the line's own two shrunk a pixel, since it
2629
+ * starts and ends on their sides.
2630
+ */
2631
+ function sweepObstacles(edge, nodes) {
2632
+ return [
2633
+ ...[edge.from, edge.to]
2634
+ .filter((node) => !faceOf(node).round)
2635
+ .map((node) => grow(extentOfBox(faceOf(node)), -1)),
2636
+ ...nodes
2637
+ .filter((node) => !contains(node, edge.from) && !contains(node, edge.to))
2638
+ .map((node) => grow(extentOfBox(faceOf(node)), ATTACH_MARGIN)),
2639
+ ];
2640
+ }
2540
2641
  function lo(box, axis) {
2541
2642
  return axis === 'x' ? box.x : box.y;
2542
2643
  }
@@ -2551,26 +2652,6 @@ function contains(outer, inner) {
2551
2652
  }
2552
2653
  return false;
2553
2654
  }
2554
- /**
2555
- * An end whose side the author did not name aims at the far box's center, which
2556
- * is the wrong thing to aim at once the line has been told to go somewhere else
2557
- * on the way. Point those ends at the corridor instead.
2558
- */
2559
- function aimFreeEnds(edges, ends, corridors) {
2560
- for (const edge of edges) {
2561
- const plan = corridors.get(edge);
2562
- if (!plan)
2563
- continue;
2564
- const current = ends.get(edge);
2565
- const start = current.start.side === undefined
2566
- ? free(faceOf(edge.from), corridorPoint(plan, plan.enter))
2567
- : current.start;
2568
- const end = current.end.side === undefined
2569
- ? free(faceOf(edge.to), corridorPoint(plan, plan.leave))
2570
- : current.end;
2571
- ends.set(edge, { start, end });
2572
- }
2573
- }
2574
2655
  /**
2575
2656
  * How much room an edge's text takes across the corridor — its depth in a
2576
2657
  * horizontal channel, its width in a vertical one. Zero for an edge with no text,
@@ -2586,48 +2667,17 @@ function laneExtent(edge, axis, measurer, fontSize) {
2586
2667
  return 0;
2587
2668
  return textExtent(edge.lines, edge.textAttrs, axis, measurer, fontSize, edge.line);
2588
2669
  }
2589
- function corridorPoint(plan, at) {
2590
- return plan.axis === 'y' ? { x: at, y: plan.lane } : { x: plan.lane, y: at };
2591
- }
2592
- /**
2593
- * The path a corridor edge takes: a curve out of its start into the gap, the
2594
- * straight run along the gap, and a curve out of the gap to its end. It is
2595
- * three pieces rather than one cubic because a single curve has no way to stay
2596
- * inside an interval over part of its length — which is the whole claim the
2597
- * author is making.
2598
- */
2599
- function corridorPath(start, end, plan) {
2600
- const p1 = corridorPoint(plan, plan.enter);
2601
- const p2 = corridorPoint(plan, plan.leave);
2602
- const forward = plan.leave >= plan.enter ? 1 : -1;
2603
- // Along the run the line travels one way, so that is its tangent at both ends
2604
- // of the straight stretch — it enters the gap already going where the gap goes.
2605
- const rt = plan.axis === 'y' ? { tx: forward, ty: 0 } : { tx: 0, ty: forward };
2606
- const r1 = corridorReach(start, p1, plan.axis);
2607
- const c1 = { x: start.x + start.tx * r1, y: start.y + start.ty * r1 };
2608
- const c2 = { x: p1.x - rt.tx * r1, y: p1.y - rt.ty * r1 };
2609
- const r2 = corridorReach(p2, end, plan.axis);
2610
- const c3 = { x: p2.x + rt.tx * r2, y: p2.y + rt.ty * r2 };
2611
- const c4 = { x: end.x + end.tx * r2, y: end.y + end.ty * r2 };
2612
- const d = [
2613
- `M ${round(start.x)} ${round(start.y)}`,
2614
- `C ${round(c1.x)} ${round(c1.y)}, ${round(c2.x)} ${round(c2.y)}, ${round(p1.x)} ${round(p1.y)}`,
2615
- `L ${round(p2.x)} ${round(p2.y)}`,
2616
- `C ${round(c3.x)} ${round(c3.y)}, ${round(c4.x)} ${round(c4.y)}, ${round(end.x)} ${round(end.y)}`,
2617
- ].join(' ');
2618
- const ink = union(cubicExtent(start, c1, c2, p1), cubicExtent(p2, c3, c4, end));
2619
- return { d, mid: { x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2 }, ink };
2620
- }
2621
- /**
2622
- * How far the handles reach on an approach curve. Bounded by half the distance
2623
- * available along the run as well as by the straight-line distance: both ends of
2624
- * this curve point along the run, so handles longer than that would reach past
2625
- * each other and bulge the line back the way it came.
2626
- */
2627
- function corridorReach(from, to, axis) {
2628
- const run = Math.abs(axis === 'y' ? to.x - from.x : to.y - from.y);
2629
- const distance = Math.hypot(to.x - from.x, to.y - from.y);
2630
- return Math.min(140, Math.max(8, Math.min(distance * 0.4, run / 2)));
2670
+ /** Where a line crosses the line across its gate, inside the gap: where its text rides. */
2671
+ function gateCrossing(points, gate) {
2672
+ for (let index = 0; index + 1 < points.length; index += 1) {
2673
+ const [p, q] = [points[index], points[index + 1]];
2674
+ const [a, b, level] = gate.run === 'x' ? [p.x, q.x, p.y] : [p.y, q.y, p.x];
2675
+ const along = gate.run === 'x' ? Math.abs(p.y - q.y) < 0.5 : Math.abs(p.x - q.x) < 0.5;
2676
+ if (along && level > gate.lo && level < gate.hi && Math.min(a, b) <= gate.at + 0.01 && Math.max(a, b) >= gate.at - 0.01) {
2677
+ return gate.run === 'x' ? { x: gate.at, y: level } : { x: level, y: gate.at };
2678
+ }
2679
+ }
2680
+ return points[Math.floor(points.length / 2)];
2631
2681
  }
2632
2682
  function sideAttr(edge, key) {
2633
2683
  const value = edge.attrs[key];