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 +2 -2
- package/SYNTAX.md +100 -3
- package/dist/ast.d.ts +7 -0
- package/dist/model.d.ts +14 -1
- package/dist/parser.js +21 -3
- package/dist/render.js +793 -6
- package/dist/resolve.js +60 -3
- package/package.json +1 -1
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.
|
|
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.
|
|
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.
|
|
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`
|
|
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`
|
|
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
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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 (
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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({
|
|
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}`))
|