reladraw 0.0.1 → 0.2.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/LICENSE +202 -0
- package/NOTICE +14 -0
- package/README.md +135 -2
- package/SYNTAX.md +617 -0
- package/dist/ast.d.ts +228 -0
- package/dist/ast.js +171 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +72 -0
- package/dist/constants.d.ts +145 -0
- package/dist/constants.js +189 -0
- package/dist/constrain.d.ts +56 -0
- package/dist/constrain.js +95 -0
- package/dist/errors.d.ts +7 -0
- package/dist/errors.js +14 -0
- package/dist/grammar.d.ts +103 -0
- package/dist/grammar.js +215 -0
- package/dist/icons.d.ts +86 -0
- package/dist/icons.js +166 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +16 -0
- package/dist/lexer.d.ts +25 -0
- package/dist/lexer.js +91 -0
- package/dist/measure.d.ts +38 -0
- package/dist/measure.js +66 -0
- package/dist/model.d.ts +78 -0
- package/dist/model.js +1 -0
- package/dist/parser.d.ts +3 -0
- package/dist/parser.js +459 -0
- package/dist/render.d.ts +31 -0
- package/dist/render.js +1180 -0
- package/dist/resolve.d.ts +22 -0
- package/dist/resolve.js +1130 -0
- package/package.json +42 -4
package/dist/render.js
ADDED
|
@@ -0,0 +1,1180 @@
|
|
|
1
|
+
import { ARROW_MARKER_WIDTH, ATTACH_MARGIN, ATTACH_STEP, DECK_STEP, DEFAULT_FONT_SIZE, ICON_GAP, ICON_LINES, LINE_WIDTH, PAD, fontSizeFor, labelExtent, labelStyleFor, } from './constants.js';
|
|
2
|
+
import { describeAxis } from './ast.js';
|
|
3
|
+
import { SourceError } from './errors.js';
|
|
4
|
+
import { ICON_STROKE, iconFor, shapeFor } from './icons.js';
|
|
5
|
+
import { monospaceMeasurer } from './measure.js';
|
|
6
|
+
/**
|
|
7
|
+
* Sampled out of `examples/reference/arch.png` rather than invented,
|
|
8
|
+
* so the benchmark render and the drawing it is measured against differ by
|
|
9
|
+
* geometry and typography alone. A container is a shade off the page and barely
|
|
10
|
+
* outlined; a leaf is the navy that carries the diagram's weight.
|
|
11
|
+
*/
|
|
12
|
+
export const DARK_THEME = {
|
|
13
|
+
background: '#111111',
|
|
14
|
+
boxFill: '#191728',
|
|
15
|
+
boxStroke: '#4f5367',
|
|
16
|
+
containerFill: '#191920',
|
|
17
|
+
containerStroke: '#25242f',
|
|
18
|
+
text: '#d9d9d9',
|
|
19
|
+
mutedText: '#8b8b8b',
|
|
20
|
+
link: '#5c5c7c',
|
|
21
|
+
// Both sampled off the reference's machine glyphs. Note that the reference
|
|
22
|
+
// gives each icon its own hue — the drive is gray, the laptop periwinkle, the
|
|
23
|
+
// workstation violet — which is a drawing tool's per-shape default and not a
|
|
24
|
+
// system. One pair for the whole set is the deliberate difference: an icon
|
|
25
|
+
// should read as part of the diagram's palette, not as clip art dropped in.
|
|
26
|
+
iconInk: '#8d8d8e',
|
|
27
|
+
iconShade: '#3e3d58',
|
|
28
|
+
};
|
|
29
|
+
const CORNER = 8;
|
|
30
|
+
/** Turn solved geometry into a standalone SVG document. */
|
|
31
|
+
export function render(layout, options = {}) {
|
|
32
|
+
const measurer = options.measurer ?? monospaceMeasurer();
|
|
33
|
+
const fontSize = options.fontSize ?? DEFAULT_FONT_SIZE;
|
|
34
|
+
// `diagram background:` is the author overruling the theme for this one
|
|
35
|
+
// drawing, so it is folded in here and everything downstream sees one theme.
|
|
36
|
+
const base = options.theme ?? DARK_THEME;
|
|
37
|
+
const stated = layout.diagram['background'];
|
|
38
|
+
const theme = stated === undefined ? base : { ...base, background: stated };
|
|
39
|
+
const body = [];
|
|
40
|
+
for (const root of layout.roots) {
|
|
41
|
+
body.push(drawNode(root, theme, measurer, fontSize));
|
|
42
|
+
}
|
|
43
|
+
// Everything the boxes cover. Links are added to it as they are drawn.
|
|
44
|
+
let ink = { minX: 0, minY: 0, maxX: layout.width, maxY: layout.height };
|
|
45
|
+
// Endpoints are planned for every link at once, because where a link meets a
|
|
46
|
+
// 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 a link takes
|
|
48
|
+
// is ordered by where its ends turned out to be.
|
|
49
|
+
const ends = planEndpoints(layout.links, measurer, fontSize);
|
|
50
|
+
const corridors = planCorridors(layout.links, ends, measurer, fontSize);
|
|
51
|
+
aimFreeEnds(layout.links, ends, corridors);
|
|
52
|
+
for (const link of layout.links) {
|
|
53
|
+
const drawn = drawLink(link, ends.get(link), corridors.get(link), theme, measurer, fontSize);
|
|
54
|
+
body.push(drawn.svg);
|
|
55
|
+
ink = union(ink, grow(drawn.ink, layout.margin));
|
|
56
|
+
}
|
|
57
|
+
// A link's geometry is measured rather than solved for, so the resolver sized
|
|
58
|
+
// the canvas from the boxes alone. A curve out of a `top` side, or a label
|
|
59
|
+
// riding above one, lands outside that — so the page grows to hold it and the
|
|
60
|
+
// origin moves with it, rather than the drawing being quietly clipped.
|
|
61
|
+
const canvas = {
|
|
62
|
+
x: Math.floor(ink.minX),
|
|
63
|
+
y: Math.floor(ink.minY),
|
|
64
|
+
width: Math.ceil(ink.maxX) - Math.floor(ink.minX),
|
|
65
|
+
height: Math.ceil(ink.maxY) - Math.floor(ink.minY),
|
|
66
|
+
};
|
|
67
|
+
const arrowColors = new Set(layout.links.map((link) => lineOf(link.appearance, theme.link)));
|
|
68
|
+
return [
|
|
69
|
+
`<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
|
+
' <defs>',
|
|
71
|
+
...[...arrowColors].map((color) => arrowMarker(color)),
|
|
72
|
+
' </defs>',
|
|
73
|
+
` <rect x="${canvas.x}" y="${canvas.y}" width="${canvas.width}" height="${canvas.height}" fill="${theme.background}"/>`,
|
|
74
|
+
...body,
|
|
75
|
+
'</svg>',
|
|
76
|
+
'',
|
|
77
|
+
].join('\n');
|
|
78
|
+
}
|
|
79
|
+
// --- nodes -------------------------------------------------------------------
|
|
80
|
+
function drawNode(node, theme, measurer, fontSize) {
|
|
81
|
+
// A note is set smaller than a box label by default, and `size:` overrides
|
|
82
|
+
// that on anything. Only this node's own text takes the size — children are
|
|
83
|
+
// drawn by their own call and carry whatever they say themselves.
|
|
84
|
+
const size = fontSizeFor(node.kind, node.appearance, fontSize, node.line);
|
|
85
|
+
const textHeight = measurer.lineHeight(size);
|
|
86
|
+
if (node.kind === 'note') {
|
|
87
|
+
return sized(textBlock(node.lines, node.x, node.y, node.width, textHeight, size, {
|
|
88
|
+
color: textOf(node.appearance, theme.text),
|
|
89
|
+
align: 'start',
|
|
90
|
+
}), size, fontSize);
|
|
91
|
+
}
|
|
92
|
+
const shape = shapeFor(node.appearance, node.line);
|
|
93
|
+
const labelStyle = labelStyleFor(node.label, node.line);
|
|
94
|
+
const glyphSide = ICON_LINES * textHeight;
|
|
95
|
+
if (shape.body !== undefined) {
|
|
96
|
+
// No outline, no fill, no padding — the node is the picture. The label, if
|
|
97
|
+
// there is one, sits under it and centered.
|
|
98
|
+
const drawn = [drawIcon(shape.body, node.x + (node.width - glyphSide) / 2, node.y, glyphSide, theme)];
|
|
99
|
+
if (node.lines.some((line) => line.length > 0)) {
|
|
100
|
+
drawn.push(sized(textBlock(node.lines, node.x, node.y + glyphSide + ICON_GAP, node.width, textHeight, size, {
|
|
101
|
+
color: textOf(node.appearance, theme.text),
|
|
102
|
+
subColor: subtextOf(node.appearance, theme),
|
|
103
|
+
align: 'middle',
|
|
104
|
+
}), size, fontSize));
|
|
105
|
+
}
|
|
106
|
+
return drawn.join('\n');
|
|
107
|
+
}
|
|
108
|
+
const parts = [];
|
|
109
|
+
const face = faceOf(node);
|
|
110
|
+
const container = node.children.length > 0;
|
|
111
|
+
const border = borderOf(node.appearance, container ? theme.containerStroke : theme.boxStroke);
|
|
112
|
+
const fill = fillOf(node.appearance, container ? theme.containerFill : theme.boxFill);
|
|
113
|
+
// A box is the one kind with two inkable parts, which is why its text needs
|
|
114
|
+
// a word of its own — `border:` cannot stand in for it.
|
|
115
|
+
const text = textOf(node.appearance, theme.text);
|
|
116
|
+
const subColor = subtextOf(node.appearance, theme);
|
|
117
|
+
// Deck copies sit behind the front face, furthest back drawn first.
|
|
118
|
+
for (let depth = node.deckLabels.length; depth >= 1; depth -= 1) {
|
|
119
|
+
const x = face.x - depth * DECK_STEP;
|
|
120
|
+
const y = face.y - depth * DECK_STEP;
|
|
121
|
+
parts.push(` <path d="${outlinePath(shape.outline, x, y, face.width, face.height)}" fill="${theme.containerFill}" stroke="${border}"/>`);
|
|
122
|
+
const label = node.deckLabels[depth - 1];
|
|
123
|
+
if (label !== undefined) {
|
|
124
|
+
parts.push(sized(textBlock([label], x + PAD, y + PAD, face.width - PAD * 2, textHeight, size, {
|
|
125
|
+
color: text,
|
|
126
|
+
align: 'start',
|
|
127
|
+
}), size, fontSize));
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
parts.push(` <path d="${outlinePath(shape.outline, face.x, face.y, face.width, face.height)}" fill="${fill}" stroke="${border}"/>`);
|
|
131
|
+
for (const extra of outlineDetail(shape.outline, face.x, face.y, face.width, face.height)) {
|
|
132
|
+
parts.push(` <path d="${extra}" fill="none" stroke="${border}"/>`);
|
|
133
|
+
}
|
|
134
|
+
// The icon takes a column on the right and the label lays out in what is
|
|
135
|
+
// left, which is the room the resolver already reserved for exactly this.
|
|
136
|
+
const icon = iconFor(node.appearance, node.line);
|
|
137
|
+
const iconSide = icon === undefined ? 0 : glyphSide;
|
|
138
|
+
const hasLabel = node.lines.some((line) => line.length > 0);
|
|
139
|
+
const iconRoom = icon === undefined ? 0 : iconSide + (hasLabel ? ICON_GAP : 0);
|
|
140
|
+
if (!container) {
|
|
141
|
+
// A leaf centers its label in the box, both ways — in the room beside the
|
|
142
|
+
// icon rather than the whole box, so the two sit side by side. Centered
|
|
143
|
+
// across is only the default: a label of several lines may say `align`, and
|
|
144
|
+
// there is genuine slack between lines of unequal length to range them in.
|
|
145
|
+
const leafAlign = labelStyleFor(node.label, node.line, 'middle').align;
|
|
146
|
+
const top = face.y + (face.height - node.lines.length * textHeight) / 2;
|
|
147
|
+
parts.push(sized(textBlock(node.lines, face.x, top, face.width - iconRoom, textHeight, size, {
|
|
148
|
+
color: text,
|
|
149
|
+
subColor,
|
|
150
|
+
align: leafAlign,
|
|
151
|
+
}), size, fontSize));
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
// The label and the icon share a band at one end of the box, and the
|
|
155
|
+
// resolver has already given the contents the other end. A heading is
|
|
156
|
+
// ranged left at the top; a caption is centered at the bottom.
|
|
157
|
+
const band = Math.max(node.lines.length * textHeight, iconSide);
|
|
158
|
+
const bandTop = labelStyle.at === 'top' ? face.y + PAD : face.y + face.height - PAD - band;
|
|
159
|
+
parts.push(sized(textBlock(node.lines, face.x + PAD, bandTop, face.width - PAD * 2 - iconRoom, textHeight, size, {
|
|
160
|
+
color: text,
|
|
161
|
+
subColor,
|
|
162
|
+
align: labelStyle.align,
|
|
163
|
+
}), size, fontSize));
|
|
164
|
+
for (const child of node.children) {
|
|
165
|
+
parts.push(drawNode(child, theme, measurer, fontSize));
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if (icon !== undefined) {
|
|
169
|
+
// A container's icon rides in the label's band, at whichever end that is; a
|
|
170
|
+
// leaf's label is centered, so the icon centers with it. Both follow the
|
|
171
|
+
// label rather than being placed by a rule of their own, which is what
|
|
172
|
+
// keeps an icon reading as part of the title block and not as a sticker.
|
|
173
|
+
const left = face.x + face.width - PAD - iconSide;
|
|
174
|
+
const top = container
|
|
175
|
+
? labelStyle.at === 'top'
|
|
176
|
+
? face.y + PAD
|
|
177
|
+
: face.y + face.height - PAD - iconSide
|
|
178
|
+
: face.y + (face.height - iconSide) / 2;
|
|
179
|
+
parts.push(drawIcon(icon, left, top, iconSide, theme));
|
|
180
|
+
}
|
|
181
|
+
return parts.join('\n');
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* How far the dog-ear cuts into the top-right corner of a `document`.
|
|
185
|
+
*
|
|
186
|
+
* Twice the corner radius, so it is the same size on every box however wide.
|
|
187
|
+
* The reference sizes its fold as a fraction of the box, which is why the fold
|
|
188
|
+
* on those two wide `pg_dump` boxes almost disappears — the idea was right and
|
|
189
|
+
* only the scaling was wrong.
|
|
190
|
+
*/
|
|
191
|
+
const FOLD = CORNER * 2;
|
|
192
|
+
/** The node's outline, as path data. */
|
|
193
|
+
function outlinePath(shape, x, y, w, h) {
|
|
194
|
+
const r = CORNER;
|
|
195
|
+
if (shape === 'document') {
|
|
196
|
+
// Every corner rounded but the top-right one, which is cut away and folded.
|
|
197
|
+
return [
|
|
198
|
+
`M${round(x + r)} ${round(y)}`,
|
|
199
|
+
`H${round(x + w - FOLD)}`,
|
|
200
|
+
`L${round(x + w)} ${round(y + FOLD)}`,
|
|
201
|
+
`V${round(y + h - r)}`,
|
|
202
|
+
`a${r} ${r} 0 0 1 ${-r} ${r}`,
|
|
203
|
+
`H${round(x + r)}`,
|
|
204
|
+
`a${r} ${r} 0 0 1 ${-r} ${-r}`,
|
|
205
|
+
`V${round(y + r)}`,
|
|
206
|
+
`a${r} ${r} 0 0 1 ${r} ${-r}`,
|
|
207
|
+
'Z',
|
|
208
|
+
].join(' ');
|
|
209
|
+
}
|
|
210
|
+
return [
|
|
211
|
+
`M${round(x + r)} ${round(y)}`,
|
|
212
|
+
`H${round(x + w - r)}`,
|
|
213
|
+
`a${r} ${r} 0 0 1 ${r} ${r}`,
|
|
214
|
+
`V${round(y + h - r)}`,
|
|
215
|
+
`a${r} ${r} 0 0 1 ${-r} ${r}`,
|
|
216
|
+
`H${round(x + r)}`,
|
|
217
|
+
`a${r} ${r} 0 0 1 ${-r} ${-r}`,
|
|
218
|
+
`V${round(y + r)}`,
|
|
219
|
+
`a${r} ${r} 0 0 1 ${r} ${-r}`,
|
|
220
|
+
'Z',
|
|
221
|
+
].join(' ');
|
|
222
|
+
}
|
|
223
|
+
/** Lines drawn inside the outline: the flap of a fold, and nothing else so far. */
|
|
224
|
+
function outlineDetail(shape, x, y, w, h) {
|
|
225
|
+
void h;
|
|
226
|
+
if (shape !== 'document')
|
|
227
|
+
return [];
|
|
228
|
+
return [
|
|
229
|
+
`M${round(x + w - FOLD)} ${round(y)} V${round(y + FOLD)} H${round(x + w)}`,
|
|
230
|
+
];
|
|
231
|
+
}
|
|
232
|
+
/** One icon, scaled from its own grid onto a square of `side` at `x, y`. */
|
|
233
|
+
function drawIcon(icon, x, y, side, theme) {
|
|
234
|
+
const scale = side / icon.grid;
|
|
235
|
+
const color = (tone) => tone === 'ink' ? theme.iconInk : tone === 'shade' ? theme.iconShade : theme.background;
|
|
236
|
+
const paths = icon.paths.map((path) => {
|
|
237
|
+
const fill = path.fill === undefined ? 'none' : color(path.fill);
|
|
238
|
+
const stroke = path.stroke === undefined
|
|
239
|
+
? ''
|
|
240
|
+
: ` stroke="${color(path.stroke)}" stroke-width="${ICON_STROKE}" stroke-linejoin="round"`;
|
|
241
|
+
return ` <path d="${path.d}" fill="${fill}"${stroke}/>`;
|
|
242
|
+
});
|
|
243
|
+
return [
|
|
244
|
+
` <g transform="translate(${round(x)} ${round(y)}) scale(${round(scale * 1000) / 1000})">`,
|
|
245
|
+
...paths,
|
|
246
|
+
' </g>',
|
|
247
|
+
].join('\n');
|
|
248
|
+
}
|
|
249
|
+
// --- links -------------------------------------------------------------------
|
|
250
|
+
function drawLink(link, ends, corridor, theme, measurer, fontSize) {
|
|
251
|
+
const { start, end } = ends;
|
|
252
|
+
const color = lineOf(link.appearance, theme.link);
|
|
253
|
+
const markerEnd = ` marker-end="url(#${markerId(color)})"`;
|
|
254
|
+
const markerStart = link.both ? ` marker-start="url(#${markerId(color)}-back)"` : '';
|
|
255
|
+
// A named side is a statement about how the line should leave or arrive, so
|
|
256
|
+
// it is drawn as a curve that actually does leave and arrive that way. With
|
|
257
|
+
// neither side named there is nothing to honor and the line stays straight.
|
|
258
|
+
const curved = start.side !== undefined || end.side !== undefined;
|
|
259
|
+
const parts = [];
|
|
260
|
+
// What the line actually covers, so the canvas can be sized to hold it. A
|
|
261
|
+
// curve leaving a `top` side rides above every box in the drawing, and the
|
|
262
|
+
// node bounds know nothing about it.
|
|
263
|
+
let ink = extentOfPoints([start, end]);
|
|
264
|
+
let midX;
|
|
265
|
+
let midY;
|
|
266
|
+
if (corridor) {
|
|
267
|
+
const path = corridorPath(start, end, corridor);
|
|
268
|
+
ink = union(ink, path.ink);
|
|
269
|
+
parts.push(` <path d="${path.d}" fill="none" stroke="${color}" stroke-width="${LINE_WIDTH}"${markerEnd}${markerStart}/>`);
|
|
270
|
+
// The label goes on the straight run rather than at the midpoint of the
|
|
271
|
+
// whole path, so it sits in the gap the author asked the line to travel.
|
|
272
|
+
midX = path.mid.x;
|
|
273
|
+
midY = path.mid.y;
|
|
274
|
+
}
|
|
275
|
+
else if (curved) {
|
|
276
|
+
const reach = controlReach(start, end);
|
|
277
|
+
// A bundle whose sides were too short to spread it takes the rest of the
|
|
278
|
+
// room in the middle, exactly as a straight group does — see `bowBundles`.
|
|
279
|
+
// Displacing both control points equally moves the curve's middle by three
|
|
280
|
+
// quarters as much, so the bow is scaled up by the inverse of that.
|
|
281
|
+
const lift = 4 / 3;
|
|
282
|
+
const bx = (ends.bow?.x ?? 0) * lift;
|
|
283
|
+
const by = (ends.bow?.y ?? 0) * lift;
|
|
284
|
+
const c1 = { x: start.x + start.tx * reach + bx, y: start.y + start.ty * reach + by };
|
|
285
|
+
const c2 = { x: end.x + end.tx * reach + bx, y: end.y + end.ty * reach + by };
|
|
286
|
+
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
|
+
ink = union(ink, cubicExtent(start, c1, c2, end));
|
|
288
|
+
// The point halfway along a cubic, which is where the label belongs.
|
|
289
|
+
midX = (start.x + 3 * c1.x + 3 * c2.x + end.x) / 8;
|
|
290
|
+
midY = (start.y + 3 * c1.y + 3 * c2.y + end.y) / 8;
|
|
291
|
+
}
|
|
292
|
+
else if (ends.bow && (ends.bow.x !== 0 || ends.bow.y !== 0)) {
|
|
293
|
+
// A straight line that could not get the room it needed at its ends, so it
|
|
294
|
+
// takes it in the middle. Both control points carry the same displacement,
|
|
295
|
+
// which keeps the arc symmetric; a cubic's middle moves three quarters of
|
|
296
|
+
// the way its controls do, so the displacement is the bow scaled up by that.
|
|
297
|
+
const lift = 4 / 3;
|
|
298
|
+
const run = { x: (end.x - start.x) / 3, y: (end.y - start.y) / 3 };
|
|
299
|
+
const c1 = {
|
|
300
|
+
x: start.x + run.x + ends.bow.x * lift,
|
|
301
|
+
y: start.y + run.y + ends.bow.y * lift,
|
|
302
|
+
};
|
|
303
|
+
const c2 = {
|
|
304
|
+
x: end.x - run.x + ends.bow.x * lift,
|
|
305
|
+
y: end.y - run.y + ends.bow.y * lift,
|
|
306
|
+
};
|
|
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}/>`);
|
|
308
|
+
ink = union(ink, cubicExtent(start, c1, c2, end));
|
|
309
|
+
midX = (start.x + 3 * c1.x + 3 * c2.x + end.x) / 8;
|
|
310
|
+
midY = (start.y + 3 * c1.y + 3 * c2.y + end.y) / 8;
|
|
311
|
+
}
|
|
312
|
+
else {
|
|
313
|
+
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}/>`);
|
|
314
|
+
midX = (start.x + end.x) / 2;
|
|
315
|
+
midY = (start.y + end.y) / 2;
|
|
316
|
+
}
|
|
317
|
+
if (link.label !== undefined) {
|
|
318
|
+
// A link label breaks on ` / ` exactly as a box label does, so a two-line
|
|
319
|
+
// caption on an arrow needs no vocabulary of its own. The block is centered
|
|
320
|
+
// on the midpoint, which keeps a one-line label where it has always been.
|
|
321
|
+
const size = fontSizeFor('link', link.appearance, fontSize, link.line);
|
|
322
|
+
const textHeight = measurer.lineHeight(size);
|
|
323
|
+
const { width, lines } = measurer.measure(link.label, size);
|
|
324
|
+
const height = lines.length * textHeight;
|
|
325
|
+
const top = midY - height / 2;
|
|
326
|
+
// The label knocks a hole in whatever it lands on rather than sitting in a
|
|
327
|
+
// chip of its own: an outlined box reads as a node, which is the one thing
|
|
328
|
+
// a label on a line is not.
|
|
329
|
+
parts.push(` <rect x="${round(midX - width / 2 - 5)}" y="${round(top)}" width="${round(width + 10)}" height="${round(height)}" fill="${theme.background}"/>`);
|
|
330
|
+
ink = union(ink, {
|
|
331
|
+
minX: midX - width / 2 - 5,
|
|
332
|
+
minY: top,
|
|
333
|
+
maxX: midX + width / 2 + 5,
|
|
334
|
+
maxY: top + height,
|
|
335
|
+
});
|
|
336
|
+
parts.push(sized(textBlock(lines, midX - width / 2, top, width, textHeight, size, {
|
|
337
|
+
// A colored link carries its meaning into its label; an uncolored
|
|
338
|
+
// one leaves the words to read as ordinary text.
|
|
339
|
+
color: textOf(link.appearance, lineOf(link.appearance, theme.text)),
|
|
340
|
+
align: 'middle',
|
|
341
|
+
}), size, fontSize));
|
|
342
|
+
}
|
|
343
|
+
// 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) };
|
|
345
|
+
}
|
|
346
|
+
function union(a, b) {
|
|
347
|
+
return {
|
|
348
|
+
minX: Math.min(a.minX, b.minX),
|
|
349
|
+
minY: Math.min(a.minY, b.minY),
|
|
350
|
+
maxX: Math.max(a.maxX, b.maxX),
|
|
351
|
+
maxY: Math.max(a.maxY, b.maxY),
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
function grow(extent, by) {
|
|
355
|
+
return {
|
|
356
|
+
minX: extent.minX - by,
|
|
357
|
+
minY: extent.minY - by,
|
|
358
|
+
maxX: extent.maxX + by,
|
|
359
|
+
maxY: extent.maxY + by,
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
function extentOfPoints(points) {
|
|
363
|
+
return {
|
|
364
|
+
minX: Math.min(...points.map((p) => p.x)),
|
|
365
|
+
minY: Math.min(...points.map((p) => p.y)),
|
|
366
|
+
maxX: Math.max(...points.map((p) => p.x)),
|
|
367
|
+
maxY: Math.max(...points.map((p) => p.y)),
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
/**
|
|
371
|
+
* What a cubic actually covers, which is not what its control points cover. A
|
|
372
|
+
* handle reaching 140 pixels up carries the curve only about three quarters of
|
|
373
|
+
* that, and sizing the page off the handles would leave a visible band of empty
|
|
374
|
+
* canvas above every curved link. Solved rather than sampled: the extremes are
|
|
375
|
+
* the ends plus wherever the derivative — a quadratic — crosses zero.
|
|
376
|
+
*/
|
|
377
|
+
function cubicExtent(p0, c1, c2, p3) {
|
|
378
|
+
const span = (a, b, c, d) => {
|
|
379
|
+
const values = [a, d];
|
|
380
|
+
// The derivative of the cubic, written as a quadratic in t.
|
|
381
|
+
const qa = 3 * (-a + 3 * b - 3 * c + d);
|
|
382
|
+
const qb = 6 * (a - 2 * b + c);
|
|
383
|
+
const qc = 3 * (b - a);
|
|
384
|
+
const roots = [];
|
|
385
|
+
if (Math.abs(qa) < 1e-9) {
|
|
386
|
+
if (Math.abs(qb) > 1e-9)
|
|
387
|
+
roots.push(-qc / qb);
|
|
388
|
+
}
|
|
389
|
+
else {
|
|
390
|
+
const disc = qb * qb - 4 * qa * qc;
|
|
391
|
+
if (disc >= 0) {
|
|
392
|
+
const root = Math.sqrt(disc);
|
|
393
|
+
roots.push((-qb + root) / (2 * qa), (-qb - root) / (2 * qa));
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
for (const t of roots) {
|
|
397
|
+
if (t <= 0 || t >= 1)
|
|
398
|
+
continue;
|
|
399
|
+
const u = 1 - t;
|
|
400
|
+
values.push(u * u * u * a + 3 * u * u * t * b + 3 * u * t * t * c + t * t * t * d);
|
|
401
|
+
}
|
|
402
|
+
return [Math.min(...values), Math.max(...values)];
|
|
403
|
+
};
|
|
404
|
+
const [minX, maxX] = span(p0.x, c1.x, c2.x, p3.x);
|
|
405
|
+
const [minY, maxY] = span(p0.y, c1.y, c2.y, p3.y);
|
|
406
|
+
return { minX, minY, maxX, maxY };
|
|
407
|
+
}
|
|
408
|
+
/** Walk out from the center of a box toward a point, stopping at the border. */
|
|
409
|
+
function edgePoint(box, toward) {
|
|
410
|
+
const center = centerOf(box);
|
|
411
|
+
const dx = toward.x - center.x;
|
|
412
|
+
const dy = toward.y - center.y;
|
|
413
|
+
if (dx === 0 && dy === 0)
|
|
414
|
+
return center;
|
|
415
|
+
const scaleX = dx === 0 ? Infinity : box.width / 2 / Math.abs(dx);
|
|
416
|
+
const scaleY = dy === 0 ? Infinity : box.height / 2 / Math.abs(dy);
|
|
417
|
+
const scale = Math.min(scaleX, scaleY);
|
|
418
|
+
return { x: center.x + dx * scale, y: center.y + dy * scale };
|
|
419
|
+
}
|
|
420
|
+
// --- where a link meets a box -------------------------------------------------
|
|
421
|
+
const SIDES = ['top', 'bottom', 'left', 'right'];
|
|
422
|
+
/**
|
|
423
|
+
* Work out where every link meets every box.
|
|
424
|
+
*
|
|
425
|
+
* An author names a *side* — `to: top` — and never a point on it. Alone on a
|
|
426
|
+
* side a link lands at its center; sharing the side with others, the points
|
|
427
|
+
* 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 links
|
|
429
|
+
* arriving at one top edge, the one coming from further left arrives further
|
|
430
|
+
* left. That is the same rule as box non-overlap — the tool separates things by
|
|
431
|
+
* default, and reads the direction off the solved layout rather than asking.
|
|
432
|
+
*
|
|
433
|
+
* Where several links run between the *same* pair of sides that rule has
|
|
434
|
+
* nothing to read, and a `Bundle` supplies the order instead — see there.
|
|
435
|
+
*/
|
|
436
|
+
function planEndpoints(links, measurer, fontSize) {
|
|
437
|
+
const claims = new Map();
|
|
438
|
+
const achieved = new Map();
|
|
439
|
+
const named = new Map();
|
|
440
|
+
const bundles = planBundles(links, measurer, fontSize);
|
|
441
|
+
const spreads = planSpreads(links, measurer, fontSize);
|
|
442
|
+
for (const link of links) {
|
|
443
|
+
named.set(link, {});
|
|
444
|
+
const fromSide = sideAttr(link, 'from');
|
|
445
|
+
const toSide = sideAttr(link, 'to');
|
|
446
|
+
if (fromSide) {
|
|
447
|
+
claim(claims, link.from, fromSide, {
|
|
448
|
+
link,
|
|
449
|
+
which: 'start',
|
|
450
|
+
side: fromSide,
|
|
451
|
+
toward: centerOf(faceOf(link.to)),
|
|
452
|
+
rank: rankIn(bundles.get(link), link, link.from, fromSide),
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
if (toSide) {
|
|
456
|
+
claim(claims, link.to, toSide, {
|
|
457
|
+
link,
|
|
458
|
+
which: 'end',
|
|
459
|
+
side: toSide,
|
|
460
|
+
toward: centerOf(faceOf(link.from)),
|
|
461
|
+
rank: rankIn(bundles.get(link), link, link.to, toSide),
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
// Place every claimed side, spreading the points that share one.
|
|
466
|
+
for (const [node, bySide] of claims) {
|
|
467
|
+
const face = faceOf(node);
|
|
468
|
+
for (const [side, group] of bySide) {
|
|
469
|
+
const along = side === 'top' || side === 'bottom' ? 'x' : 'y';
|
|
470
|
+
const span = along === 'x' ? face.width : face.height;
|
|
471
|
+
const origin = along === 'x' ? face.x : face.y;
|
|
472
|
+
// Far ends first, as ever; a bundle's own lane order settles the links
|
|
473
|
+
// that share one, which are precisely the ones the first key cannot.
|
|
474
|
+
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 labels apart rather than the points
|
|
476
|
+
// 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.link)?.step ?? 0));
|
|
478
|
+
const usable = Math.max(0, span - ATTACH_MARGIN * 2);
|
|
479
|
+
const step = ordered.length > 1 ? Math.min(wanted, usable / (ordered.length - 1)) : 0;
|
|
480
|
+
const first = origin + span / 2 - (step * (ordered.length - 1)) / 2;
|
|
481
|
+
ordered.forEach((entry, index) => {
|
|
482
|
+
const at = first + index * step;
|
|
483
|
+
named.get(entry.link)[entry.which] = anchorOn(face, side, at);
|
|
484
|
+
});
|
|
485
|
+
// What the side could actually give, which is less than `wanted` when it
|
|
486
|
+
// is too short for the group. `bowBundles` makes up the difference.
|
|
487
|
+
let steps = achieved.get(node);
|
|
488
|
+
if (!steps)
|
|
489
|
+
achieved.set(node, (steps = new Map()));
|
|
490
|
+
steps.set(side, step);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
const bows = bowBundles(bundles, achieved);
|
|
494
|
+
// Fill in the ends the author said nothing about, now that the named ones
|
|
495
|
+
// are known: an unnamed end aims at wherever its partner ended up.
|
|
496
|
+
const ends = new Map();
|
|
497
|
+
for (const link of links) {
|
|
498
|
+
const partial = named.get(link);
|
|
499
|
+
const fromFace = faceOf(link.from);
|
|
500
|
+
const toFace = faceOf(link.to);
|
|
501
|
+
// Several links between one pair of boxes with no side named anywhere: the
|
|
502
|
+
// line each would have drawn alone, moved aside so they do not coincide.
|
|
503
|
+
const spread = spreads.get(link);
|
|
504
|
+
if (spread) {
|
|
505
|
+
ends.set(link, { ...parallelEnds(fromFace, toFace, spread.offset), bow: spread.bow });
|
|
506
|
+
continue;
|
|
507
|
+
}
|
|
508
|
+
// With neither end named this is the straight line it always was, each end
|
|
509
|
+
// aiming at the other box's center.
|
|
510
|
+
const start = partial.start ?? free(fromFace, partial.end ?? centerOf(toFace));
|
|
511
|
+
const end = partial.end ?? free(toFace, partial.start ?? centerOf(fromFace));
|
|
512
|
+
ends.set(link, { start, end, bow: bows.get(link) });
|
|
513
|
+
}
|
|
514
|
+
return ends;
|
|
515
|
+
}
|
|
516
|
+
/**
|
|
517
|
+
* Group the links that run between the same pair of sides, and work out the
|
|
518
|
+
* lane order and lane width each group needs.
|
|
519
|
+
*
|
|
520
|
+
* Only a link whose author named *both* sides can be in a bundle: a bundle is a
|
|
521
|
+
* statement about two specific edges, and an end with no side named has not
|
|
522
|
+
* picked one yet.
|
|
523
|
+
*/
|
|
524
|
+
function planBundles(links, measurer, fontSize) {
|
|
525
|
+
const ids = new Map();
|
|
526
|
+
const idOf = (node) => {
|
|
527
|
+
let id = ids.get(node);
|
|
528
|
+
if (id === undefined) {
|
|
529
|
+
id = ids.size;
|
|
530
|
+
ids.set(node, id);
|
|
531
|
+
}
|
|
532
|
+
return id;
|
|
533
|
+
};
|
|
534
|
+
const groups = new Map();
|
|
535
|
+
for (const link of links) {
|
|
536
|
+
const fromSide = sideAttr(link, 'from');
|
|
537
|
+
const toSide = sideAttr(link, 'to');
|
|
538
|
+
if (!fromSide || !toSide || link.from === link.to)
|
|
539
|
+
continue;
|
|
540
|
+
const a = { node: link.from, side: fromSide };
|
|
541
|
+
const b = { node: link.to, side: toSide };
|
|
542
|
+
const keyA = `${idOf(a.node)}:${a.side}`;
|
|
543
|
+
const keyB = `${idOf(b.node)}:${b.side}`;
|
|
544
|
+
// The pair is unordered — `a -> b` and `b -> a` join the same two edges —
|
|
545
|
+
// so the key is canonical and the ends are stored in that same order.
|
|
546
|
+
const swap = keyB < keyA;
|
|
547
|
+
const key = swap ? `${keyB}|${keyA}` : `${keyA}|${keyB}`;
|
|
548
|
+
const ends = swap ? [b, a] : [a, b];
|
|
549
|
+
const group = groups.get(key);
|
|
550
|
+
if (group)
|
|
551
|
+
group.links.push(link);
|
|
552
|
+
else
|
|
553
|
+
groups.set(key, { ends, links: [link] });
|
|
554
|
+
}
|
|
555
|
+
const bundles = new Map();
|
|
556
|
+
for (const group of groups.values()) {
|
|
557
|
+
if (group.links.length < 2)
|
|
558
|
+
continue;
|
|
559
|
+
const [first, second] = group.ends;
|
|
560
|
+
const t0 = tangentOf(first.side);
|
|
561
|
+
const t1 = tangentOf(second.side);
|
|
562
|
+
const from = sideCenter(first);
|
|
563
|
+
const to = sideCenter(second);
|
|
564
|
+
const run = { x: to.x - from.x, y: to.y - from.y };
|
|
565
|
+
// Nesting is a matter of which side of the line each end steps toward. Step
|
|
566
|
+
// both ends to the same side of the run and the whole line translates;
|
|
567
|
+
// step them to opposite sides and it pivots, which is a crossing.
|
|
568
|
+
const aligned = cross(run, t0) * cross(run, t1) >= 0;
|
|
569
|
+
const sense = aligned ? 1 : -1;
|
|
570
|
+
// Two links leaving in opposite directions are the ordinary case, and which
|
|
571
|
+
// lane each takes is then read off the diagram rather than off the order the
|
|
572
|
+
// author happened to type them in: a line keeps to one side of its own run.
|
|
573
|
+
// Links pointing the same way have no such signal and fall back to the file.
|
|
574
|
+
const order = new Map(group.links.map((link, index) => [link, index]));
|
|
575
|
+
const lanes = [...group.links].sort((a, b) => Number(a.from !== first.node) - Number(b.from !== first.node) ||
|
|
576
|
+
order.get(a) - order.get(b));
|
|
577
|
+
// One lane apart moves a link's start by `step` along one side and its end
|
|
578
|
+
// by `step` along the other, so the midpoint of the line — which is where
|
|
579
|
+
// its label goes — moves by the average of the two.
|
|
580
|
+
const drift = { x: (t0.x + sense * t1.x) / 2, y: (t0.y + sense * t1.y) / 2 };
|
|
581
|
+
const bundle = {
|
|
582
|
+
ends: group.ends,
|
|
583
|
+
lanes,
|
|
584
|
+
aligned,
|
|
585
|
+
step: Math.max(ATTACH_STEP, laneStep(lanes, drift, measurer, fontSize)),
|
|
586
|
+
};
|
|
587
|
+
for (const link of lanes)
|
|
588
|
+
bundles.set(link, bundle);
|
|
589
|
+
}
|
|
590
|
+
return bundles;
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* The sideways offset each link takes when several run between the same two
|
|
594
|
+
* boxes and none of them names a side.
|
|
595
|
+
*
|
|
596
|
+
* 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 link in such a
|
|
598
|
+
* group produces the *same* ray and they are drawn on top of one another —
|
|
599
|
+
* one visible line, every label stacked on one point. `planEndpoints` cannot
|
|
600
|
+
* see this and `planBundles` will not, since a bundle is a statement about two
|
|
601
|
+
* named edges.
|
|
602
|
+
*
|
|
603
|
+
* The repair keeps the attachment rule exactly as it is and only stops two
|
|
604
|
+
* links using it at the same place: the line a link would have drawn alone is
|
|
605
|
+
* 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 link is in no group and
|
|
607
|
+
* so is untouched.
|
|
608
|
+
*
|
|
609
|
+
* Where the boxes are too small to hold the group at full spacing, the ends
|
|
610
|
+
* are squeezed evenly to fit the edge — there is nowhere further to attach —
|
|
611
|
+
* 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 labels, which
|
|
613
|
+
* ride at the midpoints, come apart even though the arrows do not. The bow is
|
|
614
|
+
* therefore derived rather than styled, and it is zero whenever the edge was
|
|
615
|
+
* long enough, which is why the ordinary case is still a straight line.
|
|
616
|
+
*
|
|
617
|
+
* A `between` link is left out. Its route is the corridor it named, its lane
|
|
618
|
+
* inside that corridor is `planCorridors`' business, and `aimFreeEnds` will
|
|
619
|
+
* re-aim these ends at the corridor afterwards regardless.
|
|
620
|
+
*/
|
|
621
|
+
function planSpreads(links, measurer, fontSize) {
|
|
622
|
+
const ids = new Map();
|
|
623
|
+
const idOf = (node) => {
|
|
624
|
+
let id = ids.get(node);
|
|
625
|
+
if (id === undefined) {
|
|
626
|
+
id = ids.size;
|
|
627
|
+
ids.set(node, id);
|
|
628
|
+
}
|
|
629
|
+
return id;
|
|
630
|
+
};
|
|
631
|
+
const groups = new Map();
|
|
632
|
+
for (const link of links) {
|
|
633
|
+
if (sideAttr(link, 'from') || sideAttr(link, 'to'))
|
|
634
|
+
continue;
|
|
635
|
+
if (link.from === link.to || link.between)
|
|
636
|
+
continue;
|
|
637
|
+
const a = idOf(link.from);
|
|
638
|
+
const b = idOf(link.to);
|
|
639
|
+
const swap = b < a;
|
|
640
|
+
const key = swap ? `${b}|${a}` : `${a}|${b}`;
|
|
641
|
+
const first = swap ? link.to : link.from;
|
|
642
|
+
const group = groups.get(key);
|
|
643
|
+
if (group)
|
|
644
|
+
group.links.push(link);
|
|
645
|
+
else
|
|
646
|
+
groups.set(key, { first, links: [link] });
|
|
647
|
+
}
|
|
648
|
+
const spreads = new Map();
|
|
649
|
+
for (const group of groups.values()) {
|
|
650
|
+
if (group.links.length < 2)
|
|
651
|
+
continue;
|
|
652
|
+
const from = centerOf(faceOf(group.first));
|
|
653
|
+
const sample = group.links[0];
|
|
654
|
+
const other = sample.from === group.first ? sample.to : sample.from;
|
|
655
|
+
const to = centerOf(faceOf(other));
|
|
656
|
+
const dx = to.x - from.x;
|
|
657
|
+
const dy = to.y - from.y;
|
|
658
|
+
const length = Math.hypot(dx, dy) || 1;
|
|
659
|
+
// Translating the line moves its midpoint — where the label goes — by
|
|
660
|
+
// exactly this, so it is the drift `laneStep` needs.
|
|
661
|
+
const across = { x: -dy / length, y: dx / length };
|
|
662
|
+
// The same derived order a bundle uses: links pointing opposite ways each
|
|
663
|
+
// keep to one side of their own run, so a reciprocal pair reads as a
|
|
664
|
+
// circulation, and only links pointing the same way fall back to the file.
|
|
665
|
+
const order = new Map(group.links.map((link, index) => [link, index]));
|
|
666
|
+
const lanes = [...group.links].sort((a, b) => Number(a.from !== group.first) - Number(b.from !== group.first) ||
|
|
667
|
+
order.get(a) - order.get(b));
|
|
668
|
+
// How far a lane may be shifted before its line no longer passes through the
|
|
669
|
+
// box at all. `exitAlong` clamps beyond that, which piles the outer lanes
|
|
670
|
+
// onto a corner and puts their labels back on top of each other — so the
|
|
671
|
+
// group is squeezed evenly instead, exactly as `planEndpoints` squeezes a
|
|
672
|
+
// side too short for the links arriving on it, and just as silently.
|
|
673
|
+
const reach = (node) => {
|
|
674
|
+
const face = faceOf(node);
|
|
675
|
+
const byX = across.x === 0 ? Infinity : face.width / 2 / Math.abs(across.x);
|
|
676
|
+
const byY = across.y === 0 ? Infinity : face.height / 2 / Math.abs(across.y);
|
|
677
|
+
return Math.max(0, Math.min(byX, byY) - ATTACH_MARGIN);
|
|
678
|
+
};
|
|
679
|
+
// Which way lane 0 lies is arbitrary, so fix it the way the rest of the
|
|
680
|
+
// renderer does — toward increasing x, or increasing y where the run is
|
|
681
|
+
// horizontal. Without this the first link written is topmost on a rightward
|
|
682
|
+
// run and rightmost on a downward one, for no reason a reader could see.
|
|
683
|
+
const orient = across.x < 0 || (across.x === 0 && across.y < 0) ? -1 : 1;
|
|
684
|
+
const usable = 2 * Math.min(reach(group.first), reach(other));
|
|
685
|
+
const wanted = Math.max(ATTACH_STEP, laneStep(lanes, across, measurer, fontSize));
|
|
686
|
+
const step = Math.min(wanted, usable / (lanes.length - 1));
|
|
687
|
+
lanes.forEach((link, index) => {
|
|
688
|
+
const place = index - (lanes.length - 1) / 2;
|
|
689
|
+
// The lane is measured across the pair's own run, which has one direction;
|
|
690
|
+
// a link written the other way round travels the opposite way and would
|
|
691
|
+
// otherwise take the same offset to the opposite side, putting a
|
|
692
|
+
// reciprocal pair back on one line. Negated, both keep to their own left,
|
|
693
|
+
// which is the circulation a bundle already draws.
|
|
694
|
+
const sense = (link.from === group.first ? 1 : -1) * orient;
|
|
695
|
+
const shortfall = place * (wanted - step) * sense;
|
|
696
|
+
spreads.set(link, {
|
|
697
|
+
offset: place * step * sense,
|
|
698
|
+
bow: { x: across.x * shortfall, y: across.y * shortfall },
|
|
699
|
+
});
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
return spreads;
|
|
703
|
+
}
|
|
704
|
+
/**
|
|
705
|
+
* The bow each bundled link needs, where the sides it was given were too short
|
|
706
|
+
* to hold the group at the spacing its labels asked for.
|
|
707
|
+
*
|
|
708
|
+
* A named side is squeezed exactly as an unnamed group's edge is — the step
|
|
709
|
+
* shrinks to `usable / (n - 1)` and the labels ride down on top of each other —
|
|
710
|
+
* and until this existed, naming the two sides the tool would have chosen
|
|
711
|
+
* anyway made the picture strictly worse than saying nothing. That is not a
|
|
712
|
+
* line worth defending, so the same repair applies: a lane's midpoint is not
|
|
713
|
+
* on an edge and is free to move, and each line makes up in the middle exactly
|
|
714
|
+
* what its two ends could not give it.
|
|
715
|
+
*
|
|
716
|
+
* The shortfall is a vector because the two ends move along different sides.
|
|
717
|
+
* `drift` is how far a lane's midpoint travels per unit of step — the average
|
|
718
|
+
* of the two ends' displacements, which is what `laneStep` sized the step
|
|
719
|
+
* against — so the room a lane wanted is `drift * step`, the room it got is the
|
|
720
|
+
* same average taken over the steps the two sides actually managed, and the
|
|
721
|
+
* bow is the difference. It is zero whenever both sides were long enough,
|
|
722
|
+
* which is why nothing that already fitted has moved.
|
|
723
|
+
*/
|
|
724
|
+
function bowBundles(bundles, achieved) {
|
|
725
|
+
const bows = new Map();
|
|
726
|
+
const stepOn = (end) => achieved.get(end.node)?.get(end.side) ?? 0;
|
|
727
|
+
for (const bundle of new Set(bundles.values())) {
|
|
728
|
+
const [first, second] = bundle.ends;
|
|
729
|
+
const t0 = tangentOf(first.side);
|
|
730
|
+
const t1 = tangentOf(second.side);
|
|
731
|
+
const sense = bundle.aligned ? 1 : -1;
|
|
732
|
+
const drift = { x: (t0.x + sense * t1.x) / 2, y: (t0.y + sense * t1.y) / 2 };
|
|
733
|
+
const s0 = stepOn(first);
|
|
734
|
+
const s1 = stepOn(second);
|
|
735
|
+
const got = { x: (s0 * t0.x + sense * s1 * t1.x) / 2, y: (s0 * t0.y + sense * s1 * t1.y) / 2 };
|
|
736
|
+
const short = {
|
|
737
|
+
x: drift.x * bundle.step - got.x,
|
|
738
|
+
y: drift.y * bundle.step - got.y,
|
|
739
|
+
};
|
|
740
|
+
if (short.x === 0 && short.y === 0)
|
|
741
|
+
continue;
|
|
742
|
+
bundle.lanes.forEach((link, index) => {
|
|
743
|
+
const place = index - (bundle.lanes.length - 1) / 2;
|
|
744
|
+
bows.set(link, { x: short.x * place, y: short.y * place });
|
|
745
|
+
});
|
|
746
|
+
}
|
|
747
|
+
return bows;
|
|
748
|
+
}
|
|
749
|
+
/**
|
|
750
|
+
* How far apart adjacent lanes must sit for their labels to clear each other.
|
|
751
|
+
*
|
|
752
|
+
* The labels are knockout rectangles, so two of them clear when they are apart
|
|
753
|
+
* on *either* axis — hence the smaller of the two answers. `drift` is how far
|
|
754
|
+
* the midpoint travels per unit of step, and it is never zero: the two ends
|
|
755
|
+
* cancel only when both sides run the same way, and two such sides are always
|
|
756
|
+
* `aligned`, which adds rather than subtracts.
|
|
757
|
+
*/
|
|
758
|
+
function laneStep(lanes, drift, measurer, fontSize) {
|
|
759
|
+
const labeled = lanes.filter((link) => link.label !== undefined);
|
|
760
|
+
if (labeled.length < 2)
|
|
761
|
+
return 0;
|
|
762
|
+
const need = (axis) => Math.max(...labeled.map((link) => labelExtent(link.label, link.appearance, axis, measurer, fontSize, link.line)));
|
|
763
|
+
const along = (axis, reach) => reach === 0 ? Infinity : need(axis) / Math.abs(reach);
|
|
764
|
+
return Math.min(along('x', drift.x), along('y', drift.y));
|
|
765
|
+
}
|
|
766
|
+
/** Which lane of its bundle a link's end at this side takes, if it is in one. */
|
|
767
|
+
function rankIn(bundle, link, node, side) {
|
|
768
|
+
if (!bundle)
|
|
769
|
+
return undefined;
|
|
770
|
+
const lane = bundle.lanes.indexOf(link);
|
|
771
|
+
const [first, second] = bundle.ends;
|
|
772
|
+
if (node === first.node && side === first.side)
|
|
773
|
+
return lane;
|
|
774
|
+
if (node === second.node && side === second.side)
|
|
775
|
+
return bundle.aligned ? lane : -lane;
|
|
776
|
+
return undefined;
|
|
777
|
+
}
|
|
778
|
+
/** The unit vector along a side, pointing the way that coordinate increases. */
|
|
779
|
+
function tangentOf(side) {
|
|
780
|
+
return side === 'top' || side === 'bottom' ? { x: 1, y: 0 } : { x: 0, y: 1 };
|
|
781
|
+
}
|
|
782
|
+
/** The midpoint of one side of a box. */
|
|
783
|
+
function sideCenter(end) {
|
|
784
|
+
const face = faceOf(end.node);
|
|
785
|
+
const along = end.side === 'top' || end.side === 'bottom' ? face.width : face.height;
|
|
786
|
+
const origin = end.side === 'top' || end.side === 'bottom' ? face.x : face.y;
|
|
787
|
+
return anchorOn(face, end.side, origin + along / 2);
|
|
788
|
+
}
|
|
789
|
+
function cross(a, b) {
|
|
790
|
+
return a.x * b.y - a.y * b.x;
|
|
791
|
+
}
|
|
792
|
+
function claim(claims, node, side, entry) {
|
|
793
|
+
let bySide = claims.get(node);
|
|
794
|
+
if (!bySide) {
|
|
795
|
+
bySide = new Map();
|
|
796
|
+
claims.set(node, bySide);
|
|
797
|
+
}
|
|
798
|
+
const group = bySide.get(side);
|
|
799
|
+
if (group)
|
|
800
|
+
group.push(entry);
|
|
801
|
+
else
|
|
802
|
+
bySide.set(side, [entry]);
|
|
803
|
+
}
|
|
804
|
+
/** The point `at` along one side of a box, with the outward normal for that side. */
|
|
805
|
+
function anchorOn(face, side, at) {
|
|
806
|
+
switch (side) {
|
|
807
|
+
case 'top':
|
|
808
|
+
return { x: at, y: face.y, tx: 0, ty: -1, side };
|
|
809
|
+
case 'bottom':
|
|
810
|
+
return { x: at, y: face.y + face.height, tx: 0, ty: 1, side };
|
|
811
|
+
case 'left':
|
|
812
|
+
return { x: face.x, y: at, tx: -1, ty: 0, side };
|
|
813
|
+
case 'right':
|
|
814
|
+
return { x: face.x + face.width, y: at, tx: 1, ty: 0, side };
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
/** An end with no side named: leave from the border, pointing at the far end. */
|
|
818
|
+
function free(face, toward) {
|
|
819
|
+
const point = edgePoint(face, toward);
|
|
820
|
+
const center = centerOf(face);
|
|
821
|
+
const dx = point.x - center.x;
|
|
822
|
+
const dy = point.y - center.y;
|
|
823
|
+
const length = Math.hypot(dx, dy) || 1;
|
|
824
|
+
return { x: point.x, y: point.y, tx: dx / length, ty: dy / length };
|
|
825
|
+
}
|
|
826
|
+
/**
|
|
827
|
+
* Walk from a point inside a box along a direction, stopping at the border.
|
|
828
|
+
*
|
|
829
|
+
* `edgePoint` walks from the center, which is the only place a single line
|
|
830
|
+
* passes through. A fanned-out group's lines are parallel to that one and
|
|
831
|
+
* beside it, so each needs the border crossing of its own line rather than of
|
|
832
|
+
* the center's — which is what keeps the group parallel instead of splayed.
|
|
833
|
+
*/
|
|
834
|
+
function exitAlong(box, from, dir) {
|
|
835
|
+
// A shift wider than the box leaves the origin outside it; clamping back in
|
|
836
|
+
// is the graceful answer, and the crowding it signals is a diagnostic.
|
|
837
|
+
const x = Math.min(Math.max(from.x, box.x), box.x + box.width);
|
|
838
|
+
const y = Math.min(Math.max(from.y, box.y), box.y + box.height);
|
|
839
|
+
const tx = dir.x === 0 ? Infinity : ((dir.x > 0 ? box.x + box.width : box.x) - x) / dir.x;
|
|
840
|
+
const ty = dir.y === 0 ? Infinity : ((dir.y > 0 ? box.y + box.height : box.y) - y) / dir.y;
|
|
841
|
+
const t = Math.min(tx, ty);
|
|
842
|
+
if (!Number.isFinite(t))
|
|
843
|
+
return { x, y };
|
|
844
|
+
return { x: x + dir.x * Math.max(0, t), y: y + dir.y * Math.max(0, t) };
|
|
845
|
+
}
|
|
846
|
+
/**
|
|
847
|
+
* Both ends of a link that named no side, moved `offset` sideways across its
|
|
848
|
+
* own run.
|
|
849
|
+
*
|
|
850
|
+
* 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 link would have
|
|
852
|
+
* drawn alone, exactly `offset` away from it. Where each end lands then falls
|
|
853
|
+
* out of that: level boxes put both points further along the same two edges,
|
|
854
|
+
* and a diagonal pair whose line leaves through a corner puts one point on each
|
|
855
|
+
* of the two edges meeting there. Neither is a case in the code.
|
|
856
|
+
*/
|
|
857
|
+
function parallelEnds(from, to, offset) {
|
|
858
|
+
const a = centerOf(from);
|
|
859
|
+
const b = centerOf(to);
|
|
860
|
+
const dx = b.x - a.x;
|
|
861
|
+
const dy = b.y - a.y;
|
|
862
|
+
const length = Math.hypot(dx, dy) || 1;
|
|
863
|
+
const dir = { x: dx / length, y: dy / length };
|
|
864
|
+
const across = { x: -dir.y * offset, y: dir.x * offset };
|
|
865
|
+
const startAt = exitAlong(from, { x: a.x + across.x, y: a.y + across.y }, dir);
|
|
866
|
+
const endAt = exitAlong(to, { x: b.x + across.x, y: b.y + across.y }, { x: -dir.x, y: -dir.y });
|
|
867
|
+
return {
|
|
868
|
+
start: { x: startAt.x, y: startAt.y, tx: dir.x, ty: dir.y },
|
|
869
|
+
end: { x: endAt.x, y: endAt.y, tx: -dir.x, ty: -dir.y },
|
|
870
|
+
};
|
|
871
|
+
}
|
|
872
|
+
/** How far the control points sit off the ends. Proportional, but bounded. */
|
|
873
|
+
function controlReach(start, end) {
|
|
874
|
+
const distance = Math.hypot(end.x - start.x, end.y - start.y);
|
|
875
|
+
return Math.max(24, Math.min(140, distance * 0.4));
|
|
876
|
+
}
|
|
877
|
+
/** The free interval between two boxes on one axis, or nothing if they overlap. */
|
|
878
|
+
function clearance(aStart, aSize, bStart, bSize) {
|
|
879
|
+
if (aStart + aSize < bStart)
|
|
880
|
+
return { lo: aStart + aSize, hi: bStart };
|
|
881
|
+
if (bStart + bSize < aStart)
|
|
882
|
+
return { lo: bStart + bSize, hi: aStart };
|
|
883
|
+
return undefined;
|
|
884
|
+
}
|
|
885
|
+
/**
|
|
886
|
+
* Which gap `between a and b` means, and how far along it reaches.
|
|
887
|
+
*
|
|
888
|
+
* The axis is derived wherever the pair leaves only one answer, the same way a
|
|
889
|
+
* separation direction is: one node is above the other, or one is left of the
|
|
890
|
+
* other, and whichever it is says which axis the gap binds. Most pairs are like
|
|
891
|
+
* that, and for them the file says nothing about axes at all.
|
|
892
|
+
*
|
|
893
|
+
* A pair sitting diagonally has two gaps and needs the author to pick, which is
|
|
894
|
+
* what `wanted` carries. That is a tie-break rather than part of the statement:
|
|
895
|
+
* where it is not needed it may still be written, and is then checked rather
|
|
896
|
+
* than ignored, because a word that silently does nothing looks like a bug in
|
|
897
|
+
* the tool.
|
|
898
|
+
*/
|
|
899
|
+
function gapBetween(a, b, aName, bName, wanted, line) {
|
|
900
|
+
const pair = `"${aName}" and "${bName}"`;
|
|
901
|
+
const found = {
|
|
902
|
+
y: clearance(a.y, a.height, b.y, b.height),
|
|
903
|
+
x: clearance(a.x, a.width, b.x, b.width),
|
|
904
|
+
};
|
|
905
|
+
if (wanted !== undefined && found[wanted] === undefined) {
|
|
906
|
+
const other = wanted === 'y' ? 'x' : 'y';
|
|
907
|
+
throw new SourceError(found[other]
|
|
908
|
+
? `${pair} have no gap between them ${describeAxis(wanted)} — they are apart ${describeAxis(other)}, so drop the word or say "${describeAxis(other)}"`
|
|
909
|
+
: `${pair} touch or overlap, so there is no gap between them to pass through`, line);
|
|
910
|
+
}
|
|
911
|
+
if (wanted === undefined && found.y && found.x) {
|
|
912
|
+
throw new SourceError(`${pair} are apart both vertically and horizontally, so I cannot tell which gap you mean — write "between ${aName} and ${bName} vertically" for the gap above and below them, or "horizontally" for the gap beside them`, line);
|
|
913
|
+
}
|
|
914
|
+
// Whichever was asked for, or whichever is the only one there is.
|
|
915
|
+
const axis = wanted ?? (found.y ? 'y' : 'x');
|
|
916
|
+
const gap = found[axis];
|
|
917
|
+
if (!gap) {
|
|
918
|
+
throw new SourceError(`${pair} touch or overlap, so there is no gap between them to pass through`, line);
|
|
919
|
+
}
|
|
920
|
+
// The corridor reaches as far as the pair does on the other axis: that is the
|
|
921
|
+
// stretch over which the line is actually passing them.
|
|
922
|
+
return {
|
|
923
|
+
axis,
|
|
924
|
+
...gap,
|
|
925
|
+
across: axis === 'y'
|
|
926
|
+
? [Math.min(a.x, b.x), Math.max(a.x + a.width, b.x + b.width)]
|
|
927
|
+
: [Math.min(a.y, b.y), Math.max(a.y + a.height, b.y + b.height)],
|
|
928
|
+
};
|
|
929
|
+
}
|
|
930
|
+
/**
|
|
931
|
+
* Route every link that named a gap.
|
|
932
|
+
*
|
|
933
|
+
* Links sharing one gap share its lanes, spread like attachments on a side and
|
|
934
|
+
* ordered the same derived way — by where their ends actually sit, so the two
|
|
935
|
+
* arriving at Dropbox's left edge in one order run through the corridor in that
|
|
936
|
+
* same order and never cross.
|
|
937
|
+
*/
|
|
938
|
+
function planCorridors(links, ends, measurer, fontSize) {
|
|
939
|
+
const plans = new Map();
|
|
940
|
+
const groups = new Map();
|
|
941
|
+
for (const link of links) {
|
|
942
|
+
if (!link.between)
|
|
943
|
+
continue;
|
|
944
|
+
const [first, second] = link.between.nodes;
|
|
945
|
+
const gap = gapBetween(faceOf(first), faceOf(second), first.name, second.name, link.between.axis, link.line);
|
|
946
|
+
// 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 links may
|
|
948
|
+
// legitimately name the same pair and take different ones.
|
|
949
|
+
const key = [gap.axis, ...[first.name, second.name].sort()].join(' ');
|
|
950
|
+
const group = groups.get(key);
|
|
951
|
+
if (group)
|
|
952
|
+
group.members.push(link);
|
|
953
|
+
else
|
|
954
|
+
groups.set(key, { ...gap, members: [link] });
|
|
955
|
+
}
|
|
956
|
+
for (const group of groups.values()) {
|
|
957
|
+
const along = (link) => {
|
|
958
|
+
const { start, end } = ends.get(link);
|
|
959
|
+
return (start[group.axis] + end[group.axis]) / 2;
|
|
960
|
+
};
|
|
961
|
+
const ordered = [...group.members].sort((a, b) => along(a) - along(b));
|
|
962
|
+
// Lanes are spread as attachments on a side are, including the squeeze when
|
|
963
|
+
// there is not enough room — see `planEndpoints`. The step is wider here,
|
|
964
|
+
// because a lane carries a whole label rather than the point of an arrow,
|
|
965
|
+
// and two lanes closer together than a label is deep would draw the labels
|
|
966
|
+
// over each other. Still derived, not chosen: it is the size of what is
|
|
967
|
+
// actually running along the corridor.
|
|
968
|
+
const span = group.hi - group.lo;
|
|
969
|
+
const usable = Math.max(0, span - ATTACH_MARGIN * 2);
|
|
970
|
+
const want = Math.max(ATTACH_STEP, ...ordered.map((link) => laneExtent(link, group.axis, measurer, fontSize)));
|
|
971
|
+
const step = ordered.length > 1 ? Math.min(want, usable / (ordered.length - 1)) : 0;
|
|
972
|
+
const firstLane = group.lo + span / 2 - (step * (ordered.length - 1)) / 2;
|
|
973
|
+
ordered.forEach((link, index) => {
|
|
974
|
+
const { start, end } = ends.get(link);
|
|
975
|
+
const run = group.axis === 'y' ? 'x' : 'y';
|
|
976
|
+
// The corridor binds only where the link is actually passing the pair, so
|
|
977
|
+
// its reach is the overlap of the pair's extent with the link's own.
|
|
978
|
+
const enterAt = Math.max(group.across[0], Math.min(start[run], end[run]));
|
|
979
|
+
const leaveAt = Math.min(group.across[1], Math.max(start[run], end[run]));
|
|
980
|
+
if (leaveAt <= enterAt) {
|
|
981
|
+
const [a, b] = link.between.nodes;
|
|
982
|
+
throw new SourceError(`this link never passes between "${a.name}" and "${b.name}"`, link.line);
|
|
983
|
+
}
|
|
984
|
+
const forward = end[run] >= start[run];
|
|
985
|
+
plans.set(link, {
|
|
986
|
+
axis: group.axis,
|
|
987
|
+
lane: firstLane + index * step,
|
|
988
|
+
enter: forward ? enterAt : leaveAt,
|
|
989
|
+
leave: forward ? leaveAt : enterAt,
|
|
990
|
+
});
|
|
991
|
+
});
|
|
992
|
+
}
|
|
993
|
+
return plans;
|
|
994
|
+
}
|
|
995
|
+
/**
|
|
996
|
+
* An end whose side the author did not name aims at the far box's center, which
|
|
997
|
+
* is the wrong thing to aim at once the line has been told to go somewhere else
|
|
998
|
+
* on the way. Point those ends at the corridor instead.
|
|
999
|
+
*/
|
|
1000
|
+
function aimFreeEnds(links, ends, corridors) {
|
|
1001
|
+
for (const link of links) {
|
|
1002
|
+
const plan = corridors.get(link);
|
|
1003
|
+
if (!plan)
|
|
1004
|
+
continue;
|
|
1005
|
+
const current = ends.get(link);
|
|
1006
|
+
const start = current.start.side === undefined
|
|
1007
|
+
? free(faceOf(link.from), corridorPoint(plan, plan.enter))
|
|
1008
|
+
: current.start;
|
|
1009
|
+
const end = current.end.side === undefined
|
|
1010
|
+
? free(faceOf(link.to), corridorPoint(plan, plan.leave))
|
|
1011
|
+
: current.end;
|
|
1012
|
+
ends.set(link, { start, end });
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
/**
|
|
1016
|
+
* How much room a link's label takes across the corridor — its depth in a
|
|
1017
|
+
* horizontal channel, its width in a vertical one. Zero for an unlabeled link,
|
|
1018
|
+
* which needs no more than the arrow spacing.
|
|
1019
|
+
*
|
|
1020
|
+
* `labelExtent` measures the knockout along whichever axis it is handed, and the
|
|
1021
|
+
* axis wanted here is the one the channel is measured on rather than the one the
|
|
1022
|
+
* link runs along — a channel measured vertically carries links running
|
|
1023
|
+
* horizontally, and what has to fit between two lanes of it is a label's depth.
|
|
1024
|
+
*/
|
|
1025
|
+
function laneExtent(link, axis, measurer, fontSize) {
|
|
1026
|
+
if (link.label === undefined)
|
|
1027
|
+
return 0;
|
|
1028
|
+
return labelExtent(link.label, link.appearance, axis, measurer, fontSize, link.line);
|
|
1029
|
+
}
|
|
1030
|
+
function corridorPoint(plan, at) {
|
|
1031
|
+
return plan.axis === 'y' ? { x: at, y: plan.lane } : { x: plan.lane, y: at };
|
|
1032
|
+
}
|
|
1033
|
+
/**
|
|
1034
|
+
* The path a corridor link takes: a curve out of its start into the gap, the
|
|
1035
|
+
* straight run along the gap, and a curve out of the gap to its end. It is
|
|
1036
|
+
* three pieces rather than one cubic because a single curve has no way to stay
|
|
1037
|
+
* inside an interval over part of its length — which is the whole claim the
|
|
1038
|
+
* author is making.
|
|
1039
|
+
*/
|
|
1040
|
+
function corridorPath(start, end, plan) {
|
|
1041
|
+
const p1 = corridorPoint(plan, plan.enter);
|
|
1042
|
+
const p2 = corridorPoint(plan, plan.leave);
|
|
1043
|
+
const forward = plan.leave >= plan.enter ? 1 : -1;
|
|
1044
|
+
// Along the run the line travels one way, so that is its tangent at both ends
|
|
1045
|
+
// of the straight stretch — it enters the gap already going where the gap goes.
|
|
1046
|
+
const rt = plan.axis === 'y' ? { tx: forward, ty: 0 } : { tx: 0, ty: forward };
|
|
1047
|
+
const r1 = corridorReach(start, p1, plan.axis);
|
|
1048
|
+
const c1 = { x: start.x + start.tx * r1, y: start.y + start.ty * r1 };
|
|
1049
|
+
const c2 = { x: p1.x - rt.tx * r1, y: p1.y - rt.ty * r1 };
|
|
1050
|
+
const r2 = corridorReach(p2, end, plan.axis);
|
|
1051
|
+
const c3 = { x: p2.x + rt.tx * r2, y: p2.y + rt.ty * r2 };
|
|
1052
|
+
const c4 = { x: end.x + end.tx * r2, y: end.y + end.ty * r2 };
|
|
1053
|
+
const d = [
|
|
1054
|
+
`M ${round(start.x)} ${round(start.y)}`,
|
|
1055
|
+
`C ${round(c1.x)} ${round(c1.y)}, ${round(c2.x)} ${round(c2.y)}, ${round(p1.x)} ${round(p1.y)}`,
|
|
1056
|
+
`L ${round(p2.x)} ${round(p2.y)}`,
|
|
1057
|
+
`C ${round(c3.x)} ${round(c3.y)}, ${round(c4.x)} ${round(c4.y)}, ${round(end.x)} ${round(end.y)}`,
|
|
1058
|
+
].join(' ');
|
|
1059
|
+
const ink = union(cubicExtent(start, c1, c2, p1), cubicExtent(p2, c3, c4, end));
|
|
1060
|
+
return { d, mid: { x: (p1.x + p2.x) / 2, y: (p1.y + p2.y) / 2 }, ink };
|
|
1061
|
+
}
|
|
1062
|
+
/**
|
|
1063
|
+
* How far the handles reach on an approach curve. Bounded by half the distance
|
|
1064
|
+
* available along the run as well as by the straight-line distance: both ends of
|
|
1065
|
+
* this curve point along the run, so handles longer than that would reach past
|
|
1066
|
+
* each other and bulge the line back the way it came.
|
|
1067
|
+
*/
|
|
1068
|
+
function corridorReach(from, to, axis) {
|
|
1069
|
+
const run = Math.abs(axis === 'y' ? to.x - from.x : to.y - from.y);
|
|
1070
|
+
const distance = Math.hypot(to.x - from.x, to.y - from.y);
|
|
1071
|
+
return Math.min(140, Math.max(8, Math.min(distance * 0.4, run / 2)));
|
|
1072
|
+
}
|
|
1073
|
+
function sideAttr(link, key) {
|
|
1074
|
+
const value = link.attrs[key];
|
|
1075
|
+
if (value === undefined)
|
|
1076
|
+
return undefined;
|
|
1077
|
+
if (!SIDES.includes(value)) {
|
|
1078
|
+
throw new SourceError(`"${key}: ${value}" is not a side — use ${SIDES.join(', ')}`, link.line);
|
|
1079
|
+
}
|
|
1080
|
+
return value;
|
|
1081
|
+
}
|
|
1082
|
+
function arrowMarker(color) {
|
|
1083
|
+
const id = markerId(color);
|
|
1084
|
+
return [
|
|
1085
|
+
` <marker id="${id}" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="${ARROW_MARKER_WIDTH}" markerHeight="${ARROW_MARKER_WIDTH}" orient="auto-start-reverse">`,
|
|
1086
|
+
` <path d="M 0 0 L 10 5 L 0 10 z" fill="${color}"/>`,
|
|
1087
|
+
' </marker>',
|
|
1088
|
+
` <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">`,
|
|
1089
|
+
` <path d="M 0 0 L 10 5 L 0 10 z" fill="${color}"/>`,
|
|
1090
|
+
' </marker>',
|
|
1091
|
+
].join('\n');
|
|
1092
|
+
}
|
|
1093
|
+
function markerId(color) {
|
|
1094
|
+
return `arrow-${color.replace(/[^a-zA-Z0-9]/g, '')}`;
|
|
1095
|
+
}
|
|
1096
|
+
/** The rectangle actually drawn. Differs from the node box only for a deck. */
|
|
1097
|
+
function faceOf(node) {
|
|
1098
|
+
return {
|
|
1099
|
+
x: node.x + node.inset,
|
|
1100
|
+
y: node.y + node.inset,
|
|
1101
|
+
width: node.width - node.inset,
|
|
1102
|
+
height: node.height - node.inset,
|
|
1103
|
+
};
|
|
1104
|
+
}
|
|
1105
|
+
function centerOf(box) {
|
|
1106
|
+
return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
|
|
1107
|
+
}
|
|
1108
|
+
/**
|
|
1109
|
+
* Wrap a block of text in its own size, but only when that differs from the
|
|
1110
|
+
* document's — everything at the default size inherits it from the <svg>
|
|
1111
|
+
* element, so an ordinary diagram's output is unchanged.
|
|
1112
|
+
*/
|
|
1113
|
+
function sized(block, size, fontSize) {
|
|
1114
|
+
if (size === fontSize || block.length === 0)
|
|
1115
|
+
return block;
|
|
1116
|
+
return ` <g font-size="${size}px">\n${block}\n </g>`;
|
|
1117
|
+
}
|
|
1118
|
+
function textBlock(lines, x, top, width, lineHeight, fontSize, style) {
|
|
1119
|
+
const anchorX = style.align === 'middle' ? x + width / 2 : style.align === 'end' ? x + width : x;
|
|
1120
|
+
return lines
|
|
1121
|
+
.map((line, index) => {
|
|
1122
|
+
if (line.length === 0)
|
|
1123
|
+
return '';
|
|
1124
|
+
const baseline = top + index * lineHeight + lineHeight / 2 + fontSize * 0.35;
|
|
1125
|
+
// A label's first line is its name; anything after it is a qualifier, and
|
|
1126
|
+
// `subtext:` is how a box says that qualifier should read as secondary.
|
|
1127
|
+
const color = index === 0 ? style.color : style.subColor ?? style.color;
|
|
1128
|
+
return ` <text x="${round(anchorX)}" y="${round(baseline)}" fill="${color}" text-anchor="${style.align}">${escapeXml(line)}</text>`;
|
|
1129
|
+
})
|
|
1130
|
+
.filter((element) => element.length > 0)
|
|
1131
|
+
.join('\n');
|
|
1132
|
+
}
|
|
1133
|
+
/**
|
|
1134
|
+
* A color is written as the viewer will receive it — `#14532d`, or any CSS
|
|
1135
|
+
* color. The renderer keeps no list of color words of its own, so a diagram
|
|
1136
|
+
* is never limited to the ones somebody remembered to add here.
|
|
1137
|
+
*
|
|
1138
|
+
* Each names the part it colors, so each reads exactly one key. The word these
|
|
1139
|
+
* replaced, `stroke:`, named no part and meant a different one on every kind,
|
|
1140
|
+
* which is why a box's text could not be colored at all until `text:`.
|
|
1141
|
+
*/
|
|
1142
|
+
function borderOf(appearance, fallback) {
|
|
1143
|
+
return appearance['border'] ?? fallback;
|
|
1144
|
+
}
|
|
1145
|
+
function lineOf(appearance, fallback) {
|
|
1146
|
+
return appearance['line'] ?? fallback;
|
|
1147
|
+
}
|
|
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
|
+
function fillOf(appearance, fallback) {
|
|
1166
|
+
return appearance['fill'] ?? fallback;
|
|
1167
|
+
}
|
|
1168
|
+
function round(value) {
|
|
1169
|
+
return Math.round(value * 100) / 100;
|
|
1170
|
+
}
|
|
1171
|
+
/**
|
|
1172
|
+
* Escapes what has to be escaped in element content, and no more. A double
|
|
1173
|
+
* quote is legal there, and some SVG renderers mishandle `"` in text.
|
|
1174
|
+
*/
|
|
1175
|
+
function escapeXml(text) {
|
|
1176
|
+
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
1177
|
+
}
|
|
1178
|
+
function quote(value) {
|
|
1179
|
+
return `"${value.replace(/"/g, "'")}"`;
|
|
1180
|
+
}
|