reladraw 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/render.js CHANGED
@@ -1,4 +1,4 @@
1
- import { ARROW_MARKER_WIDTH, ATTACH_MARGIN, ATTACH_STEP, DECK_STEP, DEFAULT_FONT_SIZE, ICON_LINES, LINE_WIDTH, PAD, fontSizeFor, textExtent, textStyleFor, widestLine, } from './constants.js';
1
+ import { ARROW_LENGTH, ARROW_MARKER_WIDTH, ATTACH_MARGIN, ATTACH_STEP, DECK_STEP, DEFAULT_FONT_SIZE, ICON_LINES, PAD, SEPARATION_GAP, arrowLength, fontSizeFor, textExtent, textStyleFor, widestLine, } from './constants.js';
2
2
  import { describeAxis } from './ast.js';
3
3
  import { SourceError } from './errors.js';
4
4
  import { ICON_STROKE } from './icons.js';
@@ -34,11 +34,34 @@ export function render(layout, options = {}) {
34
34
  // is ordered by where its ends turned out to be.
35
35
  const ends = planEndpoints(layout.edges, measurer, fontSize);
36
36
  const corridors = planCorridors(layout.edges, ends, measurer, fontSize);
37
+ const routes = planRoutes(layout.edges, layout.nodes, ends, measurer, fontSize);
38
+ planLoops(layout.edges, layout.nodes, ends, corridors, routes, measurer, fontSize);
37
39
  aimFreeEnds(layout.edges, ends, corridors);
38
- for (const edge of layout.edges) {
39
- const drawn = drawEdge(edge, ends.get(edge), corridors.get(edge), theme, measurer, fontSize, layout.markup);
40
- body.push(drawn.svg);
41
- ink = union(ink, grow(drawn.ink, layout.margin));
40
+ const drawn = layout.edges.map((edge) => drawEdge(edge, ends.get(edge), corridors.get(edge), routes.get(edge), layout.nodes, theme, measurer, fontSize, layout.markup));
41
+ // A line with a `crossing:` style is cut where it crosses an earlier one, and
42
+ // the jump drawn over the cut. The cut is a mask rather than a break in the
43
+ // path, so it works the same on a curve as on a corner, and leaves whatever
44
+ // lies underneath — a container's fill — showing through the gap.
45
+ const crossings = findCrossings(drawn);
46
+ const masks = [];
47
+ for (const edge of drawn) {
48
+ const found = crossings.get(edge);
49
+ const parts = [edge.line, ...edge.rest];
50
+ if (found) {
51
+ const id = `cut-${masks.length + 1}`;
52
+ masks.push({ id, holes: found });
53
+ parts[0] = edge.line.replace(/\/>$/, ` mask="url(#${id})"/>`);
54
+ const jumps = found
55
+ .map((crossing) => jumpAt(crossing, edge.edge.look.crossing))
56
+ .filter((d) => d !== undefined)
57
+ .map((d) => ` <path d="${d}" fill="none"${edge.stroke}/>`);
58
+ parts.splice(1, 0, ...jumps);
59
+ for (const crossing of found) {
60
+ edge.ink = union(edge.ink, grow(extentOfPoints([crossing.at]), crossing.reach + edge.edge.look.thickness));
61
+ }
62
+ }
63
+ body.push(linked(parts.join('\n'), edge.edge.attrs['url']));
64
+ ink = union(ink, grow(edge.ink, layout.margin));
42
65
  }
43
66
  // An edge's geometry is measured rather than solved for, so the resolver sized
44
67
  // the canvas from the boxes alone. A curve out of a `top` side, or a text
@@ -55,6 +78,15 @@ export function render(layout, options = {}) {
55
78
  `<svg xmlns="http://www.w3.org/2000/svg" width="${canvas.width}" height="${canvas.height}" viewBox="${canvas.x} ${canvas.y} ${canvas.width} ${canvas.height}" font-family=${quote(measurer.fontFamily)} font-size="${fontSize}px">`,
56
79
  ' <defs>',
57
80
  ...[...arrowColors].map((color) => arrowMarker(color)),
81
+ // Each mask covers the whole page, in page units: the default region is the
82
+ // line's own bounding box, which a straight level line gives no height, and
83
+ // the line would vanish entirely.
84
+ ...masks.map(({ id, holes }) => [
85
+ ` <mask id="${id}" maskUnits="userSpaceOnUse" x="${canvas.x}" y="${canvas.y}" width="${canvas.width}" height="${canvas.height}">`,
86
+ ` <rect x="${canvas.x}" y="${canvas.y}" width="${canvas.width}" height="${canvas.height}" fill="white"/>`,
87
+ ...holes.map((hole) => ` <circle cx="${round(hole.at.x)}" cy="${round(hole.at.y)}" r="${round(hole.reach)}" fill="black"/>`),
88
+ ' </mask>',
89
+ ].join('\n')),
58
90
  ' </defs>',
59
91
  ` <rect x="${canvas.x}" y="${canvas.y}" width="${canvas.width}" height="${canvas.height}" fill="${theme.background}"/>`,
60
92
  ...body,
@@ -252,16 +284,17 @@ function drawIcon(icon, x, y, side, theme) {
252
284
  ' </g>',
253
285
  ].join('\n');
254
286
  }
255
- // --- edges -------------------------------------------------------------------
256
- function drawEdge(edge, ends, corridor, theme, measurer, fontSize, markup) {
287
+ function drawEdge(edge, ends, corridor, route, nodes, theme, measurer, fontSize, markup) {
257
288
  const { start, end } = ends;
258
289
  const color = lineOf(edge.appearance, theme.edge);
290
+ const stroke = strokeOf(color, edge.look);
259
291
  const markerEnd = ` marker-end="url(#${markerId(color)})"`;
260
292
  const markerStart = edge.both ? ` marker-start="url(#${markerId(color)}-back)"` : '';
261
293
  // A named side is a statement about how the line should leave or arrive, so
262
294
  // it is drawn as a curve that actually does leave and arrive that way. With
263
295
  // neither side named there is nothing to honor and the line stays straight.
264
296
  const curved = start.side !== undefined || end.side !== undefined;
297
+ const bowed = ends.bow !== undefined && (ends.bow.x !== 0 || ends.bow.y !== 0);
265
298
  const parts = [];
266
299
  // What the line actually covers, so the canvas can be sized to hold it. A
267
300
  // curve leaving a `top` side rides above every box in the drawing, and the
@@ -269,10 +302,33 @@ function drawEdge(edge, ends, corridor, theme, measurer, fontSize, markup) {
269
302
  let ink = extentOfPoints([start, end]);
270
303
  let midX;
271
304
  let midY;
272
- if (corridor) {
305
+ const jointed = edge.look.path !== 'curved' && (route || corridor || curved || bowed);
306
+ if (jointed) {
307
+ // `path: square` and `path: straight` are the same fixed points — the ends,
308
+ // which way each faces, the run a clause asked for — joined by a different
309
+ // rule, and both come out as straight pieces meeting at corners.
310
+ const textWidth = edge.lines === undefined
311
+ ? 0
312
+ : widestLine(edge.lines, measurer, fontSizeFor('edge', edge.textAttrs, fontSize, edge.line));
313
+ const plan = jointedLine(edge, ends, corridor, route, nodes, textWidth);
314
+ const d = edge.look.corners === 'rounded'
315
+ ? roundedPath(plan.points, arrowLength(edge.look.thickness))
316
+ : sharpPath(plan.points);
317
+ parts.push(` <path d="${d}" fill="none"${stroke}${markerEnd}${markerStart}/>`);
318
+ ink = union(ink, extentOfPoints(plan.points));
319
+ midX = plan.mid.x;
320
+ midY = plan.mid.y;
321
+ }
322
+ else if (route) {
323
+ parts.push(` <path d="${roundedPath(route.points)}" fill="none"${stroke}${markerEnd}${markerStart}/>`);
324
+ ink = union(ink, extentOfPoints(route.points));
325
+ midX = route.mid.x;
326
+ midY = route.mid.y;
327
+ }
328
+ else if (corridor) {
273
329
  const path = corridorPath(start, end, corridor);
274
330
  ink = union(ink, path.ink);
275
- parts.push(` <path d="${path.d}" fill="none" stroke="${color}" stroke-width="${LINE_WIDTH}"${markerEnd}${markerStart}/>`);
331
+ parts.push(` <path d="${path.d}" fill="none"${stroke}${markerEnd}${markerStart}/>`);
276
332
  // The text goes on the straight run rather than at the midpoint of the
277
333
  // whole path, so it sits in the gap the author asked the line to travel.
278
334
  midX = path.mid.x;
@@ -289,13 +345,13 @@ function drawEdge(edge, ends, corridor, theme, measurer, fontSize, markup) {
289
345
  const by = (ends.bow?.y ?? 0) * lift;
290
346
  const c1 = { x: start.x + start.tx * reach + bx, y: start.y + start.ty * reach + by };
291
347
  const c2 = { x: end.x + end.tx * reach + bx, y: end.y + end.ty * reach + by };
292
- 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="${color}" stroke-width="${LINE_WIDTH}"${markerEnd}${markerStart}/>`);
348
+ 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}/>`);
293
349
  ink = union(ink, cubicExtent(start, c1, c2, end));
294
350
  // The point halfway along a cubic, which is where the text belongs.
295
351
  midX = (start.x + 3 * c1.x + 3 * c2.x + end.x) / 8;
296
352
  midY = (start.y + 3 * c1.y + 3 * c2.y + end.y) / 8;
297
353
  }
298
- else if (ends.bow && (ends.bow.x !== 0 || ends.bow.y !== 0)) {
354
+ else if (ends.bow && bowed) {
299
355
  // A straight line that could not get the room it needed at its ends, so it
300
356
  // takes it in the middle. Both control points carry the same displacement,
301
357
  // which keeps the arc symmetric; a cubic's middle moves three quarters of
@@ -310,13 +366,13 @@ function drawEdge(edge, ends, corridor, theme, measurer, fontSize, markup) {
310
366
  x: end.x - run.x + ends.bow.x * lift,
311
367
  y: end.y - run.y + ends.bow.y * lift,
312
368
  };
313
- 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="${color}" stroke-width="${LINE_WIDTH}"${markerEnd}${markerStart}/>`);
369
+ 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}/>`);
314
370
  ink = union(ink, cubicExtent(start, c1, c2, end));
315
371
  midX = (start.x + 3 * c1.x + 3 * c2.x + end.x) / 8;
316
372
  midY = (start.y + 3 * c1.y + 3 * c2.y + end.y) / 8;
317
373
  }
318
374
  else {
319
- parts.push(` <line x1="${round(start.x)}" y1="${round(start.y)}" x2="${round(end.x)}" y2="${round(end.y)}" stroke="${color}" stroke-width="${LINE_WIDTH}"${markerEnd}${markerStart}/>`);
375
+ parts.push(` <line x1="${round(start.x)}" y1="${round(start.y)}" x2="${round(end.x)}" y2="${round(end.y)}"${stroke}${markerEnd}${markerStart}/>`);
320
376
  midX = (start.x + end.x) / 2;
321
377
  midY = (start.y + end.y) / 2;
322
378
  }
@@ -349,7 +405,393 @@ function drawEdge(edge, ends, corridor, theme, measurer, fontSize, markup) {
349
405
  }), size, fontSize));
350
406
  }
351
407
  // The stroke straddles the path, so half of it lies outside the geometry.
352
- return { svg: linked(parts.join('\n'), edge.attrs['url']), ink: grow(ink, LINE_WIDTH / 2) };
408
+ const [line, ...rest] = parts;
409
+ return {
410
+ edge,
411
+ line: line,
412
+ rest,
413
+ ink: grow(ink, edge.look.thickness / 2),
414
+ trace: traceOf(line),
415
+ color,
416
+ stroke,
417
+ };
418
+ }
419
+ /**
420
+ * The stroke of a line: its color, its thickness and its pattern. The dashes
421
+ * are measured in thicknesses, so a pattern keeps its proportions on a thick
422
+ * line, which covers most of what a density setting would be for. A solid line
423
+ * of the ordinary thickness writes exactly what every line always did.
424
+ */
425
+ function strokeOf(color, look) {
426
+ const w = look.thickness;
427
+ const dashes = {
428
+ solid: '',
429
+ dashed: ` stroke-dasharray="${round(w * 4)} ${round(w * 3)}"`,
430
+ // A zero-length dash with a round cap is a dot as wide as the line.
431
+ dotted: ` stroke-dasharray="0 ${round(w * 3)}" stroke-linecap="round"`,
432
+ 'dash-dot': ` stroke-dasharray="${round(w * 5)} ${round(w * 3)} 0 ${round(w * 3)}" stroke-linecap="round"`,
433
+ };
434
+ return ` stroke="${color}" stroke-width="${w}"${dashes[look.pattern]}`;
435
+ }
436
+ /**
437
+ * The points of a square or straight line, and where its text sits.
438
+ *
439
+ * Both start from what the file fixed. A square line leaves each side head-on
440
+ * and turns only at right angles; a straight one goes directly from point to
441
+ * point, and bends only where a clause — `below c`, `between a and b` — puts a
442
+ * point it has to pass through.
443
+ */
444
+ function jointedLine(edge, ends, corridor, route, nodes, textWidth) {
445
+ const { start, end } = ends;
446
+ const square = edge.look.path === 'square';
447
+ const stub = ROUTE_RADIUS + arrowLength(edge.look.thickness);
448
+ const fromFace = faceOf(edge.from);
449
+ const toFace = faceOf(edge.to);
450
+ if (route) {
451
+ if (square)
452
+ return { points: route.points, mid: route.mid };
453
+ const points = straighten(route.points, edge, nodes);
454
+ return { points, mid: textSpot(points, textWidth) };
455
+ }
456
+ if (corridor) {
457
+ const p1 = corridorPoint(corridor, corridor.enter);
458
+ const p2 = corridorPoint(corridor, corridor.leave);
459
+ const mid = { x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2 };
460
+ if (!square)
461
+ return { points: tidyRoute([start, p1, p2, end]), mid };
462
+ const forward = corridor.leave >= corridor.enter ? 1 : -1;
463
+ const run = corridor.axis === 'y' ? { x: forward, y: 0 } : { x: 0, y: forward };
464
+ const points = tidyRoute([
465
+ ...rightAngles(start, headingOf(start, fromFace), p1, { x: -run.x, y: -run.y }, stub),
466
+ ...rightAngles(p2, run, end, headingOf(end, toFace), stub),
467
+ ]);
468
+ return { points, mid };
469
+ }
470
+ const bow = ends.bow ?? { x: 0, y: 0 };
471
+ const bowed = bow.x !== 0 || bow.y !== 0;
472
+ if (!square) {
473
+ // A bowed line is one of a crowded group, and its middle is the only room
474
+ // left for its text, so it bends there.
475
+ const middle = { x: (start.x + end.x) / 2 + bow.x, y: (start.y + end.y) / 2 + bow.y };
476
+ const points = bowed ? [start, middle, end] : [start, end];
477
+ return { points, mid: bowed ? middle : textSpot(points, textWidth) };
478
+ }
479
+ const out = headingOf(start, fromFace);
480
+ const back = headingOf(end, toFace);
481
+ // A bow is room for the texts of a crowded group, taken in the middle. Between
482
+ // sides that face each other that middle is a run the lanes can widen along;
483
+ // at right angles there is none, and the text finds room on a level piece.
484
+ if (!bowed || (out.x !== 0) !== (back.x !== 0)) {
485
+ const points = tidyRoute(rightAngles(start, out, end, back, stub));
486
+ return { points, mid: textSpot(points, textWidth) };
487
+ }
488
+ // Out of each side, across by the bow, and joined at right angles.
489
+ const a = { x: start.x + out.x * stub, y: start.y + out.y * stub };
490
+ const b = { x: end.x + back.x * stub, y: end.y + back.y * stub };
491
+ const points = tidyRoute(squareUp([start, a, { x: a.x + bow.x, y: a.y + bow.y }, { x: b.x + bow.x, y: b.y + bow.y }, b, end]));
492
+ return { points, mid: textSpot(points, textWidth) };
493
+ }
494
+ /**
495
+ * Which way an end points, as one of the four directions. A named side says so;
496
+ * an end the renderer placed is on some side of its box, and that is the one.
497
+ */
498
+ function headingOf(anchor, face) {
499
+ if (anchor.side !== undefined)
500
+ return { x: anchor.tx, y: anchor.ty };
501
+ const candidates = [
502
+ [Math.abs(anchor.x - face.x), { x: -1, y: 0 }],
503
+ [Math.abs(anchor.x - (face.x + face.width)), { x: 1, y: 0 }],
504
+ [Math.abs(anchor.y - face.y), { x: 0, y: -1 }],
505
+ [Math.abs(anchor.y - (face.y + face.height)), { x: 0, y: 1 }],
506
+ ];
507
+ candidates.sort((p, q) => p[0] - q[0]);
508
+ return candidates[0][1];
509
+ }
510
+ /**
511
+ * A right-angled line from `from`, leaving along `out`, to `to`, arriving
512
+ * against `back` — `back` points out of the far side, so the line's last piece
513
+ * travels the opposite way. The fewest turns that leave and arrive head-on:
514
+ * none when the two face each other in line, one when they are at right angles
515
+ * and the corner lies ahead of both, two in the middle when they face each
516
+ * other offset, and a way round past a stub at each end otherwise.
517
+ */
518
+ function rightAngles(from, out, to, back, stub) {
519
+ const across = out.x !== 0;
520
+ const parallel = across === (back.x !== 0);
521
+ const ahead = (p, q, d) => (q.x - p.x) * d.x + (q.y - p.y) * d.y;
522
+ if (parallel) {
523
+ const facing = out.x * back.x + out.y * back.y < 0;
524
+ if (facing && ahead(from, to, out) > 0) {
525
+ if (Math.abs(across ? to.y - from.y : to.x - from.x) < 0.5)
526
+ return [from, to];
527
+ const m = across ? (from.x + to.x) / 2 : (from.y + to.y) / 2;
528
+ return across
529
+ ? [from, { x: m, y: from.y }, { x: m, y: to.y }, to]
530
+ : [from, { x: from.x, y: m }, { x: to.x, y: m }, to];
531
+ }
532
+ if (!facing) {
533
+ // Both sides face the same way: out past whichever is further, and back.
534
+ const far = across
535
+ ? (out.x > 0 ? Math.max(from.x, to.x) : Math.min(from.x, to.x)) + out.x * stub
536
+ : (out.y > 0 ? Math.max(from.y, to.y) : Math.min(from.y, to.y)) + out.y * stub;
537
+ return across
538
+ ? [from, { x: far, y: from.y }, { x: far, y: to.y }, to]
539
+ : [from, { x: from.x, y: far }, { x: to.x, y: far }, to];
540
+ }
541
+ // Facing, but the far end is behind: out, across the middle, and in.
542
+ const a = { x: from.x + out.x * stub, y: from.y + out.y * stub };
543
+ const b = { x: to.x + back.x * stub, y: to.y + back.y * stub };
544
+ const m = across ? (from.y + to.y) / 2 : (from.x + to.x) / 2;
545
+ return across
546
+ ? [from, a, { x: a.x, y: m }, { x: b.x, y: m }, b, to]
547
+ : [from, a, { x: m, y: a.y }, { x: m, y: b.y }, b, to];
548
+ }
549
+ const corner = across ? { x: to.x, y: from.y } : { x: from.x, y: to.y };
550
+ if (ahead(from, corner, out) > 0 && ahead(to, corner, back) > 0)
551
+ return [from, corner, to];
552
+ // The one corner lies behind an end: step out of both sides first and join
553
+ // the two stubs with whichever corner does not double back.
554
+ const a = { x: from.x + out.x * stub, y: from.y + out.y * stub };
555
+ const b = { x: to.x + back.x * stub, y: to.y + back.y * stub };
556
+ const options = [
557
+ { x: b.x, y: a.y },
558
+ { x: a.x, y: b.y },
559
+ ];
560
+ const turn = options.find((c) => ahead(a, c, out) >= 0 && ahead(b, c, back) >= 0) ?? options[across ? 1 : 0];
561
+ return [from, a, turn, b, to];
562
+ }
563
+ /** Put a right-angled corner between any two points that are not in line. */
564
+ function squareUp(points) {
565
+ const out = [points[0]];
566
+ let alongX = true;
567
+ for (let index = 1; index < points.length; index += 1) {
568
+ const a = out[out.length - 1];
569
+ const b = points[index];
570
+ const dx = Math.abs(b.x - a.x) >= 0.5;
571
+ const dy = Math.abs(b.y - a.y) >= 0.5;
572
+ if (dx && dy) {
573
+ out.push(alongX ? { x: b.x, y: a.y } : { x: a.x, y: b.y });
574
+ }
575
+ else if (dx || dy) {
576
+ alongX = dx;
577
+ }
578
+ out.push(b);
579
+ }
580
+ return out;
581
+ }
582
+ /**
583
+ * A route's points with every corner dropped that a straight piece can skip.
584
+ * The route already keeps each clause; a straight piece may cut a corner only
585
+ * where it stays clear of every box the route kept clear of, which keeps the
586
+ * clauses too — a piece between two points below a node, touching nothing, is
587
+ * below it all the way.
588
+ */
589
+ function straighten(points, edge, nodes) {
590
+ const own = [faceOf(edge.from), faceOf(edge.to)].map((box) => grow(extentOfBox(box), -1));
591
+ const others = nodes
592
+ .filter((node) => !contains(node, edge.from) && !contains(node, edge.to))
593
+ .map((node) => grow(extentOfBox(faceOf(node)), ATTACH_MARGIN / 2));
594
+ const clear = (a, b) => ![...own, ...others].some((box) => segmentHits(a, b, box));
595
+ const kept = [points[0]];
596
+ let at = 0;
597
+ while (at < points.length - 1) {
598
+ let next = at + 1;
599
+ for (let far = points.length - 1; far > at + 1; far -= 1) {
600
+ if (clear(points[at], points[far])) {
601
+ next = far;
602
+ break;
603
+ }
604
+ }
605
+ kept.push(points[next]);
606
+ at = next;
607
+ }
608
+ return kept;
609
+ }
610
+ function extentOfBox(box) {
611
+ return { minX: box.x, minY: box.y, maxX: box.x + box.width, maxY: box.y + box.height };
612
+ }
613
+ /** Whether the segment from `a` to `b` passes through the inside of `box`. */
614
+ function segmentHits(a, b, box) {
615
+ if (box.maxX <= box.minX || box.maxY <= box.minY)
616
+ return false;
617
+ let t0 = 0;
618
+ let t1 = 1;
619
+ const dx = b.x - a.x;
620
+ const dy = b.y - a.y;
621
+ const edges = [
622
+ [-dx, a.x - box.minX],
623
+ [dx, box.maxX - a.x],
624
+ [-dy, a.y - box.minY],
625
+ [dy, box.maxY - a.y],
626
+ ];
627
+ for (const [p, q] of edges) {
628
+ if (p === 0) {
629
+ if (q <= 0)
630
+ return false;
631
+ continue;
632
+ }
633
+ const t = q / p;
634
+ if (p < 0)
635
+ t0 = Math.max(t0, t);
636
+ else
637
+ t1 = Math.min(t1, t);
638
+ if (t0 >= t1)
639
+ return false;
640
+ }
641
+ return true;
642
+ }
643
+ /**
644
+ * Where a jointed line's text rides: the middle of its longest level piece, if
645
+ * one is long enough to hold the text with line showing either side, and the
646
+ * middle of its longest piece otherwise. A level piece comes first because a
647
+ * text knocks a hole as wide as itself, which on an upright piece is a hole far
648
+ * wider than the line — and lines grouped on one side sit a text's height apart
649
+ * on their level pieces, never a text's width apart on their upright ones.
650
+ */
651
+ function textSpot(points, textWidth) {
652
+ let best = { x: (points[0].x + points[points.length - 1].x) / 2, y: (points[0].y + points[points.length - 1].y) / 2 };
653
+ let level;
654
+ let longest = -1;
655
+ let longestLevel = -1;
656
+ for (let index = 1; index < points.length; index += 1) {
657
+ const a = points[index - 1];
658
+ const b = points[index];
659
+ const here = Math.hypot(b.x - a.x, b.y - a.y);
660
+ const middle = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
661
+ if (here > longest + 0.5) {
662
+ longest = here;
663
+ best = middle;
664
+ }
665
+ if (Math.abs(b.y - a.y) < 0.5 && here >= textWidth + ARROW_LENGTH * 2 && here > longestLevel + 0.5) {
666
+ longestLevel = here;
667
+ level = middle;
668
+ }
669
+ }
670
+ return level ?? best;
671
+ }
672
+ function sharpPath(points) {
673
+ return points.map((point, index) => `${index === 0 ? 'M' : 'L'} ${round(point.x)} ${round(point.y)}`).join(' ');
674
+ }
675
+ /**
676
+ * A drawn line's element read back as a polyline, curves flattened. Reading
677
+ * the element rather than each drawing branch keeping its own record means the
678
+ * crossings are found on exactly what was drawn.
679
+ */
680
+ function traceOf(element) {
681
+ const line = /<line x1="([-\d.]+)" y1="([-\d.]+)" x2="([-\d.]+)" y2="([-\d.]+)"/.exec(element);
682
+ if (line) {
683
+ return [
684
+ { x: Number(line[1]), y: Number(line[2]) },
685
+ { x: Number(line[3]), y: Number(line[4]) },
686
+ ];
687
+ }
688
+ const d = /d="([^"]*)"/.exec(element)?.[1] ?? '';
689
+ const tokens = d.match(/[MLC]|-?\d+(?:\.\d+)?(?:e-?\d+)?/g) ?? [];
690
+ const points = [];
691
+ let command = 'M';
692
+ for (let index = 0; index < tokens.length;) {
693
+ const token = tokens[index];
694
+ if (/[MLC]/.test(token)) {
695
+ command = token;
696
+ index += 1;
697
+ continue;
698
+ }
699
+ const read = () => {
700
+ const point = { x: Number(tokens[index]), y: Number(tokens[index + 1]) };
701
+ index += 2;
702
+ return point;
703
+ };
704
+ if (command === 'C') {
705
+ const from = points[points.length - 1];
706
+ const [c1, c2, to] = [read(), read(), read()];
707
+ for (let step = 1; step <= 12; step += 1) {
708
+ const t = step / 12;
709
+ const u = 1 - t;
710
+ points.push({
711
+ x: u * u * u * from.x + 3 * u * u * t * c1.x + 3 * u * t * t * c2.x + t * t * t * to.x,
712
+ y: u * u * u * from.y + 3 * u * u * t * c1.y + 3 * u * t * t * c2.y + t * t * t * to.y,
713
+ });
714
+ }
715
+ }
716
+ else {
717
+ points.push(read());
718
+ }
719
+ }
720
+ return points;
721
+ }
722
+ /**
723
+ * Where each line with a `crossing:` style crosses a line declared before it.
724
+ * The later line is the one that jumps, so the file's order says who goes over,
725
+ * and a line never jumps where it leaves or arrives — a crossing that close to
726
+ * an end is two lines meeting at a box, not passing each other.
727
+ */
728
+ function findCrossings(drawn) {
729
+ const found = new Map();
730
+ drawn.forEach((later, index) => {
731
+ if (later.edge.look.crossing === 'none')
732
+ return;
733
+ const crossings = [];
734
+ for (const earlier of drawn.slice(0, index)) {
735
+ const reach = 3 + later.edge.look.thickness * 1.5 + earlier.edge.look.thickness / 2;
736
+ const clear = reach + arrowLength(later.edge.look.thickness);
737
+ const ends = [later.trace[0], later.trace[later.trace.length - 1], earlier.trace[0], earlier.trace[earlier.trace.length - 1]];
738
+ for (let i = 1; i < later.trace.length; i += 1) {
739
+ const a = later.trace[i - 1];
740
+ const b = later.trace[i];
741
+ for (let j = 1; j < earlier.trace.length; j += 1) {
742
+ const at = intersect(a, b, earlier.trace[j - 1], earlier.trace[j]);
743
+ if (!at)
744
+ continue;
745
+ if (ends.some((end) => Math.hypot(end.x - at.x, end.y - at.y) < clear))
746
+ continue;
747
+ if (crossings.some((c) => Math.hypot(c.at.x - at.x, c.at.y - at.y) < (c.reach + reach) * 1.2))
748
+ continue;
749
+ const length = Math.hypot(b.x - a.x, b.y - a.y) || 1;
750
+ crossings.push({ at, along: { x: (b.x - a.x) / length, y: (b.y - a.y) / length }, reach });
751
+ }
752
+ }
753
+ }
754
+ if (crossings.length > 0)
755
+ found.set(later, crossings);
756
+ });
757
+ return found;
758
+ }
759
+ /** Where two segments cross, if they do. Lines that only touch or run together do not. */
760
+ function intersect(a, b, c, d) {
761
+ const r = { x: b.x - a.x, y: b.y - a.y };
762
+ const s = { x: d.x - c.x, y: d.y - c.y };
763
+ const denominator = r.x * s.y - r.y * s.x;
764
+ if (Math.abs(denominator) < 1e-9)
765
+ return undefined;
766
+ const t = ((c.x - a.x) * s.y - (c.y - a.y) * s.x) / denominator;
767
+ const u = ((c.x - a.x) * r.y - (c.y - a.y) * r.x) / denominator;
768
+ if (t <= 0 || t >= 1 || u <= 0 || u >= 1)
769
+ return undefined;
770
+ return { x: a.x + r.x * t, y: a.y + r.y * t };
771
+ }
772
+ /**
773
+ * The jump drawn over one crossing: a half circle for `arc`, three sides of a
774
+ * square for `square`, and nothing for `gap`, which is the cut alone. A jump
775
+ * rises to the same side everywhere — up, or right on a line going straight up
776
+ * or down — so a row of them reads as one line hopping.
777
+ */
778
+ function jumpAt(crossing, style) {
779
+ const { at, along, reach } = crossing;
780
+ let normal = { x: along.y, y: -along.x };
781
+ if (normal.y > 1e-6 || (Math.abs(normal.y) <= 1e-6 && normal.x < 0))
782
+ normal = { x: -normal.x, y: -normal.y };
783
+ const a = { x: at.x - along.x * reach, y: at.y - along.y * reach };
784
+ const b = { x: at.x + along.x * reach, y: at.y + along.y * reach };
785
+ if (style === 'arc') {
786
+ // Which way round the arc sweeps depends on which side of the line is up.
787
+ const sweep = along.x * normal.y - along.y * normal.x < 0 ? 1 : 0;
788
+ return `M ${round(a.x)} ${round(a.y)} A ${round(reach)} ${round(reach)} 0 0 ${sweep} ${round(b.x)} ${round(b.y)}`;
789
+ }
790
+ if (style === 'square') {
791
+ const up = (p) => ({ x: p.x + normal.x * reach, y: p.y + normal.y * reach });
792
+ return sharpPath([a, up(a), up(b), b]);
793
+ }
794
+ return undefined;
353
795
  }
