reladraw 0.5.0 → 0.6.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 CHANGED
@@ -68,7 +68,7 @@ It is [plain Markdown](.claude/skills/reladraw/SKILL.md) with the syntax referen
68
68
 
69
69
  ## Status
70
70
 
71
- Version 0.5.0. Early, but it runs: a parser, resolver and SVG renderer in TypeScript with no runtime dependencies, and a command-line tool that takes a text file and writes a standalone SVG. The comparison at the top of this page is that pipeline run on [`examples/arch.reladraw`](examples/arch.reladraw). What is still visibly off there is typography, not placement.
71
+ Version 0.6.0. Early, but it runs: a parser, resolver and SVG renderer in TypeScript with no runtime dependencies, and a command-line tool that takes a text file and writes a standalone SVG. The comparison at the top of this page is that pipeline run on [`examples/arch.reladraw`](examples/arch.reladraw). What is still visibly off there is typography, not placement.
72
72
 
73
73
  ```
74
74
  npm install -g reladraw
@@ -143,4 +143,4 @@ The license covers the code, not the name: it grants no rights to use "reladraw"
143
143
 
144
144
  ## Contributing
145
145
 
146
- Issues are wanted — especially a diagram you tried to write and could not. Pull requests are read but not merged yet, for a reason explained in [CONTRIBUTING.md](CONTRIBUTING.md).
146
+ Issues are wanted — especially a diagram you tried to write and could not. While the language is changing quickly, an issue is more useful than a pull request; [CONTRIBUTING.md](CONTRIBUTING.md) says why.
package/SYNTAX.md CHANGED
@@ -1,4 +1,4 @@
1
- # Syntax reference — 0.5.0
1
+ # Syntax reference — 0.6.0
2
2
 
3
3
  What the language accepts. The parser, resolver and SVG renderer implement all of it; the sections at the end record what is defective, unchecked or undecided.
4
4
 
@@ -422,6 +422,23 @@ You name a side and never a point on it. Alone on a side, an edge lands at its c
422
422
 
423
423
  The space it leaves is deliberately small, and shrinks further if the side is too short to hold the whole group. On a side short enough, it shrinks to nothing and the attachments coincide. Their texts still come apart, because the lines bow in the middle to make up what the side could not give them, but the arrowheads themselves land on one point and nothing warns you — so a small node with several edges arriving on one side is worth a look.
424
424
 
425
+ #### Ends that face away from each other
426
+
427
+ ```
428
+ node parser "Parser"
429
+ node resolver "Resolver" right of parser
430
+ node renderer "Renderer" right of resolver
431
+ edge parser -> renderer "test edge" from: left to: right
432
+ ```
433
+
434
+ That line leaves Parser heading left and has to arrive at Renderer from the right, so it must turn back on itself somewhere. It turns back in the gap between its two nodes when there is one wide enough to hold the line and its text, and otherwise it goes over the top: out of its side, along the top of the row clear of the tallest node between its ends, and down into the far side. The text rides on that top stretch, so it never sits on a node. The same goes for `from: top to: bottom` in a column, which goes round the right, and for an edge from a node back to itself.
435
+
436
+ Several such lines over the same row take a lane each, the shorter inside the longer, so they nest instead of drawing on top of one another.
437
+
438
+ Sides at right angles can face away too. `from: left to: top` with the far node to the right leaves heading away from it, so the line steps out of the left, goes up past both nodes, runs along above them and comes straight down into the top. The side named at the far end says which way round: `to: bottom` takes it underneath instead.
439
+
440
+ Over the top is the rule, not a guess the tool makes each time — it does not look for the shorter way round. To send the line underneath or round the left, say which side of a node it passes: `below resolver`. See "Passing on one side of a node".
441
+
425
442
  #### Several edges between the same two sides
426
443
 
427
444
  Edges that run between the *same* pair of sides are a case of their own, because "where the far ends sit" cannot order them: every one of them goes to the same node.
@@ -487,6 +504,78 @@ Several edges may share one channel, and they take a lane each. As with attachme
487
504
 
488
505
  A named channel does not widen. It is measured off the layout you described, so if you name a gap too narrow for the lines you put through it they crowd together rather than pushing the two nodes apart. That is the difference between this and a text making room for itself, above: there, the corridor is the gap between the edge's own two ends, and opening it moves them apart exactly as anything else put between them would. Here the pair is named by an edge merely passing through, and nothing yet lets an edge bid into a gap it is only a visitor in. It is the remaining half and it is not built.
489
506
 
507
+ ### Passing on one side of a node
508
+
509
+ `above`, `below`, `left of` and `right of`, written on an edge, say which side of a node the line passes.
510
+
511
+ ```
512
+ node parser "Parser"
513
+ node resolver "Resolver" right of parser
514
+ node renderer "Renderer" right of resolver
515
+ edge parser -> renderer "underneath" from: left to: right below resolver
516
+ ```
517
+
518
+ They are the placement words, and they mean what `between` means: only where the line passes that node. `below resolver` says the line is below Resolver where it goes by Resolver, not that the whole line is. That edge would go over the top of the row on its own; with the clause it leaves Parser, drops under the row and comes up into Renderer.
519
+
520
+ Write as many as you need, one for each node you have something to say about. The line meets them in the order they sit along its way, so no clause says an order:
521
+
522
+ ```
523
+ node a "A"
524
+ node b "B" below a
525
+ edge a -> b from: top to: bottom left of a right of b
526
+ ```
527
+
528
+ That line leaves the top of A, goes round A's left, crosses over in the gap between the two, passes B on the right, and turns up into B's bottom. The line is drawn as straight stretches joined by rounded corners, each stretch as close to the two ends as its clauses allow, and pushed further out, the way its clause already points, by any node sitting on it.
529
+
530
+ One clause may name several nodes, and it is not shorthand for several clauses. `below a and b` is below the box bounding both, so it covers the stretch between them too:
531
+
532
+ ```
533
+ node t1 "T1"
534
+ node t2 "T2" right of t1 (gap: wide)
535
+ node peak "Peak" right of t1 left of t2 above t1
536
+ edge t1 -> t2 from: bottom to: bottom below t1 above peak below t2
537
+ ```
538
+
539
+ Those three clauses let the line rise over Peak in the gap. Write `below t1 and t2 above peak` instead and it is refused, because Peak sits over the stretch `below t1 and t2` covers, and the line cannot be below the one and above the other at the same point.
540
+
541
+ One edge may pass some nodes above or below and others left or right. Passing above or below is done travelling across the page, and passing left or right travelling up or down it, so the line turns between the two, as few times as it can:
542
+
543
+ ```
544
+ node a "A"
545
+ node m "M" right of a
546
+ node n "N" below m right of m
547
+ node b "B" below n right of n
548
+ edge a -> b below m left of n
549
+ ```
550
+
551
+ That line leaves the bottom of A, runs across just under M, turns down on N's left, and runs across into B. The clauses read as before: `left of n` binds only where the line is level with N, so the last stretch passing under N is not held to it.
552
+
553
+ A side named at an end says which way the line goes first: `from: right` starts it across, `from: bottom` starts it down. With no side named at either end it goes across first, then down.
554
+
555
+ Clauses of one kind may sit on different legs. Here the line goes over P on the way out, down past N, and under M on the way in, turning twice:
556
+
557
+ ```
558
+ node a "A"
559
+ node p "P" right of a
560
+ node n "N" below p right of p
561
+ node m "M" below n right of n
562
+ node b "B" right of m
563
+ edge a -> b from: right to: bottom above p right of n below m
564
+ ```
565
+
566
+ Each clause binds whichever legs pass its node, so a node wide enough to sit under two legs holds both of them to its side.
567
+
568
+ Refused, each by name:
569
+
570
+ - **Clauses that cannot all hold** at one point, as above.
571
+ - **A crossing with no room.** Two clauses on opposite sides need a gap between their nodes to cross over in. Placed flush — `gap: 0` — there is none, and the error says to give the placement a gap or drop a clause. The gap is never opened for the line: you wrote how far apart those two are.
572
+ - **A node the line never passes.** `below z` on a line that never goes by Z says nothing.
573
+ - **Both kinds where no way of turning keeps them all.** The error names the clauses, or the one reason if every way fails for the same one.
574
+ - **`between` with a side.** An edge passing between two things already has a side of each.
575
+ - **A part of a node, or a gap.** A line passes a whole box, and passes it as close as reads clearly.
576
+
577
+ An end with no side named leaves from the side facing where the line is going.
578
+
490
579
  ## Against a part of a node
491
580
 
492
581
  ```
@@ -831,7 +920,7 @@ Deliberate omissions. What they protect is that the renderer never *chooses* an
831
920
  - **Guessing an axis nobody constrained.** When two placements bind one axis and nothing binds the other, the tool refuses rather than picking a target to center on. Choosing there would decide which row a node shares, not how far it sits from something.
