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