reladraw 0.6.0 → 0.7.1
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 +45 -2
- package/dist/ast.d.ts +18 -0
- package/dist/ast.js +18 -0
- package/dist/constants.d.ts +9 -0
- package/dist/constants.js +15 -0
- package/dist/model.d.ts +14 -0
- package/dist/parser.js +29 -3
- package/dist/render.js +541 -18
- package/dist/resolve.js +50 -5
- 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.7.1. 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
|
|
@@ -121,7 +121,7 @@ Three design problems decide how much machinery this needs, and the first outran
|
|
|
121
121
|
|
|
122
122
|
**What the engine is allowed to decide.** Auto-layout is refused, because a picture chosen by an algorithm is not predictable from its source, and that predictability is the entire point. Working out coordinates from an arrangement the author stated is a different thing and is simply the job. The test between them: the engine's freedom may affect distances and never relationships. If a default can change which side of something a node sits on, the language was short a statement and the tool should say so rather than guess.
|
|
123
123
|
|
|
124
|
-
**Overlap and edge routing.** Relative placement with default spacing collides as soon as two clusters grow toward each other
|
|
124
|
+
**Overlap and edge routing.** Relative placement with default spacing collides as soon as two clusters grow toward each other, and nodes are kept apart for exactly that reason. Edges are different: which side of a node a line passes is a relationship, so the tool does not pick a route around an obstacle by itself. You say where a line goes instead — the side it leaves and arrives on, the gap it runs through, the nodes it passes above or below — and the line is drawn curved, square or straight through those points. A line that crosses something it should not is one you have not said enough about yet. Whether the tool should at least point that out, naming the clause that would fix it, is an open question.
|
|
125
125
|
|
|
126
126
|
## Prior art
|
|
127
127
|
|
package/SYNTAX.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Syntax reference — 0.
|
|
1
|
+
# Syntax reference — 0.7.1
|
|
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
|
|
|
@@ -576,6 +576,41 @@ Refused, each by name:
|
|
|
576
576
|
|
|
577
577
|
An end with no side named leaves from the side facing where the line is going.
|
|
578
578
|
|
|
579
|
+
### How the line is drawn
|
|
580
|
+
|
|
581
|
+
Everything about the line itself goes in its bracket:
|
|
582
|
+
|
|
583
|
+
```
|
|
584
|
+
edge api -> db line: (path: square, corners: rounded)
|
|
585
|
+
edge api -> cache line: (pattern: dashed, thickness: thin)
|
|
586
|
+
edge web -> api line: (color: #d2904e, thickness: thick, crossing: arc)
|
|
587
|
+
```
|
|
588
|
+
|
|
589
|
+
`line: red` is short for `line: (color: red)`, and is still the way to write a color alone. Every other property needs the bracket, and `line: square` is refused with the bracket it meant. Like any attribute, the bracket may go on an edge, in a style, or on `default edge` — `default edge line: (path: square, corners: rounded)` draws a whole diagram with right angles.
|
|
590
|
+
|
|
591
|
+
| property | takes | says |
|
|
592
|
+
|---|---|---|
|
|
593
|
+
| `color` | a color | as `line:` alone |
|
|
594
|
+
| `path` | `curved` (the default), `square`, `straight` | how the line joins the points it has to meet |
|
|
595
|
+
| `corners` | `sharp` (the default), `rounded` | how a square or straight line turns |
|
|
596
|
+
| `crossing` | `none` (the default), `arc`, `gap`, `square` | how the line is drawn where it crosses an earlier one |
|
|
597
|
+
| `pattern` | `solid` (the default), `dashed`, `dotted`, `dash-dot` | |
|
|
598
|
+
| `thickness` | `thin`, `normal` (the default), `thick`, or a number of pixels | |
|
|
599
|
+
|
|
600
|
+
**A path is the rule for joining what the file fixed**: where the line leaves and which way it heads, where it arrives and from which direction, and any sides it passes. Out of A's top and into B's left, with B up and to the right:
|
|
601
|
+
|
|
602
|
+
- `curved` bends round from heading up to heading right. It is how every line was drawn before there was a choice, including the straight line an edge naming no sides has always been.
|
|
603
|
+
- `square` goes up, then across into B — one right angle, because leaving upward and arriving from the left fixes which way it turns. A square line naming no sides picks them: boxes sharing a column join top to bottom, boxes sharing a row side to side, and otherwise the line goes across first, then down. An end naming no side, beside one that does, takes whichever side needs the fewest turns.
|
|
604
|
+
- `straight` is one straight piece from A's top to B's left, ignoring which way each faces. Given `below c` or `between a and b`, it bends where that clause puts a point it has to pass, and nowhere else.
|
|
605
|
+
|
|
606
|
+
**Corners** apply wherever a line has them: every bend in a square line, and the bends a clause puts in a straight one. A curved line has none, so `corners: rounded` written on a curved edge is refused. From a style or a default it rounds the edges that have corners and leaves curved ones alone, as a style's `fill:` lands only on the things that have an inside.
|
|
607
|
+
|
|
608
|
+
**A crossing is drawn on the later line**: the edge written further down the file jumps the one written above it. `arc` is a small half-circle hop, `square` a three-sided jump, and `gap` a short break in the later line, as though it passed underneath. A line never jumps within reach of its own ends, where two lines meeting at a box are not passing each other.
|
|
609
|
+
|
|
610
|
+
**A pattern is measured in thicknesses**, so dashes keep their proportions on a thick line.
|
|
611
|
+
|
|
612
|
+
**A thick line takes room.** Its arrowhead grows with it, and the room an edge's text makes for itself counts the arrowhead it will actually have, so thickening a line with text between two close nodes can push them apart — the same way a larger text grows its node. Color, path, corners, crossings and pattern move nothing.
|
|
613
|
+
|
|
579
614
|
## Against a part of a node
|
|
580
615
|
|
|
581
616
|
```
|
|
@@ -716,7 +751,7 @@ Every attribute, and what takes one. The kinds here are what a node's **body** i
|
|
|
716
751
|
| `fill` | ✓ | | | | color — see "A color names the part it colors" |
|
|
717
752
|
| `border` | ✓ | | | | color |
|
|
718
753
|
| `text` | ✓ | ✓ | ✓ | ✓ | the text's properties, in brackets — a style's form of what a node or an edge writes after its own words |
|
|
719
|
-
| `line` | | | | ✓ | color |
|
|
754
|
+
| `line` | | | | ✓ | color, or everything about the line in brackets — see "How the line is drawn" |
|
|
720
755
|
| `url` | ✓ | ✓ | ✓ | ✓ | a destination to open when the thing is clicked |
|
|
721
756
|
|
|
722
757
|
The `diagram` statement has a vocabulary of its own — `theme`, `background` and `text` — which is checked the same way. Writing `background:` on a node is an error that points at `fill:`, and `theme:` on one points at `diagram theme:`. A `default` takes a narrower list for each kind; see "Defaults".
|
|
@@ -977,6 +1012,14 @@ That one was found by testing the lexer, not by rendering — and it could not h
|
|
|
977
1012
|
|
|
978
1013
|
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.
|
|
979
1014
|
|
|
1015
|
+
**0.7.1**
|
|
1016
|
+
|
|
1017
|
+
- A fix, no syntax change. An edge with `line: (path: straight)` and a clause such as `below m` could draw its text on top of `m`. The line now bends through the room left for the text, so the text sits clear of the node. Existing diagrams with such an edge will look different, and correct.
|
|
1018
|
+
|
|
1019
|
+
**0.7.0**
|
|
1020
|
+
|
|
1021
|
+
- `line:` takes a bracket for everything about how the line is drawn: `path: curved | square | straight`, `corners: sharp | rounded`, `crossing: none | arc | gap | square`, `pattern: solid | dashed | dotted | dash-dot` and `thickness: thin | normal | thick` or a number. `line: red` still works, as the short form of `line: (color: red)`. Every default is how lines were drawn before, so no existing file changes.
|
|
1022
|
+
|
|
980
1023
|
**0.6.0**
|
|
981
1024
|
|
|
982
1025
|
- 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.
|
package/dist/ast.d.ts
CHANGED
|
@@ -241,6 +241,24 @@ export type Attrs = Record<string, string>;
|
|
|
241
241
|
* `align` is how its lines range against each other once it is there.
|
|
242
242
|
*/
|
|
243
243
|
export declare const TEXT_KEYS: readonly ["color", "size", "wrap", "align", "at"];
|
|
244
|
+
/**
|
|
245
|
+
* What an edge's line takes in its bracket: `line: (path: square, pattern:
|
|
246
|
+
* dashed)`. The line is a part in the sense the text is, so every property of
|
|
247
|
+
* it goes in one bracket, as the text's do. `line: red` stays as the short form
|
|
248
|
+
* of `line: (color: red)`, and both are stored under the bare `line` key.
|
|
249
|
+
*/
|
|
250
|
+
export declare const LINE_KEYS: readonly ["color", "path", "corners", "crossing", "pattern", "thickness"];
|
|
251
|
+
/**
|
|
252
|
+
* The words each of the line's properties takes, the default first. Thickness
|
|
253
|
+
* takes a plain number of pixels as well.
|
|
254
|
+
*/
|
|
255
|
+
export declare const LINE_VALUES: {
|
|
256
|
+
readonly path: readonly ["curved", "square", "straight"];
|
|
257
|
+
readonly corners: readonly ["sharp", "rounded"];
|
|
258
|
+
readonly crossing: readonly ["none", "arc", "gap", "square"];
|
|
259
|
+
readonly pattern: readonly ["solid", "dashed", "dotted", "dash-dot"];
|
|
260
|
+
readonly thickness: readonly ["normal", "thin", "thick"];
|
|
261
|
+
};
|
|
244
262
|
export interface NodeStmt {
|
|
245
263
|
kind: 'node';
|
|
246
264
|
name: string;
|
package/dist/ast.js
CHANGED
|
@@ -222,6 +222,24 @@ export function describeAxis(axis) {
|
|
|
222
222
|
* `align` is how its lines range against each other once it is there.
|
|
223
223
|
*/
|
|
224
224
|
export const TEXT_KEYS = ['color', 'size', 'wrap', 'align', 'at'];
|
|
225
|
+
/**
|
|
226
|
+
* What an edge's line takes in its bracket: `line: (path: square, pattern:
|
|
227
|
+
* dashed)`. The line is a part in the sense the text is, so every property of
|
|
228
|
+
* it goes in one bracket, as the text's do. `line: red` stays as the short form
|
|
229
|
+
* of `line: (color: red)`, and both are stored under the bare `line` key.
|
|
230
|
+
*/
|
|
231
|
+
export const LINE_KEYS = ['color', 'path', 'corners', 'crossing', 'pattern', 'thickness'];
|
|
232
|
+
/**
|
|
233
|
+
* The words each of the line's properties takes, the default first. Thickness
|
|
234
|
+
* takes a plain number of pixels as well.
|
|
235
|
+
*/
|
|
236
|
+
export const LINE_VALUES = {
|
|
237
|
+
path: ['curved', 'square', 'straight'],
|
|
238
|
+
corners: ['sharp', 'rounded'],
|
|
239
|
+
crossing: ['none', 'arc', 'gap', 'square'],
|
|
240
|
+
pattern: ['solid', 'dashed', 'dotted', 'dash-dot'],
|
|
241
|
+
thickness: ['normal', 'thin', 'thick'],
|
|
242
|
+
};
|
|
225
243
|
/**
|
|
226
244
|
* The attributes a `diagram` statement understands. `text` takes a bracket,
|
|
227
245
|
* `text: (color: …)`, and sets the text color of everything at once — the
|
package/dist/constants.d.ts
CHANGED
|
@@ -50,6 +50,15 @@ export declare const ARROW_MARKER_WIDTH = 7;
|
|
|
50
50
|
* agreed by coincidence is a number that drifts.
|
|
51
51
|
*/
|
|
52
52
|
export declare const ARROW_LENGTH: number;
|
|
53
|
+
/** What `thickness: thin | normal | thick` draw, in pixels. `normal` is the line as it always was. */
|
|
54
|
+
export declare const THICKNESS: Record<string, number>;
|
|
55
|
+
/**
|
|
56
|
+
* How much of a line of this thickness its arrowhead covers. The marker scales
|
|
57
|
+
* with the stroke, so a thick line gets a bigger head, and the room an edge's
|
|
58
|
+
* text makes for itself counts the head it will actually be drawn with — a
|
|
59
|
+
* thickness is a size, like a text's, and a size is allowed to take room.
|
|
60
|
+
*/
|
|
61
|
+
export declare function arrowLength(thickness: number): number;
|
|
53
62
|
/**
|
|
54
63
|
* Line left showing between an edge's text and the box at that end of the
|
|
55
64
|
* corridor it crosses.
|
package/dist/constants.js
CHANGED
|
@@ -56,6 +56,21 @@ export const ARROW_MARKER_WIDTH = 7;
|
|
|
56
56
|
* agreed by coincidence is a number that drifts.
|
|
57
57
|
*/
|
|
58
58
|
export const ARROW_LENGTH = ARROW_MARKER_WIDTH * LINE_WIDTH;
|
|
59
|
+
/** What `thickness: thin | normal | thick` draw, in pixels. `normal` is the line as it always was. */
|
|
60
|
+
export const THICKNESS = {
|
|
61
|
+
thin: 1,
|
|
62
|
+
normal: LINE_WIDTH,
|
|
63
|
+
thick: LINE_WIDTH * 2,
|
|
64
|
+
};
|
|
65
|
+
/**
|
|
66
|
+
* How much of a line of this thickness its arrowhead covers. The marker scales
|
|
67
|
+
* with the stroke, so a thick line gets a bigger head, and the room an edge's
|
|
68
|
+
* text makes for itself counts the head it will actually be drawn with — a
|
|
69
|
+
* thickness is a size, like a text's, and a size is allowed to take room.
|
|
70
|
+
*/
|
|
71
|
+
export function arrowLength(thickness) {
|
|
72
|
+
return ARROW_MARKER_WIDTH * thickness;
|
|
73
|
+
}
|
|
59
74
|
/**
|
|
60
75
|
* Line left showing between an edge's text and the box at that end of the
|
|
61
76
|
* corridor it crosses.
|
package/dist/model.d.ts
CHANGED
|
@@ -129,10 +129,24 @@ export interface LayoutEdge {
|
|
|
129
129
|
between?: LayoutPassage;
|
|
130
130
|
/** Which side of which nodes the line passes. Measured in the renderer, like `between`. */
|
|
131
131
|
passes?: LayoutPass[];
|
|
132
|
+
/** What the line's bracket says, checked and with every default filled in. */
|
|
133
|
+
look: LineLook;
|
|
132
134
|
attrs: Attrs;
|
|
133
135
|
appearance: Attrs;
|
|
134
136
|
line: number;
|
|
135
137
|
}
|
|
138
|
+
/**
|
|
139
|
+
* How an edge's line is drawn: `line: (path: square, corners: rounded, …)`.
|
|
140
|
+
* Only `thickness` takes room; the rest is appearance and moves nothing.
|
|
141
|
+
*/
|
|
142
|
+
export interface LineLook {
|
|
143
|
+
path: 'curved' | 'square' | 'straight';
|
|
144
|
+
corners: 'sharp' | 'rounded';
|
|
145
|
+
crossing: 'none' | 'arc' | 'gap' | 'square';
|
|
146
|
+
pattern: 'solid' | 'dashed' | 'dotted' | 'dash-dot';
|
|
147
|
+
/** In pixels. */
|
|
148
|
+
thickness: number;
|
|
149
|
+
}
|
|
136
150
|
export interface Layout {
|
|
137
151
|
/** Every node, containers and children alike, in declaration order. */
|
|
138
152
|
nodes: LayoutNode[];
|
package/dist/parser.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { COLOR_KEYS, DEFAULT_KEYS, DEFAULT_TARGETS, DIAGRAM_KEYS, DIRECTIONS, describePlacement, SIDE_AXIS, SIDES, PASSAGE_AXES, TEXT_KEYS, CONTENTS_KEYS, PLACEMENT_KEYS, BOUNDARY_PARTS, INWARD, OPPOSITE, isDirection, isPart, isPosition, listTargets, nameTarget, } from './ast.js';
|
|
1
|
+
import { COLOR_KEYS, DEFAULT_KEYS, DEFAULT_TARGETS, DIAGRAM_KEYS, DIRECTIONS, describePlacement, SIDE_AXIS, SIDES, PASSAGE_AXES, TEXT_KEYS, LINE_KEYS, LINE_VALUES, CONTENTS_KEYS, PLACEMENT_KEYS, BOUNDARY_PARTS, INWARD, OPPOSITE, isDirection, isPart, isPosition, listTargets, nameTarget, } from './ast.js';
|
|
2
2
|
import { SourceError } from './errors.js';
|
|
3
3
|
import { isAttrKey, tokenizeLine } from './lexer.js';
|
|
4
4
|
import { THEME_NAMES, THEMES } from './themes.js';
|
|
@@ -138,12 +138,25 @@ function parseTail(tokens, start, line, subject, other) {
|
|
|
138
138
|
const BRACKET_KEYS = {
|
|
139
139
|
text: TEXT_KEYS,
|
|
140
140
|
contents: CONTENTS_KEYS,
|
|
141
|
+
line: LINE_KEYS,
|
|
141
142
|
};
|
|
142
143
|
/** How each bracketed key's error quotes itself back, and what it is about. */
|
|
143
144
|
const BRACKET_ABOUT = {
|
|
144
145
|
text: { kind: 'a text', example: 'color: muted' },
|
|
145
146
|
contents: { kind: 'a `contents:` bracket', example: 'widths: match' },
|
|
147
|
+
line: { kind: 'a line', example: 'path: square' },
|
|
146
148
|
};
|
|
149
|
+
/**
|
|
150
|
+
* Which of the line's properties a word belongs to, so that `line: square` —
|
|
151
|
+
* the color key given a shape — can be pointed at the bracket.
|
|
152
|
+
*/
|
|
153
|
+
function linePropertyOf(word) {
|
|
154
|
+
for (const [property, words] of Object.entries(LINE_VALUES)) {
|
|
155
|
+
if (words.includes(word) && word !== 'normal')
|
|
156
|
+
return property;
|
|
157
|
+
}
|
|
158
|
+
return undefined;
|
|
159
|
+
}
|
|
147
160
|
/**
|
|
148
161
|
* The top-level keys that moved into the text's bracket in 0.3.0, and the
|
|
149
162
|
* substitution each one gets. They are properties of a node's *text* and never
|
|
@@ -186,7 +199,11 @@ function readAttr(tokens, at, attrs, line, subject) {
|
|
|
186
199
|
// Two brackets for one part are fine as long as they say different things;
|
|
187
200
|
// the same property in both is the same defect as `fill:` written twice.
|
|
188
201
|
for (const [inner, value] of Object.entries(read.values)) {
|
|
189
|
-
|
|
202
|
+
// A line's color is stored where `line: red` puts it, so the short form
|
|
203
|
+
// and the bracket are one key: a style's `line: red` merges under an
|
|
204
|
+
// edge's `line: (color: blue)`, and writing both on one line is a repeat.
|
|
205
|
+
const stored = key === 'line' && inner === 'color' ? 'line' : `${key}.${inner}`;
|
|
206
|
+
setOnce(attrs, stored, value, subject, line, { key: `${key}: (${inner}: …)`, value: (v) => v });
|
|
190
207
|
}
|
|
191
208
|
return read.next;
|
|
192
209
|
}
|
|
@@ -251,7 +268,7 @@ function readAttr(tokens, at, attrs, line, subject) {
|
|
|
251
268
|
});
|
|
252
269
|
return next;
|
|
253
270
|
}
|
|
254
|
-
if (bracketKeys !== undefined && key !== 'text') {
|
|
271
|
+
if (bracketKeys !== undefined && key !== 'text' && key !== 'line') {
|
|
255
272
|
// `contents: match` names the part and then says one of its two properties
|
|
256
273
|
// without saying which. The brackets are what make the level shift visible,
|
|
257
274
|
// so there is no unbracketed spelling to fall back to.
|
|
@@ -292,6 +309,15 @@ function readAttr(tokens, at, attrs, line, subject) {
|
|
|
292
309
|
? `\`text:\` is how a style says something about text, not how anything sets it — write the words in quotes after the name, as in \`node name ${quoteOf(valueToken.text)}\``
|
|
293
310
|
: `\`text:\` takes the text's properties in brackets — write \`text: (color: ${valueToken.text})\` in a style, and \`(color: ${valueToken.text})\` in the brackets after a node's or an edge's own text`, line);
|
|
294
311
|
}
|
|
312
|
+
if (key === 'line' && !valueToken.quoted) {
|
|
313
|
+
// `line: red` is the short form of the color, and the only one: every other
|
|
314
|
+
// property of the line needs the bracket to say which it is.
|
|
315
|
+
const property = linePropertyOf(valueToken.text) ?? (/^\d/.test(valueToken.text) ? 'thickness' : undefined);
|
|
316
|
+
if (property !== undefined) {
|
|
317
|
+
throw new SourceError(`\`line: ${valueToken.text}\` — \`line:\` on its own takes a color. The line's other properties go in its bracket: ` +
|
|
318
|
+
`\`line: (${property}: ${valueToken.text})\``, line);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
295
321
|
if (key === 'align' && valueToken.text === 'widths') {
|
|
296
322
|
// Removed in 0.3.0. It was a size operation wearing an alignment's name,
|
|
297
323
|
// and its value set had one member — a flag in a property's clothes. Its
|
package/dist/render.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ARROW_LENGTH, ARROW_MARKER_WIDTH, ATTACH_MARGIN, ATTACH_STEP, DECK_STEP, DEFAULT_FONT_SIZE, ICON_LINES,
|
|
1
|
+
import { ARROW_LENGTH, ARROW_MARKER_WIDTH, ATTACH_MARGIN, ATTACH_STEP, DECK_STEP, DEFAULT_FONT_SIZE, ICON_LINES, PAD, SEPARATION_GAP, arrowLength, fontSizeFor, textExtent, textStyleFor, widestLine, } from './constants.js';
|
|
2
2
|
import { describeAxis } from './ast.js';
|
|
3
3
|
import { SourceError } from './errors.js';
|
|
4
4
|
import { ICON_STROKE } from './icons.js';
|
|
@@ -37,10 +37,31 @@ export function render(layout, options = {}) {
|
|
|
37
37
|
const routes = planRoutes(layout.edges, layout.nodes, ends, measurer, fontSize);
|
|
38
38
|
planLoops(layout.edges, layout.nodes, ends, corridors, routes, measurer, fontSize);
|
|
39
39
|
aimFreeEnds(layout.edges, ends, corridors);
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
40
|
+
const drawn = layout.edges.map((edge) => drawEdge(edge, ends.get(edge), corridors.get(edge), routes.get(edge), layout.nodes, theme, measurer, fontSize, layout.markup));
|
|
41
|
+
// A line with a `crossing:` style is cut where it crosses an earlier one, and
|
|
42
|
+
// the jump drawn over the cut. The cut is a mask rather than a break in the
|
|
43
|
+
// path, so it works the same on a curve as on a corner, and leaves whatever
|
|
44
|
+
// lies underneath — a container's fill — showing through the gap.
|
|
45
|
+
const crossings = findCrossings(drawn);
|
|
46
|
+
const masks = [];
|
|
47
|
+
for (const edge of drawn) {
|
|
48
|
+
const found = crossings.get(edge);
|
|
49
|
+
const parts = [edge.line, ...edge.rest];
|
|
50
|
+
if (found) {
|
|
51
|
+
const id = `cut-${masks.length + 1}`;
|
|
52
|
+
masks.push({ id, holes: found });
|
|
53
|
+
parts[0] = edge.line.replace(/\/>$/, ` mask="url(#${id})"/>`);
|
|
54
|
+
const jumps = found
|
|
55
|
+
.map((crossing) => jumpAt(crossing, edge.edge.look.crossing))
|
|
56
|
+
.filter((d) => d !== undefined)
|
|
57
|
+
.map((d) => ` <path d="${d}" fill="none"${edge.stroke}/>`);
|
|
58
|
+
parts.splice(1, 0, ...jumps);
|
|
59
|
+
for (const crossing of found) {
|
|
60
|
+
edge.ink = union(edge.ink, grow(extentOfPoints([crossing.at]), crossing.reach + edge.edge.look.thickness));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
body.push(linked(parts.join('\n'), edge.edge.attrs['url']));
|
|
64
|
+
ink = union(ink, grow(edge.ink, layout.margin));
|
|
44
65
|
}
|
|
45
66
|
// An edge's geometry is measured rather than solved for, so the resolver sized
|
|
46
67
|
// the canvas from the boxes alone. A curve out of a `top` side, or a text
|
|
@@ -57,6 +78,15 @@ export function render(layout, options = {}) {
|
|
|
57
78
|
`<svg xmlns="http://www.w3.org/2000/svg" width="${canvas.width}" height="${canvas.height}" viewBox="${canvas.x} ${canvas.y} ${canvas.width} ${canvas.height}" font-family=${quote(measurer.fontFamily)} font-size="${fontSize}px">`,
|
|
58
79
|
' <defs>',
|
|
59
80
|
...[...arrowColors].map((color) => arrowMarker(color)),
|
|
81
|
+
// Each mask covers the whole page, in page units: the default region is the
|
|
82
|
+
// line's own bounding box, which a straight level line gives no height, and
|
|
83
|
+
// the line would vanish entirely.
|
|
84
|
+
...masks.map(({ id, holes }) => [
|
|
85
|
+
` <mask id="${id}" maskUnits="userSpaceOnUse" x="${canvas.x}" y="${canvas.y}" width="${canvas.width}" height="${canvas.height}">`,
|
|
86
|
+
` <rect x="${canvas.x}" y="${canvas.y}" width="${canvas.width}" height="${canvas.height}" fill="white"/>`,
|
|
87
|
+
...holes.map((hole) => ` <circle cx="${round(hole.at.x)}" cy="${round(hole.at.y)}" r="${round(hole.reach)}" fill="black"/>`),
|
|
88
|
+
' </mask>',
|
|
89
|
+
].join('\n')),
|
|
60
90
|
' </defs>',
|
|
61
91
|
` <rect x="${canvas.x}" y="${canvas.y}" width="${canvas.width}" height="${canvas.height}" fill="${theme.background}"/>`,
|
|
62
92
|
...body,
|
|
@@ -254,16 +284,17 @@ function drawIcon(icon, x, y, side, theme) {
|
|
|
254
284
|
' </g>',
|
|
255
285
|
].join('\n');
|
|
256
286
|
}
|
|
257
|
-
|
|
258
|
-
function drawEdge(edge, ends, corridor, route, theme, measurer, fontSize, markup) {
|
|
287
|
+
function drawEdge(edge, ends, corridor, route, nodes, theme, measurer, fontSize, markup) {
|
|
259
288
|
const { start, end } = ends;
|
|
260
289
|
const color = lineOf(edge.appearance, theme.edge);
|
|
290
|
+
const stroke = strokeOf(color, edge.look);
|
|
261
291
|
const markerEnd = ` marker-end="url(#${markerId(color)})"`;
|
|
262
292
|
const markerStart = edge.both ? ` marker-start="url(#${markerId(color)}-back)"` : '';
|
|
263
293
|
// A named side is a statement about how the line should leave or arrive, so
|
|
264
294
|
// it is drawn as a curve that actually does leave and arrive that way. With
|
|
265
295
|
// neither side named there is nothing to honor and the line stays straight.
|
|
266
296
|
const curved = start.side !== undefined || end.side !== undefined;
|
|
297
|
+
const bowed = ends.bow !== undefined && (ends.bow.x !== 0 || ends.bow.y !== 0);
|
|
267
298
|
const parts = [];
|
|
268
299
|
// What the line actually covers, so the canvas can be sized to hold it. A
|
|
269
300
|
// curve leaving a `top` side rides above every box in the drawing, and the
|
|
@@ -271,8 +302,25 @@ function drawEdge(edge, ends, corridor, route, theme, measurer, fontSize, markup
|
|
|
271
302
|
let ink = extentOfPoints([start, end]);
|
|
272
303
|
let midX;
|
|
273
304
|
let midY;
|
|
274
|
-
|
|
275
|
-
|
|
305
|
+
const jointed = edge.look.path !== 'curved' && (route || corridor || curved || bowed);
|
|
306
|
+
if (jointed) {
|
|
307
|
+
// `path: square` and `path: straight` are the same fixed points — the ends,
|
|
308
|
+
// which way each faces, the run a clause asked for — joined by a different
|
|
309
|
+
// rule, and both come out as straight pieces meeting at corners.
|
|
310
|
+
const textWidth = edge.lines === undefined
|
|
311
|
+
? 0
|
|
312
|
+
: widestLine(edge.lines, measurer, fontSizeFor('edge', edge.textAttrs, fontSize, edge.line));
|
|
313
|
+
const plan = jointedLine(edge, ends, corridor, route, nodes, textWidth);
|
|
314
|
+
const d = edge.look.corners === 'rounded'
|
|
315
|
+
? roundedPath(plan.points, arrowLength(edge.look.thickness))
|
|
316
|
+
: sharpPath(plan.points);
|
|
317
|
+
parts.push(` <path d="${d}" fill="none"${stroke}${markerEnd}${markerStart}/>`);
|
|
318
|
+
ink = union(ink, extentOfPoints(plan.points));
|
|
319
|
+
midX = plan.mid.x;
|
|
320
|
+
midY = plan.mid.y;
|
|
321
|
+
}
|
|
322
|
+
else if (route) {
|
|
323
|
+
parts.push(` <path d="${roundedPath(route.points)}" fill="none"${stroke}${markerEnd}${markerStart}/>`);
|
|
276
324
|
ink = union(ink, extentOfPoints(route.points));
|
|
277
325
|
midX = route.mid.x;
|
|
278
326
|
midY = route.mid.y;
|
|
@@ -280,7 +328,7 @@ function drawEdge(edge, ends, corridor, route, theme, measurer, fontSize, markup
|
|
|
280
328
|
else if (corridor) {
|
|
281
329
|
const path = corridorPath(start, end, corridor);
|
|
282
330
|
ink = union(ink, path.ink);
|
|
283
|
-
parts.push(` <path d="${path.d}" fill="none"
|
|
331
|
+
parts.push(` <path d="${path.d}" fill="none"${stroke}${markerEnd}${markerStart}/>`);
|
|
284
332
|
// The text goes on the straight run rather than at the midpoint of the
|
|
285
333
|
// whole path, so it sits in the gap the author asked the line to travel.
|
|
286
334
|
midX = path.mid.x;
|
|
@@ -297,13 +345,13 @@ function drawEdge(edge, ends, corridor, route, theme, measurer, fontSize, markup
|
|
|
297
345
|
const by = (ends.bow?.y ?? 0) * lift;
|
|
298
346
|
const c1 = { x: start.x + start.tx * reach + bx, y: start.y + start.ty * reach + by };
|
|
299
347
|
const c2 = { x: end.x + end.tx * reach + bx, y: end.y + end.ty * reach + by };
|
|
300
|
-
parts.push(` <path d="M ${round(start.x)} ${round(start.y)} C ${round(c1.x)} ${round(c1.y)}, ${round(c2.x)} ${round(c2.y)}, ${round(end.x)} ${round(end.y)}" fill="none"
|
|
348
|
+
parts.push(` <path d="M ${round(start.x)} ${round(start.y)} C ${round(c1.x)} ${round(c1.y)}, ${round(c2.x)} ${round(c2.y)}, ${round(end.x)} ${round(end.y)}" fill="none"${stroke}${markerEnd}${markerStart}/>`);
|
|
301
349
|
ink = union(ink, cubicExtent(start, c1, c2, end));
|
|
302
350
|
// The point halfway along a cubic, which is where the text belongs.
|
|
303
351
|
midX = (start.x + 3 * c1.x + 3 * c2.x + end.x) / 8;
|
|
304
352
|
midY = (start.y + 3 * c1.y + 3 * c2.y + end.y) / 8;
|
|
305
353
|
}
|
|
306
|
-
else if (ends.bow &&
|
|
354
|
+
else if (ends.bow && bowed) {
|
|
307
355
|
// A straight line that could not get the room it needed at its ends, so it
|
|
308
356
|
// takes it in the middle. Both control points carry the same displacement,
|
|
309
357
|
// which keeps the arc symmetric; a cubic's middle moves three quarters of
|
|
@@ -318,13 +366,13 @@ function drawEdge(edge, ends, corridor, route, theme, measurer, fontSize, markup
|
|
|
318
366
|
x: end.x - run.x + ends.bow.x * lift,
|
|
319
367
|
y: end.y - run.y + ends.bow.y * lift,
|
|
320
368
|
};
|
|
321
|
-
parts.push(` <path d="M ${round(start.x)} ${round(start.y)} C ${round(c1.x)} ${round(c1.y)}, ${round(c2.x)} ${round(c2.y)}, ${round(end.x)} ${round(end.y)}" fill="none"
|
|
369
|
+
parts.push(` <path d="M ${round(start.x)} ${round(start.y)} C ${round(c1.x)} ${round(c1.y)}, ${round(c2.x)} ${round(c2.y)}, ${round(end.x)} ${round(end.y)}" fill="none"${stroke}${markerEnd}${markerStart}/>`);
|
|
322
370
|
ink = union(ink, cubicExtent(start, c1, c2, end));
|
|
323
371
|
midX = (start.x + 3 * c1.x + 3 * c2.x + end.x) / 8;
|
|
324
372
|
midY = (start.y + 3 * c1.y + 3 * c2.y + end.y) / 8;
|
|
325
373
|
}
|
|
326
374
|
else {
|
|
327
|
-
parts.push(` <line x1="${round(start.x)}" y1="${round(start.y)}" x2="${round(end.x)}" y2="${round(end.y)}"
|
|
375
|
+
parts.push(` <line x1="${round(start.x)}" y1="${round(start.y)}" x2="${round(end.x)}" y2="${round(end.y)}"${stroke}${markerEnd}${markerStart}/>`);
|
|
328
376
|
midX = (start.x + end.x) / 2;
|
|
329
377
|
midY = (start.y + end.y) / 2;
|
|
330
378
|
}
|
|
@@ -357,7 +405,428 @@ function drawEdge(edge, ends, corridor, route, theme, measurer, fontSize, markup
|
|
|
357
405
|
}), size, fontSize));
|
|
358
406
|
}
|
|
359
407
|
// The stroke straddles the path, so half of it lies outside the geometry.
|
|
360
|
-
|
|
408
|
+
const [line, ...rest] = parts;
|
|
409
|
+
return {
|
|
410
|
+
edge,
|
|
411
|
+
line: line,
|
|
412
|
+
rest,
|
|
413
|
+
ink: grow(ink, edge.look.thickness / 2),
|
|
414
|
+
trace: traceOf(line),
|
|
415
|
+
color,
|
|
416
|
+
stroke,
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* The stroke of a line: its color, its thickness and its pattern. The dashes
|
|
421
|
+
* are measured in thicknesses, so a pattern keeps its proportions on a thick
|
|
422
|
+
* line, which covers most of what a density setting would be for. A solid line
|
|
423
|
+
* of the ordinary thickness writes exactly what every line always did.
|
|
424
|
+
*/
|
|
425
|
+
function strokeOf(color, look) {
|
|
426
|
+
const w = look.thickness;
|
|
427
|
+
const dashes = {
|
|
428
|
+
solid: '',
|
|
429
|
+
dashed: ` stroke-dasharray="${round(w * 4)} ${round(w * 3)}"`,
|
|
430
|
+
// A zero-length dash with a round cap is a dot as wide as the line.
|
|
431
|
+
dotted: ` stroke-dasharray="0 ${round(w * 3)}" stroke-linecap="round"`,
|
|
432
|
+
'dash-dot': ` stroke-dasharray="${round(w * 5)} ${round(w * 3)} 0 ${round(w * 3)}" stroke-linecap="round"`,
|
|
433
|
+
};
|
|
434
|
+
return ` stroke="${color}" stroke-width="${w}"${dashes[look.pattern]}`;
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* The points of a square or straight line, and where its text sits.
|
|
438
|
+
*
|
|
439
|
+
* Both start from what the file fixed. A square line leaves each side head-on
|
|
440
|
+
* and turns only at right angles; a straight one goes directly from point to
|
|
441
|
+
* point, and bends only where a clause — `below c`, `between a and b` — puts a
|
|
442
|
+
* point it has to pass through.
|
|
443
|
+
*/
|
|
444
|
+
function jointedLine(edge, ends, corridor, route, nodes, textWidth) {
|
|
445
|
+
const { start, end } = ends;
|
|
446
|
+
const square = edge.look.path === 'square';
|
|
447
|
+
const stub = ROUTE_RADIUS + arrowLength(edge.look.thickness);
|
|
448
|
+
const fromFace = faceOf(edge.from);
|
|
449
|
+
const toFace = faceOf(edge.to);
|
|
450
|
+
if (route) {
|
|
451
|
+
if (square)
|
|
452
|
+
return { points: route.points, mid: route.mid };
|
|
453
|
+
if (textWidth === 0) {
|
|
454
|
+
const points = straighten(route.points, edge, nodes);
|
|
455
|
+
return { points, mid: textSpot(points, textWidth) };
|
|
456
|
+
}
|
|
457
|
+
// The route pushed its run out far enough to hold the text clear of the
|
|
458
|
+
// boxes it passes, and that room is only on the run. So a straight line
|
|
459
|
+
// with a text keeps the text's point and straightens either side of it;
|
|
460
|
+
// cutting across the run would put the text back against the box.
|
|
461
|
+
const [before, after] = splitAt(route.points, route.mid);
|
|
462
|
+
const points = tidyRoute([
|
|
463
|
+
...straighten(before, edge, nodes),
|
|
464
|
+
...straighten(after, edge, nodes).slice(1),
|
|
465
|
+
]);
|
|
466
|
+
return { points, mid: route.mid };
|
|
467
|
+
}
|
|
468
|
+
if (corridor) {
|
|
469
|
+
const p1 = corridorPoint(corridor, corridor.enter);
|
|
470
|
+
const p2 = corridorPoint(corridor, corridor.leave);
|
|
471
|
+
const mid = { x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2 };
|
|
472
|
+
if (!square)
|
|
473
|
+
return { points: tidyRoute([start, p1, p2, end]), mid };
|
|
474
|
+
const forward = corridor.leave >= corridor.enter ? 1 : -1;
|
|
475
|
+
const run = corridor.axis === 'y' ? { x: forward, y: 0 } : { x: 0, y: forward };
|
|
476
|
+
const points = tidyRoute([
|
|
477
|
+
...rightAngles(start, headingOf(start, fromFace), p1, { x: -run.x, y: -run.y }, stub),
|
|
478
|
+
...rightAngles(p2, run, end, headingOf(end, toFace), stub),
|
|
479
|
+
]);
|
|
480
|
+
return { points, mid };
|
|
481
|
+
}
|
|
482
|
+
const bow = ends.bow ?? { x: 0, y: 0 };
|
|
483
|
+
const bowed = bow.x !== 0 || bow.y !== 0;
|
|
484
|
+
if (!square) {
|
|
485
|
+
// A bowed line is one of a crowded group, and its middle is the only room
|
|
486
|
+
// left for its text, so it bends there.
|
|
487
|
+
const middle = { x: (start.x + end.x) / 2 + bow.x, y: (start.y + end.y) / 2 + bow.y };
|
|
488
|
+
const points = bowed ? [start, middle, end] : [start, end];
|
|
489
|
+
return { points, mid: bowed ? middle : textSpot(points, textWidth) };
|
|
490
|
+
}
|
|
491
|
+
const out = headingOf(start, fromFace);
|
|
492
|
+
const back = headingOf(end, toFace);
|
|
493
|
+
// A bow is room for the texts of a crowded group, taken in the middle. Between
|
|
494
|
+
// sides that face each other that middle is a run the lanes can widen along;
|
|
495
|
+
// at right angles there is none, and the text finds room on a level piece.
|
|
496
|
+
if (!bowed || (out.x !== 0) !== (back.x !== 0)) {
|
|
497
|
+
const points = tidyRoute(rightAngles(start, out, end, back, stub));
|
|
498
|
+
return { points, mid: textSpot(points, textWidth) };
|
|
499
|
+
}
|
|
500
|
+
// Out of each side, across by the bow, and joined at right angles.
|
|
501
|
+
const a = { x: start.x + out.x * stub, y: start.y + out.y * stub };
|
|
502
|
+
const b = { x: end.x + back.x * stub, y: end.y + back.y * stub };
|
|
503
|
+
const points = tidyRoute(squareUp([start, a, { x: a.x + bow.x, y: a.y + bow.y }, { x: b.x + bow.x, y: b.y + bow.y }, b, end]));
|
|
504
|
+
return { points, mid: textSpot(points, textWidth) };
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* Which way an end points, as one of the four directions. A named side says so;
|
|
508
|
+
* an end the renderer placed is on some side of its box, and that is the one.
|
|
509
|
+
*/
|
|
510
|
+
function headingOf(anchor, face) {
|
|
511
|
+
if (anchor.side !== undefined)
|
|
512
|
+
return { x: anchor.tx, y: anchor.ty };
|
|
513
|
+
const candidates = [
|
|
514
|
+
[Math.abs(anchor.x - face.x), { x: -1, y: 0 }],
|
|
515
|
+
[Math.abs(anchor.x - (face.x + face.width)), { x: 1, y: 0 }],
|
|
516
|
+
[Math.abs(anchor.y - face.y), { x: 0, y: -1 }],
|
|
517
|
+
[Math.abs(anchor.y - (face.y + face.height)), { x: 0, y: 1 }],
|
|
518
|
+
];
|
|
519
|
+
candidates.sort((p, q) => p[0] - q[0]);
|
|
520
|
+
return candidates[0][1];
|
|
521
|
+
}
|
|
522
|
+
/**
|
|
523
|
+
* A right-angled line from `from`, leaving along `out`, to `to`, arriving
|
|
524
|
+
* against `back` — `back` points out of the far side, so the line's last piece
|
|
525
|
+
* travels the opposite way. The fewest turns that leave and arrive head-on:
|
|
526
|
+
* none when the two face each other in line, one when they are at right angles
|
|
527
|
+
* and the corner lies ahead of both, two in the middle when they face each
|
|
528
|
+
* other offset, and a way round past a stub at each end otherwise.
|
|
529
|
+
*/
|
|
530
|
+
function rightAngles(from, out, to, back, stub) {
|
|
531
|
+
const across = out.x !== 0;
|
|
532
|
+
const parallel = across === (back.x !== 0);
|
|
533
|
+
const ahead = (p, q, d) => (q.x - p.x) * d.x + (q.y - p.y) * d.y;
|
|
534
|
+
if (parallel) {
|
|
535
|
+
const facing = out.x * back.x + out.y * back.y < 0;
|
|
536
|
+
if (facing && ahead(from, to, out) > 0) {
|
|
537
|
+
if (Math.abs(across ? to.y - from.y : to.x - from.x) < 0.5)
|
|
538
|
+
return [from, to];
|
|
539
|
+
const m = across ? (from.x + to.x) / 2 : (from.y + to.y) / 2;
|
|
540
|
+
return across
|
|
541
|
+
? [from, { x: m, y: from.y }, { x: m, y: to.y }, to]
|
|
542
|
+
: [from, { x: from.x, y: m }, { x: to.x, y: m }, to];
|
|
543
|
+
}
|
|
544
|
+
if (!facing) {
|
|
545
|
+
// Both sides face the same way: out past whichever is further, and back.
|
|
546
|
+
const far = across
|
|
547
|
+
? (out.x > 0 ? Math.max(from.x, to.x) : Math.min(from.x, to.x)) + out.x * stub
|
|
548
|
+
: (out.y > 0 ? Math.max(from.y, to.y) : Math.min(from.y, to.y)) + out.y * stub;
|
|
549
|
+
return across
|
|
550
|
+
? [from, { x: far, y: from.y }, { x: far, y: to.y }, to]
|
|
551
|
+
: [from, { x: from.x, y: far }, { x: to.x, y: far }, to];
|
|
552
|
+
}
|
|
553
|
+
// Facing, but the far end is behind: out, across the middle, and in.
|
|
554
|
+
const a = { x: from.x + out.x * stub, y: from.y + out.y * stub };
|
|
555
|
+
const b = { x: to.x + back.x * stub, y: to.y + back.y * stub };
|
|
556
|
+
const m = across ? (from.y + to.y) / 2 : (from.x + to.x) / 2;
|
|
557
|
+
return across
|
|
558
|
+
? [from, a, { x: a.x, y: m }, { x: b.x, y: m }, b, to]
|
|
559
|
+
: [from, a, { x: m, y: a.y }, { x: m, y: b.y }, b, to];
|
|
560
|
+
}
|
|
561
|
+
const corner = across ? { x: to.x, y: from.y } : { x: from.x, y: to.y };
|
|
562
|
+
if (ahead(from, corner, out) > 0 && ahead(to, corner, back) > 0)
|
|
563
|
+
return [from, corner, to];
|
|
564
|
+
// The one corner lies behind an end: step out of both sides first and join
|
|
565
|
+
// the two stubs with whichever corner does not double back.
|
|
566
|
+
const a = { x: from.x + out.x * stub, y: from.y + out.y * stub };
|
|
567
|
+
const b = { x: to.x + back.x * stub, y: to.y + back.y * stub };
|
|
568
|
+
const options = [
|
|
569
|
+
{ x: b.x, y: a.y },
|
|
570
|
+
{ x: a.x, y: b.y },
|
|
571
|
+
];
|
|
572
|
+
const turn = options.find((c) => ahead(a, c, out) >= 0 && ahead(b, c, back) >= 0) ?? options[across ? 1 : 0];
|
|
573
|
+
return [from, a, turn, b, to];
|
|
574
|
+
}
|
|
575
|
+
/** Put a right-angled corner between any two points that are not in line. */
|
|
576
|
+
function squareUp(points) {
|
|
577
|
+
const out = [points[0]];
|
|
578
|
+
let alongX = true;
|
|
579
|
+
for (let index = 1; index < points.length; index += 1) {
|
|
580
|
+
const a = out[out.length - 1];
|
|
581
|
+
const b = points[index];
|
|
582
|
+
const dx = Math.abs(b.x - a.x) >= 0.5;
|
|
583
|
+
const dy = Math.abs(b.y - a.y) >= 0.5;
|
|
584
|
+
if (dx && dy) {
|
|
585
|
+
out.push(alongX ? { x: b.x, y: a.y } : { x: a.x, y: b.y });
|
|
586
|
+
}
|
|
587
|
+
else if (dx || dy) {
|
|
588
|
+
alongX = dx;
|
|
589
|
+
}
|
|
590
|
+
out.push(b);
|
|
591
|
+
}
|
|
592
|
+
return out;
|
|
593
|
+
}
|
|
594
|
+
/**
|
|
595
|
+
* A route's points with every corner dropped that a straight piece can skip.
|
|
596
|
+
* The route already keeps each clause; a straight piece may cut a corner only
|
|
597
|
+
* where it stays clear of every box the route kept clear of, which keeps the
|
|
598
|
+
* clauses too — a piece between two points below a node, touching nothing, is
|
|
599
|
+
* below it all the way.
|
|
600
|
+
*/
|
|
601
|
+
function straighten(points, edge, nodes) {
|
|
602
|
+
const own = [faceOf(edge.from), faceOf(edge.to)].map((box) => grow(extentOfBox(box), -1));
|
|
603
|
+
const others = nodes
|
|
604
|
+
.filter((node) => !contains(node, edge.from) && !contains(node, edge.to))
|
|
605
|
+
.map((node) => grow(extentOfBox(faceOf(node)), ATTACH_MARGIN / 2));
|
|
606
|
+
const clear = (a, b) => ![...own, ...others].some((box) => segmentHits(a, b, box));
|
|
607
|
+
const kept = [points[0]];
|
|
608
|
+
let at = 0;
|
|
609
|
+
while (at < points.length - 1) {
|
|
610
|
+
let next = at + 1;
|
|
611
|
+
for (let far = points.length - 1; far > at + 1; far -= 1) {
|
|
612
|
+
if (clear(points[at], points[far])) {
|
|
613
|
+
next = far;
|
|
614
|
+
break;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
kept.push(points[next]);
|
|
618
|
+
at = next;
|
|
619
|
+
}
|
|
620
|
+
return kept;
|
|
621
|
+
}
|
|
622
|
+
/**
|
|
623
|
+
* A line cut in two at `at`, a point on one of its pieces, each half keeping
|
|
624
|
+
* `at` as its end. A point on no piece cuts at the nearest one.
|
|
625
|
+
*/
|
|
626
|
+
function splitAt(points, at) {
|
|
627
|
+
let piece = 0;
|
|
628
|
+
let nearest = Infinity;
|
|
629
|
+
for (let index = 1; index < points.length; index += 1) {
|
|
630
|
+
const a = points[index - 1];
|
|
631
|
+
const b = points[index];
|
|
632
|
+
const length = Math.hypot(b.x - a.x, b.y - a.y);
|
|
633
|
+
const t = length === 0 ? 0 : Math.max(0, Math.min(1, ((at.x - a.x) * (b.x - a.x) + (at.y - a.y) * (b.y - a.y)) / (length * length)));
|
|
634
|
+
const off = Math.hypot(a.x + t * (b.x - a.x) - at.x, a.y + t * (b.y - a.y) - at.y);
|
|
635
|
+
if (off < nearest) {
|
|
636
|
+
nearest = off;
|
|
637
|
+
piece = index;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
return [
|
|
641
|
+
tidyRoute([...points.slice(0, piece), at]),
|
|
642
|
+
tidyRoute([at, ...points.slice(piece)]),
|
|
643
|
+
];
|
|
644
|
+
}
|
|
645
|
+
function extentOfBox(box) {
|
|
646
|
+
return { minX: box.x, minY: box.y, maxX: box.x + box.width, maxY: box.y + box.height };
|
|
647
|
+
}
|
|
648
|
+
/** Whether the segment from `a` to `b` passes through the inside of `box`. */
|
|
649
|
+
function segmentHits(a, b, box) {
|
|
650
|
+
if (box.maxX <= box.minX || box.maxY <= box.minY)
|
|
651
|
+
return false;
|
|
652
|
+
let t0 = 0;
|
|
653
|
+
let t1 = 1;
|
|
654
|
+
const dx = b.x - a.x;
|
|
655
|
+
const dy = b.y - a.y;
|
|
656
|
+
const edges = [
|
|
657
|
+
[-dx, a.x - box.minX],
|
|
658
|
+
[dx, box.maxX - a.x],
|
|
659
|
+
[-dy, a.y - box.minY],
|
|
660
|
+
[dy, box.maxY - a.y],
|
|
661
|
+
];
|
|
662
|
+
for (const [p, q] of edges) {
|
|
663
|
+
if (p === 0) {
|
|
664
|
+
if (q <= 0)
|
|
665
|
+
return false;
|
|
666
|
+
continue;
|
|
667
|
+
}
|
|
668
|
+
const t = q / p;
|
|
669
|
+
if (p < 0)
|
|
670
|
+
t0 = Math.max(t0, t);
|
|
671
|
+
else
|
|
672
|
+
t1 = Math.min(t1, t);
|
|
673
|
+
if (t0 >= t1)
|
|
674
|
+
return false;
|
|
675
|
+
}
|
|
676
|
+
return true;
|
|
677
|
+
}
|
|
678
|
+
/**
|
|
679
|
+
* Where a jointed line's text rides: the middle of its longest level piece, if
|
|
680
|
+
* one is long enough to hold the text with line showing either side, and the
|
|
681
|
+
* middle of its longest piece otherwise. A level piece comes first because a
|
|
682
|
+
* text knocks a hole as wide as itself, which on an upright piece is a hole far
|
|
683
|
+
* wider than the line — and lines grouped on one side sit a text's height apart
|
|
684
|
+
* on their level pieces, never a text's width apart on their upright ones.
|
|
685
|
+
*/
|
|
686
|
+
function textSpot(points, textWidth) {
|
|
687
|
+
let best = { x: (points[0].x + points[points.length - 1].x) / 2, y: (points[0].y + points[points.length - 1].y) / 2 };
|
|
688
|
+
let level;
|
|
689
|
+
let longest = -1;
|
|
690
|
+
let longestLevel = -1;
|
|
691
|
+
for (let index = 1; index < points.length; index += 1) {
|
|
692
|
+
const a = points[index - 1];
|
|
693
|
+
const b = points[index];
|
|
694
|
+
const here = Math.hypot(b.x - a.x, b.y - a.y);
|
|
695
|
+
const middle = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
|
|
696
|
+
if (here > longest + 0.5) {
|
|
697
|
+
longest = here;
|
|
698
|
+
best = middle;
|
|
699
|
+
}
|
|
700
|
+
if (Math.abs(b.y - a.y) < 0.5 && here >= textWidth + ARROW_LENGTH * 2 && here > longestLevel + 0.5) {
|
|
701
|
+
longestLevel = here;
|
|
702
|
+
level = middle;
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
return level ?? best;
|
|
706
|
+
}
|
|
707
|
+
function sharpPath(points) {
|
|
708
|
+
return points.map((point, index) => `${index === 0 ? 'M' : 'L'} ${round(point.x)} ${round(point.y)}`).join(' ');
|
|
709
|
+
}
|
|
710
|
+
/**
|
|
711
|
+
* A drawn line's element read back as a polyline, curves flattened. Reading
|
|
712
|
+
* the element rather than each drawing branch keeping its own record means the
|
|
713
|
+
* crossings are found on exactly what was drawn.
|
|
714
|
+
*/
|
|
715
|
+
function traceOf(element) {
|
|
716
|
+
const line = /<line x1="([-\d.]+)" y1="([-\d.]+)" x2="([-\d.]+)" y2="([-\d.]+)"/.exec(element);
|
|
717
|
+
if (line) {
|
|
718
|
+
return [
|
|
719
|
+
{ x: Number(line[1]), y: Number(line[2]) },
|
|
720
|
+
{ x: Number(line[3]), y: Number(line[4]) },
|
|
721
|
+
];
|
|
722
|
+
}
|
|
723
|
+
const d = /d="([^"]*)"/.exec(element)?.[1] ?? '';
|
|
724
|
+
const tokens = d.match(/[MLC]|-?\d+(?:\.\d+)?(?:e-?\d+)?/g) ?? [];
|
|
725
|
+
const points = [];
|
|
726
|
+
let command = 'M';
|
|
727
|
+
for (let index = 0; index < tokens.length;) {
|
|
728
|
+
const token = tokens[index];
|
|
729
|
+
if (/[MLC]/.test(token)) {
|
|
730
|
+
command = token;
|
|
731
|
+
index += 1;
|
|
732
|
+
continue;
|
|
733
|
+
}
|
|
734
|
+
const read = () => {
|
|
735
|
+
const point = { x: Number(tokens[index]), y: Number(tokens[index + 1]) };
|
|
736
|
+
index += 2;
|
|
737
|
+
return point;
|
|
738
|
+
};
|
|
739
|
+
if (command === 'C') {
|
|
740
|
+
const from = points[points.length - 1];
|
|
741
|
+
const [c1, c2, to] = [read(), read(), read()];
|
|
742
|
+
for (let step = 1; step <= 12; step += 1) {
|
|
743
|
+
const t = step / 12;
|
|
744
|
+
const u = 1 - t;
|
|
745
|
+
points.push({
|
|
746
|
+
x: u * u * u * from.x + 3 * u * u * t * c1.x + 3 * u * t * t * c2.x + t * t * t * to.x,
|
|
747
|
+
y: u * u * u * from.y + 3 * u * u * t * c1.y + 3 * u * t * t * c2.y + t * t * t * to.y,
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
else {
|
|
752
|
+
points.push(read());
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
return points;
|
|
756
|
+
}
|
|
757
|
+
/**
|
|
758
|
+
* Where each line with a `crossing:` style crosses a line declared before it.
|
|
759
|
+
* The later line is the one that jumps, so the file's order says who goes over,
|
|
760
|
+
* and a line never jumps where it leaves or arrives — a crossing that close to
|
|
761
|
+
* an end is two lines meeting at a box, not passing each other.
|
|
762
|
+
*/
|
|
763
|
+
function findCrossings(drawn) {
|
|
764
|
+
const found = new Map();
|
|
765
|
+
drawn.forEach((later, index) => {
|
|
766
|
+
if (later.edge.look.crossing === 'none')
|
|
767
|
+
return;
|
|
768
|
+
const crossings = [];
|
|
769
|
+
for (const earlier of drawn.slice(0, index)) {
|
|
770
|
+
const reach = 3 + later.edge.look.thickness * 1.5 + earlier.edge.look.thickness / 2;
|
|
771
|
+
const clear = reach + arrowLength(later.edge.look.thickness);
|
|
772
|
+
const ends = [later.trace[0], later.trace[later.trace.length - 1], earlier.trace[0], earlier.trace[earlier.trace.length - 1]];
|
|
773
|
+
for (let i = 1; i < later.trace.length; i += 1) {
|
|
774
|
+
const a = later.trace[i - 1];
|
|
775
|
+
const b = later.trace[i];
|
|
776
|
+
for (let j = 1; j < earlier.trace.length; j += 1) {
|
|
777
|
+
const at = intersect(a, b, earlier.trace[j - 1], earlier.trace[j]);
|
|
778
|
+
if (!at)
|
|
779
|
+
continue;
|
|
780
|
+
if (ends.some((end) => Math.hypot(end.x - at.x, end.y - at.y) < clear))
|
|
781
|
+
continue;
|
|
782
|
+
if (crossings.some((c) => Math.hypot(c.at.x - at.x, c.at.y - at.y) < (c.reach + reach) * 1.2))
|
|
783
|
+
continue;
|
|
784
|
+
const length = Math.hypot(b.x - a.x, b.y - a.y) || 1;
|
|
785
|
+
crossings.push({ at, along: { x: (b.x - a.x) / length, y: (b.y - a.y) / length }, reach });
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
if (crossings.length > 0)
|
|
790
|
+
found.set(later, crossings);
|
|
791
|
+
});
|
|
792
|
+
return found;
|
|
793
|
+
}
|
|
794
|
+
/** Where two segments cross, if they do. Lines that only touch or run together do not. */
|
|
795
|
+
function intersect(a, b, c, d) {
|
|
796
|
+
const r = { x: b.x - a.x, y: b.y - a.y };
|
|
797
|
+
const s = { x: d.x - c.x, y: d.y - c.y };
|
|
798
|
+
const denominator = r.x * s.y - r.y * s.x;
|
|
799
|
+
if (Math.abs(denominator) < 1e-9)
|
|
800
|
+
return undefined;
|
|
801
|
+
const t = ((c.x - a.x) * s.y - (c.y - a.y) * s.x) / denominator;
|
|
802
|
+
const u = ((c.x - a.x) * r.y - (c.y - a.y) * r.x) / denominator;
|
|
803
|
+
if (t <= 0 || t >= 1 || u <= 0 || u >= 1)
|
|
804
|
+
return undefined;
|
|
805
|
+
return { x: a.x + r.x * t, y: a.y + r.y * t };
|
|
806
|
+
}
|
|
807
|
+
/**
|
|
808
|
+
* The jump drawn over one crossing: a half circle for `arc`, three sides of a
|
|
809
|
+
* square for `square`, and nothing for `gap`, which is the cut alone. A jump
|
|
810
|
+
* rises to the same side everywhere — up, or right on a line going straight up
|
|
811
|
+
* or down — so a row of them reads as one line hopping.
|
|
812
|
+
*/
|
|
813
|
+
function jumpAt(crossing, style) {
|
|
814
|
+
const { at, along, reach } = crossing;
|
|
815
|
+
let normal = { x: along.y, y: -along.x };
|
|
816
|
+
if (normal.y > 1e-6 || (Math.abs(normal.y) <= 1e-6 && normal.x < 0))
|
|
817
|
+
normal = { x: -normal.x, y: -normal.y };
|
|
818
|
+
const a = { x: at.x - along.x * reach, y: at.y - along.y * reach };
|
|
819
|
+
const b = { x: at.x + along.x * reach, y: at.y + along.y * reach };
|
|
820
|
+
if (style === 'arc') {
|
|
821
|
+
// Which way round the arc sweeps depends on which side of the line is up.
|
|
822
|
+
const sweep = along.x * normal.y - along.y * normal.x < 0 ? 1 : 0;
|
|
823
|
+
return `M ${round(a.x)} ${round(a.y)} A ${round(reach)} ${round(reach)} 0 0 ${sweep} ${round(b.x)} ${round(b.y)}`;
|
|
824
|
+
}
|
|
825
|
+
if (style === 'square') {
|
|
826
|
+
const up = (p) => ({ x: p.x + normal.x * reach, y: p.y + normal.y * reach });
|
|
827
|
+
return sharpPath([a, up(a), up(b), b]);
|
|
828
|
+
}
|
|
829
|
+
return undefined;
|
|
361
830
|
}
|
|
362
831
|
function union(a, b) {
|
|
363
832
|
return {
|
|
@@ -1746,14 +2215,14 @@ function tidyRoute(points) {
|
|
|
1746
2215
|
* piece at either end short of the arrowhead, so two corners never overlap and
|
|
1747
2216
|
* the head always lands on a straight piece.
|
|
1748
2217
|
*/
|
|
1749
|
-
function roundedPath(points) {
|
|
2218
|
+
function roundedPath(points, arrow = ARROW_LENGTH) {
|
|
1750
2219
|
const parts = [`M ${round(points[0].x)} ${round(points[0].y)}`];
|
|
1751
2220
|
const kappa = 0.5523; // a cubic's handle, as a share of the radius, for a quarter circle
|
|
1752
2221
|
for (let index = 1; index + 1 < points.length; index += 1) {
|
|
1753
2222
|
const [before, corner, after] = [points[index - 1], points[index], points[index + 1]];
|
|
1754
2223
|
const inward = Math.hypot(corner.x - before.x, corner.y - before.y);
|
|
1755
2224
|
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 -
|
|
2225
|
+
const radius = Math.max(0, Math.min(ROUTE_RADIUS, index === 1 ? inward - arrow : inward / 2, index + 2 === points.length ? outward - arrow : outward / 2));
|
|
1757
2226
|
const din = { x: (corner.x - before.x) / inward, y: (corner.y - before.y) / inward };
|
|
1758
2227
|
const dout = { x: (after.x - corner.x) / outward, y: (after.y - corner.y) / outward };
|
|
1759
2228
|
const enter = { x: corner.x - din.x * radius, y: corner.y - din.y * radius };
|
|
@@ -1871,12 +2340,66 @@ function loopReach(from, to, axis) {
|
|
|
1871
2340
|
function sideAttr(edge, key) {
|
|
1872
2341
|
const value = edge.attrs[key];
|
|
1873
2342
|
if (value === undefined)
|
|
1874
|
-
return
|
|
2343
|
+
return impliedSide(edge, key);
|
|
1875
2344
|
if (!ATTACH_SIDES.includes(value)) {
|
|
1876
2345
|
throw new SourceError(`"${key}: ${value}" is not a side — use ${ATTACH_SIDES.join(', ')}`, edge.line);
|
|
1877
2346
|
}
|
|
1878
2347
|
return value;
|
|
1879
2348
|
}
|
|
2349
|
+
/**
|
|
2350
|
+
* The side a square line leaves or arrives on when its author named none.
|
|
2351
|
+
*
|
|
2352
|
+
* A curved or straight line with no sides aims straight at the other box, but a
|
|
2353
|
+
* square one can only leave a side head-on, so it has to pick one. Boxes that
|
|
2354
|
+
* share a column join top to bottom and boxes that share a row join side to
|
|
2355
|
+
* side; otherwise the line goes across first and then down, the tie the mixed
|
|
2356
|
+
* passes settled. An end whose partner did name a side keeps the line to one
|
|
2357
|
+
* turn where it can. Everything after this treats the side as though it had
|
|
2358
|
+
* been written, so the ends are spread and bundled like any named side.
|
|
2359
|
+
*
|
|
2360
|
+
* An edge through a `between` gap or past a named node is routed by its clauses,
|
|
2361
|
+
* and one from a node to itself or to its own container has no outside to pick
|
|
2362
|
+
* from, so none of those is given a side here.
|
|
2363
|
+
*/
|
|
2364
|
+
function impliedSide(edge, key) {
|
|
2365
|
+
if (edge.look.path !== 'square' || edge.between || edge.passes)
|
|
2366
|
+
return undefined;
|
|
2367
|
+
if (contains(edge.from, edge.to) || contains(edge.to, edge.from))
|
|
2368
|
+
return undefined;
|
|
2369
|
+
const own = faceOf(key === 'from' ? edge.from : edge.to);
|
|
2370
|
+
const other = faceOf(key === 'from' ? edge.to : edge.from);
|
|
2371
|
+
const partnerKey = key === 'from' ? 'to' : 'from';
|
|
2372
|
+
const partner = edge.attrs[partnerKey];
|
|
2373
|
+
const overlapX = own.x < other.x + other.width && other.x < own.x + own.width;
|
|
2374
|
+
const overlapY = own.y < other.y + other.height && other.y < own.y + own.height;
|
|
2375
|
+
const across = centerOf(other).x >= centerOf(own).x ? 'right' : 'left';
|
|
2376
|
+
const upDown = centerOf(other).y >= centerOf(own).y ? 'bottom' : 'top';
|
|
2377
|
+
if (partner !== undefined && ATTACH_SIDES.includes(partner)) {
|
|
2378
|
+
// The partner's side is fixed, so take whichever side of this box joins it
|
|
2379
|
+
// in the fewest turns. The order breaks ties the way the rest of this does.
|
|
2380
|
+
const theirs = anchorOn(other, partner, partner === 'top' || partner === 'bottom' ? centerOf(other).x : centerOf(other).y);
|
|
2381
|
+
const order = overlapX && !overlapY ? [upDown, across] : [across, upDown];
|
|
2382
|
+
const sides = [...order, ...ATTACH_SIDES.filter((side) => !order.includes(side))];
|
|
2383
|
+
let best = sides[0];
|
|
2384
|
+
let fewest = Infinity;
|
|
2385
|
+
for (const side of sides) {
|
|
2386
|
+
const mine = anchorOn(own, side, side === 'top' || side === 'bottom' ? centerOf(own).x : centerOf(own).y);
|
|
2387
|
+
const [from, to] = key === 'from' ? [mine, theirs] : [theirs, mine];
|
|
2388
|
+
const turns = tidyRoute(rightAngles(from, { x: from.tx, y: from.ty }, to, { x: to.tx, y: to.ty }, ROUTE_STUB)).length;
|
|
2389
|
+
if (turns < fewest) {
|
|
2390
|
+
fewest = turns;
|
|
2391
|
+
best = side;
|
|
2392
|
+
}
|
|
2393
|
+
}
|
|
2394
|
+
return best;
|
|
2395
|
+
}
|
|
2396
|
+
if (overlapX && !overlapY)
|
|
2397
|
+
return upDown;
|
|
2398
|
+
if (overlapY && !overlapX)
|
|
2399
|
+
return across;
|
|
2400
|
+
// Across first, then down: the start leaves across and the end is reached down.
|
|
2401
|
+
return key === 'from' ? across : upDown;
|
|
2402
|
+
}
|
|
1880
2403
|
function arrowMarker(color) {
|
|
1881
2404
|
const id = markerId(color);
|
|
1882
2405
|
return [
|
package/dist/resolve.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { ALL_ATTR_KEYS, ATTR_KEYS, PART_SIDES, COLOR_KEYS, COLOR_PARTS, CONTENT_ALIGNMENTS, CONTENT_WIDTHS, describePlacement, nameTarget, } from './ast.js';
|
|
2
|
-
import {
|
|
1
|
+
import { ALL_ATTR_KEYS, ATTR_KEYS, PART_SIDES, COLOR_KEYS, COLOR_PARTS, CONTENT_ALIGNMENTS, CONTENT_WIDTHS, LINE_VALUES, describePlacement, nameTarget, } from './ast.js';
|
|
2
|
+
import { CHILD_GAP, DECK_STEP, DEFAULT_FONT_SIZE, DEFAULT_MARGIN, GAPS, HEADER_GAP, ICON_GAP, ICON_LINES, TEXT_CLEARANCE, PAD, SEPARATION_GAP, THICKNESS, arrowLength, fontSizeFor, leafTop, textExtent, textStyleFor, widestLine, } from './constants.js';
|
|
3
3
|
import { fix, reachability, tightest } from './constrain.js';
|
|
4
4
|
import { SourceError } from './errors.js';
|
|
5
5
|
import { bodyFor } from './icons.js';
|
|
@@ -499,6 +499,7 @@ function buildEdges(statements, byName, styles, defaults) {
|
|
|
499
499
|
: {}),
|
|
500
500
|
...(between ? { between } : {}),
|
|
501
501
|
...(passes ? { passes } : {}),
|
|
502
|
+
look: lineLook(stmt.attrs, appearance, what, stmt.line),
|
|
502
503
|
attrs: stmt.attrs,
|
|
503
504
|
appearance,
|
|
504
505
|
line: stmt.line,
|
|
@@ -506,6 +507,49 @@ function buildEdges(statements, byName, styles, defaults) {
|
|
|
506
507
|
}
|
|
507
508
|
return edges;
|
|
508
509
|
}
|
|
510
|
+
/**
|
|
511
|
+
* The line's bracket, checked, with a default for everything unsaid. A value is
|
|
512
|
+
* checked wherever it came from — a style's `path: squar` is as much a typo as
|
|
513
|
+
* an edge's — but `corners: rounded` is refused only when the edge itself says
|
|
514
|
+
* it on a curved line, which has no corners. From a style or a default it
|
|
515
|
+
* applies to the edges that have corners, the way a style's `fill:` lands on
|
|
516
|
+
* the nodes that have an inside.
|
|
517
|
+
*/
|
|
518
|
+
function lineLook(written, appearance, what, line) {
|
|
519
|
+
const word = (property) => {
|
|
520
|
+
const value = appearance[`line.${property}`];
|
|
521
|
+
if (value === undefined)
|
|
522
|
+
return LINE_VALUES[property][0];
|
|
523
|
+
if (!LINE_VALUES[property].includes(value)) {
|
|
524
|
+
throw new SourceError(`edge ${what}: "${property}: ${value}" is not a ${property} — use ${(property === 'thickness' ? ['thin', 'normal', 'thick'] : LINE_VALUES[property]).join(', ')}` +
|
|
525
|
+
(property === 'thickness' ? ', or a number of pixels' : ''), line);
|
|
526
|
+
}
|
|
527
|
+
return value;
|
|
528
|
+
};
|
|
529
|
+
const thickness = (() => {
|
|
530
|
+
const value = appearance['line.thickness'];
|
|
531
|
+
if (value !== undefined && /^[0-9.]/.test(value)) {
|
|
532
|
+
if (!/^\d+(\.\d+)?$/.test(value) || Number(value) <= 0) {
|
|
533
|
+
throw new SourceError(`edge ${what}: "thickness: ${value}" — a thickness is thin, normal, thick, ` +
|
|
534
|
+
'or a plain number of pixels greater than zero, such as `thickness: 3`', line);
|
|
535
|
+
}
|
|
536
|
+
return Number(value);
|
|
537
|
+
}
|
|
538
|
+
return THICKNESS[word('thickness')];
|
|
539
|
+
})();
|
|
540
|
+
const look = {
|
|
541
|
+
path: word('path'),
|
|
542
|
+
corners: word('corners'),
|
|
543
|
+
crossing: word('crossing'),
|
|
544
|
+
pattern: word('pattern'),
|
|
545
|
+
thickness,
|
|
546
|
+
};
|
|
547
|
+
if (look.path === 'curved' && written['line.corners'] === 'rounded') {
|
|
548
|
+
throw new SourceError(`edge ${what}: a curved line has no corners to round — \`corners: rounded\` takes a ` +
|
|
549
|
+
'`path: square` or `path: straight` line', line);
|
|
550
|
+
}
|
|
551
|
+
return look;
|
|
552
|
+
}
|
|
509
553
|
/**
|
|
510
554
|
* Give a node a width and height, sizing its children first. Also records each
|
|
511
555
|
* child's offset within this node, which pass three turns into absolute
|
|
@@ -1358,10 +1402,11 @@ function corridorsIn(edges, locate, measurer, fontSize) {
|
|
|
1358
1402
|
// The clearance is doubled because the text is drawn at the *midpoint* of
|
|
1359
1403
|
// the line, so the room it needs is symmetric about that point whatever sits
|
|
1360
1404
|
// at either end. The arrowhead is charged on both sides for the same reason:
|
|
1361
|
-
// it covers
|
|
1362
|
-
// one end only would move the midpoint rather than lengthen the run.
|
|
1405
|
+
// it covers the head's length of the line it arrives on, and reserving that
|
|
1406
|
+
// at one end only would move the midpoint rather than lengthen the run. A
|
|
1407
|
+
// thick line's head is longer, and takes the room it needs.
|
|
1363
1408
|
const extent = (axis) => textExtent(edge.lines, edge.textAttrs, axis, measurer, fontSize, edge.line) +
|
|
1364
|
-
(TEXT_CLEARANCE +
|
|
1409
|
+
(TEXT_CLEARANCE + arrowLength(edge.look.thickness)) * 2;
|
|
1365
1410
|
corridors.push({
|
|
1366
1411
|
edge,
|
|
1367
1412
|
from,
|