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