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