reladraw 0.12.0 → 0.14.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.marks);
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
@@ -77,11 +91,19 @@ export function render(layout, options = {}) {
77
91
  width: Math.ceil(ink.maxX) - Math.floor(ink.minX),
78
92
  height: Math.ceil(ink.maxY) - Math.floor(ink.minY),
79
93
  };
80
- const arrowColors = new Set(layout.edges.map((edge) => lineOf(edge.appearance, theme, theme.edge)));
94
+ // One marker for each mark in each line color the drawing actually uses.
95
+ const markers = new Map();
96
+ for (const edge of layout.edges) {
97
+ const color = lineOf(edge.appearance, theme, theme.edge);
98
+ for (const mark of [edge.marks.from, edge.marks.to]) {
99
+ if (mark !== 'none')
100
+ markers.set(markerId(mark, color), [mark, color]);
101
+ }
102
+ }
81
103
  const svg = [
82
104
  `<svg xmlns="http://www.w3.org/2000/svg" width="${canvas.width}" height="${canvas.height}" viewBox="${canvas.x} ${canvas.y} ${canvas.width} ${canvas.height}" font-family=${quote(measurer.fontFamily)} font-size="${fontSize}px">`,
83
105
  ' <defs>',
84
- ...[...arrowColors].map((color) => arrowMarker(color)),
106
+ ...[...markers.values()].map(([mark, color]) => markMarker(mark, color, theme.background)),
85
107
  // Each mask covers the whole page, in page units: the default region is the
86
108
  // line's own bounding box, which a straight level line gives no height, and
87
109
  // the line would vanish entirely.
@@ -328,12 +350,12 @@ function drawIcon(icon, x, y, side, theme) {
328
350
  ' </g>',
329
351
  ].join('\n');
330
352
  }