832
921
  - **Placements that run in a circle.** A loop where each placement demands more room than the last cannot be satisfied and is an error naming the placements involved. A target does *not* have to be positioned before the node naming it — the whole system is solved at once — so ordinary mutual references are fine.
833
922
  - **Edge waypoints.** A point a line must pass through is a coordinate wearing a hat. Saying a line goes between two named things is not one — it names things the diagram already contains, and it survives those things moving.
834
- - **Choosing a route.** The tool will not find its own way around an obstacle. A line that crosses something it should not is a line you have not yet said enough about, and `between` is how you say it.
923
+ - **Choosing a route.** The tool will not find its own way around an obstacle. A line that crosses something it should not is a line you have not yet said enough about, and `between` and `below <node>` are how you say it.
835
924
  - **Set-level placement.** Four siblings around a hub are four statements today. Whether a durable group that reflows when a member is added is worth the same-axis conflict it introduces is undecided.
836
925
 
837
926
  Note what is *not* on this list: saying more about where something goes. A statement that lets you be more precise is not a step toward auto-layout, and the first version was short enough of them to render the benchmark wrong.
@@ -840,7 +929,7 @@ Note what is *not* on this list: saying more about where something goes. A state
840
929
 
841
930
  Designed, decided, and absent from the code. Written down so the next version has somewhere to start.
842
931
 
843
- **Nothing keeps an edge clear of a node on its own.** Non-overlap applies to nodes only. A line may still cut across a node it has nothing to do with, and an edge text may still land on top of one. `between` is how you say where a line goes when that matters, and nothing checks the ones where you have not said. A check belongs on the diagnostics list, but finding a route by itself does not — see "What the language refuses".
932
+ **Nothing keeps an edge clear of a node on its own.** Non-overlap applies to nodes only. A line may still cut across a node it has nothing to do with, and an edge text may still land on top of one. `between` and `below <node>` are how you say where a line goes when that matters, and nothing checks the ones where you have not said. A check belongs on the diagnostics list, but finding a route by itself does not — see "What the language refuses".
844
933
 
845
934
  **An icon outside the built-in seven.** The set is closed, and a diagram wanting a picture that is not in it has nowhere to go. The two shapes this could take are a declaration in the file, `icon <name> "<path data>"` beside `style`, and `icon: ./thing.svg` inlined by the tool at render time. Either keeps the output standalone, which is the constraint any answer has to meet.
846
935
 
@@ -882,10 +971,17 @@ That one was found by testing the lexer, not by rendering — and it could not h
882
971
 
883
972
  ~~An edge text ignored the line break.~~ Fixed. ` / ` split a node's text and was never applied to an edge's, so the marker came out as a literal slash on an arrow and the benchmark's two-line captions had to be flattened to one. The measurer had always returned the split lines; the renderer was handing it the raw string and drawing that instead. The block now centers on the point the text already occupied, so a one-line text sits exactly where it did.
884
973
 
974
+ ~~An edge whose ends faced away from each other was drawn through its own nodes.~~ Fixed. `from: left to: right` with the far node further right was one curve bending out at both ends, and with the two nodes in a row it flattened into a straight line through both of them and anything between, its text landing on whatever was in the middle. It was not a matter of exact alignment: a node stepped down a little drew a tilted version of the same line. Such an edge now turns back in the gap between its nodes if there is room, and goes over the top of the row if not. See "Ends that face away from each other".
975
+
885
976
  ## Changelog
886
977
 
887
978
  Pre-1.0, so the minor number is where a breaking change goes. Every removal below is refused by name with the replacement quoted, rather than dropped in silence — an older file stops with an error saying what to write instead.
888
979
 
980
+ **0.6.0**
981
+
982
+ - An edge whose ends face away from each other, such as `from: left to: right` with the far node to the right, goes around instead of through its own nodes: through the gap between them if there is room, over the top of the row if not. Several over one row take a lane each. Sides at right angles, such as `from: left to: top`, go around the same way when one faces away.
983
+ - `above`, `below`, `left of` and `right of` on an edge say which side of a node the line passes: `edge a -> b below c`. Any number, one per node; one naming several nodes covers the stretch between them too. One edge may mix the two kinds, `below m left of n`, and the line turns between them as often as they need. Clauses that cannot all be drawn are refused with the reason.
984
+
889
985
  **0.5.0**
890
986
 
891
987
  - Named themes: `diagram theme: nord`, one of thirteen, and `--theme` on the command line to render a file in another without editing it. The playground has a theme picker, which writes the `theme:` line into the source.
@@ -920,6 +1016,7 @@ Pre-1.0, so the minor number is where a breaking change goes. Every removal belo
920
1016
 
921
1017
  Open questions the benchmark raised, recorded so a later session does not rediscover them.
922
1018
 
1019
+ - One edge passing some nodes above or below and others left or right — an L-shaped line, across under one node and then down past another — is refused today. Often the picture leaves only one order for the two legs; where two orders both fit, the tool must not pick one, and whether it asks for another clause or follows a standing rule is not decided.
923
1020
  - Named gaps are the first step toward numbers, but making them minimums took most of the pressure off: they now set how much a diagram breathes, never whether something fits. Whether four names is the right number is still open.
924
1021
  - Four machines each holding a `files` child with the same text means writing the same line four times. This is the strongest case for a set-level declaration, for terseness rather than for placement.
925
1022
  - The 2×2 arrangement around a hub is four independent statements, so a fifth machine has no slot to reflow into. There are only eight directions.
package/dist/ast.d.ts CHANGED
@@ -269,6 +269,13 @@ export interface EdgeStmt {
269
269
  textAttrs: Attrs;
270
270
  /** `between desktop1 and laptop1` — the gap the line passes through. */
271
271
  between?: Passage;
272
+ /**
273
+ * `below resolver`, `left of a and b` — which side of a node the line is on
274
+ * where it passes that node. The placement words, because it is the same
275
+ * statement about the picture; on an edge it binds only the stretch where the
276
+ * line is passing, as `between` does. Always `kind: 'offset'` with no gap.
277
+ */
278
+ passes?: OffsetPlacement[];
272
279
  attrs: Attrs;
273
280
  line: number;
274
281
  }
package/dist/model.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Attrs, Axis, Kind, Placement } from './ast.js';
1
+ import type { Attrs, Axis, Direction, Kind, Placement } from './ast.js';
2
2
  import type { Line } from './text.js';
3
3
  import type { Body } from './icons.js';
4
4
  /** A `between` clause with its targets resolved. Mirrors `Passage` in `ast.ts`. */
@@ -7,6 +7,17 @@ export interface LayoutPassage {
7
7
  /** Which gap, where the pair has two. Absent when the pair leaves no doubt. */
8
8
  axis?: Axis;
9
9
  }
10
+ /**
11
+ * `below resolver` on an edge with its targets resolved: the line is on that
12
+ * side of the box bounding `nodes`, where it passes them. Mirrors the edge's
13
+ * `passes` in `ast.ts`.
14
+ */
15
+ export interface LayoutPass {
16
+ direction: Direction;
17
+ nodes: LayoutNode[];
18
+ /** As the author wrote it, for error messages. */
19
+ written: string;
20
+ }
10
21
  /** A node with its geometry solved. Coordinates are absolute, origin top-left. */
11
22
  /** A distance past each side of a box. */
