reladraw 0.1.0 → 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/README.md +25 -23
- package/SYNTAX.md +137 -54
- package/dist/ast.d.ts +73 -5
- package/dist/ast.js +99 -6
- package/dist/constants.d.ts +8 -5
- package/dist/constants.js +14 -12
- package/dist/grammar.d.ts +7 -7
- package/dist/grammar.js +9 -9
- package/dist/icons.d.ts +3 -3
- package/dist/icons.js +2 -2
- package/dist/lexer.d.ts +1 -1
- package/dist/lexer.js +1 -1
- package/dist/parser.js +47 -6
- package/dist/render.js +97 -81
- package/dist/resolve.js +144 -26
- package/package.json +1 -1
package/dist/render.js
CHANGED
|
@@ -19,7 +19,7 @@ export const DARK_THEME = {
|
|
|
19
19
|
mutedText: '#8b8b8b',
|
|
20
20
|
link: '#5c5c7c',
|
|
21
21
|
// Both sampled off the reference's machine glyphs. Note that the reference
|
|
22
|
-
// gives each icon its own hue — the drive is
|
|
22
|
+
// gives each icon its own hue — the drive is gray, the laptop periwinkle, the
|
|
23
23
|
// workstation violet — which is a drawing tool's per-shape default and not a
|
|
24
24
|
// system. One pair for the whole set is the deliberate difference: an icon
|
|
25
25
|
// should read as part of the diagram's palette, not as clip art dropped in.
|
|
@@ -64,11 +64,11 @@ export function render(layout, options = {}) {
|
|
|
64
64
|
width: Math.ceil(ink.maxX) - Math.floor(ink.minX),
|
|
65
65
|
height: Math.ceil(ink.maxY) - Math.floor(ink.minY),
|
|
66
66
|
};
|
|
67
|
-
const
|
|
67
|
+
const arrowColors = new Set(layout.links.map((link) => lineOf(link.appearance, theme.link)));
|
|
68
68
|
return [
|
|
69
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
70
|
' <defs>',
|
|
71
|
-
...[...
|
|
71
|
+
...[...arrowColors].map((color) => arrowMarker(color)),
|
|
72
72
|
' </defs>',
|
|
73
73
|
` <rect x="${canvas.x}" y="${canvas.y}" width="${canvas.width}" height="${canvas.height}" fill="${theme.background}"/>`,
|
|
74
74
|
...body,
|
|
@@ -85,7 +85,7 @@ function drawNode(node, theme, measurer, fontSize) {
|
|
|
85
85
|
const textHeight = measurer.lineHeight(size);
|
|
86
86
|
if (node.kind === 'note') {
|
|
87
87
|
return sized(textBlock(node.lines, node.x, node.y, node.width, textHeight, size, {
|
|
88
|
-
|
|
88
|
+
color: textOf(node.appearance, theme.text),
|
|
89
89
|
align: 'start',
|
|
90
90
|
}), size, fontSize);
|
|
91
91
|
}
|
|
@@ -94,12 +94,12 @@ function drawNode(node, theme, measurer, fontSize) {
|
|
|
94
94
|
const glyphSide = ICON_LINES * textHeight;
|
|
95
95
|
if (shape.body !== undefined) {
|
|
96
96
|
// No outline, no fill, no padding — the node is the picture. The label, if
|
|
97
|
-
// there is one, sits under it and
|
|
97
|
+
// there is one, sits under it and centered.
|
|
98
98
|
const drawn = [drawIcon(shape.body, node.x + (node.width - glyphSide) / 2, node.y, glyphSide, theme)];
|
|
99
99
|
if (node.lines.some((line) => line.length > 0)) {
|
|
100
100
|
drawn.push(sized(textBlock(node.lines, node.x, node.y + glyphSide + ICON_GAP, node.width, textHeight, size, {
|
|
101
|
-
|
|
102
|
-
|
|
101
|
+
color: textOf(node.appearance, theme.text),
|
|
102
|
+
subColor: subtextOf(node.appearance, theme),
|
|
103
103
|
align: 'middle',
|
|
104
104
|
}), size, fontSize));
|
|
105
105
|
}
|
|
@@ -108,25 +108,28 @@ function drawNode(node, theme, measurer, fontSize) {
|
|
|
108
108
|
const parts = [];
|
|
109
109
|
const face = faceOf(node);
|
|
110
110
|
const container = node.children.length > 0;
|
|
111
|
-
const
|
|
111
|
+
const border = borderOf(node.appearance, container ? theme.containerStroke : theme.boxStroke);
|
|
112
112
|
const fill = fillOf(node.appearance, container ? theme.containerFill : theme.boxFill);
|
|
113
|
-
|
|
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);
|
|
114
117
|
// Deck copies sit behind the front face, furthest back drawn first.
|
|
115
118
|
for (let depth = node.deckLabels.length; depth >= 1; depth -= 1) {
|
|
116
119
|
const x = face.x - depth * DECK_STEP;
|
|
117
120
|
const y = face.y - depth * DECK_STEP;
|
|
118
|
-
parts.push(` <path d="${outlinePath(shape.outline, x, y, face.width, face.height)}" fill="${theme.containerFill}" stroke="${
|
|
121
|
+
parts.push(` <path d="${outlinePath(shape.outline, x, y, face.width, face.height)}" fill="${theme.containerFill}" stroke="${border}"/>`);
|
|
119
122
|
const label = node.deckLabels[depth - 1];
|
|
120
123
|
if (label !== undefined) {
|
|
121
124
|
parts.push(sized(textBlock([label], x + PAD, y + PAD, face.width - PAD * 2, textHeight, size, {
|
|
122
|
-
|
|
125
|
+
color: text,
|
|
123
126
|
align: 'start',
|
|
124
127
|
}), size, fontSize));
|
|
125
128
|
}
|
|
126
129
|
}
|
|
127
|
-
parts.push(` <path d="${outlinePath(shape.outline, face.x, face.y, face.width, face.height)}" fill="${fill}" stroke="${
|
|
130
|
+
parts.push(` <path d="${outlinePath(shape.outline, face.x, face.y, face.width, face.height)}" fill="${fill}" stroke="${border}"/>`);
|
|
128
131
|
for (const extra of outlineDetail(shape.outline, face.x, face.y, face.width, face.height)) {
|
|
129
|
-
parts.push(` <path d="${extra}" fill="none" stroke="${
|
|
132
|
+
parts.push(` <path d="${extra}" fill="none" stroke="${border}"/>`);
|
|
130
133
|
}
|
|
131
134
|
// The icon takes a column on the right and the label lays out in what is
|
|
132
135
|
// left, which is the room the resolver already reserved for exactly this.
|
|
@@ -135,24 +138,27 @@ function drawNode(node, theme, measurer, fontSize) {
|
|
|
135
138
|
const hasLabel = node.lines.some((line) => line.length > 0);
|
|
136
139
|
const iconRoom = icon === undefined ? 0 : iconSide + (hasLabel ? ICON_GAP : 0);
|
|
137
140
|
if (!container) {
|
|
138
|
-
// A leaf
|
|
139
|
-
// icon rather than the whole box, so the two sit side by side.
|
|
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;
|
|
140
146
|
const top = face.y + (face.height - node.lines.length * textHeight) / 2;
|
|
141
147
|
parts.push(sized(textBlock(node.lines, face.x, top, face.width - iconRoom, textHeight, size, {
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
align:
|
|
148
|
+
color: text,
|
|
149
|
+
subColor,
|
|
150
|
+
align: leafAlign,
|
|
145
151
|
}), size, fontSize));
|
|
146
152
|
}
|
|
147
153
|
else {
|
|
148
154
|
// The label and the icon share a band at one end of the box, and the
|
|
149
155
|
// resolver has already given the contents the other end. A heading is
|
|
150
|
-
// ranged left at the top; a caption is
|
|
156
|
+
// ranged left at the top; a caption is centered at the bottom.
|
|
151
157
|
const band = Math.max(node.lines.length * textHeight, iconSide);
|
|
152
158
|
const bandTop = labelStyle.at === 'top' ? face.y + PAD : face.y + face.height - PAD - band;
|
|
153
159
|
parts.push(sized(textBlock(node.lines, face.x + PAD, bandTop, face.width - PAD * 2 - iconRoom, textHeight, size, {
|
|
154
|
-
|
|
155
|
-
|
|
160
|
+
color: text,
|
|
161
|
+
subColor,
|
|
156
162
|
align: labelStyle.align,
|
|
157
163
|
}), size, fontSize));
|
|
158
164
|
for (const child of node.children) {
|
|
@@ -161,7 +167,7 @@ function drawNode(node, theme, measurer, fontSize) {
|
|
|
161
167
|
}
|
|
162
168
|
if (icon !== undefined) {
|
|
163
169
|
// A container's icon rides in the label's band, at whichever end that is; a
|
|
164
|
-
// leaf's label is
|
|
170
|
+
// leaf's label is centered, so the icon centers with it. Both follow the
|
|
165
171
|
// label rather than being placed by a rule of their own, which is what
|
|
166
172
|
// keeps an icon reading as part of the title block and not as a sticker.
|
|
167
173
|
const left = face.x + face.width - PAD - iconSide;
|
|
@@ -226,12 +232,12 @@ function outlineDetail(shape, x, y, w, h) {
|
|
|
226
232
|
/** One icon, scaled from its own grid onto a square of `side` at `x, y`. */
|
|
227
233
|
function drawIcon(icon, x, y, side, theme) {
|
|
228
234
|
const scale = side / icon.grid;
|
|
229
|
-
const
|
|
235
|
+
const color = (tone) => tone === 'ink' ? theme.iconInk : tone === 'shade' ? theme.iconShade : theme.background;
|
|
230
236
|
const paths = icon.paths.map((path) => {
|
|
231
|
-
const fill = path.fill === undefined ? 'none' :
|
|
237
|
+
const fill = path.fill === undefined ? 'none' : color(path.fill);
|
|
232
238
|
const stroke = path.stroke === undefined
|
|
233
239
|
? ''
|
|
234
|
-
: ` stroke="${
|
|
240
|
+
: ` stroke="${color(path.stroke)}" stroke-width="${ICON_STROKE}" stroke-linejoin="round"`;
|
|
235
241
|
return ` <path d="${path.d}" fill="${fill}"${stroke}/>`;
|
|
236
242
|
});
|
|
237
243
|
return [
|
|
@@ -243,12 +249,12 @@ function drawIcon(icon, x, y, side, theme) {
|
|
|
243
249
|
// --- links -------------------------------------------------------------------
|
|
244
250
|
function drawLink(link, ends, corridor, theme, measurer, fontSize) {
|
|
245
251
|
const { start, end } = ends;
|
|
246
|
-
const
|
|
247
|
-
const markerEnd = ` marker-end="url(#${markerId(
|
|
248
|
-
const markerStart = link.both ? ` marker-start="url(#${markerId(
|
|
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)"` : '';
|
|
249
255
|
// A named side is a statement about how the line should leave or arrive, so
|
|
250
256
|
// it is drawn as a curve that actually does leave and arrive that way. With
|
|
251
|
-
// neither side named there is nothing to
|
|
257
|
+
// neither side named there is nothing to honor and the line stays straight.
|
|
252
258
|
const curved = start.side !== undefined || end.side !== undefined;
|
|
253
259
|
const parts = [];
|
|
254
260
|
// What the line actually covers, so the canvas can be sized to hold it. A
|
|
@@ -260,7 +266,7 @@ function drawLink(link, ends, corridor, theme, measurer, fontSize) {
|
|
|
260
266
|
if (corridor) {
|
|
261
267
|
const path = corridorPath(start, end, corridor);
|
|
262
268
|
ink = union(ink, path.ink);
|
|
263
|
-
parts.push(` <path d="${path.d}" fill="none" stroke="${
|
|
269
|
+
parts.push(` <path d="${path.d}" fill="none" stroke="${color}" stroke-width="${LINE_WIDTH}"${markerEnd}${markerStart}/>`);
|
|
264
270
|
// The label goes on the straight run rather than at the midpoint of the
|
|
265
271
|
// whole path, so it sits in the gap the author asked the line to travel.
|
|
266
272
|
midX = path.mid.x;
|
|
@@ -277,7 +283,7 @@ function drawLink(link, ends, corridor, theme, measurer, fontSize) {
|
|
|
277
283
|
const by = (ends.bow?.y ?? 0) * lift;
|
|
278
284
|
const c1 = { x: start.x + start.tx * reach + bx, y: start.y + start.ty * reach + by };
|
|
279
285
|
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="${
|
|
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}/>`);
|
|
281
287
|
ink = union(ink, cubicExtent(start, c1, c2, end));
|
|
282
288
|
// The point halfway along a cubic, which is where the label belongs.
|
|
283
289
|
midX = (start.x + 3 * c1.x + 3 * c2.x + end.x) / 8;
|
|
@@ -298,19 +304,19 @@ function drawLink(link, ends, corridor, theme, measurer, fontSize) {
|
|
|
298
304
|
x: end.x - run.x + ends.bow.x * lift,
|
|
299
305
|
y: end.y - run.y + ends.bow.y * lift,
|
|
300
306
|
};
|
|
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="${
|
|
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}/>`);
|
|
302
308
|
ink = union(ink, cubicExtent(start, c1, c2, end));
|
|
303
309
|
midX = (start.x + 3 * c1.x + 3 * c2.x + end.x) / 8;
|
|
304
310
|
midY = (start.y + 3 * c1.y + 3 * c2.y + end.y) / 8;
|
|
305
311
|
}
|
|
306
312
|
else {
|
|
307
|
-
parts.push(` <line x1="${round(start.x)}" y1="${round(start.y)}" x2="${round(end.x)}" y2="${round(end.y)}" stroke="${
|
|
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}/>`);
|
|
308
314
|
midX = (start.x + end.x) / 2;
|
|
309
315
|
midY = (start.y + end.y) / 2;
|
|
310
316
|
}
|
|
311
317
|
if (link.label !== undefined) {
|
|
312
318
|
// A link label breaks on ` / ` exactly as a box label does, so a two-line
|
|
313
|
-
// caption on an arrow needs no vocabulary of its own. The block is
|
|
319
|
+
// caption on an arrow needs no vocabulary of its own. The block is centered
|
|
314
320
|
// on the midpoint, which keeps a one-line label where it has always been.
|
|
315
321
|
const size = fontSizeFor('link', link.appearance, fontSize, link.line);
|
|
316
322
|
const textHeight = measurer.lineHeight(size);
|
|
@@ -328,9 +334,9 @@ function drawLink(link, ends, corridor, theme, measurer, fontSize) {
|
|
|
328
334
|
maxY: top + height,
|
|
329
335
|
});
|
|
330
336
|
parts.push(sized(textBlock(lines, midX - width / 2, top, width, textHeight, size, {
|
|
331
|
-
// A
|
|
337
|
+
// A colored link carries its meaning into its label; an uncolored
|
|
332
338
|
// one leaves the words to read as ordinary text.
|
|
333
|
-
|
|
339
|
+
color: textOf(link.appearance, lineOf(link.appearance, theme.text)),
|
|
334
340
|
align: 'middle',
|
|
335
341
|
}), size, fontSize));
|
|
336
342
|
}
|
|
@@ -399,17 +405,17 @@ function cubicExtent(p0, c1, c2, p3) {
|
|
|
399
405
|
const [minY, maxY] = span(p0.y, c1.y, c2.y, p3.y);
|
|
400
406
|
return { minX, minY, maxX, maxY };
|
|
401
407
|
}
|
|
402
|
-
/** Walk out from the
|
|
408
|
+
/** Walk out from the center of a box toward a point, stopping at the border. */
|
|
403
409
|
function edgePoint(box, toward) {
|
|
404
|
-
const
|
|
405
|
-
const dx = toward.x -
|
|
406
|
-
const dy = toward.y -
|
|
410
|
+
const center = centerOf(box);
|
|
411
|
+
const dx = toward.x - center.x;
|
|
412
|
+
const dy = toward.y - center.y;
|
|
407
413
|
if (dx === 0 && dy === 0)
|
|
408
|
-
return
|
|
414
|
+
return center;
|
|
409
415
|
const scaleX = dx === 0 ? Infinity : box.width / 2 / Math.abs(dx);
|
|
410
416
|
const scaleY = dy === 0 ? Infinity : box.height / 2 / Math.abs(dy);
|
|
411
417
|
const scale = Math.min(scaleX, scaleY);
|
|
412
|
-
return { x:
|
|
418
|
+
return { x: center.x + dx * scale, y: center.y + dy * scale };
|
|
413
419
|
}
|
|
414
420
|
// --- where a link meets a box -------------------------------------------------
|
|
415
421
|
const SIDES = ['top', 'bottom', 'left', 'right'];
|
|
@@ -417,7 +423,7 @@ const SIDES = ['top', 'bottom', 'left', 'right'];
|
|
|
417
423
|
* Work out where every link meets every box.
|
|
418
424
|
*
|
|
419
425
|
* An author names a *side* — `to: top` — and never a point on it. Alone on a
|
|
420
|
-
* side a link lands at its
|
|
426
|
+
* side a link lands at its center; sharing the side with others, the points
|
|
421
427
|
* spread so they do not sit on top of each other. Which one goes where is
|
|
422
428
|
* derived from where the far ends actually are, never chosen: of two links
|
|
423
429
|
* arriving at one top edge, the one coming from further left arrives further
|
|
@@ -442,7 +448,7 @@ function planEndpoints(links, measurer, fontSize) {
|
|
|
442
448
|
link,
|
|
443
449
|
which: 'start',
|
|
444
450
|
side: fromSide,
|
|
445
|
-
toward:
|
|
451
|
+
toward: centerOf(faceOf(link.to)),
|
|
446
452
|
rank: rankIn(bundles.get(link), link, link.from, fromSide),
|
|
447
453
|
});
|
|
448
454
|
}
|
|
@@ -451,7 +457,7 @@ function planEndpoints(links, measurer, fontSize) {
|
|
|
451
457
|
link,
|
|
452
458
|
which: 'end',
|
|
453
459
|
side: toSide,
|
|
454
|
-
toward:
|
|
460
|
+
toward: centerOf(faceOf(link.from)),
|
|
455
461
|
rank: rankIn(bundles.get(link), link, link.to, toSide),
|
|
456
462
|
});
|
|
457
463
|
}
|
|
@@ -500,9 +506,9 @@ function planEndpoints(links, measurer, fontSize) {
|
|
|
500
506
|
continue;
|
|
501
507
|
}
|
|
502
508
|
// 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 ??
|
|
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));
|
|
506
512
|
ends.set(link, { start, end, bow: bows.get(link) });
|
|
507
513
|
}
|
|
508
514
|
return ends;
|
|
@@ -553,8 +559,8 @@ function planBundles(links, measurer, fontSize) {
|
|
|
553
559
|
const [first, second] = group.ends;
|
|
554
560
|
const t0 = tangentOf(first.side);
|
|
555
561
|
const t1 = tangentOf(second.side);
|
|
556
|
-
const from =
|
|
557
|
-
const to =
|
|
562
|
+
const from = sideCenter(first);
|
|
563
|
+
const to = sideCenter(second);
|
|
558
564
|
const run = { x: to.x - from.x, y: to.y - from.y };
|
|
559
565
|
// Nesting is a matter of which side of the line each end steps toward. Step
|
|
560
566
|
// both ends to the same side of the run and the whole line translates;
|
|
@@ -587,7 +593,7 @@ function planBundles(links, measurer, fontSize) {
|
|
|
587
593
|
* The sideways offset each link takes when several run between the same two
|
|
588
594
|
* boxes and none of them names a side.
|
|
589
595
|
*
|
|
590
|
-
* An unnamed end has no side to spread along: it aims at the far box's
|
|
596
|
+
* An unnamed end has no side to spread along: it aims at the far box's center
|
|
591
597
|
* and attaches wherever that ray crosses the border, so every link in such a
|
|
592
598
|
* group produces the *same* ray and they are drawn on top of one another —
|
|
593
599
|
* one visible line, every label stacked on one point. `planEndpoints` cannot
|
|
@@ -643,10 +649,10 @@ function planSpreads(links, measurer, fontSize) {
|
|
|
643
649
|
for (const group of groups.values()) {
|
|
644
650
|
if (group.links.length < 2)
|
|
645
651
|
continue;
|
|
646
|
-
const from =
|
|
652
|
+
const from = centerOf(faceOf(group.first));
|
|
647
653
|
const sample = group.links[0];
|
|
648
654
|
const other = sample.from === group.first ? sample.to : sample.from;
|
|
649
|
-
const to =
|
|
655
|
+
const to = centerOf(faceOf(other));
|
|
650
656
|
const dx = to.x - from.x;
|
|
651
657
|
const dy = to.y - from.y;
|
|
652
658
|
const length = Math.hypot(dx, dy) || 1;
|
|
@@ -750,10 +756,10 @@ function bowBundles(bundles, achieved) {
|
|
|
750
756
|
* `aligned`, which adds rather than subtracts.
|
|
751
757
|
*/
|
|
752
758
|
function laneStep(lanes, drift, measurer, fontSize) {
|
|
753
|
-
const
|
|
754
|
-
if (
|
|
759
|
+
const labeled = lanes.filter((link) => link.label !== undefined);
|
|
760
|
+
if (labeled.length < 2)
|
|
755
761
|
return 0;
|
|
756
|
-
const need = (axis) => Math.max(...
|
|
762
|
+
const need = (axis) => Math.max(...labeled.map((link) => labelExtent(link.label, link.appearance, axis, measurer, fontSize, link.line)));
|
|
757
763
|
const along = (axis, reach) => reach === 0 ? Infinity : need(axis) / Math.abs(reach);
|
|
758
764
|
return Math.min(along('x', drift.x), along('y', drift.y));
|
|
759
765
|
}
|
|
@@ -774,7 +780,7 @@ function tangentOf(side) {
|
|
|
774
780
|
return side === 'top' || side === 'bottom' ? { x: 1, y: 0 } : { x: 0, y: 1 };
|
|
775
781
|
}
|
|
776
782
|
/** The midpoint of one side of a box. */
|
|
777
|
-
function
|
|
783
|
+
function sideCenter(end) {
|
|
778
784
|
const face = faceOf(end.node);
|
|
779
785
|
const along = end.side === 'top' || end.side === 'bottom' ? face.width : face.height;
|
|
780
786
|
const origin = end.side === 'top' || end.side === 'bottom' ? face.x : face.y;
|
|
@@ -811,19 +817,19 @@ function anchorOn(face, side, at) {
|
|
|
811
817
|
/** An end with no side named: leave from the border, pointing at the far end. */
|
|
812
818
|
function free(face, toward) {
|
|
813
819
|
const point = edgePoint(face, toward);
|
|
814
|
-
const
|
|
815
|
-
const dx = point.x -
|
|
816
|
-
const dy = point.y -
|
|
820
|
+
const center = centerOf(face);
|
|
821
|
+
const dx = point.x - center.x;
|
|
822
|
+
const dy = point.y - center.y;
|
|
817
823
|
const length = Math.hypot(dx, dy) || 1;
|
|
818
824
|
return { x: point.x, y: point.y, tx: dx / length, ty: dy / length };
|
|
819
825
|
}
|
|
820
826
|
/**
|
|
821
827
|
* Walk from a point inside a box along a direction, stopping at the border.
|
|
822
828
|
*
|
|
823
|
-
* `edgePoint` walks from the
|
|
829
|
+
* `edgePoint` walks from the center, which is the only place a single line
|
|
824
830
|
* passes through. A fanned-out group's lines are parallel to that one and
|
|
825
831
|
* beside it, so each needs the border crossing of its own line rather than of
|
|
826
|
-
* the
|
|
832
|
+
* the center's — which is what keeps the group parallel instead of splayed.
|
|
827
833
|
*/
|
|
828
834
|
function exitAlong(box, from, dir) {
|
|
829
835
|
// A shift wider than the box leaves the origin outside it; clamping back in
|
|
@@ -849,8 +855,8 @@ function exitAlong(box, from, dir) {
|
|
|
849
855
|
* of the two edges meeting there. Neither is a case in the code.
|
|
850
856
|
*/
|
|
851
857
|
function parallelEnds(from, to, offset) {
|
|
852
|
-
const a =
|
|
853
|
-
const b =
|
|
858
|
+
const a = centerOf(from);
|
|
859
|
+
const b = centerOf(to);
|
|
854
860
|
const dx = b.x - a.x;
|
|
855
861
|
const dy = b.y - a.y;
|
|
856
862
|
const length = Math.hypot(dx, dy) || 1;
|
|
@@ -987,7 +993,7 @@ function planCorridors(links, ends, measurer, fontSize) {
|
|
|
987
993
|
return plans;
|
|
988
994
|
}
|
|
989
995
|
/**
|
|
990
|
-
* An end whose side the author did not name aims at the far box's
|
|
996
|
+
* An end whose side the author did not name aims at the far box's center, which
|
|
991
997
|
* is the wrong thing to aim at once the line has been told to go somewhere else
|
|
992
998
|
* on the way. Point those ends at the corridor instead.
|
|
993
999
|
*/
|
|
@@ -1008,7 +1014,7 @@ function aimFreeEnds(links, ends, corridors) {
|
|
|
1008
1014
|
}
|
|
1009
1015
|
/**
|
|
1010
1016
|
* How much room a link's label takes across the corridor — its depth in a
|
|
1011
|
-
* horizontal channel, its width in a vertical one. Zero for an
|
|
1017
|
+
* horizontal channel, its width in a vertical one. Zero for an unlabeled link,
|
|
1012
1018
|
* which needs no more than the arrow spacing.
|
|
1013
1019
|
*
|
|
1014
1020
|
* `labelExtent` measures the knockout along whichever axis it is handed, and the
|
|
@@ -1073,19 +1079,19 @@ function sideAttr(link, key) {
|
|
|
1073
1079
|
}
|
|
1074
1080
|
return value;
|
|
1075
1081
|
}
|
|
1076
|
-
function arrowMarker(
|
|
1077
|
-
const id = markerId(
|
|
1082
|
+
function arrowMarker(color) {
|
|
1083
|
+
const id = markerId(color);
|
|
1078
1084
|
return [
|
|
1079
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">`,
|
|
1080
|
-
` <path d="M 0 0 L 10 5 L 0 10 z" fill="${
|
|
1086
|
+
` <path d="M 0 0 L 10 5 L 0 10 z" fill="${color}"/>`,
|
|
1081
1087
|
' </marker>',
|
|
1082
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">`,
|
|
1083
|
-
` <path d="M 0 0 L 10 5 L 0 10 z" fill="${
|
|
1089
|
+
` <path d="M 0 0 L 10 5 L 0 10 z" fill="${color}"/>`,
|
|
1084
1090
|
' </marker>',
|
|
1085
1091
|
].join('\n');
|
|
1086
1092
|
}
|
|
1087
|
-
function markerId(
|
|
1088
|
-
return `arrow-${
|
|
1093
|
+
function markerId(color) {
|
|
1094
|
+
return `arrow-${color.replace(/[^a-zA-Z0-9]/g, '')}`;
|
|
1089
1095
|
}
|
|
1090
1096
|
/** The rectangle actually drawn. Differs from the node box only for a deck. */
|
|
1091
1097
|
function faceOf(node) {
|
|
@@ -1096,7 +1102,7 @@ function faceOf(node) {
|
|
|
1096
1102
|
height: node.height - node.inset,
|
|
1097
1103
|
};
|
|
1098
1104
|
}
|
|
1099
|
-
function
|
|
1105
|
+
function centerOf(box) {
|
|
1100
1106
|
return { x: box.x + box.width / 2, y: box.y + box.height / 2 };
|
|
1101
1107
|
}
|
|
1102
1108
|
/**
|
|
@@ -1118,25 +1124,35 @@ function textBlock(lines, x, top, width, lineHeight, fontSize, style) {
|
|
|
1118
1124
|
const baseline = top + index * lineHeight + lineHeight / 2 + fontSize * 0.35;
|
|
1119
1125
|
// A label's first line is its name; anything after it is a qualifier, and
|
|
1120
1126
|
// `subtext:` is how a box says that qualifier should read as secondary.
|
|
1121
|
-
const
|
|
1122
|
-
return ` <text x="${round(anchorX)}" y="${round(baseline)}" fill="${
|
|
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>`;
|
|
1123
1129
|
})
|
|
1124
1130
|
.filter((element) => element.length > 0)
|
|
1125
1131
|
.join('\n');
|
|
1126
1132
|
}
|
|
1127
1133
|
/**
|
|
1128
|
-
* A
|
|
1129
|
-
*
|
|
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
|
|
1130
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:`.
|
|
1131
1141
|
*/
|
|
1132
|
-
function
|
|
1133
|
-
return appearance['
|
|
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;
|
|
1134
1150
|
}
|
|
1135
1151
|
/**
|
|
1136
|
-
* The
|
|
1152
|
+
* The color for every label line after the first, or undefined when the box
|
|
1137
1153
|
* said nothing and all its lines should read alike. `muted` is the one reserved
|
|
1138
1154
|
* word: it defers to the theme, so a label's qualifier stays readable when the
|
|
1139
|
-
* theme changes. Anything else is a
|
|
1155
|
+
* theme changes. Anything else is a color, same as `text` and `fill` take.
|
|
1140
1156
|
*/
|
|
1141
1157
|
function subtextOf(appearance, theme) {
|
|
1142
1158
|
const named = appearance['subtext'];
|