reladraw 0.10.0 → 0.11.1

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