reladraw 0.1.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 +45 -35
- package/SYNTAX.md +498 -193
- package/dist/ast.d.ts +257 -38
- package/dist/ast.js +250 -18
- package/dist/constants.d.ts +60 -42
- package/dist/constants.js +78 -63
- package/dist/grammar.d.ts +24 -11
- package/dist/grammar.js +61 -15
- package/dist/icons.d.ts +55 -37
- package/dist/icons.js +83 -45
- package/dist/lexer.d.ts +1 -1
- package/dist/lexer.js +7 -6
- package/dist/measure.d.ts +3 -3
- package/dist/measure.js +3 -3
- package/dist/model.d.ts +86 -16
- package/dist/parser.js +529 -179
- package/dist/render.d.ts +1 -1
- package/dist/render.js +401 -320
- package/dist/resolve.js +1287 -249
- 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,9 +18,9 @@ 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
|
-
// gives each icon its own hue — the drive is
|
|
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
|
|
24
25
|
// system. One pair for the whole set is the deliberate difference: an icon
|
|
25
26
|
// should read as part of the diagram's palette, not as clip art dropped in.
|
|
@@ -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,11 +65,11 @@ 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
|
|
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>',
|
|
71
|
-
...[...
|
|
72
|
+
...[...arrowColors].map((color) => arrowMarker(color)),
|
|
72
73
|
' </defs>',
|
|
73
74
|
` <rect x="${canvas.x}" y="${canvas.y}" width="${canvas.width}" height="${canvas.height}" fill="${theme.background}"/>`,
|
|
74
75
|
...body,
|
|
@@ -77,109 +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
|
-
|
|
111
|
-
|
|
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;
|
|
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
|
|
172
|
+
// a word of its own — `border:` cannot stand in for it.
|
|
173
|
+
const text = textColorOf(node.textAttrs, theme, theme.text);
|
|
114
174
|
// Deck copies sit behind the front face, furthest back drawn first.
|
|
115
|
-
for (let depth = node.
|
|
175
|
+
for (let depth = node.deckTexts.length; depth >= 1; depth -= 1) {
|
|
116
176
|
const x = face.x - depth * DECK_STEP;
|
|
117
177
|
const y = face.y - depth * DECK_STEP;
|
|
118
|
-
parts.push(` <path d="${outlinePath(
|
|
119
|
-
const
|
|
120
|
-
if (
|
|
121
|
-
parts.push(sized(textBlock([
|
|
122
|
-
colour: theme.text,
|
|
123
|
-
align: 'start',
|
|
124
|
-
}), 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));
|
|
125
182
|
}
|
|
126
183
|
}
|
|
127
|
-
parts.push(` <path d="${outlinePath(
|
|
128
|
-
for (const extra of outlineDetail(
|
|
129
|
-
parts.push(` <path d="${extra}" fill="none" stroke="${
|
|
130
|
-
}
|
|
131
|
-
// The icon takes a column on the right and the label lays out in what is
|
|
132
|
-
// left, which is the room the resolver already reserved for exactly this.
|
|
133
|
-
const icon = iconFor(node.appearance, node.line);
|
|
134
|
-
const iconSide = icon === undefined ? 0 : glyphSide;
|
|
135
|
-
const hasLabel = node.lines.some((line) => line.length > 0);
|
|
136
|
-
const iconRoom = icon === undefined ? 0 : iconSide + (hasLabel ? ICON_GAP : 0);
|
|
137
|
-
if (!container) {
|
|
138
|
-
// A leaf centres its label in the box, both ways — in the room beside the
|
|
139
|
-
// icon rather than the whole box, so the two sit side by side.
|
|
140
|
-
const top = face.y + (face.height - node.lines.length * textHeight) / 2;
|
|
141
|
-
parts.push(sized(textBlock(node.lines, face.x, top, face.width - iconRoom, textHeight, size, {
|
|
142
|
-
colour: theme.text,
|
|
143
|
-
subColour,
|
|
144
|
-
align: 'middle',
|
|
145
|
-
}), size, fontSize));
|
|
146
|
-
}
|
|
147
|
-
else {
|
|
148
|
-
// The label and the icon share a band at one end of the box, and the
|
|
149
|
-
// resolver has already given the contents the other end. A heading is
|
|
150
|
-
// ranged left at the top; a caption is centred at the bottom.
|
|
151
|
-
const band = Math.max(node.lines.length * textHeight, iconSide);
|
|
152
|
-
const bandTop = labelStyle.at === 'top' ? face.y + PAD : face.y + face.height - PAD - band;
|
|
153
|
-
parts.push(sized(textBlock(node.lines, face.x + PAD, bandTop, face.width - PAD * 2 - iconRoom, textHeight, size, {
|
|
154
|
-
colour: theme.text,
|
|
155
|
-
subColour,
|
|
156
|
-
align: labelStyle.align,
|
|
157
|
-
}), size, fontSize));
|
|
158
|
-
for (const child of node.children) {
|
|
159
|
-
parts.push(drawNode(child, theme, measurer, fontSize));
|
|
160
|
-
}
|
|
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)) {
|
|
186
|
+
parts.push(` <path d="${extra}" fill="none" stroke="${border}"/>`);
|
|
161
187
|
}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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));
|
|
174
200
|
}
|
|
175
|
-
return parts
|
|
201
|
+
return { own: parts, kids };
|
|
176
202
|
}
|
|
177
203
|
/**
|
|
178
204
|
* How far the dog-ear cuts into the top-right corner of a `document`.
|
|
179
205
|
*
|
|
180
206
|
* Twice the corner radius, so it is the same size on every box however wide.
|
|
181
207
|
* The reference sizes its fold as a fraction of the box, which is why the fold
|
|
182
|
-
* on those two wide
|
|
208
|
+
* on those two wide dump boxes almost disappears — the idea was right and
|
|
183
209
|
* only the scaling was wrong.
|
|
184
210
|
*/
|
|
185
211
|
const FOLD = CORNER * 2;
|
|
@@ -226,12 +252,12 @@ function outlineDetail(shape, x, y, w, h) {
|
|
|
226
252
|
/** One icon, scaled from its own grid onto a square of `side` at `x, y`. */
|
|
227
253
|
function drawIcon(icon, x, y, side, theme) {
|
|
228
254
|
const scale = side / icon.grid;
|
|
229
|
-
const
|
|
255
|
+
const color = (tone) => tone === 'ink' ? theme.iconInk : tone === 'shade' ? theme.iconShade : theme.background;
|
|
230
256
|
const paths = icon.paths.map((path) => {
|
|
231
|
-
const fill = path.fill === undefined ? 'none' :
|
|
257
|
+
const fill = path.fill === undefined ? 'none' : color(path.fill);
|
|
232
258
|
const stroke = path.stroke === undefined
|
|
233
259
|
? ''
|
|
234
|
-
: ` stroke="${
|
|
260
|
+
: ` stroke="${color(path.stroke)}" stroke-width="${ICON_STROKE}" stroke-linejoin="round"`;
|
|
235
261
|
return ` <path d="${path.d}" fill="${fill}"${stroke}/>`;
|
|
236
262
|
});
|
|
237
263
|
return [
|
|
@@ -240,15 +266,15 @@ function drawIcon(icon, x, y, side, theme) {
|
|
|
240
266
|
' </g>',
|
|
241
267
|
].join('\n');
|
|
242
268
|
}
|
|
243
|
-
// ---
|
|
244
|
-
function
|
|
269
|
+
// --- edges -------------------------------------------------------------------
|
|
270
|
+
function drawEdge(edge, ends, corridor, theme, measurer, fontSize, markup) {
|
|
245
271
|
const { start, end } = ends;
|
|
246
|
-
const
|
|
247
|
-
const markerEnd = ` marker-end="url(#${markerId(
|
|
248
|
-
const markerStart =
|
|
272
|
+
const color = lineOf(edge.appearance, theme.edge);
|
|
273
|
+
const markerEnd = ` marker-end="url(#${markerId(color)})"`;
|
|
274
|
+
const markerStart = edge.both ? ` marker-start="url(#${markerId(color)}-back)"` : '';
|
|
249
275
|
// A named side is a statement about how the line should leave or arrive, so
|
|
250
276
|
// it is drawn as a curve that actually does leave and arrive that way. With
|
|
251
|
-
// neither side named there is nothing to
|
|
277
|
+
// neither side named there is nothing to honor and the line stays straight.
|
|
252
278
|
const curved = start.side !== undefined || end.side !== undefined;
|
|
253
279
|
const parts = [];
|
|
254
280
|
// What the line actually covers, so the canvas can be sized to hold it. A
|
|
@@ -260,8 +286,8 @@ function drawLink(link, ends, corridor, theme, measurer, fontSize) {
|
|
|
260
286
|
if (corridor) {
|
|
261
287
|
const path = corridorPath(start, end, corridor);
|
|
262
288
|
ink = union(ink, path.ink);
|
|
263
|
-
parts.push(` <path d="${path.d}" fill="none" stroke="${
|
|
264
|
-
// The
|
|
289
|
+
parts.push(` <path d="${path.d}" fill="none" stroke="${color}" stroke-width="${LINE_WIDTH}"${markerEnd}${markerStart}/>`);
|
|
290
|
+
// The text goes on the straight run rather than at the midpoint of the
|
|
265
291
|
// whole path, so it sits in the gap the author asked the line to travel.
|
|
266
292
|
midX = path.mid.x;
|
|
267
293
|
midY = path.mid.y;
|
|
@@ -277,9 +303,9 @@ function drawLink(link, ends, corridor, theme, measurer, fontSize) {
|
|
|
277
303
|
const by = (ends.bow?.y ?? 0) * lift;
|
|
278
304
|
const c1 = { x: start.x + start.tx * reach + bx, y: start.y + start.ty * reach + by };
|
|
279
305
|
const c2 = { x: end.x + end.tx * reach + bx, y: end.y + end.ty * reach + by };
|
|
280
|
-
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="${
|
|
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}/>`);
|
|
281
307
|
ink = union(ink, cubicExtent(start, c1, c2, end));
|
|
282
|
-
// The point halfway along a cubic, which is where the
|
|
308
|
+
// The point halfway along a cubic, which is where the text belongs.
|
|
283
309
|
midX = (start.x + 3 * c1.x + 3 * c2.x + end.x) / 8;
|
|
284
310
|
midY = (start.y + 3 * c1.y + 3 * c2.y + end.y) / 8;
|
|
285
311
|
}
|
|
@@ -298,28 +324,29 @@ function drawLink(link, ends, corridor, theme, measurer, fontSize) {
|
|
|
298
324
|
x: end.x - run.x + ends.bow.x * lift,
|
|
299
325
|
y: end.y - run.y + ends.bow.y * lift,
|
|
300
326
|
};
|
|
301
|
-
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="${
|
|
327
|
+
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}/>`);
|
|
302
328
|
ink = union(ink, cubicExtent(start, c1, c2, end));
|
|
303
329
|
midX = (start.x + 3 * c1.x + 3 * c2.x + end.x) / 8;
|
|
304
330
|
midY = (start.y + 3 * c1.y + 3 * c2.y + end.y) / 8;
|
|
305
331
|
}
|
|
306
332
|
else {
|
|
307
|
-
parts.push(` <line x1="${round(start.x)}" y1="${round(start.y)}" x2="${round(end.x)}" y2="${round(end.y)}" stroke="${
|
|
333
|
+
parts.push(` <line x1="${round(start.x)}" y1="${round(start.y)}" x2="${round(end.x)}" y2="${round(end.y)}" stroke="${color}" stroke-width="${LINE_WIDTH}"${markerEnd}${markerStart}/>`);
|
|
308
334
|
midX = (start.x + end.x) / 2;
|
|
309
335
|
midY = (start.y + end.y) / 2;
|
|
310
336
|
}
|
|
311
|
-
if (
|
|
312
|
-
//
|
|
313
|
-
// caption on an arrow needs no vocabulary of its own. The block is
|
|
314
|
-
// on the midpoint, which keeps a one-line
|
|
315
|
-
const size = fontSizeFor('
|
|
337
|
+
if (edge.text !== undefined) {
|
|
338
|
+
// An edge text breaks on ` / ` exactly as a box text does, so a two-line
|
|
339
|
+
// caption on an arrow needs no vocabulary of its own. The block is centered
|
|
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);
|
|
316
342
|
const textHeight = measurer.lineHeight(size);
|
|
317
|
-
const
|
|
343
|
+
const lines = edge.lines;
|
|
344
|
+
const width = widestLine(lines, measurer, size);
|
|
318
345
|
const height = lines.length * textHeight;
|
|
319
346
|
const top = midY - height / 2;
|
|
320
|
-
// The
|
|
347
|
+
// The text knocks a hole in whatever it lands on rather than sitting in a
|
|
321
348
|
// chip of its own: an outlined box reads as a node, which is the one thing
|
|
322
|
-
// a
|
|
349
|
+
// a text on a line is not.
|
|
323
350
|
parts.push(` <rect x="${round(midX - width / 2 - 5)}" y="${round(top)}" width="${round(width + 10)}" height="${round(height)}" fill="${theme.background}"/>`);
|
|
324
351
|
ink = union(ink, {
|
|
325
352
|
minX: midX - width / 2 - 5,
|
|
@@ -327,15 +354,16 @@ function drawLink(link, ends, corridor, theme, measurer, fontSize) {
|
|
|
327
354
|
maxX: midX + width / 2 + 5,
|
|
328
355
|
maxY: top + height,
|
|
329
356
|
});
|
|
330
|
-
parts.push(sized(textBlock(lines, midX - width / 2, top,
|
|
331
|
-
// A
|
|
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
|
|
332
359
|
// one leaves the words to read as ordinary text.
|
|
333
|
-
|
|
360
|
+
color: textColorOf(edge.textAttrs, theme, lineOf(edge.appearance, theme.text)),
|
|
334
361
|
align: 'middle',
|
|
362
|
+
ink: (run, own) => runInk(run, own, markup, theme),
|
|
335
363
|
}), size, fontSize));
|
|
336
364
|
}
|
|
337
365
|
// The stroke straddles the path, so half of it lies outside the geometry.
|
|
338
|
-
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) };
|
|
339
367
|
}
|
|
340
368
|
function union(a, b) {
|
|
341
369
|
return {
|
|
@@ -365,7 +393,7 @@ function extentOfPoints(points) {
|
|
|
365
393
|
* What a cubic actually covers, which is not what its control points cover. A
|
|
366
394
|
* handle reaching 140 pixels up carries the curve only about three quarters of
|
|
367
395
|
* that, and sizing the page off the handles would leave a visible band of empty
|
|
368
|
-
* canvas above every curved
|
|
396
|
+
* canvas above every curved edge. Solved rather than sampled: the extremes are
|
|
369
397
|
* the ends plus wherever the derivative — a quadratic — crosses zero.
|
|
370
398
|
*/
|
|
371
399
|
function cubicExtent(p0, c1, c2, p3) {
|
|
@@ -399,60 +427,63 @@ function cubicExtent(p0, c1, c2, p3) {
|
|
|
399
427
|
const [minY, maxY] = span(p0.y, c1.y, c2.y, p3.y);
|
|
400
428
|
return { minX, minY, maxX, maxY };
|
|
401
429
|
}
|
|
402
|
-
/** Walk out from the
|
|
403
|
-
function
|
|
404
|
-
const
|
|
405
|
-
const dx = toward.x -
|
|
406
|
-
const dy = toward.y -
|
|
430
|
+
/** Walk out from the center of a box toward a point, stopping at the border. */
|
|
431
|
+
function sidePoint(box, toward) {
|
|
432
|
+
const center = centerOf(box);
|
|
433
|
+
const dx = toward.x - center.x;
|
|
434
|
+
const dy = toward.y - center.y;
|
|
407
435
|
if (dx === 0 && dy === 0)
|
|
408
|
-
return
|
|
436
|
+
return center;
|
|
409
437
|
const scaleX = dx === 0 ? Infinity : box.width / 2 / Math.abs(dx);
|
|
410
438
|
const scaleY = dy === 0 ? Infinity : box.height / 2 / Math.abs(dy);
|
|
411
439
|
const scale = Math.min(scaleX, scaleY);
|
|
412
|
-
return { x:
|
|
440
|
+
return { x: center.x + dx * scale, y: center.y + dy * scale };
|
|
413
441
|
}
|
|
414
|
-
// --- where
|
|
415
|
-
|
|
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'];
|
|
416
447
|
/**
|
|
417
|
-
* Work out where every
|
|
448
|
+
* Work out where every edge meets every box.
|
|
418
449
|
*
|
|
419
450
|
* An author names a *side* — `to: top` — and never a point on it. Alone on a
|
|
420
|
-
* side
|
|
451
|
+
* side an edge lands at its center; sharing the side with others, the points
|
|
421
452
|
* spread so they do not sit on top of each other. Which one goes where is
|
|
422
|
-
* 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
|
|
423
454
|
* arriving at one top edge, the one coming from further left arrives further
|
|
424
455
|
* left. That is the same rule as box non-overlap — the tool separates things by
|
|
425
456
|
* default, and reads the direction off the solved layout rather than asking.
|
|
426
457
|
*
|
|
427
|
-
* Where several
|
|
458
|
+
* Where several edges run between the *same* pair of sides that rule has
|
|
428
459
|
* nothing to read, and a `Bundle` supplies the order instead — see there.
|
|
429
460
|
*/
|
|
430
|
-
function planEndpoints(
|
|
461
|
+
function planEndpoints(edges, measurer, fontSize) {
|
|
431
462
|
const claims = new Map();
|
|
432
463
|
const achieved = new Map();
|
|
433
464
|
const named = new Map();
|
|
434
|
-
const bundles = planBundles(
|
|
435
|
-
const spreads = planSpreads(
|
|
436
|
-
for (const
|
|
437
|
-
named.set(
|
|
438
|
-
const fromSide = sideAttr(
|
|
439
|
-
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');
|
|
440
471
|
if (fromSide) {
|
|
441
|
-
claim(claims,
|
|
442
|
-
|
|
472
|
+
claim(claims, edge.from, fromSide, {
|
|
473
|
+
edge,
|
|
443
474
|
which: 'start',
|
|
444
475
|
side: fromSide,
|
|
445
|
-
toward:
|
|
446
|
-
rank: rankIn(bundles.get(
|
|
476
|
+
toward: centerOf(faceOf(edge.to)),
|
|
477
|
+
rank: rankIn(bundles.get(edge), edge, edge.from, fromSide),
|
|
447
478
|
});
|
|
448
479
|
}
|
|
449
480
|
if (toSide) {
|
|
450
|
-
claim(claims,
|
|
451
|
-
|
|
481
|
+
claim(claims, edge.to, toSide, {
|
|
482
|
+
edge,
|
|
452
483
|
which: 'end',
|
|
453
484
|
side: toSide,
|
|
454
|
-
toward:
|
|
455
|
-
rank: rankIn(bundles.get(
|
|
485
|
+
toward: centerOf(faceOf(edge.from)),
|
|
486
|
+
rank: rankIn(bundles.get(edge), edge, edge.to, toSide),
|
|
456
487
|
});
|
|
457
488
|
}
|
|
458
489
|
}
|
|
@@ -463,18 +494,18 @@ function planEndpoints(links, measurer, fontSize) {
|
|
|
463
494
|
const along = side === 'top' || side === 'bottom' ? 'x' : 'y';
|
|
464
495
|
const span = along === 'x' ? face.width : face.height;
|
|
465
496
|
const origin = along === 'x' ? face.x : face.y;
|
|
466
|
-
// 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
|
|
467
498
|
// that share one, which are precisely the ones the first key cannot.
|
|
468
499
|
const ordered = [...group].sort((a, b) => a.toward[along] - b.toward[along] || (a.rank ?? 0) - (b.rank ?? 0));
|
|
469
|
-
// A bundle's lanes have to hold whole
|
|
500
|
+
// A bundle's lanes have to hold whole texts apart rather than the points
|
|
470
501
|
// of two arrows, so its step is the one that governs the side it lands on.
|
|
471
|
-
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));
|
|
472
503
|
const usable = Math.max(0, span - ATTACH_MARGIN * 2);
|
|
473
504
|
const step = ordered.length > 1 ? Math.min(wanted, usable / (ordered.length - 1)) : 0;
|
|
474
505
|
const first = origin + span / 2 - (step * (ordered.length - 1)) / 2;
|
|
475
506
|
ordered.forEach((entry, index) => {
|
|
476
507
|
const at = first + index * step;
|
|
477
|
-
named.get(entry.
|
|
508
|
+
named.get(entry.edge)[entry.which] = anchorOn(face, side, at);
|
|
478
509
|
});
|
|
479
510
|
// What the side could actually give, which is less than `wanted` when it
|
|
480
511
|
// is too short for the group. `bowBundles` makes up the difference.
|
|
@@ -488,34 +519,34 @@ function planEndpoints(links, measurer, fontSize) {
|
|
|
488
519
|
// Fill in the ends the author said nothing about, now that the named ones
|
|
489
520
|
// are known: an unnamed end aims at wherever its partner ended up.
|
|
490
521
|
const ends = new Map();
|
|
491
|
-
for (const
|
|
492
|
-
const partial = named.get(
|
|
493
|
-
const fromFace = faceOf(
|
|
494
|
-
const toFace = faceOf(
|
|
495
|
-
// 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
|
|
496
527
|
// line each would have drawn alone, moved aside so they do not coincide.
|
|
497
|
-
const spread = spreads.get(
|
|
528
|
+
const spread = spreads.get(edge);
|
|
498
529
|
if (spread) {
|
|
499
|
-
ends.set(
|
|
530
|
+
ends.set(edge, { ...parallelEnds(fromFace, toFace, spread.offset), bow: spread.bow });
|
|
500
531
|
continue;
|
|
501
532
|
}
|
|
502
533
|
// With neither end named this is the straight line it always was, each end
|
|
503
|
-
// aiming at the other box's
|
|
504
|
-
const start = partial.start ?? free(fromFace, partial.end ??
|
|
505
|
-
const end = partial.end ?? free(toFace, partial.start ??
|
|
506
|
-
ends.set(
|
|
534
|
+
// aiming at the other box's center.
|
|
535
|
+
const start = partial.start ?? free(fromFace, partial.end ?? centerOf(toFace));
|
|
536
|
+
const end = partial.end ?? free(toFace, partial.start ?? centerOf(fromFace));
|
|
537
|
+
ends.set(edge, { start, end, bow: bows.get(edge) });
|
|
507
538
|
}
|
|
508
539
|
return ends;
|
|
509
540
|
}
|
|
510
541
|
/**
|
|
511
|
-
* Group the
|
|
542
|
+
* Group the edges that run between the same pair of sides, and work out the
|
|
512
543
|
* lane order and lane width each group needs.
|
|
513
544
|
*
|
|
514
|
-
* Only
|
|
545
|
+
* Only an edge whose author named *both* sides can be in a bundle: a bundle is a
|
|
515
546
|
* statement about two specific edges, and an end with no side named has not
|
|
516
547
|
* picked one yet.
|
|
517
548
|
*/
|
|
518
|
-
function planBundles(
|
|
549
|
+
function planBundles(edges, measurer, fontSize) {
|
|
519
550
|
const ids = new Map();
|
|
520
551
|
const idOf = (node) => {
|
|
521
552
|
let id = ids.get(node);
|
|
@@ -526,13 +557,13 @@ function planBundles(links, measurer, fontSize) {
|
|
|
526
557
|
return id;
|
|
527
558
|
};
|
|
528
559
|
const groups = new Map();
|
|
529
|
-
for (const
|
|
530
|
-
const fromSide = sideAttr(
|
|
531
|
-
const toSide = sideAttr(
|
|
532
|
-
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)
|
|
533
564
|
continue;
|
|
534
|
-
const a = { node:
|
|
535
|
-
const b = { node:
|
|
565
|
+
const a = { node: edge.from, side: fromSide };
|
|
566
|
+
const b = { node: edge.to, side: toSide };
|
|
536
567
|
const keyA = `${idOf(a.node)}:${a.side}`;
|
|
537
568
|
const keyB = `${idOf(b.node)}:${b.side}`;
|
|
538
569
|
// The pair is unordered — `a -> b` and `b -> a` join the same two edges —
|
|
@@ -542,35 +573,35 @@ function planBundles(links, measurer, fontSize) {
|
|
|
542
573
|
const ends = swap ? [b, a] : [a, b];
|
|
543
574
|
const group = groups.get(key);
|
|
544
575
|
if (group)
|
|
545
|
-
group.
|
|
576
|
+
group.edges.push(edge);
|
|
546
577
|
else
|
|
547
|
-
groups.set(key, { ends,
|
|
578
|
+
groups.set(key, { ends, edges: [edge] });
|
|
548
579
|
}
|
|
549
580
|
const bundles = new Map();
|
|
550
581
|
for (const group of groups.values()) {
|
|
551
|
-
if (group.
|
|
582
|
+
if (group.edges.length < 2)
|
|
552
583
|
continue;
|
|
553
584
|
const [first, second] = group.ends;
|
|
554
585
|
const t0 = tangentOf(first.side);
|
|
555
586
|
const t1 = tangentOf(second.side);
|
|
556
|
-
const from =
|
|
557
|
-
const to =
|
|
587
|
+
const from = sideCenter(first);
|
|
588
|
+
const to = sideCenter(second);
|
|
558
589
|
const run = { x: to.x - from.x, y: to.y - from.y };
|
|
559
590
|
// Nesting is a matter of which side of the line each end steps toward. Step
|
|
560
591
|
// both ends to the same side of the run and the whole line translates;
|
|
561
592
|
// step them to opposite sides and it pivots, which is a crossing.
|
|
562
593
|
const aligned = cross(run, t0) * cross(run, t1) >= 0;
|
|
563
594
|
const sense = aligned ? 1 : -1;
|
|
564
|
-
// Two
|
|
595
|
+
// Two edges leaving in opposite directions are the ordinary case, and which
|
|
565
596
|
// lane each takes is then read off the diagram rather than off the order the
|
|
566
597
|
// author happened to type them in: a line keeps to one side of its own run.
|
|
567
|
-
//
|
|
568
|
-
const order = new Map(group.
|
|
569
|
-
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) ||
|
|
570
601
|
order.get(a) - order.get(b));
|
|
571
|
-
// One lane apart moves
|
|
602
|
+
// One lane apart moves an edge's start by `step` along one side and its end
|
|
572
603
|
// by `step` along the other, so the midpoint of the line — which is where
|
|
573
|
-
// its
|
|
604
|
+
// its text goes — moves by the average of the two.
|
|
574
605
|
const drift = { x: (t0.x + sense * t1.x) / 2, y: (t0.y + sense * t1.y) / 2 };
|
|
575
606
|
const bundle = {
|
|
576
607
|
ends: group.ends,
|
|
@@ -578,41 +609,41 @@ function planBundles(links, measurer, fontSize) {
|
|
|
578
609
|
aligned,
|
|
579
610
|
step: Math.max(ATTACH_STEP, laneStep(lanes, drift, measurer, fontSize)),
|
|
580
611
|
};
|
|
581
|
-
for (const
|
|
582
|
-
bundles.set(
|
|
612
|
+
for (const edge of lanes)
|
|
613
|
+
bundles.set(edge, bundle);
|
|
583
614
|
}
|
|
584
615
|
return bundles;
|
|
585
616
|
}
|
|
586
617
|
/**
|
|
587
|
-
* The sideways offset each
|
|
618
|
+
* The sideways offset each edge takes when several run between the same two
|
|
588
619
|
* boxes and none of them names a side.
|
|
589
620
|
*
|
|
590
|
-
* An unnamed end has no side to spread along: it aims at the far box's
|
|
591
|
-
* and attaches wherever that ray crosses the border, so every
|
|
621
|
+
* An unnamed end has no side to spread along: it aims at the far box's center
|
|
622
|
+
* and attaches wherever that ray crosses the border, so every edge in such a
|
|
592
623
|
* group produces the *same* ray and they are drawn on top of one another —
|
|
593
|
-
* one visible line, every
|
|
624
|
+
* one visible line, every text stacked on one point. `planEndpoints` cannot
|
|
594
625
|
* see this and `planBundles` will not, since a bundle is a statement about two
|
|
595
626
|
* named edges.
|
|
596
627
|
*
|
|
597
628
|
* The repair keeps the attachment rule exactly as it is and only stops two
|
|
598
|
-
*
|
|
629
|
+
* edges using it at the same place: the line an edge would have drawn alone is
|
|
599
630
|
* translated across its own run by a lane, which is the straight-line version
|
|
600
|
-
* 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
|
|
601
632
|
* so is untouched.
|
|
602
633
|
*
|
|
603
634
|
* Where the boxes are too small to hold the group at full spacing, the ends
|
|
604
635
|
* are squeezed evenly to fit the edge — there is nowhere further to attach —
|
|
605
636
|
* and the shortfall is made up in the middle instead: each line bows across
|
|
606
|
-
* 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
|
|
607
638
|
* ride at the midpoints, come apart even though the arrows do not. The bow is
|
|
608
639
|
* therefore derived rather than styled, and it is zero whenever the edge was
|
|
609
640
|
* long enough, which is why the ordinary case is still a straight line.
|
|
610
641
|
*
|
|
611
|
-
* A `between`
|
|
642
|
+
* A `between` edge is left out. Its route is the corridor it named, its lane
|
|
612
643
|
* inside that corridor is `planCorridors`' business, and `aimFreeEnds` will
|
|
613
644
|
* re-aim these ends at the corridor afterwards regardless.
|
|
614
645
|
*/
|
|
615
|
-
function planSpreads(
|
|
646
|
+
function planSpreads(edges, measurer, fontSize) {
|
|
616
647
|
const ids = new Map();
|
|
617
648
|
const idOf = (node) => {
|
|
618
649
|
let id = ids.get(node);
|
|
@@ -623,47 +654,47 @@ function planSpreads(links, measurer, fontSize) {
|
|
|
623
654
|
return id;
|
|
624
655
|
};
|
|
625
656
|
const groups = new Map();
|
|
626
|
-
for (const
|
|
627
|
-
if (sideAttr(
|
|
657
|
+
for (const edge of edges) {
|
|
658
|
+
if (sideAttr(edge, 'from') || sideAttr(edge, 'to'))
|
|
628
659
|
continue;
|
|
629
|
-
if (
|
|
660
|
+
if (edge.from === edge.to || edge.between)
|
|
630
661
|
continue;
|
|
631
|
-
const a = idOf(
|
|
632
|
-
const b = idOf(
|
|
662
|
+
const a = idOf(edge.from);
|
|
663
|
+
const b = idOf(edge.to);
|
|
633
664
|
const swap = b < a;
|
|
634
665
|
const key = swap ? `${b}|${a}` : `${a}|${b}`;
|
|
635
|
-
const first = swap ?
|
|
666
|
+
const first = swap ? edge.to : edge.from;
|
|
636
667
|
const group = groups.get(key);
|
|
637
668
|
if (group)
|
|
638
|
-
group.
|
|
669
|
+
group.edges.push(edge);
|
|
639
670
|
else
|
|
640
|
-
groups.set(key, { first,
|
|
671
|
+
groups.set(key, { first, edges: [edge] });
|
|
641
672
|
}
|
|
642
673
|
const spreads = new Map();
|
|
643
674
|
for (const group of groups.values()) {
|
|
644
|
-
if (group.
|
|
675
|
+
if (group.edges.length < 2)
|
|
645
676
|
continue;
|
|
646
|
-
const from =
|
|
647
|
-
const sample = group.
|
|
677
|
+
const from = centerOf(faceOf(group.first));
|
|
678
|
+
const sample = group.edges[0];
|
|
648
679
|
const other = sample.from === group.first ? sample.to : sample.from;
|
|
649
|
-
const to =
|
|
680
|
+
const to = centerOf(faceOf(other));
|
|
650
681
|
const dx = to.x - from.x;
|
|
651
682
|
const dy = to.y - from.y;
|
|
652
683
|
const length = Math.hypot(dx, dy) || 1;
|
|
653
|
-
// Translating the line moves its midpoint — where the
|
|
684
|
+
// Translating the line moves its midpoint — where the text goes — by
|
|
654
685
|
// exactly this, so it is the drift `laneStep` needs.
|
|
655
686
|
const across = { x: -dy / length, y: dx / length };
|
|
656
|
-
// The same derived order a bundle uses:
|
|
687
|
+
// The same derived order a bundle uses: edges pointing opposite ways each
|
|
657
688
|
// keep to one side of their own run, so a reciprocal pair reads as a
|
|
658
|
-
// circulation, and only
|
|
659
|
-
const order = new Map(group.
|
|
660
|
-
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) ||
|
|
661
692
|
order.get(a) - order.get(b));
|
|
662
693
|
// How far a lane may be shifted before its line no longer passes through the
|
|
663
694
|
// box at all. `exitAlong` clamps beyond that, which piles the outer lanes
|
|
664
|
-
// onto a corner and puts their
|
|
695
|
+
// onto a corner and puts their texts back on top of each other — so the
|
|
665
696
|
// group is squeezed evenly instead, exactly as `planEndpoints` squeezes a
|
|
666
|
-
// side too short for the
|
|
697
|
+
// side too short for the edges arriving on it, and just as silently.
|
|
667
698
|
const reach = (node) => {
|
|
668
699
|
const face = faceOf(node);
|
|
669
700
|
const byX = across.x === 0 ? Infinity : face.width / 2 / Math.abs(across.x);
|
|
@@ -672,22 +703,22 @@ function planSpreads(links, measurer, fontSize) {
|
|
|
672
703
|
};
|
|
673
704
|
// Which way lane 0 lies is arbitrary, so fix it the way the rest of the
|
|
674
705
|
// renderer does — toward increasing x, or increasing y where the run is
|
|
675
|
-
// horizontal. Without this the first
|
|
706
|
+
// horizontal. Without this the first edge written is topmost on a rightward
|
|
676
707
|
// run and rightmost on a downward one, for no reason a reader could see.
|
|
677
708
|
const orient = across.x < 0 || (across.x === 0 && across.y < 0) ? -1 : 1;
|
|
678
709
|
const usable = 2 * Math.min(reach(group.first), reach(other));
|
|
679
710
|
const wanted = Math.max(ATTACH_STEP, laneStep(lanes, across, measurer, fontSize));
|
|
680
711
|
const step = Math.min(wanted, usable / (lanes.length - 1));
|
|
681
|
-
lanes.forEach((
|
|
712
|
+
lanes.forEach((edge, index) => {
|
|
682
713
|
const place = index - (lanes.length - 1) / 2;
|
|
683
714
|
// The lane is measured across the pair's own run, which has one direction;
|
|
684
|
-
//
|
|
715
|
+
// an edge written the other way round travels the opposite way and would
|
|
685
716
|
// otherwise take the same offset to the opposite side, putting a
|
|
686
717
|
// reciprocal pair back on one line. Negated, both keep to their own left,
|
|
687
718
|
// which is the circulation a bundle already draws.
|
|
688
|
-
const sense = (
|
|
719
|
+
const sense = (edge.from === group.first ? 1 : -1) * orient;
|
|
689
720
|
const shortfall = place * (wanted - step) * sense;
|
|
690
|
-
spreads.set(
|
|
721
|
+
spreads.set(edge, {
|
|
691
722
|
offset: place * step * sense,
|
|
692
723
|
bow: { x: across.x * shortfall, y: across.y * shortfall },
|
|
693
724
|
});
|
|
@@ -696,11 +727,11 @@ function planSpreads(links, measurer, fontSize) {
|
|
|
696
727
|
return spreads;
|
|
697
728
|
}
|
|
698
729
|
/**
|
|
699
|
-
* The bow each bundled
|
|
700
|
-
* 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.
|
|
701
732
|
*
|
|
702
733
|
* A named side is squeezed exactly as an unnamed group's edge is — the step
|
|
703
|
-
* shrinks to `usable / (n - 1)` and the
|
|
734
|
+
* shrinks to `usable / (n - 1)` and the texts ride down on top of each other —
|
|
704
735
|
* and until this existed, naming the two sides the tool would have chosen
|
|
705
736
|
* anyway made the picture strictly worse than saying nothing. That is not a
|
|
706
737
|
* line worth defending, so the same repair applies: a lane's midpoint is not
|
|
@@ -733,35 +764,35 @@ function bowBundles(bundles, achieved) {
|
|
|
733
764
|
};
|
|
734
765
|
if (short.x === 0 && short.y === 0)
|
|
735
766
|
continue;
|
|
736
|
-
bundle.lanes.forEach((
|
|
767
|
+
bundle.lanes.forEach((edge, index) => {
|
|
737
768
|
const place = index - (bundle.lanes.length - 1) / 2;
|
|
738
|
-
bows.set(
|
|
769
|
+
bows.set(edge, { x: short.x * place, y: short.y * place });
|
|
739
770
|
});
|
|
740
771
|
}
|
|
741
772
|
return bows;
|
|
742
773
|
}
|
|
743
774
|
/**
|
|
744
|
-
* How far apart adjacent lanes must sit for their
|
|
775
|
+
* How far apart adjacent lanes must sit for their texts to clear each other.
|
|
745
776
|
*
|
|
746
|
-
* The
|
|
777
|
+
* The texts are knockout rectangles, so two of them clear when they are apart
|
|
747
778
|
* on *either* axis — hence the smaller of the two answers. `drift` is how far
|
|
748
779
|
* the midpoint travels per unit of step, and it is never zero: the two ends
|
|
749
780
|
* cancel only when both sides run the same way, and two such sides are always
|
|
750
781
|
* `aligned`, which adds rather than subtracts.
|
|
751
782
|
*/
|
|
752
783
|
function laneStep(lanes, drift, measurer, fontSize) {
|
|
753
|
-
const
|
|
754
|
-
if (
|
|
784
|
+
const withText = lanes.filter((edge) => edge.text !== undefined);
|
|
785
|
+
if (withText.length < 2)
|
|
755
786
|
return 0;
|
|
756
|
-
const need = (axis) => Math.max(...
|
|
787
|
+
const need = (axis) => Math.max(...withText.map((edge) => textExtent(edge.lines, edge.textAttrs, axis, measurer, fontSize, edge.line)));
|
|
757
788
|
const along = (axis, reach) => reach === 0 ? Infinity : need(axis) / Math.abs(reach);
|
|
758
789
|
return Math.min(along('x', drift.x), along('y', drift.y));
|
|
759
790
|
}
|
|
760
|
-
/** Which lane of its bundle
|
|
761
|
-
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) {
|
|
762
793
|
if (!bundle)
|
|
763
794
|
return undefined;
|
|
764
|
-
const lane = bundle.lanes.indexOf(
|
|
795
|
+
const lane = bundle.lanes.indexOf(edge);
|
|
765
796
|
const [first, second] = bundle.ends;
|
|
766
797
|
if (node === first.node && side === first.side)
|
|
767
798
|
return lane;
|
|
@@ -774,7 +805,7 @@ function tangentOf(side) {
|
|
|
774
805
|
return side === 'top' || side === 'bottom' ? { x: 1, y: 0 } : { x: 0, y: 1 };
|
|
775
806
|
}
|
|
776
807
|
/** The midpoint of one side of a box. */
|
|
777
|
-
function
|
|
808
|
+
function sideCenter(end) {
|
|
778
809
|
const face = faceOf(end.node);
|
|
779
810
|
const along = end.side === 'top' || end.side === 'bottom' ? face.width : face.height;
|
|
780
811
|
const origin = end.side === 'top' || end.side === 'bottom' ? face.x : face.y;
|
|
@@ -810,20 +841,20 @@ function anchorOn(face, side, at) {
|
|
|
810
841
|
}
|
|
811
842
|
/** An end with no side named: leave from the border, pointing at the far end. */
|
|
812
843
|
function free(face, toward) {
|
|
813
|
-
const point =
|
|
814
|
-
const
|
|
815
|
-
const dx = point.x -
|
|
816
|
-
const dy = point.y -
|
|
844
|
+
const point = sidePoint(face, toward);
|
|
845
|
+
const center = centerOf(face);
|
|
846
|
+
const dx = point.x - center.x;
|
|
847
|
+
const dy = point.y - center.y;
|
|
817
848
|
const length = Math.hypot(dx, dy) || 1;
|
|
818
849
|
return { x: point.x, y: point.y, tx: dx / length, ty: dy / length };
|
|
819
850
|
}
|
|
820
851
|
/**
|
|
821
852
|
* Walk from a point inside a box along a direction, stopping at the border.
|
|
822
853
|
*
|
|
823
|
-
* `
|
|
854
|
+
* `sidePoint` walks from the center, which is the only place a single line
|
|
824
855
|
* passes through. A fanned-out group's lines are parallel to that one and
|
|
825
856
|
* beside it, so each needs the border crossing of its own line rather than of
|
|
826
|
-
* the
|
|
857
|
+
* the center's — which is what keeps the group parallel instead of splayed.
|
|
827
858
|
*/
|
|
828
859
|
function exitAlong(box, from, dir) {
|
|
829
860
|
// A shift wider than the box leaves the origin outside it; clamping back in
|
|
@@ -838,19 +869,19 @@ function exitAlong(box, from, dir) {
|
|
|
838
869
|
return { x: x + dir.x * Math.max(0, t), y: y + dir.y * Math.max(0, t) };
|
|
839
870
|
}
|
|
840
871
|
/**
|
|
841
|
-
* Both ends of
|
|
872
|
+
* Both ends of an edge that named no side, moved `offset` sideways across its
|
|
842
873
|
* own run.
|
|
843
874
|
*
|
|
844
875
|
* The whole line is translated rather than each end being nudged along its
|
|
845
|
-
* 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
|
|
846
877
|
* drawn alone, exactly `offset` away from it. Where each end lands then falls
|
|
847
878
|
* out of that: level boxes put both points further along the same two edges,
|
|
848
879
|
* and a diagonal pair whose line leaves through a corner puts one point on each
|
|
849
880
|
* of the two edges meeting there. Neither is a case in the code.
|
|
850
881
|
*/
|
|
851
882
|
function parallelEnds(from, to, offset) {
|
|
852
|
-
const a =
|
|
853
|
-
const b =
|
|
883
|
+
const a = centerOf(from);
|
|
884
|
+
const b = centerOf(to);
|
|
854
885
|
const dx = b.x - a.x;
|
|
855
886
|
const dy = b.y - a.y;
|
|
856
887
|
const length = Math.hypot(dx, dy) || 1;
|
|
@@ -922,61 +953,61 @@ function gapBetween(a, b, aName, bName, wanted, line) {
|
|
|
922
953
|
};
|
|
923
954
|
}
|
|
924
955
|
/**
|
|
925
|
-
* Route every
|
|
956
|
+
* Route every edge that named a gap.
|
|
926
957
|
*
|
|
927
|
-
*
|
|
958
|
+
* Edges sharing one gap share its lanes, spread like attachments on a side and
|
|
928
959
|
* ordered the same derived way — by where their ends actually sit, so the two
|
|
929
|
-
* arriving at
|
|
960
|
+
* arriving at the hub's left edge in one order run through the corridor in that
|
|
930
961
|
* same order and never cross.
|
|
931
962
|
*/
|
|
932
|
-
function planCorridors(
|
|
963
|
+
function planCorridors(edges, ends, measurer, fontSize) {
|
|
933
964
|
const plans = new Map();
|
|
934
965
|
const groups = new Map();
|
|
935
|
-
for (const
|
|
936
|
-
if (!
|
|
966
|
+
for (const edge of edges) {
|
|
967
|
+
if (!edge.between)
|
|
937
968
|
continue;
|
|
938
|
-
const [first, second] =
|
|
939
|
-
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);
|
|
940
971
|
// The pair names one gap however the author ordered them. The axis is in
|
|
941
|
-
// 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
|
|
942
973
|
// legitimately name the same pair and take different ones.
|
|
943
974
|
const key = [gap.axis, ...[first.name, second.name].sort()].join(' ');
|
|
944
975
|
const group = groups.get(key);
|
|
945
976
|
if (group)
|
|
946
|
-
group.members.push(
|
|
977
|
+
group.members.push(edge);
|
|
947
978
|
else
|
|
948
|
-
groups.set(key, { ...gap, members: [
|
|
979
|
+
groups.set(key, { ...gap, members: [edge] });
|
|
949
980
|
}
|
|
950
981
|
for (const group of groups.values()) {
|
|
951
|
-
const along = (
|
|
952
|
-
const { start, end } = ends.get(
|
|
982
|
+
const along = (edge) => {
|
|
983
|
+
const { start, end } = ends.get(edge);
|
|
953
984
|
return (start[group.axis] + end[group.axis]) / 2;
|
|
954
985
|
};
|
|
955
986
|
const ordered = [...group.members].sort((a, b) => along(a) - along(b));
|
|
956
987
|
// Lanes are spread as attachments on a side are, including the squeeze when
|
|
957
988
|
// there is not enough room — see `planEndpoints`. The step is wider here,
|
|
958
|
-
// because a lane carries a whole
|
|
959
|
-
// 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
|
|
960
991
|
// over each other. Still derived, not chosen: it is the size of what is
|
|
961
992
|
// actually running along the corridor.
|
|
962
993
|
const span = group.hi - group.lo;
|
|
963
994
|
const usable = Math.max(0, span - ATTACH_MARGIN * 2);
|
|
964
|
-
const want = Math.max(ATTACH_STEP, ...ordered.map((
|
|
995
|
+
const want = Math.max(ATTACH_STEP, ...ordered.map((edge) => laneExtent(edge, group.axis, measurer, fontSize)));
|
|
965
996
|
const step = ordered.length > 1 ? Math.min(want, usable / (ordered.length - 1)) : 0;
|
|
966
997
|
const firstLane = group.lo + span / 2 - (step * (ordered.length - 1)) / 2;
|
|
967
|
-
ordered.forEach((
|
|
968
|
-
const { start, end } = ends.get(
|
|
998
|
+
ordered.forEach((edge, index) => {
|
|
999
|
+
const { start, end } = ends.get(edge);
|
|
969
1000
|
const run = group.axis === 'y' ? 'x' : 'y';
|
|
970
|
-
// The corridor binds only where the
|
|
971
|
-
// 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.
|
|
972
1003
|
const enterAt = Math.max(group.across[0], Math.min(start[run], end[run]));
|
|
973
1004
|
const leaveAt = Math.min(group.across[1], Math.max(start[run], end[run]));
|
|
974
1005
|
if (leaveAt <= enterAt) {
|
|
975
|
-
const [a, b] =
|
|
976
|
-
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);
|
|
977
1008
|
}
|
|
978
1009
|
const forward = end[run] >= start[run];
|
|
979
|
-
plans.set(
|
|
1010
|
+
plans.set(edge, {
|
|
980
1011
|
axis: group.axis,
|
|
981
1012
|
lane: firstLane + index * step,
|
|
982
1013
|
enter: forward ? enterAt : leaveAt,
|
|
@@ -987,45 +1018,45 @@ function planCorridors(links, ends, measurer, fontSize) {
|
|
|
987
1018
|
return plans;
|
|
988
1019
|
}
|
|
989
1020
|
/**
|
|
990
|
-
* An end whose side the author did not name aims at the far box's
|
|
1021
|
+
* An end whose side the author did not name aims at the far box's center, which
|
|
991
1022
|
* is the wrong thing to aim at once the line has been told to go somewhere else
|
|
992
1023
|
* on the way. Point those ends at the corridor instead.
|
|
993
1024
|
*/
|
|
994
|
-
function aimFreeEnds(
|
|
995
|
-
for (const
|
|
996
|
-
const plan = corridors.get(
|
|
1025
|
+
function aimFreeEnds(edges, ends, corridors) {
|
|
1026
|
+
for (const edge of edges) {
|
|
1027
|
+
const plan = corridors.get(edge);
|
|
997
1028
|
if (!plan)
|
|
998
1029
|
continue;
|
|
999
|
-
const current = ends.get(
|
|
1030
|
+
const current = ends.get(edge);
|
|
1000
1031
|
const start = current.start.side === undefined
|
|
1001
|
-
? free(faceOf(
|
|
1032
|
+
? free(faceOf(edge.from), corridorPoint(plan, plan.enter))
|
|
1002
1033
|
: current.start;
|
|
1003
1034
|
const end = current.end.side === undefined
|
|
1004
|
-
? free(faceOf(
|
|
1035
|
+
? free(faceOf(edge.to), corridorPoint(plan, plan.leave))
|
|
1005
1036
|
: current.end;
|
|
1006
|
-
ends.set(
|
|
1037
|
+
ends.set(edge, { start, end });
|
|
1007
1038
|
}
|
|
1008
1039
|
}
|
|
1009
1040
|
/**
|
|
1010
|
-
* How much room
|
|
1011
|
-
* 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,
|
|
1012
1043
|
* which needs no more than the arrow spacing.
|
|
1013
1044
|
*
|
|
1014
|
-
* `
|
|
1045
|
+
* `textExtent` measures the knockout along whichever axis it is handed, and the
|
|
1015
1046
|
* axis wanted here is the one the channel is measured on rather than the one the
|
|
1016
|
-
*
|
|
1017
|
-
* 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.
|
|
1018
1049
|
*/
|
|
1019
|
-
function laneExtent(
|
|
1020
|
-
if (
|
|
1050
|
+
function laneExtent(edge, axis, measurer, fontSize) {
|
|
1051
|
+
if (edge.lines === undefined)
|
|
1021
1052
|
return 0;
|
|
1022
|
-
return
|
|
1053
|
+
return textExtent(edge.lines, edge.textAttrs, axis, measurer, fontSize, edge.line);
|
|
1023
1054
|
}
|
|
1024
1055
|
function corridorPoint(plan, at) {
|
|
1025
1056
|
return plan.axis === 'y' ? { x: at, y: plan.lane } : { x: plan.lane, y: at };
|
|
1026
1057
|
}
|
|
1027
1058
|
/**
|
|
1028
|
-
* The path a corridor
|
|
1059
|
+
* The path a corridor edge takes: a curve out of its start into the gap, the
|
|
1029
1060
|
* straight run along the gap, and a curve out of the gap to its end. It is
|
|
1030
1061
|
* three pieces rather than one cubic because a single curve has no way to stay
|
|
1031
1062
|
* inside an interval over part of its length — which is the whole claim the
|
|
@@ -1064,28 +1095,28 @@ function corridorReach(from, to, axis) {
|
|
|
1064
1095
|
const distance = Math.hypot(to.x - from.x, to.y - from.y);
|
|
1065
1096
|
return Math.min(140, Math.max(8, Math.min(distance * 0.4, run / 2)));
|
|
1066
1097
|
}
|
|
1067
|
-
function sideAttr(
|
|
1068
|
-
const value =
|
|
1098
|
+
function sideAttr(edge, key) {
|
|
1099
|
+
const value = edge.attrs[key];
|
|
1069
1100
|
if (value === undefined)
|
|
1070
1101
|
return undefined;
|
|
1071
|
-
if (!
|
|
1072
|
-
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);
|
|
1073
1104
|
}
|
|
1074
1105
|
return value;
|
|
1075
1106
|
}
|
|
1076
|
-
function arrowMarker(
|
|
1077
|
-
const id = markerId(
|
|
1107
|
+
function arrowMarker(color) {
|
|
1108
|
+
const id = markerId(color);
|
|
1078
1109
|
return [
|
|
1079
1110
|
` <marker id="${id}" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="${ARROW_MARKER_WIDTH}" markerHeight="${ARROW_MARKER_WIDTH}" orient="auto-start-reverse">`,
|
|
1080
|
-
` <path d="M 0 0 L 10 5 L 0 10 z" fill="${
|
|
1111
|
+
` <path d="M 0 0 L 10 5 L 0 10 z" fill="${color}"/>`,
|
|
1081
1112
|
' </marker>',
|
|
1082
1113
|
` <marker id="${id}-back" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="${ARROW_MARKER_WIDTH}" markerHeight="${ARROW_MARKER_WIDTH}" orient="auto-start-reverse">`,
|
|
1083
|
-
` <path d="M 0 0 L 10 5 L 0 10 z" fill="${
|
|
1114
|
+
` <path d="M 0 0 L 10 5 L 0 10 z" fill="${color}"/>`,
|
|
1084
1115
|
' </marker>',
|
|
1085
1116
|
].join('\n');
|
|
1086
1117
|
}
|
|
1087
|
-
function markerId(
|
|
1088
|
-
return `arrow-${
|
|
1118
|
+
function markerId(color) {
|
|
1119
|
+
return `arrow-${color.replace(/[^a-zA-Z0-9]/g, '')}`;
|
|
1089
1120
|
}
|
|
1090
1121
|
/** The rectangle actually drawn. Differs from the node box only for a deck. */
|
|
1091
1122
|
function faceOf(node) {
|
|
@@ -1096,7 +1127,7 @@ function faceOf(node) {
|
|
|
1096
1127
|
height: node.height - node.inset,
|
|
1097
1128
|
};
|
|
1098
1129
|
}
|
|
1099
|
-
function
|
|
1130
|
+
function centerOf(box) {
|
|
1100
1131
|
return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
|
|
1101
1132
|
}
|
|
1102
1133
|
/**
|
|
@@ -1109,42 +1140,92 @@ function sized(block, size, fontSize) {
|
|
|
1109
1140
|
return block;
|
|
1110
1141
|
return ` <g font-size="${size}px">\n${block}\n </g>`;
|
|
1111
1142
|
}
|
|
1112
|
-
|
|
1113
|
-
|
|
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;
|
|
1114
1167
|
return lines
|
|
1115
1168
|
.map((line, index) => {
|
|
1116
|
-
if (line.length === 0)
|
|
1169
|
+
if (plain(line).length === 0)
|
|
1117
1170
|
return '';
|
|
1118
1171
|
const baseline = top + index * lineHeight + lineHeight / 2 + fontSize * 0.35;
|
|
1119
|
-
//
|
|
1120
|
-
//
|
|
1121
|
-
|
|
1122
|
-
|
|
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>`;
|
|
1123
1187
|
})
|
|
1124
1188
|
.filter((element) => element.length > 0)
|
|
1125
1189
|
.join('\n');
|
|
1126
1190
|
}
|
|
1127
1191
|
/**
|
|
1128
|
-
*
|
|
1129
|
-
*
|
|
1130
|
-
*
|
|
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.
|
|
1131
1197
|
*/
|
|
1132
|
-
function
|
|
1133
|
-
|
|
1198
|
+
function runInk(run, own, markup, theme) {
|
|
1199
|
+
if (run.style === undefined)
|
|
1200
|
+
return own;
|
|
1201
|
+
return namedColor(markup[run.style], theme);
|
|
1134
1202
|
}
|
|
1135
1203
|
/**
|
|
1136
|
-
*
|
|
1137
|
-
*
|
|
1138
|
-
*
|
|
1139
|
-
* theme changes. Anything else is a colour, same as `stroke` and `fill` take.
|
|
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.
|
|
1140
1207
|
*/
|
|
1141
|
-
function
|
|
1142
|
-
const
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
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
|
+
}
|
|
1215
|
+
/**
|
|
1216
|
+
* A color is written as the viewer will receive it — `#14532d`, or any CSS
|
|
1217
|
+
* color. The renderer keeps no list of color words of its own, so a diagram
|
|
1218
|
+
* is never limited to the ones somebody remembered to add here.
|
|
1219
|
+
*
|
|
1220
|
+
* Each names the part it colors, so each reads exactly one key. The word these
|
|
1221
|
+
* replaced, `stroke:`, named no part and meant a different one on every kind,
|
|
1222
|
+
* which is why a box's text could not be colored at all until `text:`.
|
|
1223
|
+
*/
|
|
1224
|
+
function borderOf(appearance, fallback) {
|
|
1225
|
+
return appearance['border'] ?? fallback;
|
|
1226
|
+
}
|
|
1227
|
+
function lineOf(appearance, fallback) {
|
|
1228
|
+
return appearance['line'] ?? fallback;
|
|
1148
1229
|
}
|
|
1149
1230
|
function fillOf(appearance, fallback) {
|
|
1150
1231
|
return appearance['fill'] ?? fallback;
|