354
796
  function union(a, b) {
355
797
  return {
@@ -1003,6 +1445,775 @@ function planCorridors(edges, ends, measurer, fontSize) {
1003
1445
  }
1004
1446
  return plans;
1005
1447
  }
1448
+ /**
1449
+ * Route every edge that has to go around its own two boxes.
1450
+ *
1451
+ * An edge leaving the left of one box for the right of another, with the second
1452
+ * box further right, has to turn back on itself, and a single curve can only
1453
+ * do that by crossing its own boxes. In a row it flattened into a straight line
1454
+ * through both; stepped down, even by a `normal` gap, it still doubled back
1455
+ * across the first box. So such an edge runs along a channel instead, turning
1456
+ * back at each end: in the gap between its two boxes if that holds the line
1457
+ * and its text, and otherwise over the top of everything between its ends,
1458
+ * with its text on the top, where it cannot land on a box.
1459
+ *
1460
+ * Over the top, always, and round the right for a column. Nothing here weighs
1461
+ * one way round against the other: the shorter way ties in the case that
1462
+ * seemed to argue for it, and a default that flips on one box's height is
1463
+ * harder to predict than one that never does. The run is placed the way a
1464
+ * `between` channel is, measured off where the boxes landed.
1465
+ */
1466
+ function planLoops(edges, nodes, ends, corridors, routes, measurer, fontSize) {
1467
+ // Loops over the top, gathered so that two sharing a stretch can take a lane
1468
+ // each rather than drawing on top of one another.
1469
+ const tops = [];
1470
+ for (const edge of edges) {
1471
+ if (corridors.has(edge) || edge.passes)
1472
+ continue;
1473
+ const { start, end } = ends.get(edge);
1474
+ if (start.side === undefined || end.side === undefined)
1475
+ continue;
1476
+ // Sides at right angles, one facing away from the other end.
1477
+ if (start.tx * end.tx + start.ty * end.ty === 0) {
1478
+ const inWay = nodes.filter((node) => !(contains(node, edge.from) && contains(node, edge.to)));
1479
+ const plan = turnBack(edge, start, end, inWay, routes, measurer, fontSize);
1480
+ if (plan)
1481
+ tops.push(plan);
1482
+ continue;
1483
+ }
1484
+ // The two ends point opposite ways along one axis, each away from the other.
1485
+ if (start.tx !== -end.tx || start.ty !== -end.ty)
1486
+ continue;
1487
+ const run = start.tx !== 0 ? 'x' : 'y';
1488
+ const across = run === 'x' ? 'y' : 'x';
1489
+ const outward = run === 'x' ? start.tx : start.ty;
1490
+ if (outward * (end[run] - start[run]) >= 0)
1491
+ continue;
1492
+ const a = faceOf(edge.from);
1493
+ const b = faceOf(edge.to);
1494
+ // Far enough out that the line reads as passing, and that half the text,
1495
+ // centered on the line, still clears the box beside it.
1496
+ const clear = Math.max(SEPARATION_GAP, laneExtent(edge, across, measurer, fontSize) / 2 + ATTACH_MARGIN);
1497
+ const from = Math.min(lo(a, run), lo(b, run));
1498
+ const to = Math.max(hi(a, run), hi(b, run));
1499
+ const inWay = nodes.filter((node) => !(contains(node, edge.from) && contains(node, edge.to)));
1500
+ const blocked = (low, high) => inWay.some((node) => {
1501
+ const face = faceOf(node);
1502
+ return (lo(face, run) < to && hi(face, run) > from &&
1503
+ lo(face, across) < high && hi(face, across) > low);
1504
+ });
1505
+ // Boxes apart across the axis have a gap between them, and a line that
1506
+ // fits in it turns back through that — the shortest way, and the one a
1507
+ // single curve was reaching for.
1508
+ const [upper, lower] = lo(a, across) <= lo(b, across) ? [a, b] : [b, a];
1509
+ const gap = { lo: hi(upper, across), hi: lo(lower, across) };
1510
+ const middle = (gap.lo + gap.hi) / 2;
1511
+ if (gap.hi - gap.lo >= clear * 2 && !blocked(middle - clear, middle + clear)) {
1512
+ corridors.set(edge, {
1513
+ axis: across,
1514
+ lane: middle,
1515
+ enter: start[run],
1516
+ leave: end[run],
1517
+ loop: true,
1518
+ });
1519
+ continue;
1520
+ }
1521
+ // Otherwise over the top. Measured as distance outward — up for a row,
1522
+ // right for a column — so one loop serves both.
1523
+ const sign = across === 'y' ? -1 : 1;
1524
+ const out = (box) => {
1525
+ const [p, q] = [sign * lo(box, across), sign * hi(box, across)];
1526
+ return [Math.min(p, q), Math.max(p, q)];
1527
+ };
1528
+ const inner = Math.min(sign * start[across], sign * end[across]);
1529
+ // A box that shares the stretch and reaches into the band the line needs
1530
+ // pushes the line out past it, which can bring another into the band.
1531
+ const pushOut = (start) => {
1532
+ let lane = start;
1533
+ for (let moved = true; moved;) {
1534
+ moved = false;
1535
+ for (const node of inWay) {
1536
+ const face = faceOf(node);
1537
+ if (lo(face, run) >= to || hi(face, run) <= from)
1538
+ continue;
1539
+ const [near, far] = out(face);
1540
+ if (far + clear > lane && near < lane + clear && far > inner) {
1541
+ lane = far + clear;
1542
+ moved = true;
1543
+ }
1544
+ }
1545
+ }
1546
+ return lane;
1547
+ };
1548
+ tops.push({
1549
+ edge,
1550
+ across,
1551
+ sign,
1552
+ lane: pushOut(Math.max(out(a)[1], out(b)[1]) + clear),
1553
+ loop: true,
1554
+ from,
1555
+ to,
1556
+ height: sign * start[across] + sign * end[across],
1557
+ pushOut,
1558
+ place: (lane) => corridors.set(edge, { axis: across, lane: sign * lane, enter: start[run], leave: end[run], loop: true }),
1559
+ });
1560
+ }
1561
+ // Lines over one stretch nest so they do not cross. A turn-back goes inside
1562
+ // any loop, because it comes in to a box from the side the loops pass over;
1563
+ // otherwise the shorter goes inside, and of two the same length, the one
1564
+ // whose ends sit further out. Each lane is as far from the one inside it as
1565
+ // the lanes of a named gap are.
1566
+ tops.sort((p, q) => Number(p.loop) - Number(q.loop) || p.to - p.from - (q.to - q.from) || q.height - p.height);
1567
+ const placed = [];
1568
+ for (const top of tops) {
1569
+ for (let moved = true; moved;) {
1570
+ moved = false;
1571
+ for (const other of placed) {
1572
+ if (other.across !== top.across || other.sign !== top.sign)
1573
+ continue;
1574
+ if (other.from >= top.to || top.from >= other.to)
1575
+ continue;
1576
+ const step = Math.max(ATTACH_STEP, laneExtent(top.edge, top.across, measurer, fontSize), laneExtent(other.edge, other.across, measurer, fontSize));
1577
+ if (Math.abs(top.lane - other.lane) < step - 0.5) {
1578
+ top.lane = top.pushOut(other.lane + step);
1579
+ moved = true;
1580
+ }
1581
+ }
1582
+ }
1583
+ placed.push(top);
1584
+ top.place(top.lane);
1585
+ }
1586
+ }
1587
+ /**
1588
+ * A route for an edge whose named sides are at right angles and one of which
1589
+ * faces away from the other end: `from: left to: top` with the far node to
1590
+ * the right. A single curve leaving that side can only turn back across its
1591
+ * own box. So the line steps out of the side facing away, goes out past both
1592
+ * boxes the way the other side faces, runs along there, and comes straight in
1593
+ * to the other side. Undefined when neither side faces away, where the curve
1594
+ * already reads right. The route is recorded in `routes` once its lane is
1595
+ * settled against any other line running outside the same boxes.
1596
+ */
1597
+ function turnBack(edge, start, end, inWay, routes, measurer, fontSize) {
1598
+ const facesAway = (from, to) => from.tx * (to.x - from.x) + from.ty * (to.y - from.y) < 0;
1599
+ const startAway = facesAway(start, end);
1600
+ if (!startAway && !facesAway(end, start))
1601
+ return undefined;
1602
+ // Planned from the end facing away, and turned round if that is the far end.
1603
+ const [away, other] = startAway ? [start, end] : [end, start];
1604
+ const [awayBox, otherBox] = (startAway ? [edge.from, edge.to] : [edge.to, edge.from]).map(faceOf);
1605
+ const run = away.tx !== 0 ? 'x' : 'y';
1606
+ const across = run === 'x' ? 'y' : 'x';
1607
+ const make = axesAcross(across).make;
1608
+ // The way the other side faces, which is the way the line goes out.
1609
+ const sign = across === 'y' ? other.ty : other.tx;
1610
+ const clear = Math.max(SEPARATION_GAP, laneExtent(edge, across, measurer, fontSize) / 2 + ATTACH_MARGIN);
1611
+ const stub = away[run] + (run === 'x' ? away.tx : away.ty) * ROUTE_STUB;
1612
+ const from = Math.min(stub, other[run], lo(awayBox, run), lo(otherBox, run));
1613
+ const to = Math.max(stub, other[run], hi(awayBox, run), hi(otherBox, run));
1614
+ // Measured as distance outward, the way the line goes.
1615
+ const out = (box) => {
1616
+ const [p, q] = [sign * lo(box, across), sign * hi(box, across)];
1617
+ return [Math.min(p, q), Math.max(p, q)];
1618
+ };
1619
+ const inner = Math.min(sign * away[across], sign * other[across]);
1620
+ const pushOut = (start) => {
1621
+ let level = start;
1622
+ for (let moved = true; moved;) {
1623
+ moved = false;
1624
+ for (const node of inWay) {
1625
+ const face = faceOf(node);
1626
+ if (lo(face, run) >= to || hi(face, run) <= from)
1627
+ continue;
1628
+ const [near, far] = out(face);
1629
+ if (far + clear > level && near < level + clear && far > inner) {
1630
+ level = far + clear;
1631
+ moved = true;
1632
+ }
1633
+ }
1634
+ }
1635
+ return level;
1636
+ };
1637
+ return {
1638
+ edge,
1639
+ across,
1640
+ sign,
1641
+ lane: pushOut(Math.max(out(awayBox)[1], out(otherBox)[1]) + clear),
1642
+ loop: false,
1643
+ from,
1644
+ to,
1645
+ height: sign * away[across] + sign * other[across],
1646
+ pushOut,
1647
+ place: (lane) => {
1648
+ const at = sign * lane;
1649
+ // A shallow turn steps out no further than half its depth, the half
1650
+ // circle a loop's turn makes, so it stays inside any loop turning
1651
+ // round the same box.
1652
+ const outward = run === 'x' ? away.tx : away.ty;
1653
+ const step = away[run] + outward * Math.min(ROUTE_STUB, Math.abs(at - away[across]) / 2);
1654
+ const points = tidyRoute([away, make(step, away[across]), make(step, at), make(other[run], at), other]);
1655
+ // The text rides on the longest piece, which is the run outside the
1656
+ // boxes unless the two ends are nearly level.
1657
+ let mid = make((stub + other[run]) / 2, at);
1658
+ let best = -1;
1659
+ for (let index = 0; index + 1 < points.length; index += 1) {
1660
+ const [p, q] = [points[index], points[index + 1]];
1661
+ const length = Math.hypot(q.x - p.x, q.y - p.y);
1662
+ if (length > best) {
1663
+ best = length;
1664
+ mid = { x: (p.x + q.x) / 2, y: (p.y + q.y) / 2 };
1665
+ }
1666
+ }
1667
+ routes.set(edge, { points: startAway ? points : points.reverse(), mid });
1668
+ },
1669
+ };
1670
+ }
1671
+ /** The most a route's corner is rounded by. */
1672
+ const ROUTE_RADIUS = 20;
1673
+ /**
1674
+ * How far a route leaves its side before its first turn: a full corner, and the
1675
+ * arrowhead's length on top so the head lands on a straight piece of line.
1676
+ */
1677
+ const ROUTE_STUB = ROUTE_RADIUS + ARROW_LENGTH;
1678
+ /** The narrowest gap a route will cross over in, between two nodes it passes on opposite sides. */
1679
+ const CROSSING_ROOM = ATTACH_MARGIN * 2;
1680
+ function axesAcross(across) {
1681
+ return across === 'y'
1682
+ ? { across, run: 'x', make: (along, level) => ({ x: along, y: level }) }
1683
+ : { across, run: 'y', make: (along, level) => ({ x: level, y: along }) };
1684
+ }
1685
+ /**
1686
+ * Route every edge that says which side of something it passes.
1687
+ *
1688
+ * `below resolver` means that where the line passes Resolver it is below it —
1689
+ * not that the whole line is. So the line is a run of straight stretches along
1690
+ * the way it travels, and each clause binds only the stretch lying alongside
1691
+ * its nodes. A clause naming several nodes binds the stretch alongside all of
1692
+ * them, which is how `below a and b` says "with no rising in between" and two
1693
+ * separate clauses do not. Consecutive stretches that can share one level do;
1694
+ * where they cannot, the line crosses over in the gap between the two sets of
1695
+ * nodes, and if there is no gap, the file is refused rather than drawn through
1696
+ * a box. No clause says an order: the line meets the nodes in the order they
1697
+ * sit along its way.
1698
+ *
1699
+ * Above and below are passed travelling across the page, left and right
1700
+ * travelling up or down it, so an edge naming both kinds turns between such
1701
+ * runs, as few times as keeps every clause. Which comes first is the named
1702
+ * side's to say, and across when no end names one.
1703
+ *
1704
+ * Nothing here moves a node or looks for a path. Every level is read off where
1705
+ * the named nodes landed, as a `between` channel is, and the only other nodes
1706
+ * consulted are ones sitting on a stretch, which push it further the way its
1707
+ * clause already points.
1708
+ */
1709
+ function planRoutes(edges, nodes, ends, measurer, fontSize) {
1710
+ const routes = new Map();
1711
+ for (const edge of edges) {
1712
+ if (edge.passes)
1713
+ routes.set(edge, planRoute(edge, edge.passes, nodes, ends.get(edge), measurer, fontSize));
1714
+ }
1715
+ return routes;
1716
+ }
1717
+ function planRoute(edge, passes, nodes, { start, end }, measurer, fontSize) {
1718
+ const subject = `edge ${edge.from.name} -> ${edge.to.name}`;
1719
+ const a = faceOf(edge.from);
1720
+ const b = faceOf(edge.to);
1721
+ const named = (anchor, face) => anchor.side === undefined ? centerOf(face) : anchor;
1722
+ // Far enough out that the line reads as passing. The one stretch carrying
1723
+ // the text is held further out, so that half the text, centered on the line,
1724
+ // still clears the box beside it.
1725
+ const clear = SEPARATION_GAP;
1726
+ const inWay = nodes.filter((node) => !(contains(node, edge.from) && contains(node, edge.to)));
1727
+ // Above and below are passed travelling across the page, left and right
1728
+ // travelling up or down it. An edge naming only one kind is one section.
1729
+ const acrossPasses = passes.filter((pass) => sideAxis(pass) === 'y');
1730
+ const downPasses = passes.filter((pass) => sideAxis(pass) === 'x');
1731
+ if (acrossPasses.length === 0 || downPasses.length === 0) {
1732
+ const axes = axesAcross(sideAxis(passes[0]));
1733
+ const travel = Math.sign(named(end, b)[axes.run] - named(start, a)[axes.run]) || 1;
1734
+ const section = cut(axes, passes, reach(start, a, axes), reach(end, b, axes), travel);
1735
+ const carrier = longest(section.stretches);
1736
+ settle(section, (centerOf(a)[axes.across] + centerOf(b)[axes.across]) / 2, carrier);
1737
+ return finish([
1738
+ ...approach(start, a, axes, section.stretches[0].level, travel),
1739
+ ...crossings(section),
1740
+ ...approach(end, b, axes, section.stretches[section.stretches.length - 1].level, -travel).reverse(),
1741
+ ], carrier, axes);
1742
+ }
1743
+ // Both kinds: the line turns between travelling across the page and
1744
+ // travelling down it, as often as its clauses need. A side named at an end
1745
+ // says which it does first; with none named, it goes across first. The
1746
+ // fewest turns that keep every clause win, and at each count the other
1747
+ // order is tried before adding a turn. If every attempt fails the same way,
1748
+ // that is the error; if they fail differently, no one of them is the reason
1749
+ // and the error names every clause.
1750
+ const sideways = (anchor) => anchor.side === undefined ? undefined : anchor.side === 'left' || anchor.side === 'right';
1751
+ const acrossFirst = sideways(start) ?? !(sideways(end) ?? false);
1752
+ const refusals = [];
1753
+ for (const count of [2, 3, 4]) {
1754
+ for (const order of [acrossFirst, !acrossFirst]) {
1755
+ try {
1756
+ return turned(order, count);
1757
+ }
1758
+ catch (error) {
1759
+ if (!(error instanceof SourceError))
1760
+ throw error;
1761
+ refusals.push(error);
1762
+ }
1763
+ }
1764
+ }
1765
+ if (refusals.every((refusal) => refusal.message === refusals[0].message))
1766
+ throw refusals[0];
1767
+ const written = passes.map((pass) => `"${pass.written}"`);
1768
+ throw new SourceError(`${subject}: no line keeps ${written.slice(0, -1).join(', ')} and ${written[written.length - 1]} ` +
1769
+ 'all at once, whichever way it turns — each way misses one of those nodes or runs into one. ' +
1770
+ 'Give the nodes more room, or drop a clause', edge.line);
1771
+ /**
1772
+ * A route in `count` sections, alternating across and down, joined at
1773
+ * corners. Each section is planned as an edge naming one kind is, running
1774
+ * from the level of the section before it to the level of the one after,
1775
+ * and each clause binds every section of its kind that passes its node.
1776
+ * The first and last keep as near their own ends as their clauses allow,
1777
+ * so with nothing in the way two sections make an L through the corner
1778
+ * level with both ends; a section between hugs its own clauses. Where the
1779
+ * corners land depends on every section, so they are planned in turn
1780
+ * until none moves.
1781
+ */
1782
+ function turned(acrossFirst, count) {
1783
+ const axes = Array.from({ length: count }, (_, index) => axesAcross((index % 2 === 0) === acrossFirst ? 'y' : 'x'));
1784
+ const last = count - 1;
1785
+ const targets = axes.map(({ across }, index) => index === 0
1786
+ ? centerOf(a)[across]
1787
+ : index === last
1788
+ ? centerOf(b)[across]
1789
+ : (centerOf(a)[across] + centerOf(b)[across]) / 2);
1790
+ // Each section's first and last level, which bound its neighbours' runs.
1791
+ const firstLevels = [...targets];
1792
+ const lastLevels = [...targets];
1793
+ const startAt = reach(start, a, axes[0]);
1794
+ const endAt = reach(end, b, axes[last]);
1795
+ // A section starts or ends at the middle of its end's own node, which
1796
+ // must not push it: the line leaves that node from the side facing it.
1797
+ const obstacles = inWay.filter((node) => !contains(node, edge.from) && !contains(node, edge.to));
1798
+ const near = new Set(passes.flatMap((pass) => pass.nodes));
1799
+ // Which stretch carries the text: its section and index, from the last round.
1800
+ let carries;
1801
+ let sections = [];
1802
+ let travels = [];
1803
+ let bound = new Set();
1804
+ for (let round = 0; round < 8; round += 1) {
1805
+ let moved = false;
1806
+ sections = [];
1807
+ travels = [];
1808
+ bound = new Set();
1809
+ axes.forEach((section, index) => {
1810
+ const from = index === 0 ? startAt : lastLevels[index - 1];
1811
+ const to = index === last ? endAt : firstLevels[index + 1];
1812
+ const travel = Math.sign((index === last ? named(end, b)[section.run] : to) -
1813
+ (index === 0 ? named(start, a)[section.run] : from)) || 1;
1814
+ const low = Math.min(from, to);
1815
+ const high = Math.max(from, to);
1816
+ const mine = passes.filter((pass) => {
1817
+ const box = boundingBox(pass.nodes.map(faceOf));
1818
+ return sideAxis(pass) === section.across && hi(box, section.run) > low && lo(box, section.run) < high;
1819
+ });
1820
+ mine.forEach((pass) => bound.add(pass));
1821
+ const planned = cut(section, mine, from, to, travel);
1822
+ const carrier = carries?.[0] === index ? planned.stretches[carries[1]] : undefined;
1823
+ settle(planned, targets[index], carrier, obstacles, index > 0 && index < last, near);
1824
+ const first = planned.stretches[0].level;
1825
+ const final = planned.stretches[planned.stretches.length - 1].level;
1826
+ if (Math.abs(first - firstLevels[index]) >= 0.5 || Math.abs(final - lastLevels[index]) >= 0.5) {
1827
+ moved = true;
1828
+ }
1829
+ firstLevels[index] = first;
1830
+ lastLevels[index] = final;
1831
+ sections.push(planned);
1832
+ travels.push(travel);
1833
+ });
1834
+ let wanted;
1835
+ if (edge.lines !== undefined) {
1836
+ sections.forEach((section, index) => {
1837
+ const best = longest(section.stretches);
1838
+ if (!wanted || length(best) > length(sections[wanted[0]].stretches[wanted[1]])) {
1839
+ wanted = [index, section.stretches.indexOf(best)];
1840
+ }
1841
+ });
1842
+ }
1843
+ const settled = round > 0 && !moved && wanted?.[0] === carries?.[0] && wanted?.[1] === carries?.[1];
1844
+ carries = wanted;
1845
+ if (settled)
1846
+ break;
1847
+ }
1848
+ const unpassed = passes.find((pass) => !bound.has(pass));
1849
+ if (unpassed) {
1850
+ throw new SourceError(`${subject}: the line never passes ${quoteNames(unpassed.nodes)}, so "${unpassed.written}" says ` +
1851
+ 'nothing about it', edge.line);
1852
+ }
1853
+ const points = [...approach(start, a, axes[0], firstLevels[0], travels[0])];
1854
+ sections.forEach((section, index) => {
1855
+ points.push(...crossings(section));
1856
+ if (index < last)
1857
+ points.push(section.axes.make(firstLevels[index + 1], lastLevels[index]));
1858
+ });
1859
+ points.push(...approach(end, b, axes[last], lastLevels[last], -travels[last]).reverse());
1860
+ const broken = breaks(points);
1861
+ if (broken) {
1862
+ throw new SourceError(`${subject}: a line passing things both above or below and left or right has to turn, and ` +
1863
+ `no way it can turn keeps "${broken.written}" — drop that clause, or one of the others`, edge.line);
1864
+ }
1865
+ const [which, index] = carries ?? [0, 0];
1866
+ const carrier = edge.lines === undefined ? undefined : sections[which].stretches[index];
1867
+ return finish(points, carrier, axes[which]);
1868
+ }
1869
+ /** The first clause the drawn line breaks, wherever it lies alongside that clause's nodes. */
1870
+ function breaks(points) {
1871
+ for (const pass of passes) {
1872
+ const box = boundingBox(pass.nodes.map(faceOf));
1873
+ const across = sideAxis(pass);
1874
+ const run = across === 'y' ? 'x' : 'y';
1875
+ const further = pass.direction === 'below' || pass.direction === 'right';
1876
+ for (let index = 0; index + 1 < points.length; index += 1) {
1877
+ const [p, q] = [points[index], points[index + 1]];
1878
+ const alongside = Math.abs(p[run] - q[run]) < 0.5
1879
+ ? p[run] > lo(box, run) + 0.5 && p[run] < hi(box, run) - 0.5
1880
+ : Math.min(Math.max(p[run], q[run]), hi(box, run)) -
1881
+ Math.max(Math.min(p[run], q[run]), lo(box, run)) > 0.5;
1882
+ if (!alongside)
1883
+ continue;
1884
+ const kept = further
1885
+ ? Math.min(p[across], q[across]) >= hi(box, across) - 0.5
1886
+ : Math.max(p[across], q[across]) <= lo(box, across) + 0.5;
1887
+ if (!kept)
1888
+ return pass;
1889
+ }
1890
+ }
1891
+ return undefined;
1892
+ }
1893
+ /**
1894
+ * Where the line reaches along a run at one end, before it turns onto a
1895
+ * stretch. Enough to tell which nodes it passes.
1896
+ */
1897
+ function reach(anchor, face, { run }) {
1898
+ const along = run === 'x' ? anchor.tx : anchor.ty;
1899
+ return anchor.side !== undefined && along !== 0
1900
+ ? anchor[run] + along * ROUTE_STUB
1901
+ : named(anchor, face)[run];
1902
+ }
1903
+ function length(stretch) {
1904
+ return Math.abs(stretch.to - stretch.from);
1905
+ }
1906
+ function longest(stretches) {
1907
+ return stretches.reduce((best, stretch) => (length(stretch) > length(best) ? stretch : best));
1908
+ }
1909
+ /**
1910
+ * The way from `first` to `last` along a run, cut wherever a clause starts
1911
+ * or stops binding, with each piece given the band its clauses leave the
1912
+ * line, and consecutive pieces grouped into stretches that can share one
1913
+ * level. Levels are left for `settle`.
1914
+ */
1915
+ function cut(axes, passes, first, last, travel) {
1916
+ const { across, run } = axes;
1917
+ const low = Math.min(first, last);
1918
+ const high = Math.max(first, last);
1919
+ const clauses = passes.map((pass) => {
1920
+ const box = boundingBox(pass.nodes.map(faceOf));
1921
+ if (hi(box, run) <= low || lo(box, run) >= high) {
1922
+ throw new SourceError(`${subject}: the line never passes ${quoteNames(pass.nodes)}, so "${pass.written}" says nothing ` +
1923
+ 'about it', edge.line);
1924
+ }
1925
+ const further = pass.direction === 'below' || pass.direction === 'right';
1926
+ return {
1927
+ pass,
1928
+ from: lo(box, run),
1929
+ to: hi(box, run),
1930
+ bound: further ? hi(box, across) + clear : lo(box, across) - clear,
1931
+ further,
1932
+ };
1933
+ });
1934
+ // Cut the way into pieces at every place a clause starts or stops binding,
1935
+ // and give each piece the band its clauses leave the line.
1936
+ const cuts = [...new Set([low, high, ...clauses.flatMap((c) => [c.from, c.to])])]
1937
+ .filter((at) => at >= low && at <= high)
1938
+ .sort((p, q) => (p - q) * travel);
1939
+ const pieces = cuts.slice(0, -1).map((from, index) => {
1940
+ const to = cuts[index + 1];
1941
+ const middle = (from + to) / 2;
1942
+ const binding = clauses.filter((c) => c.from < middle && c.to > middle);
1943
+ const floor = binding.filter((c) => c.further).sort((p, q) => q.bound - p.bound)[0];
1944
+ const ceiling = binding.filter((c) => !c.further).sort((p, q) => p.bound - q.bound)[0];
1945
+ if (floor && ceiling && floor.bound > ceiling.bound) {
1946
+ const grouped = [floor, ceiling].some((c) => c.pass.nodes.length > 1);
1947
+ throw new SourceError(`${subject}: "${floor.pass.written}" and "${ceiling.pass.written}" cannot both hold — there ` +
1948
+ 'is a stretch where the line is alongside both, and it cannot be ' +
1949
+ `${sideWord(floor.pass)} ${quoteNames(floor.pass.nodes)} and ${sideWord(ceiling.pass)} ` +
1950
+ `${quoteNames(ceiling.pass.nodes)} at the same point. Drop one` +
1951
+ (grouped
1952
+ ? ', or name the nodes in separate clauses so the line may cross over between them'
1953
+ : ''), edge.line);
1954
+ }
1955
+ return {
1956
+ from,
1957
+ to,
1958
+ lo: floor?.bound ?? -Infinity,
1959
+ hi: ceiling?.bound ?? Infinity,
1960
+ floor,
1961
+ ceiling,
1962
+ };
1963
+ });
1964
+ // A way of no length, as the first section of a turned route can be when
1965
+ // the corner is level with its start, is one piece binding nothing.
1966
+ if (pieces.length === 0) {
1967
+ pieces.push({ from: low, to: high, lo: -Infinity, hi: Infinity, floor: undefined, ceiling: undefined });
1968
+ }
1969
+ // Consecutive pieces share one level for as long as their bands overlap.
1970
+ const stretches = [];
1971
+ let current = { lo: -Infinity, hi: Infinity, first: 0, last: 0, from: 0, to: 0, level: 0 };
1972
+ pieces.forEach((piece, index) => {
1973
+ const lower = Math.max(current.lo, piece.lo);
1974
+ const upper = Math.min(current.hi, piece.hi);
1975
+ if (lower <= upper) {
1976
+ Object.assign(current, { lo: lower, hi: upper, last: index });
1977
+ }
1978
+ else {
1979
+ stretches.push(current);
1980
+ current = { lo: piece.lo, hi: piece.hi, first: index, last: index, from: 0, to: 0, level: 0 };
1981
+ }
1982
+ });
1983
+ stretches.push(current);
1984
+ for (const stretch of stretches) {
1985
+ stretch.from = pieces[stretch.first].from;
1986
+ stretch.to = pieces[stretch.last].to;
1987
+ }
1988
+ return { axes, pieces, stretches };
1989
+ }
1990
+ /**
1991
+ * Each stretch sits as near `target` as its band allows, and a node lying
1992
+ * on it pushes it on the way its clause already points. `carrier` is the
1993
+ * stretch held clear for the text, if this section has it.
1994
+ */
1995
+ function settle({ axes: { across, run }, stretches }, target, carrier, obstacles = inWay, hug = false, near = new Set()) {
1996
+ const textClear = Math.max(clear, laneExtent(edge, across, measurer, fontSize) / 2 + ATTACH_MARGIN);
1997
+ for (const stretch of stretches) {
1998
+ const room = stretch === carrier && edge.lines !== undefined ? textClear : clear;
1999
+ const extra = room - clear;
2000
+ const lower = stretch.lo + extra;
2001
+ const upper = stretch.hi - extra;
2002
+ // A stretch hugging its clauses sits as close as its one bound allows.
2003
+ const wanted = !hug
2004
+ ? target
2005
+ : stretch.hi === Infinity && stretch.lo !== -Infinity
2006
+ ? lower
2007
+ : stretch.lo === -Infinity && stretch.hi !== Infinity
2008
+ ? upper
2009
+ : target;
2010
+ stretch.level = lower <= upper
2011
+ ? Math.min(Math.max(wanted, lower), upper)
2012
+ : (stretch.lo + stretch.hi) / 2;
2013
+ const push = stretch.hi === Infinity ? 1 : stretch.lo === -Infinity ? -1 : 0;
2014
+ if (push === 0)
2015
+ continue;
2016
+ const from = Math.min(stretch.from, stretch.to);
2017
+ const to = Math.max(stretch.from, stretch.to);
2018
+ for (let moved = true; moved;) {
2019
+ moved = false;
2020
+ for (const node of obstacles) {
2021
+ const face = faceOf(node);
2022
+ if (lo(face, run) >= to || hi(face, run) <= from)
2023
+ continue;
2024
+ // A node the edge names is one it is meant to go close by, so it
2025
+ // keeps the line only as far off as a crossing does.
2026
+ const off = near.has(node) && room === clear ? ATTACH_MARGIN : room;
2027
+ if (lo(face, across) >= stretch.level + off || hi(face, across) <= stretch.level - off) {
2028
+ continue;
2029
+ }
2030
+ stretch.level = push > 0 ? hi(face, across) + off : lo(face, across) - off;
2031
+ moved = true;
2032
+ }
2033
+ }
2034
+ }
2035
+ }
2036
+ /**
2037
+ * Between two stretches the line crosses over, in whatever run of pieces at
2038
+ * the end of the first leaves room for both levels.
2039
+ */
2040
+ function crossings({ axes: { make }, pieces, stretches }) {
2041
+ const points = [];
2042
+ for (let index = 0; index + 1 < stretches.length; index += 1) {
2043
+ const here = stretches[index];
2044
+ const next = stretches[index + 1];
2045
+ let open = here.last + 1;
2046
+ while (open > here.first &&
2047
+ pieces[open - 1].lo <= next.level &&
2048
+ pieces[open - 1].hi >= next.level) {
2049
+ open -= 1;
2050
+ }
2051
+ const from = open <= here.last ? pieces[open].from : pieces[here.last].to;
2052
+ const to = pieces[here.last].to;
2053
+ if (Math.abs(to - from) < CROSSING_ROOM) {
2054
+ const behind = [...pieces.slice(here.first, here.last + 1)]
2055
+ .reverse()
2056
+ .map((piece) => piece.floor ?? piece.ceiling)
2057
+ .find((clause) => clause !== undefined);
2058
+ const entering = pieces[next.first];
2059
+ const ahead = (next.level > here.level ? entering.floor : entering.ceiling) ??
2060
+ entering.floor ?? entering.ceiling;
2061
+ throw new SourceError(`${subject}: to pass "${behind.pass.written}" and "${ahead.pass.written}" the line has to ` +
2062
+ `cross over between ${quoteNames(behind.pass.nodes)} and ${quoteNames(ahead.pass.nodes)}, and there ` +
2063
+ 'is no room between them — give the placement between them a gap, or drop one of the two', edge.line);
2064
+ }
2065
+ const at = (from + to) / 2;
2066
+ points.push(make(at, here.level), make(at, next.level));
2067
+ }
2068
+ return points;
2069
+ }
2070
+ /**
2071
+ * The route drawn through `points`, with its text at the middle of the
2072
+ * piece that lies on the stretch held clear for it.
2073
+ */
2074
+ function finish(points, carrier, { across, run }) {
2075
+ const drawn = tidyRoute(points);
2076
+ let mid = centerOf(boundingBox([a, b]));
2077
+ let best = -1;
2078
+ for (let index = 0; index + 1 < drawn.length; index += 1) {
2079
+ const [p, q] = [drawn[index], drawn[index + 1]];
2080
+ if (!carrier)
2081
+ break;
2082
+ if (Math.abs(p[across] - carrier.level) > 0.5 || Math.abs(q[across] - carrier.level) > 0.5) {
2083
+ continue;
2084
+ }
2085
+ const length = Math.abs(q[run] - p[run]);
2086
+ if (length > best) {
2087
+ best = length;
2088
+ mid = { x: (p.x + q.x) / 2, y: (p.y + q.y) / 2 };
2089
+ }
2090
+ }
2091
+ return { points: drawn, mid };
2092
+ }
2093
+ /**
2094
+ * The points from one end of the line to the level of its nearest stretch.
2095
+ * `toward` is the way the line heads along its run from this end.
2096
+ */
2097
+ function approach(given, face, axes, level, toward) {
2098
+ const { across, run, make } = axes;
2099
+ const anchor = given.side !== undefined ? given : leaving(face, axes, level, toward);
2100
+ const along = run === 'x' ? anchor.tx : anchor.ty;
2101
+ const out = across === 'x' ? anchor.tx : anchor.ty;
2102
+ if (along !== 0) {
2103
+ // Out along the run, one way or the other, then across to the level.
2104
+ const turn = make(anchor[run] + along * ROUTE_STUB, anchor[across]);
2105
+ return [anchor, turn, make(turn[run], level)];
2106
+ }
2107
+ if ((level - anchor[across]) * out >= 0)
2108
+ return [anchor, make(anchor[run], level)];
2109
+ // The side faces away from the level, so the line steps round the back of
2110
+ // its own node — the end the run is heading away from — to get there.
2111
+ const stub = make(anchor[run], anchor[across] + out * ROUTE_STUB);
2112
+ const back = toward > 0 ? lo(face, run) - ROUTE_STUB : hi(face, run) + ROUTE_STUB;
2113
+ return [anchor, stub, make(back, stub[across]), make(back, level)];
2114
+ }
2115
+ /**
2116
+ * An end naming no side leaves from the one facing the level it is going
2117
+ * to, or, when the level is alongside the node, the one facing its way.
2118
+ */
2119
+ function leaving(face, { across, run }, level, toward) {
2120
+ const center = centerOf(face);
2121
+ const side = level > hi(face, across)
2122
+ ? across === 'y' ? 'bottom' : 'right'
2123
+ : level < lo(face, across)
2124
+ ? across === 'y' ? 'top' : 'left'
2125
+ : run === 'x'
2126
+ ? toward > 0 ? 'right' : 'left'
2127
+ : toward > 0 ? 'bottom' : 'top';
2128
+ return anchorOn(face, side, side === 'top' || side === 'bottom' ? center.x : center.y);
2129
+ }
2130
+ }
2131
+ /** Which axis a clause's side sits on: above and below are a matter of y. */
2132
+ function sideAxis(pass) {
2133
+ return pass.direction === 'above' || pass.direction === 'below' ? 'y' : 'x';
2134
+ }
2135
+ /** "below", "left of" — the side as the author would say it. */
2136
+ function sideWord(pass) {
2137
+ return pass.direction === 'above' || pass.direction === 'below'
2138
+ ? pass.direction
2139
+ : `${pass.direction} of`;
2140
+ }
2141
+ /** The box that just bounds several. */
2142
+ function boundingBox(boxes) {
2143
+ const x = Math.min(...boxes.map((box) => box.x));
2144
+ const y = Math.min(...boxes.map((box) => box.y));
2145
+ return {
2146
+ x,
2147
+ y,
2148
+ width: Math.max(...boxes.map((box) => box.x + box.width)) - x,
2149
+ height: Math.max(...boxes.map((box) => box.y + box.height)) - y,
2150
+ };
2151
+ }
2152
+ /** `"a"`, `"a" and "b"` — for error messages. */
2153
+ function quoteNames(nodes) {
2154
+ const quoted = nodes.map((node) => `"${node.name}"`);
2155
+ return quoted.length <= 1
2156
+ ? (quoted[0] ?? '')
2157
+ : `${quoted.slice(0, -1).join(', ')} and ${quoted[quoted.length - 1]}`;
2158
+ }
2159
+ /** Drop repeated points and ones partway along a straight piece, which are not corners. */
2160
+ function tidyRoute(points) {
2161
+ const kept = [];
2162
+ for (const point of points) {
2163
+ const previous = kept[kept.length - 1];
2164
+ if (previous && Math.hypot(point.x - previous.x, point.y - previous.y) < 0.5)
2165
+ continue;
2166
+ const before = kept[kept.length - 2];
2167
+ if (before && previous &&
2168
+ Math.abs((previous.x - before.x) * (point.y - previous.y) - (previous.y - before.y) * (point.x - previous.x)) < 1e-6 &&
2169
+ (previous.x - before.x) * (point.x - previous.x) + (previous.y - before.y) * (point.y - previous.y) >= 0) {
2170
+ kept[kept.length - 1] = point;
2171
+ continue;
2172
+ }
2173
+ kept.push(point);
2174
+ }
2175
+ return kept;
2176
+ }
2177
+ /**
2178
+ * A line through `points` with every corner rounded. A corner takes at most
2179
+ * half of each piece it shares with a neighboring corner, and the whole of a
2180
+ * piece at either end short of the arrowhead, so two corners never overlap and
2181
+ * the head always lands on a straight piece.
2182
+ */
2183
+ function roundedPath(points, arrow = ARROW_LENGTH) {
2184
+ const parts = [`M ${round(points[0].x)} ${round(points[0].y)}`];
2185
+ const kappa = 0.5523; // a cubic's handle, as a share of the radius, for a quarter circle
2186
+ for (let index = 1; index + 1 < points.length; index += 1) {
2187
+ const [before, corner, after] = [points[index - 1], points[index], points[index + 1]];
2188
+ const inward = Math.hypot(corner.x - before.x, corner.y - before.y);
2189
+ const outward = Math.hypot(after.x - corner.x, after.y - corner.y);
2190
+ const radius = Math.max(0, Math.min(ROUTE_RADIUS, index === 1 ? inward - arrow : inward / 2, index + 2 === points.length ? outward - arrow : outward / 2));
2191
+ const din = { x: (corner.x - before.x) / inward, y: (corner.y - before.y) / inward };
2192
+ const dout = { x: (after.x - corner.x) / outward, y: (after.y - corner.y) / outward };
2193
+ const enter = { x: corner.x - din.x * radius, y: corner.y - din.y * radius };
2194
+ const leave = { x: corner.x + dout.x * radius, y: corner.y + dout.y * radius };
2195
+ parts.push(`L ${round(enter.x)} ${round(enter.y)}`, `C ${round(enter.x + din.x * radius * kappa)} ${round(enter.y + din.y * radius * kappa)}, ` +
2196
+ `${round(leave.x - dout.x * radius * kappa)} ${round(leave.y - dout.y * radius * kappa)}, ` +
2197
+ `${round(leave.x)} ${round(leave.y)}`);
2198
+ }
2199
+ const last = points[points.length - 1];
2200
+ parts.push(`L ${round(last.x)} ${round(last.y)}`);
2201
+ return parts.join(' ');
2202
+ }
2203
+ function lo(box, axis) {
2204
+ return axis === 'x' ? box.x : box.y;
2205
+ }
2206
+ function hi(box, axis) {
2207
+ return axis === 'x' ? box.x + box.width : box.y + box.height;
2208
+ }
2209
+ /** Whether `inner` is `outer` or sits somewhere inside it. */
2210
+ function contains(outer, inner) {
2211
+ for (let node = inner; node; node = node.parent) {
2212
+ if (node === outer)
2213
+ return true;
2214
+ }
2215
+ return false;
2216
+ }
1006
2217
  /**
1007
2218
  * An end whose side the author did not name aims at the far box's center, which
1008
2219
  * is the wrong thing to aim at once the line has been told to go somewhere else
@@ -1055,10 +2266,11 @@ function corridorPath(start, end, plan) {
1055
2266
  // Along the run the line travels one way, so that is its tangent at both ends
1056
2267
  // of the straight stretch — it enters the gap already going where the gap goes.
1057
2268
  const rt = plan.axis === 'y' ? { tx: forward, ty: 0 } : { tx: 0, ty: forward };
1058
- const r1 = corridorReach(start, p1, plan.axis);
2269
+ const reach = plan.loop ? loopReach : corridorReach;
2270
+ const r1 = reach(start, p1, plan.axis);
1059
2271
  const c1 = { x: start.x + start.tx * r1, y: start.y + start.ty * r1 };
1060
2272
  const c2 = { x: p1.x - rt.tx * r1, y: p1.y - rt.ty * r1 };
1061
- const r2 = corridorReach(p2, end, plan.axis);
2273
+ const r2 = reach(p2, end, plan.axis);
1062
2274
  const c3 = { x: p2.x + rt.tx * r2, y: p2.y + rt.ty * r2 };
1063
2275
  const c4 = { x: end.x + end.tx * r2, y: end.y + end.ty * r2 };
1064
2276
  const d = [
@@ -1081,15 +2293,78 @@ function corridorReach(from, to, axis) {
1081
2293
  const distance = Math.hypot(to.x - from.x, to.y - from.y);
1082
2294
  return Math.min(140, Math.max(8, Math.min(distance * 0.4, run / 2)));
1083
2295
  }
2296
+ /**
2297
+ * How far the handles reach on the turn at either end of a loop. The turn
2298
+ * leaves heading one way and joins the run heading the other, over the depth
2299
+ * between the side and the run, so it is a half circle on that depth — and a
2300
+ * cubic comes closest to a half circle with handles two thirds of its diameter.
2301
+ */
2302
+ function loopReach(from, to, axis) {
2303
+ return (Math.abs(axis === 'y' ? to.y - from.y : to.x - from.x) * 2) / 3;
2304
+ }
1084
2305
  function sideAttr(edge, key) {
1085
2306
  const value = edge.attrs[key];
1086
2307
  if (value === undefined)
1087
- return undefined;
2308
+ return impliedSide(edge, key);
1088
2309
  if (!ATTACH_SIDES.includes(value)) {
1089
2310
  throw new SourceError(`"${key}: ${value}" is not a side — use ${ATTACH_SIDES.join(', ')}`, edge.line);
1090
2311
  }
1091
2312
  return value;
1092
2313
  }
2314
+ /**
2315
+ * The side a square line leaves or arrives on when its author named none.
2316
+ *
2317
+ * A curved or straight line with no sides aims straight at the other box, but a
2318
+ * square one can only leave a side head-on, so it has to pick one. Boxes that
2319
+ * share a column join top to bottom and boxes that share a row join side to
2320
+ * side; otherwise the line goes across first and then down, the tie the mixed
2321
+ * passes settled. An end whose partner did name a side keeps the line to one
2322
+ * turn where it can. Everything after this treats the side as though it had
2323
+ * been written, so the ends are spread and bundled like any named side.
2324
+ *
2325
+ * An edge through a `between` gap or past a named node is routed by its clauses,
2326
+ * and one from a node to itself or to its own container has no outside to pick
2327
+ * from, so none of those is given a side here.
2328
+ */
2329
+ function impliedSide(edge, key) {
2330
+ if (edge.look.path !== 'square' || edge.between || edge.passes)
2331
+ return undefined;
2332
+ if (contains(edge.from, edge.to) || contains(edge.to, edge.from))
2333
+ return undefined;
2334
+ const own = faceOf(key === 'from' ? edge.from : edge.to);
2335
+ const other = faceOf(key === 'from' ? edge.to : edge.from);
2336
+ const partnerKey = key === 'from' ? 'to' : 'from';
2337
+ const partner = edge.attrs[partnerKey];
2338
+ const overlapX = own.x < other.x + other.width && other.x < own.x + own.width;
2339
+ const overlapY = own.y < other.y + other.height && other.y < own.y + own.height;
2340
+ const across = centerOf(other).x >= centerOf(own).x ? 'right' : 'left';
2341
+ const upDown = centerOf(other).y >= centerOf(own).y ? 'bottom' : 'top';
2342
+ if (partner !== undefined && ATTACH_SIDES.includes(partner)) {
2343
+ // The partner's side is fixed, so take whichever side of this box joins it
2344
+ // in the fewest turns. The order breaks ties the way the rest of this does.
2345
+ const theirs = anchorOn(other, partner, partner === 'top' || partner === 'bottom' ? centerOf(other).x : centerOf(other).y);
2346
+ const order = overlapX && !overlapY ? [upDown, across] : [across, upDown];
2347
+ const sides = [...order, ...ATTACH_SIDES.filter((side) => !order.includes(side))];
2348
+ let best = sides[0];
2349
+ let fewest = Infinity;
2350
+ for (const side of sides) {
2351
+ const mine = anchorOn(own, side, side === 'top' || side === 'bottom' ? centerOf(own).x : centerOf(own).y);
2352
+ const [from, to] = key === 'from' ? [mine, theirs] : [theirs, mine];
2353
+ const turns = tidyRoute(rightAngles(from, { x: from.tx, y: from.ty }, to, { x: to.tx, y: to.ty }, ROUTE_STUB)).length;
2354
+ if (turns < fewest) {
2355
+ fewest = turns;
2356
+ best = side;
2357
+ }
2358
+ }
2359
+ return best;
2360
+ }
2361
+ if (overlapX && !overlapY)
2362
+ return upDown;
2363
+ if (overlapY && !overlapX)
2364
+ return across;
2365
+ // Across first, then down: the start leaves across and the end is reached down.
2366
+ return key === 'from' ? across : upDown;
2367
+ }
1093
2368
  function arrowMarker(color) {
1094
2369
  const id = markerId(color);
1095
2370
  return [