331
- function drawEdge(edge, ends, corridor, route, nodes, theme, measurer, fontSize, markup) {
353
+ function drawEdge(edge, ends, route, sweep, nodes, theme, measurer, fontSize, markup) {
332
354
  const { start, end } = ends;
333
355
  const color = lineOf(edge.appearance, theme, theme.edge);
334
356
  const stroke = strokeOf(color, edge.look);
335
- const markerEnd = ` marker-end="url(#${markerId(color)})"`;
336
- const markerStart = edge.both ? ` marker-start="url(#${markerId(color)}-back)"` : '';
357
+ const markerEnd = edge.marks.to !== 'none' ? ` marker-end="url(#${markerId(edge.marks.to, color)})"` : '';
358
+ const markerStart = edge.marks.from !== 'none' ? ` marker-start="url(#${markerId(edge.marks.from, color)})"` : '';
337
359
  // A named side is a statement about how the line should leave or arrive, so
338
360
  // it is drawn as a curve that actually does leave and arrive that way. With
339
361
  // neither side named there is nothing to honor and the line stays straight.
@@ -346,7 +368,7 @@ function drawEdge(edge, ends, corridor, route, nodes, theme, measurer, fontSize,
346
368
  let ink = extentOfPoints([start, end]);
347
369
  let midX;
348
370
  let midY;
349
- const jointed = edge.look.path !== 'curved' && (route || corridor || curved || bowed);
371
+ const jointed = edge.look.path !== 'curved' && (route || curved || bowed);
350
372
  if (jointed) {
351
373
  // `path: square` and `path: straight` are the same fixed points — the ends,
352
374
  // which way each faces, the run a clause asked for — joined by a different
@@ -354,7 +376,7 @@ function drawEdge(edge, ends, corridor, route, nodes, theme, measurer, fontSize,
354
376
  const textWidth = edge.lines === undefined
355
377
  ? 0
356
378
  : widestLine(edge.lines, measurer, fontSizeFor('edge', edge.textAttrs, fontSize, edge.line));
357
- const plan = jointedLine(edge, ends, corridor, route, nodes, textWidth);
379
+ const plan = jointedLine(edge, ends, route, nodes, textWidth);
358
380
  const d = edge.look.corners === 'rounded'
359
381
  ? roundedPath(plan.points, arrowLength(edge.look.thickness))
360
382
  : sharpPath(plan.points);
@@ -364,20 +386,15 @@ function drawEdge(edge, ends, corridor, route, nodes, theme, measurer, fontSize,
364
386
  midY = plan.mid.y;
365
387
  }
366
388
  else if (route) {
367
- parts.push(` <path d="${roundedPath(route.points)}" fill="none"${stroke}${markerEnd}${markerStart}/>`);
389
+ // A curved line along a found way sweeps through each turn, as widely as
390
+ // the room around the turn allows — worked out for every line at once, in
391
+ // `render`, since each sweep keeps off the others.
392
+ parts.push(` <path d="${sweep}" fill="none"${stroke}${markerEnd}${markerStart}/>`);
393
+ // A sweep cuts inside its corners, so the way's own points bound it.
368
394
  ink = union(ink, extentOfPoints(route.points));
369
395
  midX = route.mid.x;
370
396
  midY = route.mid.y;
371
397
  }
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
398
  else if (curved) {
382
399
  const { p0, c1, c2, p3 } = sideCurve(edge, ends);
383
400
  const lead = p0.x !== start.x || p0.y !== start.y ? `M ${round(start.x)} ${round(start.y)} L` : 'M';
@@ -574,7 +591,7 @@ function strokeOf(color, look) {
574
591
  * point, and bends only where a clause — `below c`, `between a and b` — puts a
575
592
  * point it has to pass through.
576
593
  */
577
- function jointedLine(edge, ends, corridor, route, nodes, textWidth) {
594
+ function jointedLine(edge, ends, route, nodes, textWidth) {
578
595
  const { start, end } = ends;
579
596
  const square = edge.look.path === 'square';
580
597
  const stub = ROUTE_RADIUS + arrowLength(edge.look.thickness);
@@ -598,20 +615,6 @@ function jointedLine(edge, ends, corridor, route, nodes, textWidth) {
598
615
  ]);
599
616
  return { points, mid: route.mid };
600
617
  }
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
618
  const bow = ends.bow ?? { x: 0, y: 0 };
616
619
  const bowed = bow.x !== 0 || bow.y !== 0;
617
620
  if (!square) {
@@ -1239,9 +1242,9 @@ function planBundles(edges, measurer, fontSize) {
1239
1242
  * therefore derived rather than styled, and it is zero whenever the edge was
1240
1243
  * long enough, which is why the ordinary case is still a straight line.
1241
1244
  *
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.
1245
+ * A `between` edge is left out. Its way through the gap it named, lane
1246
+ * included, is searched for in `planWays`, which picks a side for any end
1247
+ * naming none.
1245
1248
  */
1246
1249
  function planSpreads(edges, measurer, fontSize) {
1247
1250
  const ids = new Map();
@@ -1591,67 +1594,31 @@ function gapBetween(a, b, aName, bName, wanted, line) {
1591
1594
  };
1592
1595
  }
1593
1596
  /**
1594
- * Route every edge that named a gap.
1597
+ * The gap each `between` edge named, and where along it the line must cross.
1595
1598
  *
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.
1599
+ * Only the gap: which lane a line takes through it, and how far it keeps from
1600
+ * the others there, is the search's, as it is for every line (see `planWays`).
1600
1601
  */
1601
- function planCorridors(edges, ends, measurer, fontSize) {
1602
+ function planCorridors(edges, ends) {
1602
1603
  const plans = new Map();
1603
- const groups = new Map();
1604
1604
  for (const edge of edges) {
1605
1605
  if (!edge.between)
1606
1606
  continue;
1607
1607
  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
- });
1608
+ const a = faceOf(first);
1609
+ const b = faceOf(second);
1610
+ const gap = gapBetween(a, b, first.name, second.name, edge.between.axis, edge.line);
1611
+ const run = gap.axis === 'y' ? 'x' : 'y';
1612
+ // The line passes the pair only if its own ends reach past their extent.
1613
+ const { start, end } = ends.get(edge);
1614
+ if (Math.min(gap.across[1], Math.max(start[run], end[run])) <= Math.max(gap.across[0], Math.min(start[run], end[run]))) {
1615
+ throw new SourceError(`this edge never passes between "${first.name}" and "${second.name}"`, edge.line);
1616
+ }
1617
+ // Where the two face each other, the middle of that; where they sit
1618
+ // diagonally, the middle of the space between them.
1619
+ const near = Math.max(lo(a, run), lo(b, run));
1620
+ const far = Math.min(hi(a, run), hi(b, run));
1621
+ plans.set(edge, { axis: gap.axis, lo: gap.lo, hi: gap.hi, at: (near + far) / 2 });
1655
1622
  }
1656
1623
  return plans;
1657
1624
  }
@@ -1663,33 +1630,46 @@ function planCorridors(edges, ends, measurer, fontSize) {
1663
1630
  * today. Where it is not, the way is searched for through the open space (see
1664
1631
  * `search.ts`), and the line is drawn along what was found.
1665
1632
  *
1666
- * A `between` edge and one with clauses are left to their own planning: the
1667
- * author said which way they go.
1633
+ * A line with clauses is the same line with rules added. `below m` fences off
1634
+ * everything above M within M's width (see `fenceOf`), and the direct line is
1635
+ * kept only if it keeps clear of the fences as well as the boxes; otherwise
1636
+ * the search goes round them as it goes round a box. A `between` line must pass
1637
+ * through the gap it names: the search carries the gap as a gate, and finds
1638
+ * its lane there with the rest of its way. A clause line whose shortest way
1639
+ * never reaches the side a clause names is sent through a point on that side,
1640
+ * searched for in legs into and out of it (see `clauseGate`).
1668
1641
  *
1669
1642
  * Lines searched for are placed shortest first, and each later one keeps a lane
1670
1643
  * 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.
1644
+ * nests outside a shorter one rather than drawing on top of it. Two lines that
1645
+ * cross are then placed again the other way round, and kept so if they cross
1646
+ * less.
1672
1647
  */
1673
- function planWays(edges, nodes, ends, corridors, routes, measurer, fontSize) {
1648
+ function planWays(edges, nodes, ends, corridors, measurer, fontSize) {
1649
+ const routes = new Map();
1674
1650
  const wanted = [];
1675
1651
  // Direct lines that turn twice, across a gap in its middle.
1676
1652
  const turning = [];
1677
1653
  for (const edge of edges) {
1678
- if (corridors.has(edge) || edge.passes || routes.has(edge))
1654
+ if (corridors.has(edge)) {
1655
+ wanted.push(edge);
1679
1656
  continue;
1657
+ }
1680
1658
  // A line from a container to something inside it lives inside the box it
1681
1659
  // leaves, and has no way round anything.
1682
- if (edge.from !== edge.to && (contains(edge.from, edge.to) || contains(edge.to, edge.from)))
1660
+ if (!edge.passes && edge.from !== edge.to && (contains(edge.from, edge.to) || contains(edge.to, edge.from)))
1683
1661
  continue;
1684
1662
  const plan = ends.get(edge);
1685
- const boxes = obstaclesFor(edge, nodes);
1663
+ // A fence is checked at the box's own edge here, not grown: a line leaving
1664
+ // M's bottom with `below m` starts on the fence's border, not inside it.
1665
+ const boxes = [...obstaclesFor(edge, nodes), ...(edge.passes ?? []).map((pass) => fenceOf(pass, 0))];
1686
1666
  const { start, end } = plan;
1687
1667
  if (start.side !== undefined && end.side !== undefined) {
1688
1668
  const direct = directWay(start, end);
1689
- if (direct && wayClear(direct, boxes)) {
1669
+ if (direct && wayClear(direct, boxes) && passesAll(edge, direct)) {
1690
1670
  // The curve is the direct way with its turns swept, and is kept — unless
1691
1671
  // it bulges into a box the direct way clears, when the way is drawn.
1692
- if (edge.look.path === 'curved' && curveHits(edge, plan, nodes)) {
1672
+ if (edge.passes || (edge.look.path === 'curved' && curveHits(edge, plan, boxes))) {
1693
1673
  routes.set(edge, { points: direct, mid: textOnWay(direct, edge, nodes, measurer, fontSize) });
1694
1674
  }
1695
1675
  if (direct.length === 4)
@@ -1698,10 +1678,10 @@ function planWays(edges, nodes, ends, corridors, routes, measurer, fontSize) {
1698
1678
  }
1699
1679
  }
1700
1680
  else if (start.side === undefined && end.side === undefined) {
1701
- if (wayClear([start, end], boxes))
1681
+ if (wayClear([start, end], boxes) && passesAll(edge, [start, end]))
1702
1682
  continue;
1703
1683
  }
1704
- else if (!curveHits(edge, plan, nodes)) {
1684
+ else if (!curveHits(edge, plan, boxes) && passesAll(edge, curveTrace(edge, plan))) {
1705
1685
  continue;
1706
1686
  }
1707
1687
  wanted.push(edge);
@@ -1712,16 +1692,364 @@ function planWays(edges, nodes, ends, corridors, routes, measurer, fontSize) {
1712
1692
  return Math.abs(end.x - start.x) + Math.abs(end.y - start.y);
1713
1693
  };
1714
1694
  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);
1695
+ // Lines between the same two sides, told the same things, go the same way
1696
+ // round, and the one placed first takes the inside lane. So the one whose end
1697
+ // sits furthest the way the line turns goes first: out of a left side and
1698
+ // down, the lowest end takes the inside, and none of them crosses another.
1699
+ const sameWay = (edge) => {
1700
+ const { start, end } = ends.get(edge);
1701
+ return [
1702
+ edge.from.name, edge.to.name, start.side, end.side, start.tx, start.ty, end.tx, end.ty,
1703
+ corridors.has(edge) ? 'between' : '',
1704
+ ...(edge.passes ?? []).map((pass) => pass.written),
1705
+ ].join(' ');
1706
+ };
1707
+ for (let index = 0; index < wanted.length; index += 1) {
1708
+ const key = sameWay(wanted[index]);
1709
+ const group = wanted.filter((edge) => sameWay(edge) === key);
1710
+ if (group.length < 2 || group[0] !== wanted[index] || corridors.has(group[0]))
1711
+ continue;
1712
+ const trial = searchWay(group[0], nodes, ends.get(group[0]).start, group[0].from, ends.get(group[0]).end, group[0].to, [], ATTACH_STEP);
1713
+ if (!trial || trial.points.length < 3)
1714
+ continue;
1715
+ const [, bend, after] = trial.points;
1716
+ const turn = { x: Math.sign(after.x - bend.x), y: Math.sign(after.y - bend.y) };
1717
+ const ordered = [...group].sort((p, q) => {
1718
+ const a = ends.get(p).start;
1719
+ const b = ends.get(q).start;
1720
+ return (b.x - a.x) * turn.x + (b.y - a.y) * turn.y;
1721
+ });
1722
+ const rest = wanted.filter((edge) => !group.includes(edge));
1723
+ rest.splice(index, 0, ...ordered);
1724
+ wanted.splice(0, wanted.length, ...rest);
1725
+ index += group.length - 1;
1726
+ }
1727
+ // Where several lines leave one side, their ends may be squeezed closer than
1728
+ // a lane; the lines then run out of them that close together, and the lanes
1729
+ // beyond keep the same spacing, so they nest rather than finding no room.
1730
+ const attached = [];
1731
+ for (const [edge, { start, end }] of ends) {
1732
+ attached.push({ node: edge.from, edge, anchor: start }, { node: edge.to, edge, anchor: end });
1733
+ }
1734
+ const laneFor = (edge) => {
1735
+ const { start, end } = ends.get(edge);
1736
+ let lane = ATTACH_STEP;
1737
+ for (const [node, anchor] of [[edge.from, start], [edge.to, end]]) {
1738
+ for (const other of attached) {
1739
+ if (other.edge === edge || other.node !== node)
1740
+ continue;
1741
+ if (other.anchor.tx !== anchor.tx || other.anchor.ty !== anchor.ty)
1742
+ continue;
1743
+ const apart = Math.hypot(other.anchor.x - anchor.x, other.anchor.y - anchor.y);
1744
+ if (apart > 0.5)
1745
+ lane = Math.min(lane, apart);
1746
+ }
1747
+ }
1748
+ return lane;
1749
+ };
1750
+ // The ends as planned, before a search chose a side for any end naming none,
1751
+ // so a line placed again chooses afresh.
1752
+ const planned = new Map(ends);
1753
+ const found = new Map();
1754
+ // For a placed line, per stretch, half the depth across it of its text where
1755
+ // the text rides, so a line beside that stretch keeps clear of the words.
1756
+ const halvesOf = (other) => {
1757
+ const { points, mid } = found.get(other);
1758
+ return points.slice(1).map((q, index) => {
1759
+ const p = points[index];
1760
+ if (other.lines === undefined)
1761
+ return 0;
1762
+ const level = Math.abs(p.y - q.y) < 0.5;
1763
+ const on = level
1764
+ ? 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
1765
+ : 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;
1766
+ return on ? laneExtent(other, level ? 'y' : 'x', measurer, fontSize) / 2 : 0;
1767
+ });
1768
+ };
1769
+ const place = (edge, placed) => {
1770
+ const plan = planned.get(edge);
1771
+ const lane = laneFor(edge);
1772
+ const taken = placed.map((other) => found.get(other).points);
1773
+ const takenHalves = placed.map(halvesOf);
1774
+ const used = [...ends].flatMap(([other, { start, end }]) => other === edge ? [] : [{ node: other.from, anchor: start }, { node: other.to, anchor: end }]);
1775
+ // The way through a run of stops in order, each a straight stretch entered
1776
+ // heading along it and left the same way; each leg between them is
1777
+ // searched on its own, with a straight stretch into and out of every stop.
1778
+ const through = (stops) => {
1779
+ let from = plan.start;
1780
+ let fromNode = edge.from;
1781
+ let first;
1782
+ const points = [];
1783
+ for (const { enter, leave, run } of stops) {
1784
+ const leg = searchWay(edge, nodes, from, fromNode, { x: enter.x, y: enter.y, tx: -run.x, ty: -run.y }, undefined, taken, lane, used, { takenHalves });
1785
+ if (!leg)
1786
+ return undefined;
1787
+ first ??= leg.start;
1788
+ points.push(...leg.points);
1789
+ from = { x: leave.x, y: leave.y, tx: run.x, ty: run.y };
1790
+ fromNode = undefined;
1791
+ }
1792
+ const last = searchWay(edge, nodes, from, fromNode, plan.end, edge.to, taken, lane, used, { takenHalves });
1793
+ if (!last)
1794
+ return undefined;
1795
+ return { start: first ?? last.start, end: last.end, points: tidyRoute([...points, ...last.points]) };
1796
+ };
1797
+ const corridor = corridors.get(edge);
1798
+ let way;
1799
+ let mid;
1800
+ if (corridor) {
1801
+ // Somewhere along its way the line crosses the gap, travelling along it;
1802
+ // which lane it takes there is found with the rest of the way, and its
1803
+ // text, which rides in the gap, keeps the lines beside it clear.
1804
+ const run = corridor.axis === 'y' ? 'x' : 'y';
1805
+ const gate = { run, at: corridor.at, lo: corridor.lo, hi: corridor.hi };
1806
+ const ownHalf = laneExtent(edge, corridor.axis, measurer, fontSize) / 2;
1807
+ way = searchWay(edge, nodes, plan.start, edge.from, plan.end, edge.to, taken, lane, used, { gate, ownHalf, takenHalves });
1808
+ if (!way)
1809
+ throw new SourceError(gateMessage(edge, corridor, nodes), edge.line);
1810
+ mid = gateCrossing(way.points, gate);
1811
+ }
1812
+ else {
1813
+ way = searchWay(edge, nodes, plan.start, edge.from, plan.end, edge.to, taken, lane, used, { takenHalves });
1814
+ if (!way)
1815
+ throw new SourceError(edge.passes ? clauseMessage(edge, edge.passes) : blockedMessage(edge, plan, nodes), edge.line);
1816
+ if (!passesAll(edge, way.points)) {
1817
+ // The shortest way keeps clear of every fence without ever coming round
1818
+ // to the side a clause names — a line leaving a node that sits over M,
1819
+ // told to pass below M, can simply leave sideways. It is sent through a
1820
+ // point on that side of each clause it missed, in the order they lie
1821
+ // along its way.
1822
+ const missed = edge.passes.filter((pass) => !alongside(way.points, pass));
1823
+ const gates = missed.map((pass) => clauseGate(pass, plan.start, plan.end));
1824
+ if (gates.some((gate) => gate === undefined)) {
1825
+ throw new SourceError(neverPassesMessage(edge, way.points), edge.line);
1826
+ }
1827
+ const travel = { x: plan.end.x - plan.start.x, y: plan.end.y - plan.start.y };
1828
+ const stops = gates
1829
+ .map((gate) => gate)
1830
+ .sort((p, q) => (p.at.x - q.at.x) * travel.x + (p.at.y - q.at.y) * travel.y)
1831
+ .map(({ at, run }) => ({ enter: at, leave: at, run }));
1832
+ way = through(stops);
1833
+ if (!way)
1834
+ throw new SourceError(clauseMessage(edge, edge.passes), edge.line);
1835
+ if (!passesAll(edge, way.points))
1836
+ throw new SourceError(neverPassesMessage(edge, way.points), edge.line);
1837
+ }
1720
1838
  }
1839
+ return { ...way, mid: mid ?? textOnWay(way.points, edge, nodes, measurer, fontSize) };
1840
+ };
1841
+ const order = [];
1842
+ const settle = (edge, way) => {
1843
+ found.set(edge, way);
1721
1844
  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);
1845
+ };
1846
+ for (const edge of wanted) {
1847
+ settle(edge, place(edge, [...order]));
1848
+ order.push(edge);
1849
+ }
1850
+ // Which line takes the inside of a shared stretch depends on which was placed
1851
+ // first, and the wrong order makes two lines cross where they need not. So a
1852
+ // pair that crosses is placed again the other way round, and kept that way if
1853
+ // it crosses less.
1854
+ const crossingsOf = (edge) => order.reduce((sum, other) => (other === edge ? sum : sum + linesCross(found.get(edge).points, found.get(other).points)), 0);
1855
+ for (let pass = 0; pass < 3; pass += 1) {
1856
+ let better = false;
1857
+ for (let i = 0; i < order.length; i += 1) {
1858
+ for (let j = i + 1; j < order.length; j += 1) {
1859
+ const [first, second] = [order[i], order[j]];
1860
+ if (linesCross(found.get(first).points, found.get(second).points) === 0)
1861
+ continue;
1862
+ const before = crossingsOf(first) + crossingsOf(second);
1863
+ const kept = [found.get(first), found.get(second)];
1864
+ const rest = order.filter((edge) => edge !== first && edge !== second);
1865
+ try {
1866
+ settle(second, place(second, rest));
1867
+ settle(first, place(first, [...rest, second]));
1868
+ }
1869
+ catch (error) {
1870
+ if (!(error instanceof SourceError))
1871
+ throw error;
1872
+ settle(first, kept[0]);
1873
+ settle(second, kept[1]);
1874
+ continue;
1875
+ }
1876
+ if (crossingsOf(first) + crossingsOf(second) < before) {
1877
+ order.splice(j, 1);
1878
+ order.splice(i, 0, second);
1879
+ better = true;
1880
+ }
1881
+ else {
1882
+ settle(first, kept[0]);
1883
+ settle(second, kept[1]);
1884
+ }
1885
+ }
1886
+ }
1887
+ if (!better)
1888
+ break;
1724
1889
  }
1890
+ // In the order placed, inside lanes first, which is the order sweeps are drawn in.
1891
+ for (const edge of order) {
1892
+ const { points, mid } = found.get(edge);
1893
+ routes.set(edge, { points, mid });
1894
+ }
1895
+ return routes;
1896
+ }
1897
+ /** How many times two lines cross, each drawn as straight pieces through its points. */
1898
+ function linesCross(a, b) {
1899
+ let count = 0;
1900
+ for (let i = 0; i + 1 < a.length; i += 1) {
1901
+ for (let j = 0; j + 1 < b.length; j += 1) {
1902
+ if (intersect(a[i], a[i + 1], b[j], b[j + 1]))
1903
+ count += 1;
1904
+ }
1905
+ }
1906
+ return count;
1907
+ }
1908
+ /** Far enough to stand for the edge of the page on any side. */
1909
+ const FAR = 1e5;
1910
+ /**
1911
+ * The region a clause fences a line out of: everything from its nodes to the
1912
+ * edge of the page on the far side, within their width or height. `below m` is
1913
+ * M's whole column above M's bottom; "wherever the line is within M's width, it
1914
+ * is below M" is exactly "the line never enters this". Grown by `by`, so the
1915
+ * search keeps the margin a box gets.
1916
+ */
1917
+ function fenceOf(pass, by) {
1918
+ const box = grow(extentOfBox(boundingBox(pass.nodes.map(faceOf))), by);
1919
+ switch (pass.direction) {
1920
+ case 'below':
1921
+ return { ...box, minY: -FAR };
1922
+ case 'above':
1923
+ return { ...box, maxY: FAR };
1924
+ case 'left':
1925
+ return { ...box, maxX: FAR };
1926
+ default:
1927
+ return { ...box, minX: -FAR };
1928
+ }
1929
+ }
1930
+ /** Whether a line along these points is alongside every clause's nodes somewhere. */
1931
+ function passesAll(edge, points) {
1932
+ return (edge.passes ?? []).every((pass) => alongside(points, pass));
1933
+ }
1934
+ /**
1935
+ * Whether the line passes a clause's nodes on the side it names somewhere:
1936
+ * some of it lies within their width (or height) and beyond their named side.
1937
+ * A line that is within their width only on the wrong side — leaving a node
1938
+ * that sits over M, say — has not passed M at all.
1939
+ */
1940
+ function alongside(points, pass) {
1941
+ const box = extentOfBox(boundingBox(pass.nodes.map(faceOf)));
1942
+ const across = sideAxis(pass) === 'y';
1943
+ const [lo, hi] = across ? [box.minX, box.maxX] : [box.minY, box.maxY];
1944
+ // The side named, as a test on the other coordinate.
1945
+ const beyond = (level) => pass.direction === 'below' ? level >= box.maxY - 0.5
1946
+ : pass.direction === 'above' ? level <= box.minY + 0.5
1947
+ : pass.direction === 'left' ? level <= box.minX + 0.5
1948
+ : level >= box.maxX - 0.5;
1949
+ for (let index = 0; index + 1 < points.length; index += 1) {
1950
+ const [p, q] = [points[index], points[index + 1]];
1951
+ const [a, b] = across ? [p.x, q.x] : [p.y, q.y];
1952
+ const [u, v] = across ? [p.y, q.y] : [p.x, q.x];
1953
+ const overlap = Math.min(hi, Math.max(a, b)) - Math.max(lo, Math.min(a, b));
1954
+ if (Math.abs(a - b) > 0.5) {
1955
+ // Travelling along the run: within the range, at one level.
1956
+ if (overlap > 0.5 && beyond(u))
1957
+ return true;
1958
+ }
1959
+ else if (a > lo && a < hi && (beyond(u) || beyond(v))) {
1960
+ // Travelling across the run, inside the range: reaching the named side.
1961
+ return true;
1962
+ }
1963
+ }
1964
+ return false;
1965
+ }
1966
+ /**
1967
+ * A point the line must pass through to be on a clause's named side of its
1968
+ * nodes: under the middle of them for `below`, a clear gap out, heading the way
1969
+ * the line travels. Undefined when the line's ends do not reach past the
1970
+ * nodes' width at all, so the clause has nothing to say about it.
1971
+ */
1972
+ function clauseGate(pass, start, end) {
1973
+ const box = extentOfBox(boundingBox(pass.nodes.map(faceOf)));
1974
+ const across = sideAxis(pass) === 'y';
1975
+ const [lo, hi] = across ? [box.minX, box.maxX] : [box.minY, box.maxY];
1976
+ const [a, b] = across ? [start.x, end.x] : [start.y, end.y];
1977
+ const from = Math.max(lo, Math.min(a, b));
1978
+ const to = Math.min(hi, Math.max(a, b));
1979
+ if (to - from < -0.5)
1980
+ return undefined;
1981
+ // The middle of the nodes, or as near it as the line's own span reaches.
1982
+ const middle = Math.max(Math.min(a, b), Math.min(Math.max(a, b), (lo + hi) / 2));
1983
+ const travel = Math.sign(b - a) || 1;
1984
+ const level = pass.direction === 'below' ? box.maxY + SEPARATION_GAP
1985
+ : pass.direction === 'above' ? box.minY - SEPARATION_GAP
1986
+ : pass.direction === 'left' ? box.minX - SEPARATION_GAP
1987
+ : box.maxX + SEPARATION_GAP;
1988
+ return across
1989
+ ? { at: { x: middle, y: level }, run: { x: travel, y: 0 } }
1990
+ : { at: { x: level, y: middle }, run: { x: 0, y: travel } };
1991
+ }
1992
+ function neverPassesMessage(edge, points) {
1993
+ const pass = edge.passes.find((one) => !alongside(points, one));
1994
+ return `edge ${edge.from.name} -> ${edge.to.name}: the line never passes ${quoteNames(pass.nodes)}, so ` +
1995
+ `"${pass.written}" says nothing about it`;
1996
+ }
1997
+ /**
1998
+ * Why no line keeps an edge's clauses. Two clauses that pull opposite ways over
1999
+ * the same stretch cannot both hold; two that pull opposite ways one after the
2000
+ * other need room between their nodes to cross over in. Otherwise the clauses
2001
+ * and the boxes between them leave no way through.
2002
+ */
2003
+ function clauseMessage(edge, passes) {
2004
+ const subject = `edge ${edge.from.name} -> ${edge.to.name}`;
2005
+ const opposite = { below: 'above', above: 'below', left: 'right', right: 'left' };
2006
+ for (const floor of passes) {
2007
+ for (const ceiling of passes) {
2008
+ if (opposite[floor.direction] !== ceiling.direction)
2009
+ continue;
2010
+ if (floor.direction !== 'below' && floor.direction !== 'right')
2011
+ continue;
2012
+ const f = extentOfBox(boundingBox(floor.nodes.map(faceOf)));
2013
+ const c = extentOfBox(boundingBox(ceiling.nodes.map(faceOf)));
2014
+ const across = sideAxis(floor) === 'y';
2015
+ // The floor's far edge and the ceiling's near edge, on the axis the clauses bound.
2016
+ const band = across ? c.minY - f.maxY : c.minX - f.maxX;
2017
+ if (band >= CROSSING_ROOM)
2018
+ continue;
2019
+ const [flo, fhi, clo, chi] = across ? [f.minX, f.maxX, c.minX, c.maxX] : [f.minY, f.maxY, c.minY, c.maxY];
2020
+ const apart = Math.max(clo - fhi, flo - chi);
2021
+ if (apart < 0) {
2022
+ return `${subject}: "${floor.written}" and "${ceiling.written}" cannot both hold — there is a stretch ` +
2023
+ `where the line is alongside both, and it cannot be ${sideWord(floor)} ${quoteNames(floor.nodes)} and ` +
2024
+ `${sideWord(ceiling)} ${quoteNames(ceiling.nodes)} at the same point. Drop one`;
2025
+ }
2026
+ if (apart < CROSSING_ROOM) {
2027
+ return `${subject}: to pass "${floor.written}" and "${ceiling.written}" the line has to cross over ` +
2028
+ `between ${quoteNames(floor.nodes)} and ${quoteNames(ceiling.nodes)}, and there is no room between ` +
2029
+ 'them — give the placement between them a gap, or drop one of the two';
2030
+ }
2031
+ }
2032
+ }
2033
+ const written = passes.map((pass) => `"${pass.written}"`);
2034
+ const listed = written.length === 1 ? written[0] : `${written.slice(0, -1).join(', ')} and ${written[written.length - 1]}`;
2035
+ return `${subject}: no line from ${edge.from.name} to ${edge.to.name} keeps ${listed} without passing through ` +
2036
+ 'another box. Give the nodes more room, or drop a clause';
2037
+ }
2038
+ /** Why a `between` line has no way into or out of its gap. */
2039
+ function gateMessage(edge, corridor, nodes) {
2040
+ const [a, b] = edge.between.nodes;
2041
+ // Straight across the gap where the line has to cross it.
2042
+ const [p1, p2] = corridor.axis === 'y'
2043
+ ? [{ x: corridor.at, y: corridor.lo }, { x: corridor.at, y: corridor.hi }]
2044
+ : [{ x: corridor.lo, y: corridor.at }, { x: corridor.hi, y: corridor.at }];
2045
+ const blocker = nodes.find((node) => node !== a && node !== b && !contains(node, a) && !contains(node, b) &&
2046
+ segmentHits(p1, p2, extentOfBox(faceOf(node))));
2047
+ if (blocker) {
2048
+ return `edge ${edge.from.name} -> ${edge.to.name}: the gap between "${a.name}" and "${b.name}" is blocked ` +
2049
+ `by ${blocker.name}, so the line cannot pass through it`;
2050
+ }
2051
+ return `edge ${edge.from.name} -> ${edge.to.name}: no way from ${edge.from.name} through the gap between ` +
2052
+ `"${a.name}" and "${b.name}" to ${edge.to.name} passes clear of the other boxes. Give them room, or name other sides`;
1725
2053
  }
1726
2054
  /**
1727
2055
  * Direct lines that turn in the middle of the same gap and share some of it
@@ -1869,12 +2197,20 @@ function textOnWay(points, edge, nodes, measurer, fontSize) {
1869
2197
  /** How much a turn costs the search, in the same units as length. */
1870
2198
  const SEARCH_TURN = SEPARATION_GAP * 2;
1871
2199
  /**
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.
2200
+ * The searched way for one edge, or one half of a `between` edge. A named side
2201
+ * is fixed; an end with no side named may leave or arrive by any of its box's
2202
+ * four, at that side's middle, and takes whichever makes the way cheapest. An
2203
+ * end with no node is a point in a gap, heading along it, and is fixed too.
2204
+ *
2205
+ * The edge's clauses are walls like the boxes: each fences off the region
2206
+ * `fenceOf` gives. An end starting inside a fence — a line leaving M's left
2207
+ * side with `below m` — crosses its margin on the way out, as it crosses its
2208
+ * own box's. An end heading into the fenced side — M's top with `below m` —
2209
+ * gets a stub's room before the fence begins, so it leaves, steps round, and
2210
+ * comes back below.
1875
2211
  */
1876
- function searchWay(edge, nodes, { start, end }, taken) {
1877
- const holders = (node) => nodes.filter((other) => contains(other, node));
2212
+ function searchWay(edge, nodes, start, startNode, end, endNode, taken, lane, used = [], more = {}) {
2213
+ const holders = (node) => node ? nodes.filter((other) => contains(other, node)) : [];
1878
2214
  const walls = new Map();
1879
2215
  for (const node of nodes) {
1880
2216
  // A container holding an end — one or both — is crossed, or lived inside,
@@ -1885,24 +2221,88 @@ function searchWay(edge, nodes, { start, end }, taken) {
1885
2221
  walls.set(node, wallOf(grow(extentOfBox(faceOf(node)), ATTACH_MARGIN)));
1886
2222
  }
1887
2223
  const ownOf = (node) => holders(node).map((holder) => walls.get(holder)).filter((wall) => wall !== undefined);
1888
- const choices = (anchor, node) => anchor.side !== undefined
2224
+ // An end with no side named takes a side's middle, or the nearest point
2225
+ // along it a step clear of where another line already meets that side.
2226
+ const choices = (anchor, node) => anchor.side !== undefined || !node
1889
2227
  ? [anchor]
1890
2228
  : ATTACH_SIDES.map((side) => {
1891
- const center = centerOf(faceOf(node));
1892
- return anchorOn(faceOf(node), side, side === 'top' || side === 'bottom' ? center.x : center.y);
2229
+ const face = faceOf(node);
2230
+ const center = centerOf(face);
2231
+ const level = side === 'top' || side === 'bottom';
2232
+ const middle = level ? center.x : center.y;
2233
+ const out = endOf(anchorOn(face, side, middle));
2234
+ const others = used
2235
+ .filter((one) => one.node === node)
2236
+ .map((one) => ({ at: one.anchor, heading: endOf(one.anchor) }))
2237
+ .filter(({ heading }) => heading.dx === out.dx && heading.dy === out.dy)
2238
+ .map(({ at }) => (level ? at.x : at.y));
2239
+ const [lo, hi] = level
2240
+ ? [face.x + ATTACH_MARGIN, face.x + face.width - ATTACH_MARGIN]
2241
+ : [face.y + ATTACH_MARGIN, face.y + face.height - ATTACH_MARGIN];
2242
+ const unused = (at) => others.every((other) => Math.abs(other - at) >= ATTACH_STEP - 0.5);
2243
+ let at = middle;
2244
+ for (let step = 1; !unused(at) && step <= 8; step += 1) {
2245
+ const candidates = [middle + step * ATTACH_STEP, middle - step * ATTACH_STEP]
2246
+ .filter((one) => one >= lo && one <= hi && unused(one));
2247
+ if (candidates[0] !== undefined)
2248
+ at = candidates[0];
2249
+ }
2250
+ return anchorOn(face, side, at);
1893
2251
  });
1894
2252
  const stub = ROUTE_STUB;
2253
+ const clear = SEPARATION_GAP - ATTACH_MARGIN;
2254
+ // Which way each fence runs off to the edge of the page.
2255
+ const far = { below: { x: 0, y: -1 }, above: { x: 0, y: 1 }, left: { x: 1, y: 0 }, right: { x: -1, y: 0 } };
1895
2256
  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()], {
2257
+ for (const from of choices(start, startNode)) {
2258
+ for (const to of choices(end, endNode)) {
2259
+ const s = endOf(from);
2260
+ const e = endOf(to);
2261
+ const ownStart = ownOf(startNode);
2262
+ const ownEnd = ownOf(endNode);
2263
+ const fences = [];
2264
+ for (const pass of edge.passes ?? []) {
2265
+ const fence = wallOf(fenceOf(pass, ATTACH_MARGIN));
2266
+ const away = far[pass.direction];
2267
+ let inStart = false;
2268
+ let inEnd = false;
2269
+ for (const [point, own] of [[s, ownStart], [e, ownEnd]]) {
2270
+ if (!within(point, fence))
2271
+ continue;
2272
+ if (point.dx === away.x && point.dy === away.y) {
2273
+ const room = stub + clear;
2274
+ if (away.y < 0)
2275
+ fence.maxY = Math.min(fence.maxY, point.y - room);
2276
+ if (away.y > 0)
2277
+ fence.minY = Math.max(fence.minY, point.y + room);
2278
+ if (away.x > 0)
2279
+ fence.minX = Math.max(fence.minX, point.x + room);
2280
+ if (away.x < 0)
2281
+ fence.maxX = Math.min(fence.maxX, point.x - room);
2282
+ }
2283
+ else if (own === ownStart) {
2284
+ inStart = true;
2285
+ }
2286
+ else {
2287
+ inEnd = true;
2288
+ }
2289
+ }
2290
+ fences.push(fence);
2291
+ if (inStart)
2292
+ ownStart.push(fence);
2293
+ if (inEnd)
2294
+ ownEnd.push(fence);
2295
+ }
2296
+ const found = searchRoute(s, e, [...walls.values(), ...fences], {
1899
2297
  stub,
1900
- clear: SEPARATION_GAP - ATTACH_MARGIN,
2298
+ clear,
1901
2299
  turn: SEARCH_TURN,
1902
- ownStart: ownOf(edge.from),
1903
- ownEnd: ownOf(edge.to),
2300
+ ownStart,
2301
+ ownEnd,
1904
2302
  taken,
1905
- lane: ATTACH_STEP,
2303
+ lane,
2304
+ ...more,
2305
+ bounds: wallOf(grow(nodes.map((node) => extentOfBox(faceOf(node))).reduce(union), ROUTE_STUB * 4)),
1906
2306
  });
1907
2307
  if (found && (!best || found.cost < best.cost - 1e-6))
1908
2308
  best = { start: from, end: to, ...found };
@@ -1957,7 +2357,11 @@ function blockedMessage(edge, { start, end }, nodes) {
1957
2357
  * such edge the long way round with square corners, which the user rejected
1958
2358
  * outright.
1959
2359
  */
1960
- function curveHits(edge, ends, nodes) {
2360
+ function curveHits(edge, ends, boxes) {
2361
+ return !wayClear(curveTrace(edge, ends), boxes);
2362
+ }
2363
+ /** The curve an edge with a named side is drawn as, as points close enough together to test against boxes. */
2364
+ function curveTrace(edge, ends) {
1961
2365
  const curve = sideCurve(edge, ends);
1962
2366
  const { p0, c1, c2, p3 } = curve;
1963
2367
  const at = (t) => {
@@ -1967,12 +2371,12 @@ function curveHits(edge, ends, nodes) {
1967
2371
  y: u * u * u * p0.y + 3 * u * u * t * c1.y + 3 * u * t * t * c2.y + t * t * t * p3.y,
1968
2372
  };
1969
2373
  };
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
2374
  const steps = 32;
1974
- const points = [curve.start, ...Array.from({ length: steps + 1 }, (_, step) => at(step / steps)), curve.end];
1975
- return !wayClear(points, boxes);
2375
+ return [curve.start, ...Array.from({ length: steps + 1 }, (_, step) => at(step / steps)), curve.end];
2376
+ }
2377
+ /** Whether a point lies strictly inside a wall. */
2378
+ function within(point, wall) {
2379
+ return point.x > wall.minX && point.x < wall.maxX && point.y > wall.minY && point.y < wall.maxY;
1976
2380
  }
1977
2381
  /**
1978
2382
  * The curve a line with a named side is drawn as. Its handles leave along each
@@ -1986,8 +2390,8 @@ function sideCurve(edge, { start, end, bow }) {
1986
2390
  const tip = (anchor, arrowed) => arrowed && anchor.side !== undefined
1987
2391
  ? { x: anchor.x + anchor.tx * head, y: anchor.y + anchor.ty * head }
1988
2392
  : { x: anchor.x, y: anchor.y };
1989
- const p0 = tip(start, edge.both);
1990
- const p3 = tip(end, true);
2393
+ const p0 = tip(start, edge.marks.from !== 'none');
2394
+ const p3 = tip(end, edge.marks.to !== 'none');
1991
2395
  const reach = controlReach(start, end);
1992
2396
  // A bundle whose sides were too short to spread it takes the rest of the
1993
2397
  // room in the middle — see `bowBundles`. Displacing both control points
@@ -2014,457 +2418,6 @@ const ROUTE_RADIUS = 20;
2014
2418
  const ROUTE_STUB = ROUTE_RADIUS + ARROW_LENGTH;
2015
2419
  /** The narrowest gap a route will cross over in, between two nodes it passes on opposite sides. */
2016
2420
  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
2421
  /** Which axis a clause's side sits on: above and below are a matter of y. */
2469
2422
  function sideAxis(pass) {
2470
2423
  return pass.direction === 'above' || pass.direction === 'below' ? 'y' : 'x';
@@ -2537,6 +2490,162 @@ function roundedPath(points, arrow = ARROW_LENGTH) {
2537
2490
  parts.push(`L ${round(last.x)} ${round(last.y)}`);
2538
2491
  return parts.join(' ');
2539
2492
  }
2493
+ /** The furthest a sweep reaches along a piece from the turn it rounds. */
2494
+ const SWEEP_REACH = 120;
2495
+ /** The nearest a sweep comes to another line, except where the two cross. */
2496
+ const SWEEP_AIR = 5;
2497
+ /**
2498
+ * A line through `points` that sweeps through its turns rather than cornering.
2499
+ *
2500
+ * A jog — a turn one way and straight back the other, as a line stepping into
2501
+ * a gap does — is one S from well back along the piece before it to well along
2502
+ * the piece after, the middle piece taken up entirely. Any other turn is a
2503
+ * quarter sweep, starting as far back along the piece coming in and ending as
2504
+ * far along the piece going out as the room allows. A turn may take all of an
2505
+ * end piece short of the arrowhead, and half of a piece it shares with the next
2506
+ * turn, so a U under a row is two sweeps meeting in its middle.
2507
+ *
2508
+ * Neither may cut into a box or come near another line; each is tried at its
2509
+ * widest and shrunk until clear. A jog that cannot clear as one S is swept as
2510
+ * two turns, and a turn at its smallest is the rounded corner a square line
2511
+ * has. So where the room is tight the line looks nearly square, and where there
2512
+ * is room it flows.
2513
+ */
2514
+ function sweptPath(points, boxes, neighbors, arrow, marks) {
2515
+ const kappa = 0.5523;
2516
+ const count = points.length;
2517
+ const legLength = (leg) => Math.hypot(points[leg + 1].x - points[leg].x, points[leg + 1].y - points[leg].y);
2518
+ const heading = (leg) => {
2519
+ const length = legLength(leg);
2520
+ return { x: (points[leg + 1].x - points[leg].x) / length, y: (points[leg + 1].y - points[leg].y) / length };
2521
+ };
2522
+ // How much of a piece one of its turns may take.
2523
+ const room = (leg) => {
2524
+ const length = legLength(leg);
2525
+ if (leg === 0)
2526
+ return Math.max(0, length - (marks.from !== 'none' ? arrow : 0));
2527
+ if (leg === count - 2)
2528
+ return Math.max(0, length - (marks.to !== 'none' ? arrow : 0));
2529
+ return length / 2;
2530
+ };
2531
+ const cubicAt = (p0, c1, c2, p3, t) => {
2532
+ const u = 1 - t;
2533
+ return {
2534
+ x: u * u * u * p0.x + 3 * u * u * t * c1.x + 3 * u * t * t * c2.x + t * t * t * p3.x,
2535
+ y: u * u * u * p0.y + 3 * u * u * t * c1.y + 3 * u * t * t * c2.y + t * t * t * p3.y,
2536
+ };
2537
+ };
2538
+ const distance = (p, a, b) => {
2539
+ const dx = b.x - a.x;
2540
+ const dy = b.y - a.y;
2541
+ const squared = dx * dx + dy * dy;
2542
+ const t = squared === 0 ? 0 : Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / squared));
2543
+ return Math.hypot(p.x - (a.x + dx * t), p.y - (a.y + dy * t));
2544
+ };
2545
+ // Whether a curve keeps out of every box and clear of every other line.
2546
+ const clear = (p0, c1, c2, p3) => {
2547
+ const steps = 24;
2548
+ const trace = Array.from({ length: steps + 1 }, (_, step) => cubicAt(p0, c1, c2, p3, step / steps));
2549
+ for (const point of trace) {
2550
+ if (boxes.some((box) => point.x > box.minX && point.x < box.maxX && point.y > box.minY && point.y < box.maxY)) {
2551
+ return false;
2552
+ }
2553
+ }
2554
+ for (const line of neighbors) {
2555
+ for (let index = 0; index + 1 < line.length; index += 1) {
2556
+ const [p, q] = [line[index], line[index + 1]];
2557
+ for (let step = 0; step < steps; step += 1) {
2558
+ if (intersect(trace[step], trace[step + 1], p, q))
2559
+ return false;
2560
+ }
2561
+ // The ends of the sweep sit on the line's own way, which already keeps
2562
+ // its lane; only the part cut inside the turn is held off.
2563
+ if (trace.slice(1, -1).some((point) => distance(point, p, q) < SWEEP_AIR))
2564
+ return false;
2565
+ }
2566
+ }
2567
+ return true;
2568
+ };
2569
+ const along = (from, direction, by) => ({
2570
+ x: from.x + direction.x * by,
2571
+ y: from.y + direction.y * by,
2572
+ });
2573
+ const parts = [`M ${round(points[0].x)} ${round(points[0].y)}`];
2574
+ const curve = (p0, c1, c2, p3) => {
2575
+ 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)}`);
2576
+ };
2577
+ for (let index = 1; index + 1 < count; index += 1) {
2578
+ const din = heading(index - 1);
2579
+ const dout = heading(index);
2580
+ const wantIn = Math.min(SWEEP_REACH, room(index - 1));
2581
+ // A jog: this turn and the next go opposite ways, so the line comes out
2582
+ // heading the way it went in.
2583
+ if (index + 2 < count) {
2584
+ const dafter = heading(index + 1);
2585
+ if (Math.abs(dafter.x - din.x) < 1e-6 && Math.abs(dafter.y - din.y) < 1e-6) {
2586
+ const wantOut = Math.min(SWEEP_REACH, room(index + 1));
2587
+ // The two ends shrink separately, since a neighbor often crowds only
2588
+ // one of them, and the widest S that clears wins.
2589
+ const sizes = (want) => {
2590
+ const found = [];
2591
+ for (let size = want; size >= ROUTE_RADIUS; size *= 0.8)
2592
+ found.push(size);
2593
+ return found;
2594
+ };
2595
+ const tries = sizes(wantIn)
2596
+ .flatMap((a) => sizes(wantOut).map((b) => [a, b]))
2597
+ .sort((p, q) => q[0] + q[1] - (p[0] + p[1]));
2598
+ const jog = tries
2599
+ .map(([a, b]) => {
2600
+ const p0 = along(points[index], din, -a);
2601
+ const p3 = along(points[index + 1], din, b);
2602
+ const run = (p3.x - p0.x) * din.x + (p3.y - p0.y) * din.y;
2603
+ return [p0, along(p0, din, run / 2), along(p3, din, -run / 2), p3];
2604
+ })
2605
+ .find((piece) => clear(...piece));
2606
+ if (jog) {
2607
+ curve(...jog);
2608
+ index += 1;
2609
+ continue;
2610
+ }
2611
+ }
2612
+ }
2613
+ const wantOut = Math.min(SWEEP_REACH, room(index));
2614
+ // The smallest a sweep gets: a square line's rounded corner.
2615
+ const least = Math.max(0, Math.min(ROUTE_RADIUS, wantIn, wantOut));
2616
+ let a = Math.max(0, wantIn);
2617
+ let b = Math.max(0, wantOut);
2618
+ const corner = points[index];
2619
+ const quarter = (a, b) => {
2620
+ const p0 = along(corner, din, -a);
2621
+ const p3 = along(corner, dout, b);
2622
+ return [p0, along(p0, din, a * kappa), along(p3, dout, -b * kappa), p3];
2623
+ };
2624
+ while ((a > least || b > least) && !clear(...quarter(a, b))) {
2625
+ a = Math.max(least, a * 0.85);
2626
+ b = Math.max(least, b * 0.85);
2627
+ }
2628
+ curve(...quarter(a, b));
2629
+ }
2630
+ const last = points[count - 1];
2631
+ parts.push(`L ${round(last.x)} ${round(last.y)}`);
2632
+ return parts.join(' ');
2633
+ }
2634
+ /**
2635
+ * What a sweep may not cut into: every box that holds neither end, with the
2636
+ * margin a line keeps from it, and the line's own two shrunk a pixel, since it
2637
+ * starts and ends on their sides.
2638
+ */
2639
+ function sweepObstacles(edge, nodes) {
2640
+ return [
2641
+ ...[edge.from, edge.to]
2642
+ .filter((node) => !faceOf(node).round)
2643
+ .map((node) => grow(extentOfBox(faceOf(node)), -1)),
2644
+ ...nodes
2645
+ .filter((node) => !contains(node, edge.from) && !contains(node, edge.to))
2646
+ .map((node) => grow(extentOfBox(faceOf(node)), ATTACH_MARGIN)),
2647
+ ];
2648
+ }
2540
2649
  function lo(box, axis) {
2541
2650
  return axis === 'x' ? box.x : box.y;
2542
2651
  }
@@ -2551,26 +2660,6 @@ function contains(outer, inner) {
2551
2660
  }
2552
2661
  return false;
2553
2662
  }
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
2663
  /**
2575
2664
  * How much room an edge's text takes across the corridor — its depth in a
2576
2665
  * horizontal channel, its width in a vertical one. Zero for an edge with no text,
@@ -2586,48 +2675,17 @@ function laneExtent(edge, axis, measurer, fontSize) {
2586
2675
  return 0;
2587
2676
  return textExtent(edge.lines, edge.textAttrs, axis, measurer, fontSize, edge.line);
2588
2677
  }
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)));
2678
+ /** Where a line crosses the line across its gate, inside the gap: where its text rides. */
2679
+ function gateCrossing(points, gate) {
2680
+ for (let index = 0; index + 1 < points.length; index += 1) {
2681
+ const [p, q] = [points[index], points[index + 1]];
2682
+ const [a, b, level] = gate.run === 'x' ? [p.x, q.x, p.y] : [p.y, q.y, p.x];
2683
+ const along = gate.run === 'x' ? Math.abs(p.y - q.y) < 0.5 : Math.abs(p.x - q.x) < 0.5;
2684
+ if (along && level > gate.lo && level < gate.hi && Math.min(a, b) <= gate.at + 0.01 && Math.max(a, b) >= gate.at - 0.01) {
2685
+ return gate.run === 'x' ? { x: gate.at, y: level } : { x: level, y: gate.at };
2686
+ }
2687
+ }
2688
+ return points[Math.floor(points.length / 2)];
2631
2689
  }
2632
2690
  function sideAttr(edge, key) {
2633
2691
  const value = edge.attrs[key];
@@ -2692,19 +2750,46 @@ function impliedSide(edge, key) {
2692
2750
  // Across first, then down: the start leaves across and the end is reached down.
2693
2751
  return key === 'from' ? across : upDown;
2694
2752
  }
2695
- function arrowMarker(color) {
2696
- const id = markerId(color);
2753
+ /**
2754
+ * The shape of each mark, in a 10 by 10 box whose right edge is the end of the
2755
+ * line, pointing along it. The marker scales with the stroke, so a unit is 0.7
2756
+ * of the line's width and an outline of 1.4 units is as thick as the line.
2757
+ *
2758
+ * An outlined mark is filled with the page's background so the line running
2759
+ * under it does not show through. Over a filled container that is the wrong
2760
+ * color inside the outline; nothing has hit it yet.
2761
+ */
2762
+ function markShape(mark, color, background) {
2763
+ const outline = `fill="${background}" stroke="${color}" stroke-width="1.4"`;
2764
+ switch (mark) {
2765
+ case 'arrow':
2766
+ return `<path d="M 0 0 L 10 5 L 0 10 z" fill="${color}"/>`;
2767
+ case 'oarrow':
2768
+ return `<path d="M 0.7 1.2 L 9.3 5 L 0.7 8.8 z" ${outline}/>`;
2769
+ case 'dot':
2770
+ return `<circle cx="5" cy="5" r="4" fill="${color}"/>`;
2771
+ case 'odot':
2772
+ return `<circle cx="5.3" cy="5" r="3.4" ${outline}/>`;
2773
+ case 'diamond':
2774
+ return `<path d="M 0 5 L 5 1.2 L 10 5 L 5 8.8 z" fill="${color}"/>`;
2775
+ case 'odiamond':
2776
+ return `<path d="M 0.9 5 L 5 1.9 L 9.1 5 L 5 8.1 z" ${outline}/>`;
2777
+ case 'bar':
2778
+ return `<rect x="5.5" y="0.5" width="1.4" height="9" fill="${color}"/>`;
2779
+ case 'none':
2780
+ return '';
2781
+ }
2782
+ }
2783
+ /** One marker, drawn at either end: `auto-start-reverse` turns it round at the start. */
2784
+ function markMarker(mark, color, background) {
2697
2785
  return [
2698
- ` <marker id="${id}" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="${ARROW_MARKER_WIDTH}" markerHeight="${ARROW_MARKER_WIDTH}" orient="auto-start-reverse">`,
2699
- ` <path d="M 0 0 L 10 5 L 0 10 z" fill="${color}"/>`,
2700
- ' </marker>',
2701
- ` <marker id="${id}-back" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="${ARROW_MARKER_WIDTH}" markerHeight="${ARROW_MARKER_WIDTH}" orient="auto-start-reverse">`,
2702
- ` <path d="M 0 0 L 10 5 L 0 10 z" fill="${color}"/>`,
2786
+ ` <marker id="${markerId(mark, color)}" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="${ARROW_MARKER_WIDTH}" markerHeight="${ARROW_MARKER_WIDTH}" orient="auto-start-reverse">`,
2787
+ ` ${markShape(mark, color, background)}`,
2703
2788
  ' </marker>',
2704
2789
  ].join('\n');
2705
2790
  }
2706
- function markerId(color) {
2707
- return `arrow-${color.replace(/[^a-zA-Z0-9]/g, '')}`;
2791
+ function markerId(mark, color) {
2792
+ return `${mark}-${color.replace(/[^a-zA-Z0-9]/g, '')}`;
2708
2793
  }
2709
2794
  /** The rectangle actually drawn. Differs from the node box only for a deck. */
2710
2795
  function faceOf(node) {