12
23
  export interface Reach {
@@ -116,6 +127,8 @@ export interface LayoutEdge {
116
127
  * rather than solved for, so edges stay out of the constraint system entirely.
117
128
  */
118
129
  between?: LayoutPassage;
130
+ /** Which side of which nodes the line passes. Measured in the renderer, like `between`. */
131
+ passes?: LayoutPass[];
119
132
  attrs: Attrs;
120
133
  appearance: Attrs;
121
134
  line: number;
package/dist/parser.js CHANGED
@@ -461,9 +461,26 @@ function parseEdge(head, line) {
461
461
  between = { targets: read.targets, ...(axis !== undefined ? { axis } : {}) };
462
462
  return next;
463
463
  });
464
- if (tail.placements.length > 0) {
465
- throw new SourceError(`${subject}: "${describePlacement(tail.placements[0])}" places a node, and an edge is not ` +
466
- 'placed — it joins two things that are', line);
464
+ // An edge is not placed, so a direction on its line says which side of that
465
+ // node the line passes. The other placements say where a thing *is*, and
466
+ // have no reading for a line.
467
+ const passes = [];
468
+ for (const placement of tail.placements) {
469
+ const written = describePlacement(placement);
470
+ if (placement.kind !== 'offset' || placement.written !== undefined) {
471
+ throw new SourceError(`${subject}: "${written}" places a node, and an edge is not placed — it joins two things ` +
472
+ 'that are. On an edge, above, below, left of and right of say which side of a node the ' +
473
+ 'line passes', line);
474
+ }
475
+ if (placement.gap !== undefined) {
476
+ throw new SourceError(`${subject}: "${written}" gives a gap, and a gap is kept between nodes — the line passes ` +
477
+ 'as close as it reads clearly. Drop the brackets', line);
478
+ }
479
+ passes.push(placement);
480
+ }
481
+ if (passes.length > 0 && between) {
482
+ throw new SourceError(`${subject}: "between" and "${describePlacement(passes[0])}" on one edge — an edge passing ` +
483
+ 'between two things already has a side of each, so say one or the other', line);
467
484
  }
468
485
  refuseTextKey(tail.attrs, subject, 'after the arrow', line);
469
486
  return {
@@ -474,6 +491,7 @@ function parseEdge(head, line) {
474
491
  both: arrow.text === '<->',
475
492
  ...(textToken ? { text: textToken.text } : {}),
476
493
  ...(between ? { between } : {}),
494
+ ...(passes.length > 0 ? { passes } : {}),
477
495
  attrs: tail.attrs,
478
496
  line,
479
497
  };
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, LINE_WIDTH, PAD, SEPARATION_GAP, 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,9 +34,11 @@ 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
40
  for (const edge of layout.edges) {
39
- const drawn = drawEdge(edge, ends.get(edge), corridors.get(edge), theme, measurer, fontSize, layout.markup);
41
+ const drawn = drawEdge(edge, ends.get(edge), corridors.get(edge), routes.get(edge), theme, measurer, fontSize, layout.markup);
40
42
  body.push(drawn.svg);
41
43
  ink = union(ink, grow(drawn.ink, layout.margin));
42
44
  }
@@ -253,7 +255,7 @@ function drawIcon(icon, x, y, side, theme) {
253
255
  ].join('\n');
254
256
  }
255
257
  // --- edges -------------------------------------------------------------------
256
- function drawEdge(edge, ends, corridor, theme, measurer, fontSize, markup) {
258
+ function drawEdge(edge, ends, corridor, route, theme, measurer, fontSize, markup) {
257
259
  const { start, end } = ends;
258
260
  const color = lineOf(edge.appearance, theme.edge);
259
261
  const markerEnd = ` marker-end="url(#${markerId(color)})"`;
@@ -269,7 +271,13 @@ function drawEdge(edge, ends, corridor, theme, measurer, fontSize, markup) {
269
271
  let ink = extentOfPoints([start, end]);
270
272
  let midX;
271
273
  let midY;
272
- if (corridor) {
274
+ if (route) {
275
+ parts.push(` <path d="${roundedPath(route.points)}" fill="none" stroke="${color}" stroke-width="${LINE_WIDTH}"${markerEnd}${markerStart}/>`);
276
+ ink = union(ink, extentOfPoints(route.points));
277
+ midX = route.mid.x;
278
+ midY = route.mid.y;
279
+ }
280
+ else if (corridor) {
273
281
  const path = corridorPath(start, end, corridor);
274
282
  ink = union(ink, path.ink);
275
283
  parts.push(` <path d="${path.d}" fill="none" stroke="${color}" stroke-width="${LINE_WIDTH}"${markerEnd}${markerStart}/>`);
@@ -1003,6 +1011,775 @@ function planCorridors(edges, ends, measurer, fontSize) {
1003
1011
  }
1004
1012
  return plans;
1005
1013
  }
1014
+ /**
1015
+ * Route every edge that has to go around its own two boxes.
1016
+ *
1017
+ * An edge leaving the left of one box for the right of another, with the second
1018
+ * box further right, has to turn back on itself, and a single curve can only
1019
+ * do that by crossing its own boxes. In a row it flattened into a straight line
1020
+ * through both; stepped down, even by a `normal` gap, it still doubled back
1021
+ * across the first box. So such an edge runs along a channel instead, turning
1022
+ * back at each end: in the gap between its two boxes if that holds the line
1023
+ * and its text, and otherwise over the top of everything between its ends,
1024
+ * with its text on the top, where it cannot land on a box.
1025
+ *
1026
+ * Over the top, always, and round the right for a column. Nothing here weighs
1027
+ * one way round against the other: the shorter way ties in the case that
1028
+ * seemed to argue for it, and a default that flips on one box's height is
1029
+ * harder to predict than one that never does. The run is placed the way a
1030
+ * `between` channel is, measured off where the boxes landed.
1031
+ */
1032
+ function planLoops(edges, nodes, ends, corridors, routes, measurer, fontSize) {
1033
+ // Loops over the top, gathered so that two sharing a stretch can take a lane
1034
+ // each rather than drawing on top of one another.
1035
+ const tops = [];
1036
+ for (const edge of edges) {
1037
+ if (corridors.has(edge) || edge.passes)
1038
+ continue;
1039
+ const { start, end } = ends.get(edge);
1040
+ if (start.side === undefined || end.side === undefined)
1041
+ continue;
1042
+ // Sides at right angles, one facing away from the other end.
1043
+ if (start.tx * end.tx + start.ty * end.ty === 0) {
1044
+ const inWay = nodes.filter((node) => !(contains(node, edge.from) && contains(node, edge.to)));
1045
+ const plan = turnBack(edge, start, end, inWay, routes, measurer, fontSize);
1046
+ if (plan)
1047
+ tops.push(plan);
1048
+ continue;
1049
+ }
1050
+ // The two ends point opposite ways along one axis, each away from the other.
1051
+ if (start.tx !== -end.tx || start.ty !== -end.ty)
1052
+ continue;
1053
+ const run = start.tx !== 0 ? 'x' : 'y';
1054
+ const across = run === 'x' ? 'y' : 'x';
1055
+ const outward = run === 'x' ? start.tx : start.ty;
1056
+ if (outward * (end[run] - start[run]) >= 0)
1057
+ continue;
1058
+ const a = faceOf(edge.from);
1059
+ const b = faceOf(edge.to);
1060
+ // Far enough out that the line reads as passing, and that half the text,
1061
+ // centered on the line, still clears the box beside it.
1062
+ const clear = Math.max(SEPARATION_GAP, laneExtent(edge, across, measurer, fontSize) / 2 + ATTACH_MARGIN);
1063
+ const from = Math.min(lo(a, run), lo(b, run));
1064
+ const to = Math.max(hi(a, run), hi(b, run));
1065
+ const inWay = nodes.filter((node) => !(contains(node, edge.from) && contains(node, edge.to)));
1066
+ const blocked = (low, high) => inWay.some((node) => {
1067
+ const face = faceOf(node);
1068
+ return (lo(face, run) < to && hi(face, run) > from &&
1069
+ lo(face, across) < high && hi(face, across) > low);
1070
+ });
1071
+ // Boxes apart across the axis have a gap between them, and a line that
1072
+ // fits in it turns back through that — the shortest way, and the one a
1073
+ // single curve was reaching for.
1074
+ const [upper, lower] = lo(a, across) <= lo(b, across) ? [a, b] : [b, a];
1075
+ const gap = { lo: hi(upper, across), hi: lo(lower, across) };
1076
+ const middle = (gap.lo + gap.hi) / 2;
1077
+ if (gap.hi - gap.lo >= clear * 2 && !blocked(middle - clear, middle + clear)) {
1078
+ corridors.set(edge, {
1079
+ axis: across,
1080
+ lane: middle,
1081
+ enter: start[run],
1082
+ leave: end[run],
1083
+ loop: true,
1084
+ });
1085
+ continue;
1086
+ }
1087
+ // Otherwise over the top. Measured as distance outward — up for a row,
1088
+ // right for a column — so one loop serves both.
1089
+ const sign = across === 'y' ? -1 : 1;
1090
+ const out = (box) => {
1091
+ const [p, q] = [sign * lo(box, across), sign * hi(box, across)];
1092
+ return [Math.min(p, q), Math.max(p, q)];
1093
+ };
1094
+ const inner = Math.min(sign * start[across], sign * end[across]);
1095
+ // A box that shares the stretch and reaches into the band the line needs
1096
+ // pushes the line out past it, which can bring another into the band.
1097
+ const pushOut = (start) => {
1098
+ let lane = start;
1099
+ for (let moved = true; moved;) {
1100
+ moved = false;
1101
+ for (const node of inWay) {
1102
+ const face = faceOf(node);
1103
+ if (lo(face, run) >= to || hi(face, run) <= from)
1104
+ continue;
1105
+ const [near, far] = out(face);
1106
+ if (far + clear > lane && near < lane + clear && far > inner) {
1107
+ lane = far + clear;
1108
+ moved = true;
1109
+ }
1110
+ }
1111
+ }
1112
+ return lane;
1113
+ };
1114
+ tops.push({
1115
+ edge,
1116
+ across,
1117
+ sign,
1118
+ lane: pushOut(Math.max(out(a)[1], out(b)[1]) + clear),
1119
+ loop: true,
1120
+ from,
1121
+ to,
1122
+ height: sign * start[across] + sign * end[across],
1123
+ pushOut,
1124
+ place: (lane) => corridors.set(edge, { axis: across, lane: sign * lane, enter: start[run], leave: end[run], loop: true }),
1125
+ });
1126
+ }
1127
+ // Lines over one stretch nest so they do not cross. A turn-back goes inside
1128
+ // any loop, because it comes in to a box from the side the loops pass over;
1129
+ // otherwise the shorter goes inside, and of two the same length, the one
1130
+ // whose ends sit further out. Each lane is as far from the one inside it as
1131
+ // the lanes of a named gap are.
1132
+ tops.sort((p, q) => Number(p.loop) - Number(q.loop) || p.to - p.from - (q.to - q.from) || q.height - p.height);
1133
+ const placed = [];
1134
+ for (const top of tops) {
1135
+ for (let moved = true; moved;) {
1136
+ moved = false;
1137
+ for (const other of placed) {
1138
+ if (other.across !== top.across || other.sign !== top.sign)
1139
+ continue;
1140
+ if (other.from >= top.to || top.from >= other.to)
1141
+ continue;
1142
+ const step = Math.max(ATTACH_STEP, laneExtent(top.edge, top.across, measurer, fontSize), laneExtent(other.edge, other.across, measurer, fontSize));
1143
+ if (Math.abs(top.lane - other.lane) < step - 0.5) {
1144
+ top.lane = top.pushOut(other.lane + step);
1145
+ moved = true;
1146
+ }
1147
+ }
1148
+ }
1149
+ placed.push(top);
1150
+ top.place(top.lane);
1151
+ }
1152
+ }
1153
+ /**
1154
+ * A route for an edge whose named sides are at right angles and one of which
1155
+ * faces away from the other end: `from: left to: top` with the far node to
1156
+ * the right. A single curve leaving that side can only turn back across its
1157
+ * own box. So the line steps out of the side facing away, goes out past both
1158
+ * boxes the way the other side faces, runs along there, and comes straight in
1159
+ * to the other side. Undefined when neither side faces away, where the curve
1160
+ * already reads right. The route is recorded in `routes` once its lane is
1161
+ * settled against any other line running outside the same boxes.
1162
+ */
1163
+ function turnBack(edge, start, end, inWay, routes, measurer, fontSize) {
1164
+ const facesAway = (from, to) => from.tx * (to.x - from.x) + from.ty * (to.y - from.y) < 0;
1165
+ const startAway = facesAway(start, end);
1166
+ if (!startAway && !facesAway(end, start))
1167
+ return undefined;
1168
+ // Planned from the end facing away, and turned round if that is the far end.
1169
+ const [away, other] = startAway ? [start, end] : [end, start];
1170
+ const [awayBox, otherBox] = (startAway ? [edge.from, edge.to] : [edge.to, edge.from]).map(faceOf);
1171
+ const run = away.tx !== 0 ? 'x' : 'y';
1172
+ const across = run === 'x' ? 'y' : 'x';
1173
+ const make = axesAcross(across).make;
1174
+ // The way the other side faces, which is the way the line goes out.
1175
+ const sign = across === 'y' ? other.ty : other.tx;
1176
+ const clear = Math.max(SEPARATION_GAP, laneExtent(edge, across, measurer, fontSize) / 2 + ATTACH_MARGIN);
1177
+ const stub = away[run] + (run === 'x' ? away.tx : away.ty) * ROUTE_STUB;
1178
+ const from = Math.min(stub, other[run], lo(awayBox, run), lo(otherBox, run));
1179
+ const to = Math.max(stub, other[run], hi(awayBox, run), hi(otherBox, run));
1180
+ // Measured as distance outward, the way the line goes.
1181
+ const out = (box) => {
1182
+ const [p, q] = [sign * lo(box, across), sign * hi(box, across)];
1183
+ return [Math.min(p, q), Math.max(p, q)];
1184
+ };
1185
+ const inner = Math.min(sign * away[across], sign * other[across]);
1186
+ const pushOut = (start) => {
1187
+ let level = start;
1188
+ for (let moved = true; moved;) {
1189
+ moved = false;
1190
+ for (const node of inWay) {
1191
+ const face = faceOf(node);
1192
+ if (lo(face, run) >= to || hi(face, run) <= from)
1193
+ continue;
1194
+ const [near, far] = out(face);
1195
+ if (far + clear > level && near < level + clear && far > inner) {
1196
+ level = far + clear;
1197
+ moved = true;
1198
+ }
1199
+ }
1200
+ }
1201
+ return level;
1202
+ };
1203
+ return {
1204
+ edge,
1205
+ across,
1206
+ sign,
1207
+ lane: pushOut(Math.max(out(awayBox)[1], out(otherBox)[1]) + clear),
1208
+ loop: false,
1209
+ from,
1210
+ to,
1211
+ height: sign * away[across] + sign * other[across],
1212
+ pushOut,
1213
+ place: (lane) => {
1214
+ const at = sign * lane;
1215
+ // A shallow turn steps out no further than half its depth, the half
1216
+ // circle a loop's turn makes, so it stays inside any loop turning
1217
+ // round the same box.
1218
+ const outward = run === 'x' ? away.tx : away.ty;
1219
+ const step = away[run] + outward * Math.min(ROUTE_STUB, Math.abs(at - away[across]) / 2);
1220
+ const points = tidyRoute([away, make(step, away[across]), make(step, at), make(other[run], at), other]);
1221
+ // The text rides on the longest piece, which is the run outside the
1222
+ // boxes unless the two ends are nearly level.
1223
+ let mid = make((stub + other[run]) / 2, at);
1224
+ let best = -1;
1225
+ for (let index = 0; index + 1 < points.length; index += 1) {
1226
+ const [p, q] = [points[index], points[index + 1]];
1227
+ const length = Math.hypot(q.x - p.x, q.y - p.y);
1228
+ if (length > best) {
1229
+ best = length;
1230
+ mid = { x: (p.x + q.x) / 2, y: (p.y + q.y) / 2 };
1231
+ }
1232
+ }
1233
+ routes.set(edge, { points: startAway ? points : points.reverse(), mid });
1234
+ },
1235
+ };
1236
+ }
1237
+ /** The most a route's corner is rounded by. */
1238
+ const ROUTE_RADIUS = 20;
1239
+ /**
1240
+ * How far a route leaves its side before its first turn: a full corner, and the
1241
+ * arrowhead's length on top so the head lands on a straight piece of line.
1242
+ */
1243
+ const ROUTE_STUB = ROUTE_RADIUS + ARROW_LENGTH;
1244
+ /** The narrowest gap a route will cross over in, between two nodes it passes on opposite sides. */
1245
+ const CROSSING_ROOM = ATTACH_MARGIN * 2;
1246
+ function axesAcross(across) {
1247
+ return across === 'y'
1248
+ ? { across, run: 'x', make: (along, level) => ({ x: along, y: level }) }
1249
+ : { across, run: 'y', make: (along, level) => ({ x: level, y: along }) };
1250
+ }
1251
+ /**
1252
+ * Route every edge that says which side of something it passes.
1253
+ *
1254
+ * `below resolver` means that where the line passes Resolver it is below it —
1255
+ * not that the whole line is. So the line is a run of straight stretches along
1256
+ * the way it travels, and each clause binds only the stretch lying alongside
1257
+ * its nodes. A clause naming several nodes binds the stretch alongside all of
1258
+ * them, which is how `below a and b` says "with no rising in between" and two
1259
+ * separate clauses do not. Consecutive stretches that can share one level do;
1260
+ * where they cannot, the line crosses over in the gap between the two sets of
1261
+ * nodes, and if there is no gap, the file is refused rather than drawn through
1262
+ * a box. No clause says an order: the line meets the nodes in the order they
1263
+ * sit along its way.
1264
+ *
1265
+ * Above and below are passed travelling across the page, left and right
1266
+ * travelling up or down it, so an edge naming both kinds turns between such
1267
+ * runs, as few times as keeps every clause. Which comes first is the named
1268
+ * side's to say, and across when no end names one.
1269
+ *
1270
+ * Nothing here moves a node or looks for a path. Every level is read off where
1271
+ * the named nodes landed, as a `between` channel is, and the only other nodes
1272
+ * consulted are ones sitting on a stretch, which push it further the way its
1273
+ * clause already points.
1274
+ */
1275
+ function planRoutes(edges, nodes, ends, measurer, fontSize) {
1276
+ const routes = new Map();
1277
+ for (const edge of edges) {
1278
+ if (edge.passes)
1279
+ routes.set(edge, planRoute(edge, edge.passes, nodes, ends.get(edge), measurer, fontSize));
1280
+ }
1281
+ return routes;
1282
+ }
1283
+ function planRoute(edge, passes, nodes, { start, end }, measurer, fontSize) {
1284
+ const subject = `edge ${edge.from.name} -> ${edge.to.name}`;
1285
+ const a = faceOf(edge.from);
1286
+ const b = faceOf(edge.to);
1287
+ const named = (anchor, face) => anchor.side === undefined ? centerOf(face) : anchor;
1288
+ // Far enough out that the line reads as passing. The one stretch carrying
1289
+ // the text is held further out, so that half the text, centered on the line,
1290
+ // still clears the box beside it.
1291
+ const clear = SEPARATION_GAP;
1292
+ const inWay = nodes.filter((node) => !(contains(node, edge.from) && contains(node, edge.to)));
1293
+ // Above and below are passed travelling across the page, left and right
1294
+ // travelling up or down it. An edge naming only one kind is one section.
1295
+ const acrossPasses = passes.filter((pass) => sideAxis(pass) === 'y');
1296
+ const downPasses = passes.filter((pass) => sideAxis(pass) === 'x');
1297
+ if (acrossPasses.length === 0 || downPasses.length === 0) {
1298
+ const axes = axesAcross(sideAxis(passes[0]));
1299
+ const travel = Math.sign(named(end, b)[axes.run] - named(start, a)[axes.run]) || 1;
1300
+ const section = cut(axes, passes, reach(start, a, axes), reach(end, b, axes), travel);
1301
+ const carrier = longest(section.stretches);
1302
+ settle(section, (centerOf(a)[axes.across] + centerOf(b)[axes.across]) / 2, carrier);
1303
+ return finish([
1304
+ ...approach(start, a, axes, section.stretches[0].level, travel),
1305
+ ...crossings(section),
1306
+ ...approach(end, b, axes, section.stretches[section.stretches.length - 1].level, -travel).reverse(),
1307
+ ], carrier, axes);
1308
+ }
1309
+ // Both kinds: the line turns between travelling across the page and
1310
+ // travelling down it, as often as its clauses need. A side named at an end
1311
+ // says which it does first; with none named, it goes across first. The
1312
+ // fewest turns that keep every clause win, and at each count the other
1313
+ // order is tried before adding a turn. If every attempt fails the same way,
1314
+ // that is the error; if they fail differently, no one of them is the reason
1315
+ // and the error names every clause.
1316
+ const sideways = (anchor) => anchor.side === undefined ? undefined : anchor.side === 'left' || anchor.side === 'right';
1317
+ const acrossFirst = sideways(start) ?? !(sideways(end) ?? false);
1318
+ const refusals = [];
1319
+ for (const count of [2, 3, 4]) {
1320
+ for (const order of [acrossFirst, !acrossFirst]) {
1321
+ try {
1322
+ return turned(order, count);
1323
+ }
1324
+ catch (error) {
1325
+ if (!(error instanceof SourceError))
1326
+ throw error;
1327
+ refusals.push(error);
1328
+ }
1329
+ }
1330
+ }
1331
+ if (refusals.every((refusal) => refusal.message === refusals[0].message))
1332
+ throw refusals[0];
1333
+ const written = passes.map((pass) => `"${pass.written}"`);
1334
+ throw new SourceError(`${subject}: no line keeps ${written.slice(0, -1).join(', ')} and ${written[written.length - 1]} ` +
1335
+ 'all at once, whichever way it turns — each way misses one of those nodes or runs into one. ' +
1336
+ 'Give the nodes more room, or drop a clause', edge.line);
1337
+ /**
1338
+ * A route in `count` sections, alternating across and down, joined at
1339
+ * corners. Each section is planned as an edge naming one kind is, running
1340
+ * from the level of the section before it to the level of the one after,
1341
+ * and each clause binds every section of its kind that passes its node.
1342
+ * The first and last keep as near their own ends as their clauses allow,
1343
+ * so with nothing in the way two sections make an L through the corner
1344
+ * level with both ends; a section between hugs its own clauses. Where the
1345
+ * corners land depends on every section, so they are planned in turn
1346
+ * until none moves.
1347
+ */
1348
+ function turned(acrossFirst, count) {
1349
+ const axes = Array.from({ length: count }, (_, index) => axesAcross((index % 2 === 0) === acrossFirst ? 'y' : 'x'));
1350
+ const last = count - 1;
1351
+ const targets = axes.map(({ across }, index) => index === 0
1352
+ ? centerOf(a)[across]
1353
+ : index === last
1354
+ ? centerOf(b)[across]
1355
+ : (centerOf(a)[across] + centerOf(b)[across]) / 2);
1356
+ // Each section's first and last level, which bound its neighbours' runs.
1357
+ const firstLevels = [...targets];
1358
+ const lastLevels = [...targets];
1359
+ const startAt = reach(start, a, axes[0]);
1360
+ const endAt = reach(end, b, axes[last]);
1361
+ // A section starts or ends at the middle of its end's own node, which
1362
+ // must not push it: the line leaves that node from the side facing it.
1363
+ const obstacles = inWay.filter((node) => !contains(node, edge.from) && !contains(node, edge.to));
1364
+ const near = new Set(passes.flatMap((pass) => pass.nodes));
1365
+ // Which stretch carries the text: its section and index, from the last round.
1366
+ let carries;
1367
+ let sections = [];
1368
+ let travels = [];
1369
+ let bound = new Set();
1370
+ for (let round = 0; round < 8; round += 1) {
1371
+ let moved = false;
1372
+ sections = [];
1373
+ travels = [];
1374
+ bound = new Set();
1375
+ axes.forEach((section, index) => {
1376
+ const from = index === 0 ? startAt : lastLevels[index - 1];
1377
+ const to = index === last ? endAt : firstLevels[index + 1];
1378
+ const travel = Math.sign((index === last ? named(end, b)[section.run] : to) -
1379
+ (index === 0 ? named(start, a)[section.run] : from)) || 1;
1380
+ const low = Math.min(from, to);
1381
+ const high = Math.max(from, to);
1382
+ const mine = passes.filter((pass) => {
1383
+ const box = boundingBox(pass.nodes.map(faceOf));
1384
+ return sideAxis(pass) === section.across && hi(box, section.run) > low && lo(box, section.run) < high;
1385
+ });
1386
+ mine.forEach((pass) => bound.add(pass));
1387
+ const planned = cut(section, mine, from, to, travel);
1388
+ const carrier = carries?.[0] === index ? planned.stretches[carries[1]] : undefined;
1389
+ settle(planned, targets[index], carrier, obstacles, index > 0 && index < last, near);
1390
+ const first = planned.stretches[0].level;
1391
+ const final = planned.stretches[planned.stretches.length - 1].level;
1392
+ if (Math.abs(first - firstLevels[index]) >= 0.5 || Math.abs(final - lastLevels[index]) >= 0.5) {
1393
+ moved = true;
1394
+ }
1395
+ firstLevels[index] = first;
1396
+ lastLevels[index] = final;
1397
+ sections.push(planned);
1398
+ travels.push(travel);
1399
+ });
1400
+ let wanted;
1401
+ if (edge.lines !== undefined) {
1402
+ sections.forEach((section, index) => {
1403
+ const best = longest(section.stretches);
1404
+ if (!wanted || length(best) > length(sections[wanted[0]].stretches[wanted[1]])) {
1405
+ wanted = [index, section.stretches.indexOf(best)];
1406
+ }
1407
+ });
1408
+ }
1409
+ const settled = round > 0 && !moved && wanted?.[0] === carries?.[0] && wanted?.[1] === carries?.[1];
1410
+ carries = wanted;
1411
+ if (settled)
1412
+ break;
1413
+ }
1414
+ const unpassed = passes.find((pass) => !bound.has(pass));
1415
+ if (unpassed) {
1416
+ throw new SourceError(`${subject}: the line never passes ${quoteNames(unpassed.nodes)}, so "${unpassed.written}" says ` +
1417
+ 'nothing about it', edge.line);
1418
+ }
1419
+ const points = [...approach(start, a, axes[0], firstLevels[0], travels[0])];
1420
+ sections.forEach((section, index) => {
1421
+ points.push(...crossings(section));
1422
+ if (index < last)
1423
+ points.push(section.axes.make(firstLevels[index + 1], lastLevels[index]));
1424
+ });
1425
+ points.push(...approach(end, b, axes[last], lastLevels[last], -travels[last]).reverse());
1426
+ const broken = breaks(points);
1427
+ if (broken) {
1428
+ throw new SourceError(`${subject}: a line passing things both above or below and left or right has to turn, and ` +
1429
+ `no way it can turn keeps "${broken.written}" — drop that clause, or one of the others`, edge.line);
1430
+ }
1431
+ const [which, index] = carries ?? [0, 0];
1432
+ const carrier = edge.lines === undefined ? undefined : sections[which].stretches[index];
1433
+ return finish(points, carrier, axes[which]);
1434
+ }
1435
+ /** The first clause the drawn line breaks, wherever it lies alongside that clause's nodes. */
1436
+ function breaks(points) {
1437
+ for (const pass of passes) {
1438
+ const box = boundingBox(pass.nodes.map(faceOf));
1439
+ const across = sideAxis(pass);
1440
+ const run = across === 'y' ? 'x' : 'y';
1441
+ const further = pass.direction === 'below' || pass.direction === 'right';
1442
+ for (let index = 0; index + 1 < points.length; index += 1) {
1443
+ const [p, q] = [points[index], points[index + 1]];
1444
+ const alongside = Math.abs(p[run] - q[run]) < 0.5
1445
+ ? p[run] > lo(box, run) + 0.5 && p[run] < hi(box, run) - 0.5
1446
+ : Math.min(Math.max(p[run], q[run]), hi(box, run)) -
1447
+ Math.max(Math.min(p[run], q[run]), lo(box, run)) > 0.5;
1448
+ if (!alongside)
1449
+ continue;
1450
+ const kept = further
1451
+ ? Math.min(p[across], q[across]) >= hi(box, across) - 0.5
1452
+ : Math.max(p[across], q[across]) <= lo(box, across) + 0.5;
1453
+ if (!kept)
1454
+ return pass;
1455
+ }
1456
+ }
1457
+ return undefined;
1458
+ }
1459
+ /**
1460
+ * Where the line reaches along a run at one end, before it turns onto a
1461
+ * stretch. Enough to tell which nodes it passes.
1462
+ */
1463
+ function reach(anchor, face, { run }) {
1464
+ const along = run === 'x' ? anchor.tx : anchor.ty;
1465
+ return anchor.side !== undefined && along !== 0
1466
+ ? anchor[run] + along * ROUTE_STUB
1467
+ : named(anchor, face)[run];
1468
+ }
1469
+ function length(stretch) {
1470
+ return Math.abs(stretch.to - stretch.from);
1471
+ }
1472
+ function longest(stretches) {
1473
+ return stretches.reduce((best, stretch) => (length(stretch) > length(best) ? stretch : best));
1474
+ }
1475
+ /**
1476
+ * The way from `first` to `last` along a run, cut wherever a clause starts
1477
+ * or stops binding, with each piece given the band its clauses leave the
1478
+ * line, and consecutive pieces grouped into stretches that can share one
1479
+ * level. Levels are left for `settle`.
1480
+ */
1481
+ function cut(axes, passes, first, last, travel) {
1482
+ const { across, run } = axes;
1483
+ const low = Math.min(first, last);
1484
+ const high = Math.max(first, last);
1485
+ const clauses = passes.map((pass) => {
1486
+ const box = boundingBox(pass.nodes.map(faceOf));
1487
+ if (hi(box, run) <= low || lo(box, run) >= high) {
1488
+ throw new SourceError(`${subject}: the line never passes ${quoteNames(pass.nodes)}, so "${pass.written}" says nothing ` +
1489
+ 'about it', edge.line);
1490
+ }
1491
+ const further = pass.direction === 'below' || pass.direction === 'right';
1492
+ return {
1493
+ pass,
1494
+ from: lo(box, run),
1495
+ to: hi(box, run),
1496
+ bound: further ? hi(box, across) + clear : lo(box, across) - clear,
1497
+ further,
1498
+ };
1499
+ });
1500
+ // Cut the way into pieces at every place a clause starts or stops binding,
1501
+ // and give each piece the band its clauses leave the line.
1502
+ const cuts = [...new Set([low, high, ...clauses.flatMap((c) => [c.from, c.to])])]
1503
+ .filter((at) => at >= low && at <= high)
1504
+ .sort((p, q) => (p - q) * travel);
1505
+ const pieces = cuts.slice(0, -1).map((from, index) => {
1506
+ const to = cuts[index + 1];
1507
+ const middle = (from + to) / 2;
1508
+ const binding = clauses.filter((c) => c.from < middle && c.to > middle);
1509
+ const floor = binding.filter((c) => c.further).sort((p, q) => q.bound - p.bound)[0];
1510
+ const ceiling = binding.filter((c) => !c.further).sort((p, q) => p.bound - q.bound)[0];
1511
+ if (floor && ceiling && floor.bound > ceiling.bound) {
1512
+ const grouped = [floor, ceiling].some((c) => c.pass.nodes.length > 1);
1513
+ throw new SourceError(`${subject}: "${floor.pass.written}" and "${ceiling.pass.written}" cannot both hold — there ` +
1514
+ 'is a stretch where the line is alongside both, and it cannot be ' +
1515
+ `${sideWord(floor.pass)} ${quoteNames(floor.pass.nodes)} and ${sideWord(ceiling.pass)} ` +
1516
+ `${quoteNames(ceiling.pass.nodes)} at the same point. Drop one` +
1517
+ (grouped
1518
+ ? ', or name the nodes in separate clauses so the line may cross over between them'
1519
+ : ''), edge.line);
1520
+ }
1521
+ return {
1522
+ from,
1523
+ to,
1524
+ lo: floor?.bound ?? -Infinity,
1525
+ hi: ceiling?.bound ?? Infinity,
1526
+ floor,
1527
+ ceiling,
1528
+ };
1529
+ });
1530
+ // A way of no length, as the first section of a turned route can be when
1531
+ // the corner is level with its start, is one piece binding nothing.
1532
+ if (pieces.length === 0) {
1533
+ pieces.push({ from: low, to: high, lo: -Infinity, hi: Infinity, floor: undefined, ceiling: undefined });
1534
+ }
1535
+ // Consecutive pieces share one level for as long as their bands overlap.
1536
+ const stretches = [];
1537
+ let current = { lo: -Infinity, hi: Infinity, first: 0, last: 0, from: 0, to: 0, level: 0 };
1538
+ pieces.forEach((piece, index) => {
1539
+ const lower = Math.max(current.lo, piece.lo);
1540
+ const upper = Math.min(current.hi, piece.hi);
1541
+ if (lower <= upper) {
1542
+ Object.assign(current, { lo: lower, hi: upper, last: index });
1543
+ }
1544
+ else {
1545
+ stretches.push(current);
1546
+ current = { lo: piece.lo, hi: piece.hi, first: index, last: index, from: 0, to: 0, level: 0 };
1547
+ }
1548
+ });
1549
+ stretches.push(current);
1550
+ for (const stretch of stretches) {
1551
+ stretch.from = pieces[stretch.first].from;
1552
+ stretch.to = pieces[stretch.last].to;
1553
+ }
1554
+ return { axes, pieces, stretches };
1555
+ }
1556
+ /**
1557
+ * Each stretch sits as near `target` as its band allows, and a node lying
1558
+ * on it pushes it on the way its clause already points. `carrier` is the
1559
+ * stretch held clear for the text, if this section has it.
1560
+ */
1561
+ function settle({ axes: { across, run }, stretches }, target, carrier, obstacles = inWay, hug = false, near = new Set()) {
1562
+ const textClear = Math.max(clear, laneExtent(edge, across, measurer, fontSize) / 2 + ATTACH_MARGIN);
1563
+ for (const stretch of stretches) {
1564
+ const room = stretch === carrier && edge.lines !== undefined ? textClear : clear;
1565
+ const extra = room - clear;
1566
+ const lower = stretch.lo + extra;
1567
+ const upper = stretch.hi - extra;
1568
+ // A stretch hugging its clauses sits as close as its one bound allows.
1569
+ const wanted = !hug
1570
+ ? target
1571
+ : stretch.hi === Infinity && stretch.lo !== -Infinity
1572
+ ? lower
1573
+ : stretch.lo === -Infinity && stretch.hi !== Infinity
1574
+ ? upper
1575
+ : target;
1576
+ stretch.level = lower <= upper
1577
+ ? Math.min(Math.max(wanted, lower), upper)
1578
+ : (stretch.lo + stretch.hi) / 2;
1579
+ const push = stretch.hi === Infinity ? 1 : stretch.lo === -Infinity ? -1 : 0;
1580
+ if (push === 0)
1581
+ continue;
1582
+ const from = Math.min(stretch.from, stretch.to);
1583
+ const to = Math.max(stretch.from, stretch.to);
1584
+ for (let moved = true; moved;) {
1585
+ moved = false;
1586
+ for (const node of obstacles) {
1587
+ const face = faceOf(node);
1588
+ if (lo(face, run) >= to || hi(face, run) <= from)
1589
+ continue;
1590
+ // A node the edge names is one it is meant to go close by, so it
1591
+ // keeps the line only as far off as a crossing does.
1592
+ const off = near.has(node) && room === clear ? ATTACH_MARGIN : room;
1593
+ if (lo(face, across) >= stretch.level + off || hi(face, across) <= stretch.level - off) {
1594
+ continue;
1595
+ }
1596
+ stretch.level = push > 0 ? hi(face, across) + off : lo(face, across) - off;
1597
+ moved = true;
1598
+ }
1599
+ }
1600
+ }
1601
+ }
1602
+ /**
1603
+ * Between two stretches the line crosses over, in whatever run of pieces at
1604
+ * the end of the first leaves room for both levels.
1605
+ */
1606
+ function crossings({ axes: { make }, pieces, stretches }) {
1607
+ const points = [];
1608
+ for (let index = 0; index + 1 < stretches.length; index += 1) {
1609
+ const here = stretches[index];
1610
+ const next = stretches[index + 1];
1611
+ let open = here.last + 1;
1612
+ while (open > here.first &&
1613
+ pieces[open - 1].lo <= next.level &&
1614
+ pieces[open - 1].hi >= next.level) {
1615
+ open -= 1;
1616
+ }
1617
+ const from = open <= here.last ? pieces[open].from : pieces[here.last].to;
1618
+ const to = pieces[here.last].to;
1619
+ if (Math.abs(to - from) < CROSSING_ROOM) {
1620
+ const behind = [...pieces.slice(here.first, here.last + 1)]
1621
+ .reverse()
1622
+ .map((piece) => piece.floor ?? piece.ceiling)
1623
+ .find((clause) => clause !== undefined);
1624
+ const entering = pieces[next.first];
1625
+ const ahead = (next.level > here.level ? entering.floor : entering.ceiling) ??
1626
+ entering.floor ?? entering.ceiling;
1627
+ throw new SourceError(`${subject}: to pass "${behind.pass.written}" and "${ahead.pass.written}" the line has to ` +
1628
+ `cross over between ${quoteNames(behind.pass.nodes)} and ${quoteNames(ahead.pass.nodes)}, and there ` +
1629
+ 'is no room between them — give the placement between them a gap, or drop one of the two', edge.line);
1630
+ }
1631
+ const at = (from + to) / 2;
1632
+ points.push(make(at, here.level), make(at, next.level));
1633
+ }
1634
+ return points;
1635
+ }
1636
+ /**
1637
+ * The route drawn through `points`, with its text at the middle of the
1638
+ * piece that lies on the stretch held clear for it.
1639
+ */
1640
+ function finish(points, carrier, { across, run }) {
1641
+ const drawn = tidyRoute(points);
1642
+ let mid = centerOf(boundingBox([a, b]));
1643
+ let best = -1;
1644
+ for (let index = 0; index + 1 < drawn.length; index += 1) {
1645
+ const [p, q] = [drawn[index], drawn[index + 1]];
1646
+ if (!carrier)
1647
+ break;
1648
+ if (Math.abs(p[across] - carrier.level) > 0.5 || Math.abs(q[across] - carrier.level) > 0.5) {
1649
+ continue;
1650
+ }
1651
+ const length = Math.abs(q[run] - p[run]);
1652
+ if (length > best) {
1653
+ best = length;
1654
+ mid = { x: (p.x + q.x) / 2, y: (p.y + q.y) / 2 };
1655
+ }
1656
+ }
1657
+ return { points: drawn, mid };
1658
+ }
1659
+ /**
1660
+ * The points from one end of the line to the level of its nearest stretch.
1661
+ * `toward` is the way the line heads along its run from this end.
1662
+ */
1663
+ function approach(given, face, axes, level, toward) {
1664
+ const { across, run, make } = axes;
1665
+ const anchor = given.side !== undefined ? given : leaving(face, axes, level, toward);
1666
+ const along = run === 'x' ? anchor.tx : anchor.ty;
1667
+ const out = across === 'x' ? anchor.tx : anchor.ty;
1668
+ if (along !== 0) {
1669
+ // Out along the run, one way or the other, then across to the level.
1670
+ const turn = make(anchor[run] + along * ROUTE_STUB, anchor[across]);
1671
+ return [anchor, turn, make(turn[run], level)];
1672
+ }
1673
+ if ((level - anchor[across]) * out >= 0)
1674
+ return [anchor, make(anchor[run], level)];
1675
+ // The side faces away from the level, so the line steps round the back of
1676
+ // its own node — the end the run is heading away from — to get there.
1677
+ const stub = make(anchor[run], anchor[across] + out * ROUTE_STUB);
1678
+ const back = toward > 0 ? lo(face, run) - ROUTE_STUB : hi(face, run) + ROUTE_STUB;
1679
+ return [anchor, stub, make(back, stub[across]), make(back, level)];
1680
+ }
1681
+ /**
1682
+ * An end naming no side leaves from the one facing the level it is going
1683
+ * to, or, when the level is alongside the node, the one facing its way.
1684
+ */
1685
+ function leaving(face, { across, run }, level, toward) {
1686
+ const center = centerOf(face);
1687
+ const side = level > hi(face, across)
1688
+ ? across === 'y' ? 'bottom' : 'right'
1689
+ : level < lo(face, across)
1690
+ ? across === 'y' ? 'top' : 'left'
1691
+ : run === 'x'
1692
+ ? toward > 0 ? 'right' : 'left'
1693
+ : toward > 0 ? 'bottom' : 'top';
1694
+ return anchorOn(face, side, side === 'top' || side === 'bottom' ? center.x : center.y);
1695
+ }
1696
+ }
1697
+ /** Which axis a clause's side sits on: above and below are a matter of y. */
1698
+ function sideAxis(pass) {
1699
+ return pass.direction === 'above' || pass.direction === 'below' ? 'y' : 'x';
1700
+ }
1701
+ /** "below", "left of" — the side as the author would say it. */
1702
+ function sideWord(pass) {
1703
+ return pass.direction === 'above' || pass.direction === 'below'
1704
+ ? pass.direction
1705
+ : `${pass.direction} of`;
1706
+ }
1707
+ /** The box that just bounds several. */
1708
+ function boundingBox(boxes) {
1709
+ const x = Math.min(...boxes.map((box) => box.x));
1710
+ const y = Math.min(...boxes.map((box) => box.y));
1711
+ return {
1712
+ x,
1713
+ y,
1714
+ width: Math.max(...boxes.map((box) => box.x + box.width)) - x,
1715
+ height: Math.max(...boxes.map((box) => box.y + box.height)) - y,
1716
+ };
1717
+ }
1718
+ /** `"a"`, `"a" and "b"` — for error messages. */
1719
+ function quoteNames(nodes) {
1720
+ const quoted = nodes.map((node) => `"${node.name}"`);
1721
+ return quoted.length <= 1
1722
+ ? (quoted[0] ?? '')
1723
+ : `${quoted.slice(0, -1).join(', ')} and ${quoted[quoted.length - 1]}`;
1724
+ }
1725
+ /** Drop repeated points and ones partway along a straight piece, which are not corners. */
1726
+ function tidyRoute(points) {
1727
+ const kept = [];
1728
+ for (const point of points) {
1729
+ const previous = kept[kept.length - 1];
1730
+ if (previous && Math.hypot(point.x - previous.x, point.y - previous.y) < 0.5)
1731
+ continue;
1732
+ const before = kept[kept.length - 2];
1733
+ if (before && previous &&
1734
+ Math.abs((previous.x - before.x) * (point.y - previous.y) - (previous.y - before.y) * (point.x - previous.x)) < 1e-6 &&
1735
+ (previous.x - before.x) * (point.x - previous.x) + (previous.y - before.y) * (point.y - previous.y) >= 0) {
1736
+ kept[kept.length - 1] = point;
1737
+ continue;
1738
+ }
1739
+ kept.push(point);
1740
+ }
1741
+ return kept;
1742
+ }
1743
+ /**
1744
+ * A line through `points` with every corner rounded. A corner takes at most
1745
+ * half of each piece it shares with a neighboring corner, and the whole of a
1746
+ * piece at either end short of the arrowhead, so two corners never overlap and
1747
+ * the head always lands on a straight piece.
1748
+ */
1749
+ function roundedPath(points) {
1750
+ const parts = [`M ${round(points[0].x)} ${round(points[0].y)}`];
1751
+ const kappa = 0.5523; // a cubic's handle, as a share of the radius, for a quarter circle
1752
+ for (let index = 1; index + 1 < points.length; index += 1) {
1753
+ const [before, corner, after] = [points[index - 1], points[index], points[index + 1]];
1754
+ const inward = Math.hypot(corner.x - before.x, corner.y - before.y);
1755
+ const outward = Math.hypot(after.x - corner.x, after.y - corner.y);
1756
+ const radius = Math.max(0, Math.min(ROUTE_RADIUS, index === 1 ? inward - ARROW_LENGTH : inward / 2, index + 2 === points.length ? outward - ARROW_LENGTH : outward / 2));
1757
+ const din = { x: (corner.x - before.x) / inward, y: (corner.y - before.y) / inward };
1758
+ const dout = { x: (after.x - corner.x) / outward, y: (after.y - corner.y) / outward };
1759
+ const enter = { x: corner.x - din.x * radius, y: corner.y - din.y * radius };
1760
+ const leave = { x: corner.x + dout.x * radius, y: corner.y + dout.y * radius };
1761
+ parts.push(`L ${round(enter.x)} ${round(enter.y)}`, `C ${round(enter.x + din.x * radius * kappa)} ${round(enter.y + din.y * radius * kappa)}, ` +
1762
+ `${round(leave.x - dout.x * radius * kappa)} ${round(leave.y - dout.y * radius * kappa)}, ` +
1763
+ `${round(leave.x)} ${round(leave.y)}`);
1764
+ }
1765
+ const last = points[points.length - 1];
1766
+ parts.push(`L ${round(last.x)} ${round(last.y)}`);
1767
+ return parts.join(' ');
1768
+ }
1769
+ function lo(box, axis) {
1770
+ return axis === 'x' ? box.x : box.y;
1771
+ }
1772
+ function hi(box, axis) {
1773
+ return axis === 'x' ? box.x + box.width : box.y + box.height;
1774
+ }
1775
+ /** Whether `inner` is `outer` or sits somewhere inside it. */
1776
+ function contains(outer, inner) {
1777
+ for (let node = inner; node; node = node.parent) {
1778
+ if (node === outer)
1779
+ return true;
1780
+ }
1781
+ return false;
1782
+ }
1006
1783
  /**
1007
1784
  * An end whose side the author did not name aims at the far box's center, which
1008
1785
  * is the wrong thing to aim at once the line has been told to go somewhere else
@@ -1055,10 +1832,11 @@ function corridorPath(start, end, plan) {
1055
1832
  // Along the run the line travels one way, so that is its tangent at both ends
1056
1833
  // of the straight stretch — it enters the gap already going where the gap goes.
1057
1834
  const rt = plan.axis === 'y' ? { tx: forward, ty: 0 } : { tx: 0, ty: forward };
1058
- const r1 = corridorReach(start, p1, plan.axis);
1835
+ const reach = plan.loop ? loopReach : corridorReach;
1836
+ const r1 = reach(start, p1, plan.axis);
1059
1837
  const c1 = { x: start.x + start.tx * r1, y: start.y + start.ty * r1 };
1060
1838
  const c2 = { x: p1.x - rt.tx * r1, y: p1.y - rt.ty * r1 };
1061
- const r2 = corridorReach(p2, end, plan.axis);
1839
+ const r2 = reach(p2, end, plan.axis);
1062
1840
  const c3 = { x: p2.x + rt.tx * r2, y: p2.y + rt.ty * r2 };
1063
1841
  const c4 = { x: end.x + end.tx * r2, y: end.y + end.ty * r2 };
1064
1842
  const d = [
@@ -1081,6 +1859,15 @@ function corridorReach(from, to, axis) {
1081
1859
  const distance = Math.hypot(to.x - from.x, to.y - from.y);
1082
1860
  return Math.min(140, Math.max(8, Math.min(distance * 0.4, run / 2)));
1083
1861
  }
1862
+ /**
1863
+ * How far the handles reach on the turn at either end of a loop. The turn
1864
+ * leaves heading one way and joins the run heading the other, over the depth
1865
+ * between the side and the run, so it is a half circle on that depth — and a
1866
+ * cubic comes closest to a half circle with handles two thirds of its diameter.
1867
+ */
1868
+ function loopReach(from, to, axis) {
1869
+ return (Math.abs(axis === 'y' ? to.y - from.y : to.x - from.x) * 2) / 3;
1870
+ }
1084
1871
  function sideAttr(edge, key) {
1085
1872
  const value = edge.attrs[key];
1086
1873
  if (value === undefined)
package/dist/resolve.js CHANGED
@@ -457,6 +457,25 @@ function buildEdges(statements, byName, styles, defaults) {
457
457
  }),
458
458
  ...(stmt.between.axis !== undefined ? { axis: stmt.between.axis } : {}),
459
459
  };
460
+ const passes = stmt.passes?.map((placement) => {
461
+ const written = describePlacement(placement);
462
+ return {
463
+ direction: placement.direction,
464
+ written,
465
+ nodes: placement.targets.map(({ name, part }) => {
466
+ if (part !== undefined) {
467
+ // The line passes a box, and a side or a point has no side of its
468
+ // own to be passed on. Refused by name rather than dropped.
469
+ throw new SourceError(`edge passes "${written}", and a line passes a whole box — drop "${part}"`, stmt.line);
470
+ }
471
+ const node = byName.get(name);
472
+ if (!node) {
473
+ throw new SourceError(`edge passes "${written}", and "${name}" does not exist`, stmt.line);
474
+ }
475
+ return node;
476
+ }),
477
+ };
478
+ });
460
479
  const appearance = { ...defaults.get('edge'), ...appearanceOf(stmt.attrs, styles, stmt.line) };
461
480
  const what = `${stmt.from} -> ${stmt.to}`;
462
481
  checkAttrs('edge', what, stmt.attrs, stmt.line);
@@ -479,6 +498,7 @@ function buildEdges(statements, byName, styles, defaults) {
479
498
  ? { text: stmt.text, lines: linesFor(stmt.text, textAttrs, `edge ${what}`, stmt.line) }
480
499
  : {}),
481
500
  ...(between ? { between } : {}),
501
+ ...(passes ? { passes } : {}),
482
502
  attrs: stmt.attrs,
483
503
  appearance,
484
504
  line: stmt.line,
@@ -1268,6 +1288,30 @@ const AXIS_WORD = { x: 'horizontally', y: 'vertically' };
1268
1288
  function memberOn(target, axis) {
1269
1289
  return target.byAxis ? target.byAxis[axis] : target.index;
1270
1290
  }
1291
+ /**
1292
+ * The ways named sides can face away from the other end. Opposite sides face
1293
+ * away together or not at all (`left right` with `from` first); sides at right
1294
+ * angles each may, and either is enough (`left top` with `from` first, where
1295
+ * the edge turns back over the top, or with `to` below, where it goes round).
1296
+ */
1297
+ function facingAway(fromSide, toSide) {
1298
+ // The axis a side is on, and whether it faces away when its own end is first.
1299
+ const SIDES = {
1300
+ left: { axis: 'x', first: true },
1301
+ right: { axis: 'x', first: false },
1302
+ top: { axis: 'y', first: true },
1303
+ bottom: { axis: 'y', first: false },
1304
+ };
1305
+ const f = SIDES[String(fromSide)];
1306
+ const t = SIDES[String(toSide)];
1307
+ if (!f || !t)
1308
+ return [];
1309
+ const byFrom = { axis: f.axis, fromFirst: f.first };
1310
+ const byTo = { axis: t.axis, fromFirst: !t.first };
1311
+ if (f.axis !== t.axis)
1312
+ return [byFrom, byTo];
1313
+ return byFrom.fromFirst === byTo.fromFirst ? [byFrom] : [];
1314
+ }
1271
1315
  /**
1272
1316
  * Where a node sits within the group being solved: which member holds it, and
1273
1317
  * where inside that member. An edge may name anything at any depth, so its ends
@@ -1303,7 +1347,9 @@ function corridorsIn(edges, locate, measurer, fontSize) {
1303
1347
  // edge told to pass between two named things carries its text in *that*
1304
1348
  // corridor rather than in the gap between its own ends, so widening this one
1305
1349
  // would make room where the text never goes.
1306
- if (edge.text === undefined || edge.between)
1350
+ // An edge that says which side of something it passes is routed round it,
1351
+ // and its text rides on that route for the same reason.
1352
+ if (edge.text === undefined || edge.between || edge.passes)
1307
1353
  continue;
1308
1354
  const from = locate(edge.from);
1309
1355
  const to = locate(edge.to);
@@ -1316,7 +1362,13 @@ function corridorsIn(edges, locate, measurer, fontSize) {
1316
1362
  // one end only would move the midpoint rather than lengthen the run.
1317
1363
  const extent = (axis) => textExtent(edge.lines, edge.textAttrs, axis, measurer, fontSize, edge.line) +
1318
1364
  (TEXT_CLEARANCE + ARROW_LENGTH) * 2;
1319
- corridors.push({ edge, from, to, need: { x: extent('x'), y: extent('y') } });
1365
+ corridors.push({
1366
+ edge,
1367
+ from,
1368
+ to,
1369
+ need: { x: extent('x'), y: extent('y') },
1370
+ away: facingAway(edge.attrs['from'], edge.attrs['to']),
1371
+ });
1320
1372
  }
1321
1373
  return corridors;
1322
1374
  }
@@ -1658,7 +1710,7 @@ function alignedAt(side, span, own) {
1658
1710
  */
1659
1711
  function room(corridors, constraints, solved, solveAll, made) {
1660
1712
  let added = false;
1661
- for (const [index, { from, to, need }] of corridors.entries()) {
1713
+ for (const [index, { from, to, need, away }] of corridors.entries()) {
1662
1714
  const clear = (axis) => {
1663
1715
  const at = (end) => solved[axis][end.index] + end.offset[axis];
1664
1716
  const size = (end) => (axis === 'x' ? end.width : end.height);
@@ -1672,6 +1724,11 @@ function room(corridors, constraints, solved, solveAll, made) {
1672
1724
  if (open.length !== 1)
1673
1725
  continue;
1674
1726
  const axis = open[0];
1727
+ // Ends facing away along the only open axis mean the boxes share a row, and
1728
+ // the renderer takes the line over the top with its text — see `planLoops`.
1729
+ // Widening the gap would make room where the text never goes.
1730
+ if (away.some((way) => way.axis === axis && (clear(axis).before === from) === way.fromFirst))
1731
+ continue;
1675
1732
  // Given already, and a minimum stays met: asking again would widen
1676
1733
  // nothing and only keep the caller looking.
1677
1734
  if (made.has(`${index}:${axis}`))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reladraw",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "A diagram language where you say where things go.",
5
5
  "type": "module",
6
6
  "bin": {