reladraw 0.2.0 → 0.3.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 +30 -22
- package/SYNTAX.md +435 -213
- package/dist/ast.d.ts +206 -55
- package/dist/ast.js +190 -51
- package/dist/constants.d.ts +60 -45
- package/dist/constants.js +76 -63
- package/dist/grammar.d.ts +17 -4
- package/dist/grammar.js +53 -7
- package/dist/icons.d.ts +53 -35
- package/dist/icons.js +81 -43
- package/dist/lexer.js +6 -5
- package/dist/measure.d.ts +3 -3
- package/dist/measure.js +3 -3
- package/dist/model.d.ts +86 -16
- package/dist/parser.js +522 -213
- package/dist/render.d.ts +1 -1
- package/dist/render.js +347 -282
- package/dist/resolve.js +1185 -265
- package/dist/text.d.ts +48 -0
- package/dist/text.js +196 -0
- package/package.json +2 -2
package/dist/render.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { ARROW_MARKER_WIDTH, ATTACH_MARGIN, ATTACH_STEP, DECK_STEP, DEFAULT_FONT_SIZE,
|
|
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';
|
|
2
2
|
import { describeAxis } from './ast.js';
|
|
3
3
|
import { SourceError } from './errors.js';
|
|
4
|
-
import { ICON_STROKE
|
|
4
|
+
import { ICON_STROKE } from './icons.js';
|
|
5
5
|
import { monospaceMeasurer } from './measure.js';
|
|
6
|
+
import { plain } from './text.js';
|
|
6
7
|
/**
|
|
7
8
|
* Sampled out of `examples/reference/arch.png` rather than invented,
|
|
8
9
|
* so the benchmark render and the drawing it is measured against differ by
|
|
@@ -17,7 +18,7 @@ export const DARK_THEME = {
|
|
|
17
18
|
containerStroke: '#25242f',
|
|
18
19
|
text: '#d9d9d9',
|
|
19
20
|
mutedText: '#8b8b8b',
|
|
20
|
-
|
|
21
|
+
edge: '#5c5c7c',
|
|
21
22
|
// Both sampled off the reference's machine glyphs. Note that the reference
|
|
22
23
|
// gives each icon its own hue — the drive is gray, the laptop periwinkle, the
|
|
23
24
|
// workstation violet — which is a drawing tool's per-shape default and not a
|
|
@@ -38,24 +39,24 @@ export function render(layout, options = {}) {
|
|
|
38
39
|
const theme = stated === undefined ? base : { ...base, background: stated };
|
|
39
40
|
const body = [];
|
|
40
41
|
for (const root of layout.roots) {
|
|
41
|
-
body.push(drawNode(root, theme, measurer, fontSize));
|
|
42
|
+
body.push(drawNode(root, theme, measurer, fontSize, layout.markup));
|
|
42
43
|
}
|
|
43
|
-
// Everything the boxes cover.
|
|
44
|
+
// Everything the boxes cover. Edges are added to it as they are drawn.
|
|
44
45
|
let ink = { minX: 0, minY: 0, maxX: layout.width, maxY: layout.height };
|
|
45
|
-
// Endpoints are planned for every
|
|
46
|
+
// Endpoints are planned for every edge at once, because where an edge meets a
|
|
46
47
|
// side depends on what else meets that same side. Corridors come after, for
|
|
47
|
-
// the same reason in the other direction: which lane of a gap
|
|
48
|
+
// the same reason in the other direction: which lane of a gap an edge takes
|
|
48
49
|
// is ordered by where its ends turned out to be.
|
|
49
|
-
const ends = planEndpoints(layout.
|
|
50
|
-
const corridors = planCorridors(layout.
|
|
51
|
-
aimFreeEnds(layout.
|
|
52
|
-
for (const
|
|
53
|
-
const drawn =
|
|
50
|
+
const ends = planEndpoints(layout.edges, measurer, fontSize);
|
|
51
|
+
const corridors = planCorridors(layout.edges, ends, measurer, fontSize);
|
|
52
|
+
aimFreeEnds(layout.edges, ends, corridors);
|
|
53
|
+
for (const edge of layout.edges) {
|
|
54
|
+
const drawn = drawEdge(edge, ends.get(edge), corridors.get(edge), theme, measurer, fontSize, layout.markup);
|
|
54
55
|
body.push(drawn.svg);
|
|
55
56
|
ink = union(ink, grow(drawn.ink, layout.margin));
|
|
56
57
|
}
|
|
57
|
-
//
|
|
58
|
-
// the canvas from the boxes alone. A curve out of a `top` side, or a
|
|
58
|
+
// An edge's geometry is measured rather than solved for, so the resolver sized
|
|
59
|
+
// the canvas from the boxes alone. A curve out of a `top` side, or a text
|
|
59
60
|
// riding above one, lands outside that — so the page grows to hold it and the
|
|
60
61
|
// origin moves with it, rather than the drawing being quietly clipped.
|
|
61
62
|
const canvas = {
|
|
@@ -64,7 +65,7 @@ export function render(layout, options = {}) {
|
|
|
64
65
|
width: Math.ceil(ink.maxX) - Math.floor(ink.minX),
|
|
65
66
|
height: Math.ceil(ink.maxY) - Math.floor(ink.minY),
|
|
66
67
|
};
|
|
67
|
-
const arrowColors = new Set(layout.
|
|
68
|
+
const arrowColors = new Set(layout.edges.map((edge) => lineOf(edge.appearance, theme.edge)));
|
|
68
69
|
return [
|
|
69
70
|
`<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">`,
|
|
70
71
|
' <defs>',
|
|
@@ -77,115 +78,134 @@ export function render(layout, options = {}) {
|
|
|
77
78
|
].join('\n');
|
|
78
79
|
}
|
|
79
80
|
// --- nodes -------------------------------------------------------------------
|
|
80
|
-
|
|
81
|
-
|
|
81
|
+
/**
|
|
82
|
+
* `<a href>` around whatever a node or an edge draws, when it named a
|
|
83
|
+
* destination.
|
|
84
|
+
*
|
|
85
|
+
* SVG has this natively, so a standalone SVG stays standalone and a rasteriser
|
|
86
|
+
* drops it, leaving a PNG unharmed. Plain `href` and not `xlink:href`: the
|
|
87
|
+
* SVG 1.1 spelling would need an `xmlns:xlink` on every drawing whether or not
|
|
88
|
+
* anything in it links anywhere, and every current browser takes the SVG 2 one.
|
|
89
|
+
*
|
|
90
|
+
* `target="_blank"` always, because the playground inlines the SVG into its own
|
|
91
|
+
* page and a click inside it would otherwise navigate the playground away;
|
|
92
|
+
* `rel="noopener"` goes with it as it does anywhere else.
|
|
93
|
+
*
|
|
94
|
+
* **An `<a>` is never nested inside another.** Nesting is the obvious way to let
|
|
95
|
+
* a container carry a destination while a child carries its own, and it does not
|
|
96
|
+
* work: Chrome draws nothing at all inside the inner one, so the child simply
|
|
97
|
+
* disappears from the picture. Every node's anchor therefore wraps only what
|
|
98
|
+
* that node draws — outline and text — and its children are emitted beside
|
|
99
|
+
* it, each wrapping itself. The reading comes out the same anyway, because the
|
|
100
|
+
* container's filled outline lies under the children and catches every click
|
|
101
|
+
* that does not land on one of them.
|
|
102
|
+
*
|
|
103
|
+
* A destination therefore reaches down the tree instead of enclosing it: a child
|
|
104
|
+
* that names none of its own is drawn inside an anchor carrying its container's,
|
|
105
|
+
* and one that names its own overrules it. That is the reading nesting would
|
|
106
|
+
* have given — the container catches every click its children do not — reached
|
|
107
|
+
* by repeating the destination rather than by wrapping.
|
|
108
|
+
*/
|
|
109
|
+
function linked(svg, url) {
|
|
110
|
+
if (url === undefined || svg.length === 0)
|
|
111
|
+
return svg;
|
|
112
|
+
// An `&` between query parameters is ordinary in a url and illegal raw in an
|
|
113
|
+
// attribute, so the value is escaped as markup rather than merely quoted.
|
|
114
|
+
return ` <a href=${quote(escapeXml(url))} target="_blank" rel="noopener">\n${svg}\n </a>`;
|
|
115
|
+
}
|
|
116
|
+
function drawNode(node, theme, measurer, fontSize, markup, inherited) {
|
|
117
|
+
const url = node.attrs['url'] ?? inherited;
|
|
118
|
+
const { own, kids } = nodeSvg(node, theme, measurer, fontSize, markup, url);
|
|
119
|
+
return [linked(own.join('\n'), url), ...kids]
|
|
120
|
+
.filter((part) => part.length > 0)
|
|
121
|
+
.join('\n');
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* What a node draws, in two pieces: its own ink and its children's. They are
|
|
125
|
+
* kept apart so the node's `<a>` can wrap what is the node's without
|
|
126
|
+
* swallowing what is a child's.
|
|
127
|
+
*/
|
|
128
|
+
function nodeSvg(node, theme, measurer, fontSize, markup, url) {
|
|
129
|
+
// A note is set smaller than a box text by default, and `size:` overrides
|
|
82
130
|
// that on anything. Only this node's own text takes the size — children are
|
|
83
131
|
// drawn by their own call and carry whatever they say themselves.
|
|
84
|
-
const size = fontSizeFor(node.kind, node.
|
|
132
|
+
const size = fontSizeFor(node.kind, node.textAttrs, fontSize, node.line);
|
|
85
133
|
const textHeight = measurer.lineHeight(size);
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
134
|
+
const blockWidth = widestLine(node.lines, measurer, size);
|
|
135
|
+
const ink = (run, own) => runInk(run, own, markup, theme);
|
|
136
|
+
if (node.body.kind === 'none') {
|
|
137
|
+
const style = textStyleFor(node.textAttrs, node.line, 'start', 'center');
|
|
138
|
+
return { kids: [], own: [sized(textBlock(node.lines, node.x, node.y, textHeight, size, node.textBox, {
|
|
139
|
+
color: textColorOf(node.textAttrs, theme, theme.text),
|
|
140
|
+
align: style.align,
|
|
141
|
+
ink,
|
|
142
|
+
}), size, fontSize)] };
|
|
91
143
|
}
|
|
92
|
-
const shape = shapeFor(node.appearance, node.line);
|
|
93
|
-
const labelStyle = labelStyleFor(node.label, node.line);
|
|
94
144
|
const glyphSide = ICON_LINES * textHeight;
|
|
95
|
-
if (
|
|
96
|
-
// No outline, no fill, no padding — the node is the picture. The
|
|
97
|
-
// there is one, sits under it
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
145
|
+
if (node.body.kind === 'icon') {
|
|
146
|
+
// No outline, no fill, no padding — the node is the picture. The text, if
|
|
147
|
+
// there is one, sits under it. `at`'s vertical half has nothing to say
|
|
148
|
+
// here — the caption is under the picture and nowhere else — so only its
|
|
149
|
+
// horizontal half is read.
|
|
150
|
+
const style = textStyleFor(node.textAttrs, node.line, 'middle', 'center');
|
|
151
|
+
const drawn = [drawIcon(node.body.icon, node.x + (node.width - glyphSide) / 2, node.y, glyphSide, theme)];
|
|
152
|
+
if (node.lines.some((line) => plain(line).length > 0)) {
|
|
153
|
+
drawn.push(sized(textBlock(node.lines, node.x, node.y, textHeight, size, node.textBox, {
|
|
154
|
+
color: textColorOf(node.textAttrs, theme, theme.text),
|
|
155
|
+
align: style.align,
|
|
156
|
+
ink,
|
|
104
157
|
}), size, fontSize));
|
|
105
158
|
}
|
|
106
|
-
return drawn
|
|
159
|
+
return { own: drawn, kids: [] };
|
|
107
160
|
}
|
|
161
|
+
const outline = node.body.outline;
|
|
108
162
|
const parts = [];
|
|
163
|
+
const kids = [];
|
|
109
164
|
const face = faceOf(node);
|
|
110
|
-
|
|
165
|
+
// A container *looks* like one because things stack beside its text, not
|
|
166
|
+
// because it has children: a node whose only child sits beside its text is
|
|
167
|
+
// drawn as the leaf it reads as.
|
|
168
|
+
const container = node.banded;
|
|
111
169
|
const border = borderOf(node.appearance, container ? theme.containerStroke : theme.boxStroke);
|
|
112
170
|
const fill = fillOf(node.appearance, container ? theme.containerFill : theme.boxFill);
|
|
113
171
|
// A box is the one kind with two inkable parts, which is why its text needs
|
|
114
172
|
// a word of its own — `border:` cannot stand in for it.
|
|
115
|
-
const text =
|
|
116
|
-
const subColor = subtextOf(node.appearance, theme);
|
|
173
|
+
const text = textColorOf(node.textAttrs, theme, theme.text);
|
|
117
174
|
// Deck copies sit behind the front face, furthest back drawn first.
|
|
118
|
-
for (let depth = node.
|
|
175
|
+
for (let depth = node.deckTexts.length; depth >= 1; depth -= 1) {
|
|
119
176
|
const x = face.x - depth * DECK_STEP;
|
|
120
177
|
const y = face.y - depth * DECK_STEP;
|
|
121
|
-
parts.push(` <path d="${outlinePath(
|
|
122
|
-
const
|
|
123
|
-
if (
|
|
124
|
-
parts.push(sized(textBlock([
|
|
125
|
-
color: text,
|
|
126
|
-
align: 'start',
|
|
127
|
-
}), size, fontSize));
|
|
178
|
+
parts.push(` <path d="${outlinePath(outline, x, y, face.width, face.height)}" fill="${theme.containerFill}" stroke="${border}"/>`);
|
|
179
|
+
const copy = node.deckTexts[depth - 1];
|
|
180
|
+
if (copy !== undefined) {
|
|
181
|
+
parts.push(sized(textBlock([[{ text: copy }]], x, y, textHeight, size, { x: PAD, y: PAD, width: face.width - PAD * 2, height: textHeight }, { color: text, align: 'start', ink }), size, fontSize));
|
|
128
182
|
}
|
|
129
183
|
}
|
|
130
|
-
parts.push(` <path d="${outlinePath(
|
|
131
|
-
for (const extra of outlineDetail(
|
|
184
|
+
parts.push(` <path d="${outlinePath(outline, face.x, face.y, face.width, face.height)}" fill="${fill}" stroke="${border}"/>`);
|
|
185
|
+
for (const extra of outlineDetail(outline, face.x, face.y, face.width, face.height)) {
|
|
132
186
|
parts.push(` <path d="${extra}" fill="none" stroke="${border}"/>`);
|
|
133
187
|
}
|
|
134
|
-
//
|
|
135
|
-
// left
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
const top = face.y + (face.height - node.lines.length * textHeight) / 2;
|
|
147
|
-
parts.push(sized(textBlock(node.lines, face.x, top, face.width - iconRoom, textHeight, size, {
|
|
148
|
-
color: text,
|
|
149
|
-
subColor,
|
|
150
|
-
align: leafAlign,
|
|
151
|
-
}), size, fontSize));
|
|
152
|
-
}
|
|
153
|
-
else {
|
|
154
|
-
// The label and the icon share a band at one end of the box, and the
|
|
155
|
-
// resolver has already given the contents the other end. A heading is
|
|
156
|
-
// ranged left at the top; a caption is centered at the bottom.
|
|
157
|
-
const band = Math.max(node.lines.length * textHeight, iconSide);
|
|
158
|
-
const bandTop = labelStyle.at === 'top' ? face.y + PAD : face.y + face.height - PAD - band;
|
|
159
|
-
parts.push(sized(textBlock(node.lines, face.x + PAD, bandTop, face.width - PAD * 2 - iconRoom, textHeight, size, {
|
|
160
|
-
color: text,
|
|
161
|
-
subColor,
|
|
162
|
-
align: labelStyle.align,
|
|
163
|
-
}), size, fontSize));
|
|
164
|
-
for (const child of node.children) {
|
|
165
|
-
parts.push(drawNode(child, theme, measurer, fontSize));
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
if (icon !== undefined) {
|
|
169
|
-
// A container's icon rides in the label's band, at whichever end that is; a
|
|
170
|
-
// leaf's label is centered, so the icon centers with it. Both follow the
|
|
171
|
-
// label rather than being placed by a rule of their own, which is what
|
|
172
|
-
// keeps an icon reading as part of the title block and not as a sticker.
|
|
173
|
-
const left = face.x + face.width - PAD - iconSide;
|
|
174
|
-
const top = container
|
|
175
|
-
? labelStyle.at === 'top'
|
|
176
|
-
? face.y + PAD
|
|
177
|
-
: face.y + face.height - PAD - iconSide
|
|
178
|
-
: face.y + (face.height - iconSide) / 2;
|
|
179
|
-
parts.push(drawIcon(icon, left, top, iconSide, theme));
|
|
188
|
+
// A leaf's text defaults to the middle of its box, a container's to the top
|
|
189
|
+
// left of the band; both then read `at` for where it really goes. Where the
|
|
190
|
+
// text sits is the resolver's answer, in `textBox`; only the alignment of
|
|
191
|
+
// its lines against each other is read here.
|
|
192
|
+
const textStyle = textStyleFor(node.textAttrs, node.line, container ? 'start' : 'middle', container ? 'top-left' : 'center');
|
|
193
|
+
parts.push(sized(textBlock(node.lines, node.x, node.y, textHeight, size, node.textBox, {
|
|
194
|
+
color: text,
|
|
195
|
+
align: textStyle.align,
|
|
196
|
+
ink,
|
|
197
|
+
}), size, fontSize));
|
|
198
|
+
for (const child of node.children) {
|
|
199
|
+
kids.push(drawNode(child, theme, measurer, fontSize, markup, url));
|
|
180
200
|
}
|
|
181
|
-
return parts
|
|
201
|
+
return { own: parts, kids };
|
|
182
202
|
}
|
|
183
203
|
/**
|
|
184
204
|
* How far the dog-ear cuts into the top-right corner of a `document`.
|
|
185
205
|
*
|
|
186
206
|
* Twice the corner radius, so it is the same size on every box however wide.
|
|
187
207
|
* The reference sizes its fold as a fraction of the box, which is why the fold
|
|
188
|
-
* on those two wide
|
|
208
|
+
* on those two wide dump boxes almost disappears — the idea was right and
|
|
189
209
|
* only the scaling was wrong.
|
|
190
210
|
*/
|
|
191
211
|
const FOLD = CORNER * 2;
|
|
@@ -246,12 +266,12 @@ function drawIcon(icon, x, y, side, theme) {
|
|
|
246
266
|
' </g>',
|
|
247
267
|
].join('\n');
|
|
248
268
|
}
|
|
249
|
-
// ---
|
|
250
|
-
function
|
|
269
|
+
// --- edges -------------------------------------------------------------------
|
|
270
|
+
function drawEdge(edge, ends, corridor, theme, measurer, fontSize, markup) {
|
|
251
271
|
const { start, end } = ends;
|
|
252
|
-
const color = lineOf(
|
|
272
|
+
const color = lineOf(edge.appearance, theme.edge);
|
|
253
273
|
const markerEnd = ` marker-end="url(#${markerId(color)})"`;
|
|
254
|
-
const markerStart =
|
|
274
|
+
const markerStart = edge.both ? ` marker-start="url(#${markerId(color)}-back)"` : '';
|
|
255
275
|
// A named side is a statement about how the line should leave or arrive, so
|
|
256
276
|
// it is drawn as a curve that actually does leave and arrive that way. With
|
|
257
277
|
// neither side named there is nothing to honor and the line stays straight.
|
|
@@ -267,7 +287,7 @@ function drawLink(link, ends, corridor, theme, measurer, fontSize) {
|
|
|
267
287
|
const path = corridorPath(start, end, corridor);
|
|
268
288
|
ink = union(ink, path.ink);
|
|
269
289
|
parts.push(` <path d="${path.d}" fill="none" stroke="${color}" stroke-width="${LINE_WIDTH}"${markerEnd}${markerStart}/>`);
|
|
270
|
-
// The
|
|
290
|
+
// The text goes on the straight run rather than at the midpoint of the
|
|
271
291
|
// whole path, so it sits in the gap the author asked the line to travel.
|
|
272
292
|
midX = path.mid.x;
|
|
273
293
|
midY = path.mid.y;
|
|
@@ -285,7 +305,7 @@ function drawLink(link, ends, corridor, theme, measurer, fontSize) {
|
|
|
285
305
|
const c2 = { x: end.x + end.tx * reach + bx, y: end.y + end.ty * reach + by };
|
|
286
306
|
parts.push(` <path d="M ${round(start.x)} ${round(start.y)} C ${round(c1.x)} ${round(c1.y)}, ${round(c2.x)} ${round(c2.y)}, ${round(end.x)} ${round(end.y)}" fill="none" stroke="${color}" stroke-width="${LINE_WIDTH}"${markerEnd}${markerStart}/>`);
|
|
287
307
|
ink = union(ink, cubicExtent(start, c1, c2, end));
|
|
288
|
-
// The point halfway along a cubic, which is where the
|
|
308
|
+
// The point halfway along a cubic, which is where the text belongs.
|
|
289
309
|
midX = (start.x + 3 * c1.x + 3 * c2.x + end.x) / 8;
|
|
290
310
|
midY = (start.y + 3 * c1.y + 3 * c2.y + end.y) / 8;
|
|
291
311
|
}
|
|
@@ -314,18 +334,19 @@ function drawLink(link, ends, corridor, theme, measurer, fontSize) {
|
|
|
314
334
|
midX = (start.x + end.x) / 2;
|
|
315
335
|
midY = (start.y + end.y) / 2;
|
|
316
336
|
}
|
|
317
|
-
if (
|
|
318
|
-
//
|
|
337
|
+
if (edge.text !== undefined) {
|
|
338
|
+
// An edge text breaks on ` / ` exactly as a box text does, so a two-line
|
|
319
339
|
// caption on an arrow needs no vocabulary of its own. The block is centered
|
|
320
|
-
// on the midpoint, which keeps a one-line
|
|
321
|
-
const size = fontSizeFor('
|
|
340
|
+
// on the midpoint, which keeps a one-line text where it has always been.
|
|
341
|
+
const size = fontSizeFor('edge', edge.textAttrs, fontSize, edge.line);
|
|
322
342
|
const textHeight = measurer.lineHeight(size);
|
|
323
|
-
const
|
|
343
|
+
const lines = edge.lines;
|
|
344
|
+
const width = widestLine(lines, measurer, size);
|
|
324
345
|
const height = lines.length * textHeight;
|
|
325
346
|
const top = midY - height / 2;
|
|
326
|
-
// The
|
|
347
|
+
// The text knocks a hole in whatever it lands on rather than sitting in a
|
|
327
348
|
// chip of its own: an outlined box reads as a node, which is the one thing
|
|
328
|
-
// a
|
|
349
|
+
// a text on a line is not.
|
|
329
350
|
parts.push(` <rect x="${round(midX - width / 2 - 5)}" y="${round(top)}" width="${round(width + 10)}" height="${round(height)}" fill="${theme.background}"/>`);
|
|
330
351
|
ink = union(ink, {
|
|
331
352
|
minX: midX - width / 2 - 5,
|
|
@@ -333,15 +354,16 @@ function drawLink(link, ends, corridor, theme, measurer, fontSize) {
|
|
|
333
354
|
maxX: midX + width / 2 + 5,
|
|
334
355
|
maxY: top + height,
|
|
335
356
|
});
|
|
336
|
-
parts.push(sized(textBlock(lines, midX - width / 2, top,
|
|
337
|
-
// A colored
|
|
357
|
+
parts.push(sized(textBlock(lines, midX - width / 2, top, textHeight, size, { x: 0, y: 0, width, height }, {
|
|
358
|
+
// A colored edge carries its meaning into its text; an uncolored
|
|
338
359
|
// one leaves the words to read as ordinary text.
|
|
339
|
-
color:
|
|
360
|
+
color: textColorOf(edge.textAttrs, theme, lineOf(edge.appearance, theme.text)),
|
|
340
361
|
align: 'middle',
|
|
362
|
+
ink: (run, own) => runInk(run, own, markup, theme),
|
|
341
363
|
}), size, fontSize));
|
|
342
364
|
}
|
|
343
365
|
// The stroke straddles the path, so half of it lies outside the geometry.
|
|
344
|
-
return { svg: parts.join('\n'), ink: grow(ink, LINE_WIDTH / 2) };
|
|
366
|
+
return { svg: linked(parts.join('\n'), edge.attrs['url']), ink: grow(ink, LINE_WIDTH / 2) };
|
|
345
367
|
}
|
|
346
368
|
function union(a, b) {
|
|
347
369
|
return {
|
|
@@ -371,7 +393,7 @@ function extentOfPoints(points) {
|
|
|
371
393
|
* What a cubic actually covers, which is not what its control points cover. A
|
|
372
394
|
* handle reaching 140 pixels up carries the curve only about three quarters of
|
|
373
395
|
* that, and sizing the page off the handles would leave a visible band of empty
|
|
374
|
-
* canvas above every curved
|
|
396
|
+
* canvas above every curved edge. Solved rather than sampled: the extremes are
|
|
375
397
|
* the ends plus wherever the derivative — a quadratic — crosses zero.
|
|
376
398
|
*/
|
|
377
399
|
function cubicExtent(p0, c1, c2, p3) {
|
|
@@ -406,7 +428,7 @@ function cubicExtent(p0, c1, c2, p3) {
|
|
|
406
428
|
return { minX, minY, maxX, maxY };
|
|
407
429
|
}
|
|
408
430
|
/** Walk out from the center of a box toward a point, stopping at the border. */
|
|
409
|
-
function
|
|
431
|
+
function sidePoint(box, toward) {
|
|
410
432
|
const center = centerOf(box);
|
|
411
433
|
const dx = toward.x - center.x;
|
|
412
434
|
const dy = toward.y - center.y;
|
|
@@ -417,48 +439,51 @@ function edgePoint(box, toward) {
|
|
|
417
439
|
const scale = Math.min(scaleX, scaleY);
|
|
418
440
|
return { x: center.x + dx * scale, y: center.y + dy * scale };
|
|
419
441
|
}
|
|
420
|
-
// --- where
|
|
421
|
-
|
|
442
|
+
// --- where an edge meets a box -------------------------------------------------
|
|
443
|
+
// The four sides an edge may attach to. Deliberately not `ATTACH_SIDES` from
|
|
444
|
+
// `ast.ts`, which carries `center` as well because an alignment can share a
|
|
445
|
+
// center line and an attachment cannot sit on one.
|
|
446
|
+
const ATTACH_SIDES = ['top', 'bottom', 'left', 'right'];
|
|
422
447
|
/**
|
|
423
|
-
* Work out where every
|
|
448
|
+
* Work out where every edge meets every box.
|
|
424
449
|
*
|
|
425
450
|
* An author names a *side* — `to: top` — and never a point on it. Alone on a
|
|
426
|
-
* side
|
|
451
|
+
* side an edge lands at its center; sharing the side with others, the points
|
|
427
452
|
* spread so they do not sit on top of each other. Which one goes where is
|
|
428
|
-
* derived from where the far ends actually are, never chosen: of two
|
|
453
|
+
* derived from where the far ends actually are, never chosen: of two edges
|
|
429
454
|
* arriving at one top edge, the one coming from further left arrives further
|
|
430
455
|
* left. That is the same rule as box non-overlap — the tool separates things by
|
|
431
456
|
* default, and reads the direction off the solved layout rather than asking.
|
|
432
457
|
*
|
|
433
|
-
* Where several
|
|
458
|
+
* Where several edges run between the *same* pair of sides that rule has
|
|
434
459
|
* nothing to read, and a `Bundle` supplies the order instead — see there.
|
|
435
460
|
*/
|
|
436
|
-
function planEndpoints(
|
|
461
|
+
function planEndpoints(edges, measurer, fontSize) {
|
|
437
462
|
const claims = new Map();
|
|
438
463
|
const achieved = new Map();
|
|
439
464
|
const named = new Map();
|
|
440
|
-
const bundles = planBundles(
|
|
441
|
-
const spreads = planSpreads(
|
|
442
|
-
for (const
|
|
443
|
-
named.set(
|
|
444
|
-
const fromSide = sideAttr(
|
|
445
|
-
const toSide = sideAttr(
|
|
465
|
+
const bundles = planBundles(edges, measurer, fontSize);
|
|
466
|
+
const spreads = planSpreads(edges, measurer, fontSize);
|
|
467
|
+
for (const edge of edges) {
|
|
468
|
+
named.set(edge, {});
|
|
469
|
+
const fromSide = sideAttr(edge, 'from');
|
|
470
|
+
const toSide = sideAttr(edge, 'to');
|
|
446
471
|
if (fromSide) {
|
|
447
|
-
claim(claims,
|
|
448
|
-
|
|
472
|
+
claim(claims, edge.from, fromSide, {
|
|
473
|
+
edge,
|
|
449
474
|
which: 'start',
|
|
450
475
|
side: fromSide,
|
|
451
|
-
toward: centerOf(faceOf(
|
|
452
|
-
rank: rankIn(bundles.get(
|
|
476
|
+
toward: centerOf(faceOf(edge.to)),
|
|
477
|
+
rank: rankIn(bundles.get(edge), edge, edge.from, fromSide),
|
|
453
478
|
});
|
|
454
479
|
}
|
|
455
480
|
if (toSide) {
|
|
456
|
-
claim(claims,
|
|
457
|
-
|
|
481
|
+
claim(claims, edge.to, toSide, {
|
|
482
|
+
edge,
|
|
458
483
|
which: 'end',
|
|
459
484
|
side: toSide,
|
|
460
|
-
toward: centerOf(faceOf(
|
|
461
|
-
rank: rankIn(bundles.get(
|
|
485
|
+
toward: centerOf(faceOf(edge.from)),
|
|
486
|
+
rank: rankIn(bundles.get(edge), edge, edge.to, toSide),
|
|
462
487
|
});
|
|
463
488
|
}
|
|
464
489
|
}
|
|
@@ -469,18 +494,18 @@ function planEndpoints(links, measurer, fontSize) {
|
|
|
469
494
|
const along = side === 'top' || side === 'bottom' ? 'x' : 'y';
|
|
470
495
|
const span = along === 'x' ? face.width : face.height;
|
|
471
496
|
const origin = along === 'x' ? face.x : face.y;
|
|
472
|
-
// Far ends first, as ever; a bundle's own lane order settles the
|
|
497
|
+
// Far ends first, as ever; a bundle's own lane order settles the edges
|
|
473
498
|
// that share one, which are precisely the ones the first key cannot.
|
|
474
499
|
const ordered = [...group].sort((a, b) => a.toward[along] - b.toward[along] || (a.rank ?? 0) - (b.rank ?? 0));
|
|
475
|
-
// A bundle's lanes have to hold whole
|
|
500
|
+
// A bundle's lanes have to hold whole texts apart rather than the points
|
|
476
501
|
// of two arrows, so its step is the one that governs the side it lands on.
|
|
477
|
-
const wanted = Math.max(ATTACH_STEP, ...group.map((entry) => bundles.get(entry.
|
|
502
|
+
const wanted = Math.max(ATTACH_STEP, ...group.map((entry) => bundles.get(entry.edge)?.step ?? 0));
|
|
478
503
|
const usable = Math.max(0, span - ATTACH_MARGIN * 2);
|
|
479
504
|
const step = ordered.length > 1 ? Math.min(wanted, usable / (ordered.length - 1)) : 0;
|
|
480
505
|
const first = origin + span / 2 - (step * (ordered.length - 1)) / 2;
|
|
481
506
|
ordered.forEach((entry, index) => {
|
|
482
507
|
const at = first + index * step;
|
|
483
|
-
named.get(entry.
|
|
508
|
+
named.get(entry.edge)[entry.which] = anchorOn(face, side, at);
|
|
484
509
|
});
|
|
485
510
|
// What the side could actually give, which is less than `wanted` when it
|
|
486
511
|
// is too short for the group. `bowBundles` makes up the difference.
|
|
@@ -494,34 +519,34 @@ function planEndpoints(links, measurer, fontSize) {
|
|
|
494
519
|
// Fill in the ends the author said nothing about, now that the named ones
|
|
495
520
|
// are known: an unnamed end aims at wherever its partner ended up.
|
|
496
521
|
const ends = new Map();
|
|
497
|
-
for (const
|
|
498
|
-
const partial = named.get(
|
|
499
|
-
const fromFace = faceOf(
|
|
500
|
-
const toFace = faceOf(
|
|
501
|
-
// Several
|
|
522
|
+
for (const edge of edges) {
|
|
523
|
+
const partial = named.get(edge);
|
|
524
|
+
const fromFace = faceOf(edge.from);
|
|
525
|
+
const toFace = faceOf(edge.to);
|
|
526
|
+
// Several edges between one pair of boxes with no side named anywhere: the
|
|
502
527
|
// line each would have drawn alone, moved aside so they do not coincide.
|
|
503
|
-
const spread = spreads.get(
|
|
528
|
+
const spread = spreads.get(edge);
|
|
504
529
|
if (spread) {
|
|
505
|
-
ends.set(
|
|
530
|
+
ends.set(edge, { ...parallelEnds(fromFace, toFace, spread.offset), bow: spread.bow });
|
|
506
531
|
continue;
|
|
507
532
|
}
|
|
508
533
|
// With neither end named this is the straight line it always was, each end
|
|
509
534
|
// aiming at the other box's center.
|
|
510
535
|
const start = partial.start ?? free(fromFace, partial.end ?? centerOf(toFace));
|
|
511
536
|
const end = partial.end ?? free(toFace, partial.start ?? centerOf(fromFace));
|
|
512
|
-
ends.set(
|
|
537
|
+
ends.set(edge, { start, end, bow: bows.get(edge) });
|
|
513
538
|
}
|
|
514
539
|
return ends;
|
|
515
540
|
}
|
|
516
541
|
/**
|
|
517
|
-
* Group the
|
|
542
|
+
* Group the edges that run between the same pair of sides, and work out the
|
|
518
543
|
* lane order and lane width each group needs.
|
|
519
544
|
*
|
|
520
|
-
* Only
|
|
545
|
+
* Only an edge whose author named *both* sides can be in a bundle: a bundle is a
|
|
521
546
|
* statement about two specific edges, and an end with no side named has not
|
|
522
547
|
* picked one yet.
|
|
523
548
|
*/
|
|
524
|
-
function planBundles(
|
|
549
|
+
function planBundles(edges, measurer, fontSize) {
|
|
525
550
|
const ids = new Map();
|
|
526
551
|
const idOf = (node) => {
|
|
527
552
|
let id = ids.get(node);
|
|
@@ -532,13 +557,13 @@ function planBundles(links, measurer, fontSize) {
|
|
|
532
557
|
return id;
|
|
533
558
|
};
|
|
534
559
|
const groups = new Map();
|
|
535
|
-
for (const
|
|
536
|
-
const fromSide = sideAttr(
|
|
537
|
-
const toSide = sideAttr(
|
|
538
|
-
if (!fromSide || !toSide ||
|
|
560
|
+
for (const edge of edges) {
|
|
561
|
+
const fromSide = sideAttr(edge, 'from');
|
|
562
|
+
const toSide = sideAttr(edge, 'to');
|
|
563
|
+
if (!fromSide || !toSide || edge.from === edge.to)
|
|
539
564
|
continue;
|
|
540
|
-
const a = { node:
|
|
541
|
-
const b = { node:
|
|
565
|
+
const a = { node: edge.from, side: fromSide };
|
|
566
|
+
const b = { node: edge.to, side: toSide };
|
|
542
567
|
const keyA = `${idOf(a.node)}:${a.side}`;
|
|
543
568
|
const keyB = `${idOf(b.node)}:${b.side}`;
|
|
544
569
|
// The pair is unordered — `a -> b` and `b -> a` join the same two edges —
|
|
@@ -548,13 +573,13 @@ function planBundles(links, measurer, fontSize) {
|
|
|
548
573
|
const ends = swap ? [b, a] : [a, b];
|
|
549
574
|
const group = groups.get(key);
|
|
550
575
|
if (group)
|
|
551
|
-
group.
|
|
576
|
+
group.edges.push(edge);
|
|
552
577
|
else
|
|
553
|
-
groups.set(key, { ends,
|
|
578
|
+
groups.set(key, { ends, edges: [edge] });
|
|
554
579
|
}
|
|
555
580
|
const bundles = new Map();
|
|
556
581
|
for (const group of groups.values()) {
|
|
557
|
-
if (group.
|
|
582
|
+
if (group.edges.length < 2)
|
|
558
583
|
continue;
|
|
559
584
|
const [first, second] = group.ends;
|
|
560
585
|
const t0 = tangentOf(first.side);
|
|
@@ -567,16 +592,16 @@ function planBundles(links, measurer, fontSize) {
|
|
|
567
592
|
// step them to opposite sides and it pivots, which is a crossing.
|
|
568
593
|
const aligned = cross(run, t0) * cross(run, t1) >= 0;
|
|
569
594
|
const sense = aligned ? 1 : -1;
|
|
570
|
-
// Two
|
|
595
|
+
// Two edges leaving in opposite directions are the ordinary case, and which
|
|
571
596
|
// lane each takes is then read off the diagram rather than off the order the
|
|
572
597
|
// author happened to type them in: a line keeps to one side of its own run.
|
|
573
|
-
//
|
|
574
|
-
const order = new Map(group.
|
|
575
|
-
const lanes = [...group.
|
|
598
|
+
// Edges pointing the same way have no such signal and fall back to the file.
|
|
599
|
+
const order = new Map(group.edges.map((edge, index) => [edge, index]));
|
|
600
|
+
const lanes = [...group.edges].sort((a, b) => Number(a.from !== first.node) - Number(b.from !== first.node) ||
|
|
576
601
|
order.get(a) - order.get(b));
|
|
577
|
-
// One lane apart moves
|
|
602
|
+
// One lane apart moves an edge's start by `step` along one side and its end
|
|
578
603
|
// by `step` along the other, so the midpoint of the line — which is where
|
|
579
|
-
// its
|
|
604
|
+
// its text goes — moves by the average of the two.
|
|
580
605
|
const drift = { x: (t0.x + sense * t1.x) / 2, y: (t0.y + sense * t1.y) / 2 };
|
|
581
606
|
const bundle = {
|
|
582
607
|
ends: group.ends,
|
|
@@ -584,41 +609,41 @@ function planBundles(links, measurer, fontSize) {
|
|
|
584
609
|
aligned,
|
|
585
610
|
step: Math.max(ATTACH_STEP, laneStep(lanes, drift, measurer, fontSize)),
|
|
586
611
|
};
|
|
587
|
-
for (const
|
|
588
|
-
bundles.set(
|
|
612
|
+
for (const edge of lanes)
|
|
613
|
+
bundles.set(edge, bundle);
|
|
589
614
|
}
|
|
590
615
|
return bundles;
|
|
591
616
|
}
|
|
592
617
|
/**
|
|
593
|
-
* The sideways offset each
|
|
618
|
+
* The sideways offset each edge takes when several run between the same two
|
|
594
619
|
* boxes and none of them names a side.
|
|
595
620
|
*
|
|
596
621
|
* An unnamed end has no side to spread along: it aims at the far box's center
|
|
597
|
-
* and attaches wherever that ray crosses the border, so every
|
|
622
|
+
* and attaches wherever that ray crosses the border, so every edge in such a
|
|
598
623
|
* group produces the *same* ray and they are drawn on top of one another —
|
|
599
|
-
* one visible line, every
|
|
624
|
+
* one visible line, every text stacked on one point. `planEndpoints` cannot
|
|
600
625
|
* see this and `planBundles` will not, since a bundle is a statement about two
|
|
601
626
|
* named edges.
|
|
602
627
|
*
|
|
603
628
|
* The repair keeps the attachment rule exactly as it is and only stops two
|
|
604
|
-
*
|
|
629
|
+
* edges using it at the same place: the line an edge would have drawn alone is
|
|
605
630
|
* translated across its own run by a lane, which is the straight-line version
|
|
606
|
-
* of the nesting a bundle already gives curves. A lone
|
|
631
|
+
* of the nesting a bundle already gives curves. A lone edge is in no group and
|
|
607
632
|
* so is untouched.
|
|
608
633
|
*
|
|
609
634
|
* Where the boxes are too small to hold the group at full spacing, the ends
|
|
610
635
|
* are squeezed evenly to fit the edge — there is nowhere further to attach —
|
|
611
636
|
* and the shortfall is made up in the middle instead: each line bows across
|
|
612
|
-
* its run by exactly what its endpoints could not give it, so the
|
|
637
|
+
* its run by exactly what its endpoints could not give it, so the texts, which
|
|
613
638
|
* ride at the midpoints, come apart even though the arrows do not. The bow is
|
|
614
639
|
* therefore derived rather than styled, and it is zero whenever the edge was
|
|
615
640
|
* long enough, which is why the ordinary case is still a straight line.
|
|
616
641
|
*
|
|
617
|
-
* A `between`
|
|
642
|
+
* A `between` edge is left out. Its route is the corridor it named, its lane
|
|
618
643
|
* inside that corridor is `planCorridors`' business, and `aimFreeEnds` will
|
|
619
644
|
* re-aim these ends at the corridor afterwards regardless.
|
|
620
645
|
*/
|
|
621
|
-
function planSpreads(
|
|
646
|
+
function planSpreads(edges, measurer, fontSize) {
|
|
622
647
|
const ids = new Map();
|
|
623
648
|
const idOf = (node) => {
|
|
624
649
|
let id = ids.get(node);
|
|
@@ -629,47 +654,47 @@ function planSpreads(links, measurer, fontSize) {
|
|
|
629
654
|
return id;
|
|
630
655
|
};
|
|
631
656
|
const groups = new Map();
|
|
632
|
-
for (const
|
|
633
|
-
if (sideAttr(
|
|
657
|
+
for (const edge of edges) {
|
|
658
|
+
if (sideAttr(edge, 'from') || sideAttr(edge, 'to'))
|
|
634
659
|
continue;
|
|
635
|
-
if (
|
|
660
|
+
if (edge.from === edge.to || edge.between)
|
|
636
661
|
continue;
|
|
637
|
-
const a = idOf(
|
|
638
|
-
const b = idOf(
|
|
662
|
+
const a = idOf(edge.from);
|
|
663
|
+
const b = idOf(edge.to);
|
|
639
664
|
const swap = b < a;
|
|
640
665
|
const key = swap ? `${b}|${a}` : `${a}|${b}`;
|
|
641
|
-
const first = swap ?
|
|
666
|
+
const first = swap ? edge.to : edge.from;
|
|
642
667
|
const group = groups.get(key);
|
|
643
668
|
if (group)
|
|
644
|
-
group.
|
|
669
|
+
group.edges.push(edge);
|
|
645
670
|
else
|
|
646
|
-
groups.set(key, { first,
|
|
671
|
+
groups.set(key, { first, edges: [edge] });
|
|
647
672
|
}
|
|
648
673
|
const spreads = new Map();
|
|
649
674
|
for (const group of groups.values()) {
|
|
650
|
-
if (group.
|
|
675
|
+
if (group.edges.length < 2)
|
|
651
676
|
continue;
|
|
652
677
|
const from = centerOf(faceOf(group.first));
|
|
653
|
-
const sample = group.
|
|
678
|
+
const sample = group.edges[0];
|
|
654
679
|
const other = sample.from === group.first ? sample.to : sample.from;
|
|
655
680
|
const to = centerOf(faceOf(other));
|
|
656
681
|
const dx = to.x - from.x;
|
|
657
682
|
const dy = to.y - from.y;
|
|
658
683
|
const length = Math.hypot(dx, dy) || 1;
|
|
659
|
-
// Translating the line moves its midpoint — where the
|
|
684
|
+
// Translating the line moves its midpoint — where the text goes — by
|
|
660
685
|
// exactly this, so it is the drift `laneStep` needs.
|
|
661
686
|
const across = { x: -dy / length, y: dx / length };
|
|
662
|
-
// The same derived order a bundle uses:
|
|
687
|
+
// The same derived order a bundle uses: edges pointing opposite ways each
|
|
663
688
|
// keep to one side of their own run, so a reciprocal pair reads as a
|
|
664
|
-
// circulation, and only
|
|
665
|
-
const order = new Map(group.
|
|
666
|
-
const lanes = [...group.
|
|
689
|
+
// circulation, and only edges pointing the same way fall back to the file.
|
|
690
|
+
const order = new Map(group.edges.map((edge, index) => [edge, index]));
|
|
691
|
+
const lanes = [...group.edges].sort((a, b) => Number(a.from !== group.first) - Number(b.from !== group.first) ||
|
|
667
692
|
order.get(a) - order.get(b));
|
|
668
693
|
// How far a lane may be shifted before its line no longer passes through the
|
|
669
694
|
// box at all. `exitAlong` clamps beyond that, which piles the outer lanes
|
|
670
|
-
// onto a corner and puts their
|
|
695
|
+
// onto a corner and puts their texts back on top of each other — so the
|
|
671
696
|
// group is squeezed evenly instead, exactly as `planEndpoints` squeezes a
|
|
672
|
-
// side too short for the
|
|
697
|
+
// side too short for the edges arriving on it, and just as silently.
|
|
673
698
|
const reach = (node) => {
|
|
674
699
|
const face = faceOf(node);
|
|
675
700
|
const byX = across.x === 0 ? Infinity : face.width / 2 / Math.abs(across.x);
|
|
@@ -678,22 +703,22 @@ function planSpreads(links, measurer, fontSize) {
|
|
|
678
703
|
};
|
|
679
704
|
// Which way lane 0 lies is arbitrary, so fix it the way the rest of the
|
|
680
705
|
// renderer does — toward increasing x, or increasing y where the run is
|
|
681
|
-
// horizontal. Without this the first
|
|
706
|
+
// horizontal. Without this the first edge written is topmost on a rightward
|
|
682
707
|
// run and rightmost on a downward one, for no reason a reader could see.
|
|
683
708
|
const orient = across.x < 0 || (across.x === 0 && across.y < 0) ? -1 : 1;
|
|
684
709
|
const usable = 2 * Math.min(reach(group.first), reach(other));
|
|
685
710
|
const wanted = Math.max(ATTACH_STEP, laneStep(lanes, across, measurer, fontSize));
|
|
686
711
|
const step = Math.min(wanted, usable / (lanes.length - 1));
|
|
687
|
-
lanes.forEach((
|
|
712
|
+
lanes.forEach((edge, index) => {
|
|
688
713
|
const place = index - (lanes.length - 1) / 2;
|
|
689
714
|
// The lane is measured across the pair's own run, which has one direction;
|
|
690
|
-
//
|
|
715
|
+
// an edge written the other way round travels the opposite way and would
|
|
691
716
|
// otherwise take the same offset to the opposite side, putting a
|
|
692
717
|
// reciprocal pair back on one line. Negated, both keep to their own left,
|
|
693
718
|
// which is the circulation a bundle already draws.
|
|
694
|
-
const sense = (
|
|
719
|
+
const sense = (edge.from === group.first ? 1 : -1) * orient;
|
|
695
720
|
const shortfall = place * (wanted - step) * sense;
|
|
696
|
-
spreads.set(
|
|
721
|
+
spreads.set(edge, {
|
|
697
722
|
offset: place * step * sense,
|
|
698
723
|
bow: { x: across.x * shortfall, y: across.y * shortfall },
|
|
699
724
|
});
|
|
@@ -702,11 +727,11 @@ function planSpreads(links, measurer, fontSize) {
|
|
|
702
727
|
return spreads;
|
|
703
728
|
}
|
|
704
729
|
/**
|
|
705
|
-
* The bow each bundled
|
|
706
|
-
* to hold the group at the spacing its
|
|
730
|
+
* The bow each bundled edge needs, where the sides it was given were too short
|
|
731
|
+
* to hold the group at the spacing its texts asked for.
|
|
707
732
|
*
|
|
708
733
|
* A named side is squeezed exactly as an unnamed group's edge is — the step
|
|
709
|
-
* shrinks to `usable / (n - 1)` and the
|
|
734
|
+
* shrinks to `usable / (n - 1)` and the texts ride down on top of each other —
|
|
710
735
|
* and until this existed, naming the two sides the tool would have chosen
|
|
711
736
|
* anyway made the picture strictly worse than saying nothing. That is not a
|
|
712
737
|
* line worth defending, so the same repair applies: a lane's midpoint is not
|
|
@@ -739,35 +764,35 @@ function bowBundles(bundles, achieved) {
|
|
|
739
764
|
};
|
|
740
765
|
if (short.x === 0 && short.y === 0)
|
|
741
766
|
continue;
|
|
742
|
-
bundle.lanes.forEach((
|
|
767
|
+
bundle.lanes.forEach((edge, index) => {
|
|
743
768
|
const place = index - (bundle.lanes.length - 1) / 2;
|
|
744
|
-
bows.set(
|
|
769
|
+
bows.set(edge, { x: short.x * place, y: short.y * place });
|
|
745
770
|
});
|
|
746
771
|
}
|
|
747
772
|
return bows;
|
|
748
773
|
}
|
|
749
774
|
/**
|
|
750
|
-
* How far apart adjacent lanes must sit for their
|
|
775
|
+
* How far apart adjacent lanes must sit for their texts to clear each other.
|
|
751
776
|
*
|
|
752
|
-
* The
|
|
777
|
+
* The texts are knockout rectangles, so two of them clear when they are apart
|
|
753
778
|
* on *either* axis — hence the smaller of the two answers. `drift` is how far
|
|
754
779
|
* the midpoint travels per unit of step, and it is never zero: the two ends
|
|
755
780
|
* cancel only when both sides run the same way, and two such sides are always
|
|
756
781
|
* `aligned`, which adds rather than subtracts.
|
|
757
782
|
*/
|
|
758
783
|
function laneStep(lanes, drift, measurer, fontSize) {
|
|
759
|
-
const
|
|
760
|
-
if (
|
|
784
|
+
const withText = lanes.filter((edge) => edge.text !== undefined);
|
|
785
|
+
if (withText.length < 2)
|
|
761
786
|
return 0;
|
|
762
|
-
const need = (axis) => Math.max(...
|
|
787
|
+
const need = (axis) => Math.max(...withText.map((edge) => textExtent(edge.lines, edge.textAttrs, axis, measurer, fontSize, edge.line)));
|
|
763
788
|
const along = (axis, reach) => reach === 0 ? Infinity : need(axis) / Math.abs(reach);
|
|
764
789
|
return Math.min(along('x', drift.x), along('y', drift.y));
|
|
765
790
|
}
|
|
766
|
-
/** Which lane of its bundle
|
|
767
|
-
function rankIn(bundle,
|
|
791
|
+
/** Which lane of its bundle an edge's end at this side takes, if it is in one. */
|
|
792
|
+
function rankIn(bundle, edge, node, side) {
|
|
768
793
|
if (!bundle)
|
|
769
794
|
return undefined;
|
|
770
|
-
const lane = bundle.lanes.indexOf(
|
|
795
|
+
const lane = bundle.lanes.indexOf(edge);
|
|
771
796
|
const [first, second] = bundle.ends;
|
|
772
797
|
if (node === first.node && side === first.side)
|
|
773
798
|
return lane;
|
|
@@ -816,7 +841,7 @@ function anchorOn(face, side, at) {
|
|
|
816
841
|
}
|
|
817
842
|
/** An end with no side named: leave from the border, pointing at the far end. */
|
|
818
843
|
function free(face, toward) {
|
|
819
|
-
const point =
|
|
844
|
+
const point = sidePoint(face, toward);
|
|
820
845
|
const center = centerOf(face);
|
|
821
846
|
const dx = point.x - center.x;
|
|
822
847
|
const dy = point.y - center.y;
|
|
@@ -826,7 +851,7 @@ function free(face, toward) {
|
|
|
826
851
|
/**
|
|
827
852
|
* Walk from a point inside a box along a direction, stopping at the border.
|
|
828
853
|
*
|
|
829
|
-
* `
|
|
854
|
+
* `sidePoint` walks from the center, which is the only place a single line
|
|
830
855
|
* passes through. A fanned-out group's lines are parallel to that one and
|
|
831
856
|
* beside it, so each needs the border crossing of its own line rather than of
|
|
832
857
|
* the center's — which is what keeps the group parallel instead of splayed.
|
|
@@ -844,11 +869,11 @@ function exitAlong(box, from, dir) {
|
|
|
844
869
|
return { x: x + dir.x * Math.max(0, t), y: y + dir.y * Math.max(0, t) };
|
|
845
870
|
}
|
|
846
871
|
/**
|
|
847
|
-
* Both ends of
|
|
872
|
+
* Both ends of an edge that named no side, moved `offset` sideways across its
|
|
848
873
|
* own run.
|
|
849
874
|
*
|
|
850
875
|
* The whole line is translated rather than each end being nudged along its
|
|
851
|
-
* border, so the result is genuinely parallel to the line the
|
|
876
|
+
* border, so the result is genuinely parallel to the line the edge would have
|
|
852
877
|
* drawn alone, exactly `offset` away from it. Where each end lands then falls
|
|
853
878
|
* out of that: level boxes put both points further along the same two edges,
|
|
854
879
|
* and a diagonal pair whose line leaves through a corner puts one point on each
|
|
@@ -928,61 +953,61 @@ function gapBetween(a, b, aName, bName, wanted, line) {
|
|
|
928
953
|
};
|
|
929
954
|
}
|
|
930
955
|
/**
|
|
931
|
-
* Route every
|
|
956
|
+
* Route every edge that named a gap.
|
|
932
957
|
*
|
|
933
|
-
*
|
|
958
|
+
* Edges sharing one gap share its lanes, spread like attachments on a side and
|
|
934
959
|
* ordered the same derived way — by where their ends actually sit, so the two
|
|
935
|
-
* arriving at
|
|
960
|
+
* arriving at the hub's left edge in one order run through the corridor in that
|
|
936
961
|
* same order and never cross.
|
|
937
962
|
*/
|
|
938
|
-
function planCorridors(
|
|
963
|
+
function planCorridors(edges, ends, measurer, fontSize) {
|
|
939
964
|
const plans = new Map();
|
|
940
965
|
const groups = new Map();
|
|
941
|
-
for (const
|
|
942
|
-
if (!
|
|
966
|
+
for (const edge of edges) {
|
|
967
|
+
if (!edge.between)
|
|
943
968
|
continue;
|
|
944
|
-
const [first, second] =
|
|
945
|
-
const gap = gapBetween(faceOf(first), faceOf(second), first.name, second.name,
|
|
969
|
+
const [first, second] = edge.between.nodes;
|
|
970
|
+
const gap = gapBetween(faceOf(first), faceOf(second), first.name, second.name, edge.between.axis, edge.line);
|
|
946
971
|
// The pair names one gap however the author ordered them. The axis is in
|
|
947
|
-
// the key because a diagonal pair really does have two, and two
|
|
972
|
+
// the key because a diagonal pair really does have two, and two edges may
|
|
948
973
|
// legitimately name the same pair and take different ones.
|
|
949
974
|
const key = [gap.axis, ...[first.name, second.name].sort()].join(' ');
|
|
950
975
|
const group = groups.get(key);
|
|
951
976
|
if (group)
|
|
952
|
-
group.members.push(
|
|
977
|
+
group.members.push(edge);
|
|
953
978
|
else
|
|
954
|
-
groups.set(key, { ...gap, members: [
|
|
979
|
+
groups.set(key, { ...gap, members: [edge] });
|
|
955
980
|
}
|
|
956
981
|
for (const group of groups.values()) {
|
|
957
|
-
const along = (
|
|
958
|
-
const { start, end } = ends.get(
|
|
982
|
+
const along = (edge) => {
|
|
983
|
+
const { start, end } = ends.get(edge);
|
|
959
984
|
return (start[group.axis] + end[group.axis]) / 2;
|
|
960
985
|
};
|
|
961
986
|
const ordered = [...group.members].sort((a, b) => along(a) - along(b));
|
|
962
987
|
// Lanes are spread as attachments on a side are, including the squeeze when
|
|
963
988
|
// there is not enough room — see `planEndpoints`. The step is wider here,
|
|
964
|
-
// because a lane carries a whole
|
|
965
|
-
// and two lanes closer together than a
|
|
989
|
+
// because a lane carries a whole text rather than the point of an arrow,
|
|
990
|
+
// and two lanes closer together than a text is deep would draw the texts
|
|
966
991
|
// over each other. Still derived, not chosen: it is the size of what is
|
|
967
992
|
// actually running along the corridor.
|
|
968
993
|
const span = group.hi - group.lo;
|
|
969
994
|
const usable = Math.max(0, span - ATTACH_MARGIN * 2);
|
|
970
|
-
const want = Math.max(ATTACH_STEP, ...ordered.map((
|
|
995
|
+
const want = Math.max(ATTACH_STEP, ...ordered.map((edge) => laneExtent(edge, group.axis, measurer, fontSize)));
|
|
971
996
|
const step = ordered.length > 1 ? Math.min(want, usable / (ordered.length - 1)) : 0;
|
|
972
997
|
const firstLane = group.lo + span / 2 - (step * (ordered.length - 1)) / 2;
|
|
973
|
-
ordered.forEach((
|
|
974
|
-
const { start, end } = ends.get(
|
|
998
|
+
ordered.forEach((edge, index) => {
|
|
999
|
+
const { start, end } = ends.get(edge);
|
|
975
1000
|
const run = group.axis === 'y' ? 'x' : 'y';
|
|
976
|
-
// The corridor binds only where the
|
|
977
|
-
// its reach is the overlap of the pair's extent with the
|
|
1001
|
+
// The corridor binds only where the edge is actually passing the pair, so
|
|
1002
|
+
// its reach is the overlap of the pair's extent with the edge's own.
|
|
978
1003
|
const enterAt = Math.max(group.across[0], Math.min(start[run], end[run]));
|
|
979
1004
|
const leaveAt = Math.min(group.across[1], Math.max(start[run], end[run]));
|
|
980
1005
|
if (leaveAt <= enterAt) {
|
|
981
|
-
const [a, b] =
|
|
982
|
-
throw new SourceError(`this
|
|
1006
|
+
const [a, b] = edge.between.nodes;
|
|
1007
|
+
throw new SourceError(`this edge never passes between "${a.name}" and "${b.name}"`, edge.line);
|
|
983
1008
|
}
|
|
984
1009
|
const forward = end[run] >= start[run];
|
|
985
|
-
plans.set(
|
|
1010
|
+
plans.set(edge, {
|
|
986
1011
|
axis: group.axis,
|
|
987
1012
|
lane: firstLane + index * step,
|
|
988
1013
|
enter: forward ? enterAt : leaveAt,
|
|
@@ -997,41 +1022,41 @@ function planCorridors(links, ends, measurer, fontSize) {
|
|
|
997
1022
|
* is the wrong thing to aim at once the line has been told to go somewhere else
|
|
998
1023
|
* on the way. Point those ends at the corridor instead.
|
|
999
1024
|
*/
|
|
1000
|
-
function aimFreeEnds(
|
|
1001
|
-
for (const
|
|
1002
|
-
const plan = corridors.get(
|
|
1025
|
+
function aimFreeEnds(edges, ends, corridors) {
|
|
1026
|
+
for (const edge of edges) {
|
|
1027
|
+
const plan = corridors.get(edge);
|
|
1003
1028
|
if (!plan)
|
|
1004
1029
|
continue;
|
|
1005
|
-
const current = ends.get(
|
|
1030
|
+
const current = ends.get(edge);
|
|
1006
1031
|
const start = current.start.side === undefined
|
|
1007
|
-
? free(faceOf(
|
|
1032
|
+
? free(faceOf(edge.from), corridorPoint(plan, plan.enter))
|
|
1008
1033
|
: current.start;
|
|
1009
1034
|
const end = current.end.side === undefined
|
|
1010
|
-
? free(faceOf(
|
|
1035
|
+
? free(faceOf(edge.to), corridorPoint(plan, plan.leave))
|
|
1011
1036
|
: current.end;
|
|
1012
|
-
ends.set(
|
|
1037
|
+
ends.set(edge, { start, end });
|
|
1013
1038
|
}
|
|
1014
1039
|
}
|
|
1015
1040
|
/**
|
|
1016
|
-
* How much room
|
|
1017
|
-
* horizontal channel, its width in a vertical one. Zero for an
|
|
1041
|
+
* How much room an edge's text takes across the corridor — its depth in a
|
|
1042
|
+
* horizontal channel, its width in a vertical one. Zero for an edge with no text,
|
|
1018
1043
|
* which needs no more than the arrow spacing.
|
|
1019
1044
|
*
|
|
1020
|
-
* `
|
|
1045
|
+
* `textExtent` measures the knockout along whichever axis it is handed, and the
|
|
1021
1046
|
* axis wanted here is the one the channel is measured on rather than the one the
|
|
1022
|
-
*
|
|
1023
|
-
* horizontally, and what has to fit between two lanes of it is a
|
|
1047
|
+
* edge runs along — a channel measured vertically carries edges running
|
|
1048
|
+
* horizontally, and what has to fit between two lanes of it is a text's depth.
|
|
1024
1049
|
*/
|
|
1025
|
-
function laneExtent(
|
|
1026
|
-
if (
|
|
1050
|
+
function laneExtent(edge, axis, measurer, fontSize) {
|
|
1051
|
+
if (edge.lines === undefined)
|
|
1027
1052
|
return 0;
|
|
1028
|
-
return
|
|
1053
|
+
return textExtent(edge.lines, edge.textAttrs, axis, measurer, fontSize, edge.line);
|
|
1029
1054
|
}
|
|
1030
1055
|
function corridorPoint(plan, at) {
|
|
1031
1056
|
return plan.axis === 'y' ? { x: at, y: plan.lane } : { x: plan.lane, y: at };
|
|
1032
1057
|
}
|
|
1033
1058
|
/**
|
|
1034
|
-
* The path a corridor
|
|
1059
|
+
* The path a corridor edge takes: a curve out of its start into the gap, the
|
|
1035
1060
|
* straight run along the gap, and a curve out of the gap to its end. It is
|
|
1036
1061
|
* three pieces rather than one cubic because a single curve has no way to stay
|
|
1037
1062
|
* inside an interval over part of its length — which is the whole claim the
|
|
@@ -1070,12 +1095,12 @@ function corridorReach(from, to, axis) {
|
|
|
1070
1095
|
const distance = Math.hypot(to.x - from.x, to.y - from.y);
|
|
1071
1096
|
return Math.min(140, Math.max(8, Math.min(distance * 0.4, run / 2)));
|
|
1072
1097
|
}
|
|
1073
|
-
function sideAttr(
|
|
1074
|
-
const value =
|
|
1098
|
+
function sideAttr(edge, key) {
|
|
1099
|
+
const value = edge.attrs[key];
|
|
1075
1100
|
if (value === undefined)
|
|
1076
1101
|
return undefined;
|
|
1077
|
-
if (!
|
|
1078
|
-
throw new SourceError(`"${key}: ${value}" is not a side — use ${
|
|
1102
|
+
if (!ATTACH_SIDES.includes(value)) {
|
|
1103
|
+
throw new SourceError(`"${key}: ${value}" is not a side — use ${ATTACH_SIDES.join(', ')}`, edge.line);
|
|
1079
1104
|
}
|
|
1080
1105
|
return value;
|
|
1081
1106
|
}
|
|
@@ -1115,21 +1140,78 @@ function sized(block, size, fontSize) {
|
|
|
1115
1140
|
return block;
|
|
1116
1141
|
return ` <g font-size="${size}px">\n${block}\n </g>`;
|
|
1117
1142
|
}
|
|
1118
|
-
|
|
1119
|
-
|
|
1143
|
+
/**
|
|
1144
|
+
* Draw a block of text into the room it was given.
|
|
1145
|
+
*
|
|
1146
|
+
* Two independent questions, which is why there are two words for them. `side`
|
|
1147
|
+
* is where the block sits across that room, from the horizontal half of the
|
|
1148
|
+
* text's `at`. `align` is how the block's own lines range against each other,
|
|
1149
|
+
* which matters whenever they are of unequal length and is a different thing
|
|
1150
|
+
* from where the block is.
|
|
1151
|
+
*
|
|
1152
|
+
* Where the block is as wide as the room — which is every text whose box is
|
|
1153
|
+
* sized from it, so nearly all of them — the two coincide and `side` changes
|
|
1154
|
+
* nothing.
|
|
1155
|
+
*/
|
|
1156
|
+
function textBlock(lines, x, y, lineHeight, fontSize, box, style) {
|
|
1157
|
+
// `box` is the ink the text occupies, worked out by the resolver — the one
|
|
1158
|
+
// place that decides where a text sits, because `hub text` is a placement
|
|
1159
|
+
// target and the answer has to be a number before anything is solved.
|
|
1160
|
+
const blockLeft = x + box.x;
|
|
1161
|
+
const top = y + box.y;
|
|
1162
|
+
const anchorX = style.align === 'middle'
|
|
1163
|
+
? blockLeft + box.width / 2
|
|
1164
|
+
: style.align === 'end'
|
|
1165
|
+
? blockLeft + box.width
|
|
1166
|
+
: blockLeft;
|
|
1120
1167
|
return lines
|
|
1121
1168
|
.map((line, index) => {
|
|
1122
|
-
if (line.length === 0)
|
|
1169
|
+
if (plain(line).length === 0)
|
|
1123
1170
|
return '';
|
|
1124
1171
|
const baseline = top + index * lineHeight + lineHeight / 2 + fontSize * 0.35;
|
|
1125
|
-
//
|
|
1126
|
-
//
|
|
1127
|
-
|
|
1128
|
-
|
|
1172
|
+
// One `<text>` per line, with a `<tspan>` per run inside it, so the runs
|
|
1173
|
+
// flow from the line's own anchor and a mark never moves a character.
|
|
1174
|
+
// A line drawn in one color says so on the `<text>` and emits no spans at
|
|
1175
|
+
// all, which is what keeps a whole quiet line identical to what the
|
|
1176
|
+
// `subtext:` it replaced produced.
|
|
1177
|
+
const colors = line.map((run) => style.ink(run, style.color));
|
|
1178
|
+
const uniform = colors.every((color) => color === colors[0]);
|
|
1179
|
+
const body = uniform
|
|
1180
|
+
? escapeXml(plain(line))
|
|
1181
|
+
: line
|
|
1182
|
+
.map((run, run_index) => colors[run_index] === style.color
|
|
1183
|
+
? escapeXml(run.text)
|
|
1184
|
+
: `<tspan fill="${colors[run_index]}">${escapeXml(run.text)}</tspan>`)
|
|
1185
|
+
.join('');
|
|
1186
|
+
return ` <text x="${round(anchorX)}" y="${round(baseline)}" fill="${uniform ? colors[0] ?? style.color : style.color}" text-anchor="${style.align}">${body}</text>`;
|
|
1129
1187
|
})
|
|
1130
1188
|
.filter((element) => element.length > 0)
|
|
1131
1189
|
.join('\n');
|
|
1132
1190
|
}
|
|
1191
|
+
/**
|
|
1192
|
+
* The color a marked-up run is drawn in. The mark names a *style*, never a
|
|
1193
|
+
* color, so the word borrows a meaning the file already has rather than
|
|
1194
|
+
* restating a value that goes stale the day the thing it means is recolored.
|
|
1195
|
+
* The resolver has already refused a mark naming a style that does not exist or
|
|
1196
|
+
* that says nothing about text.
|
|
1197
|
+
*/
|
|
1198
|
+
function runInk(run, own, markup, theme) {
|
|
1199
|
+
if (run.style === undefined)
|
|
1200
|
+
return own;
|
|
1201
|
+
return namedColor(markup[run.style], theme);
|
|
1202
|
+
}
|
|
1203
|
+
/**
|
|
1204
|
+
* A text's own color. `muted` is the one reserved word: it defers to the theme,
|
|
1205
|
+
* so a quiet line stays readable when the theme changes. Anything else is a
|
|
1206
|
+
* color, the same as `fill:` and `border:` take.
|
|
1207
|
+
*/
|
|
1208
|
+
function textColorOf(textAttrs, theme, fallback) {
|
|
1209
|
+
const value = textAttrs['color'];
|
|
1210
|
+
return value === undefined ? fallback : namedColor(value, theme);
|
|
1211
|
+
}
|
|
1212
|
+
function namedColor(value, theme) {
|
|
1213
|
+
return value === 'muted' ? theme.mutedText : value;
|
|
1214
|
+
}
|
|
1133
1215
|
/**
|
|
1134
1216
|
* A color is written as the viewer will receive it — `#14532d`, or any CSS
|
|
1135
1217
|
* color. The renderer keeps no list of color words of its own, so a diagram
|
|
@@ -1145,23 +1227,6 @@ function borderOf(appearance, fallback) {
|
|
|
1145
1227
|
function lineOf(appearance, fallback) {
|
|
1146
1228
|
return appearance['line'] ?? fallback;
|
|
1147
1229
|
}
|
|
1148
|
-
function textOf(appearance, fallback) {
|
|
1149
|
-
return appearance['text'] ?? fallback;
|
|
1150
|
-
}
|
|
1151
|
-
/**
|
|
1152
|
-
* The color for every label line after the first, or undefined when the box
|
|
1153
|
-
* said nothing and all its lines should read alike. `muted` is the one reserved
|
|
1154
|
-
* word: it defers to the theme, so a label's qualifier stays readable when the
|
|
1155
|
-
* theme changes. Anything else is a color, same as `text` and `fill` take.
|
|
1156
|
-
*/
|
|
1157
|
-
function subtextOf(appearance, theme) {
|
|
1158
|
-
const named = appearance['subtext'];
|
|
1159
|
-
if (named === undefined)
|
|
1160
|
-
return undefined;
|
|
1161
|
-
if (named === 'muted')
|
|
1162
|
-
return theme.mutedText;
|
|
1163
|
-
return named;
|
|
1164
|
-
}
|
|
1165
1230
|
function fillOf(appearance, fallback) {
|
|
1166
1231
|
return appearance['fill'] ?? fallback;
|
|
1167
1232
|
}
|