@rse/gradia 1.2.3 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +50 -27
- package/dst/gradia-api-config.d.ts +4 -0
- package/dst/gradia-api-config.js +5 -1
- package/dst/gradia-api-render-base.js +3 -3
- package/dst/gradia-api-render-container.js +4 -1
- package/dst/gradia-api-render-edge.js +7 -6
- package/dst/gradia-api-render-group.js +4 -0
- package/dst/gradia-api-render-node.js +5 -6
- package/dst/gradia-api-render-svg.js +71 -59
- package/dst/gradia-api-type-graph.js +10 -6
- package/dst/gradia-api-type-grid.js +2 -1
- package/dst/gradia-api-type-hub.js +90 -26
- package/dst/gradia-api.js +1 -1
- package/dst/gradia-api.umd.js +2 -2
- package/dst/gradia-cli.js +0 -0
- package/dst/gradia-mcp.js +5 -1
- package/package.json +7 -7
package/README.md
CHANGED
|
@@ -269,8 +269,6 @@ following conditions hold:
|
|
|
269
269
|
- Every edge either points to or originates from that primary node. An
|
|
270
270
|
edge between two non-primary nodes is rejected.
|
|
271
271
|
|
|
272
|
-
- The primary node carries no self-loop.
|
|
273
|
-
|
|
274
272
|
- Every non-primary node is an input or an output of the primary node,
|
|
275
273
|
i.e., no node is free-standing.
|
|
276
274
|
|
|
@@ -279,6 +277,11 @@ twice, once in the input column and once in the output column. The second
|
|
|
279
277
|
placement is rendered as a dashed "ghost" box, colored by the
|
|
280
278
|
`color-node-ghost-*` options.
|
|
281
279
|
|
|
280
|
+
A self-loop on the primary node is unrolled: the primary node is placed a
|
|
281
|
+
second time on top of the output column, as the target of the self-loop.
|
|
282
|
+
This second placement is rendered as a dashed "self" box, colored by the
|
|
283
|
+
(darker grey) `color-node-self-*` options.
|
|
284
|
+
|
|
282
285
|
The diagram type `grid` rejects the input with an error as soon as the
|
|
283
286
|
graph contains at least one edge.
|
|
284
287
|
|
|
@@ -304,31 +307,33 @@ command-line options (`font-embed` being command-line only, see below).
|
|
|
304
307
|
The recognized options, and their default values, are:
|
|
305
308
|
|
|
306
309
|
```
|
|
307
|
-
font-family Helvetica size-
|
|
308
|
-
font-embed false size-edge-
|
|
309
|
-
color-node-regular-name #336699 size-edge-
|
|
310
|
-
color-node-regular-box #e0f0ff size-edge-
|
|
311
|
-
color-node-regular-border #c0d0e0
|
|
312
|
-
color-node-primary-name #ffffff group-box-
|
|
313
|
-
color-node-primary-box #336699
|
|
314
|
-
color-node-primary-border #003366
|
|
315
|
-
color-node-ghost-name #666666 graph-
|
|
316
|
-
color-node-ghost-box #f0f0f0 graph-channel-width-
|
|
317
|
-
color-node-ghost-border #a0a0a0 graph-
|
|
318
|
-
color-
|
|
319
|
-
color-
|
|
320
|
-
color-
|
|
321
|
-
color-
|
|
322
|
-
color-
|
|
323
|
-
color-
|
|
324
|
-
color-
|
|
325
|
-
color-
|
|
326
|
-
color-
|
|
327
|
-
color-edge-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
size-
|
|
310
|
+
font-family Helvetica size-node-height-scale 2.25
|
|
311
|
+
font-embed false size-edge-corner-radius 20
|
|
312
|
+
color-node-regular-name #336699 size-edge-hop-radius 8
|
|
313
|
+
color-node-regular-box #e0f0ff size-edge-track-gap 12
|
|
314
|
+
color-node-regular-border #c0d0e0 size-edge-port-gap 24
|
|
315
|
+
color-node-primary-name #ffffff group-box-padding 30
|
|
316
|
+
color-node-primary-box #336699 group-box-gap 40
|
|
317
|
+
color-node-primary-border #003366 container-box-padding 30
|
|
318
|
+
color-node-ghost-name #666666 graph-columns-max 4
|
|
319
|
+
color-node-ghost-box #f0f0f0 graph-channel-width-max 140
|
|
320
|
+
color-node-ghost-border #a0a0a0 graph-channel-width-min 24
|
|
321
|
+
color-node-self-name #444444 graph-gutter-height-max 90
|
|
322
|
+
color-node-self-box #e0e0e0 graph-gutter-height-min 20
|
|
323
|
+
color-node-self-border #707070 graph-node-separation 30
|
|
324
|
+
color-group-name #6699cc graph-rank-separation 60
|
|
325
|
+
color-group-box #f4f8fc graph-node-degree-max 3
|
|
326
|
+
color-group-border #c0d0e0 hub-channel-width-max 340
|
|
327
|
+
color-container-name #666666 hub-channel-width-min 240
|
|
328
|
+
color-container-box #f6f6f6 hub-node-gap 20
|
|
329
|
+
color-container-border #a0a0a0 hub-node-degree-max 3
|
|
330
|
+
color-edge-line #999999 hub-node-count-max 0
|
|
331
|
+
color-edge-name #333333 grid-columns-max 4
|
|
332
|
+
color-edge-arity #333333 grid-columns-min 3
|
|
333
|
+
color-edge-halo #ffffff grid-gap-horizontal 40
|
|
334
|
+
size-canvas-margin 40 grid-gap-vertical 20
|
|
335
|
+
size-node-width-min 220 grid-node-width-equal true
|
|
336
|
+
size-node-width-max 0 grid-node-height-equal true
|
|
332
337
|
```
|
|
333
338
|
|
|
334
339
|
The `size-*`, `group-*`, `container-*`, `graph-*`, `hub-*`, and
|
|
@@ -371,6 +376,15 @@ unlabeled diagram can be spaced out, while the `-max` options cap it, so
|
|
|
371
376
|
that neither many parallel edges nor a long label can push the nodes
|
|
372
377
|
apart without bound.
|
|
373
378
|
|
|
379
|
+
The `hub-node-count-max` option controls the wrapping of the input and
|
|
380
|
+
output column of a `hub` diagram: given a positive value, a column of
|
|
381
|
+
more nodes than this wraps into two staggered sub-columns, whose nodes
|
|
382
|
+
alternate between the outer sub-column and the inner one, with every
|
|
383
|
+
outer node vertically centered onto a gap between two inner nodes,
|
|
384
|
+
through which its edges reach the primary node. This trades nearly
|
|
385
|
+
half of the height of a large diagram for additional width. The value
|
|
386
|
+
`0` disables the wrapping entirely.
|
|
387
|
+
|
|
374
388
|
The `grid-columns-min` and `grid-columns-max` options control the column
|
|
375
389
|
count of a `grid` diagram, which by default is derived from the node
|
|
376
390
|
count as a roughly square grid: `grid-columns-min` raises the derived
|
|
@@ -399,6 +413,15 @@ hard-coded into the SVG instead. The resulting precedence is: first
|
|
|
399
413
|
`#config`/`--config`, then the CSS custom property `--gradia-<option>`,
|
|
400
414
|
and finally the built-in default.
|
|
401
415
|
|
|
416
|
+
The styling is not repeated on every element of the generated SVG, but
|
|
417
|
+
declared once as CSS classes in a `<style>` element, which the elements
|
|
418
|
+
reference. A class is named `gradia-<digest>` after the digest of its
|
|
419
|
+
own declarations, so equal declarations always yield the very same
|
|
420
|
+
class. As the class names are global once multiple diagrams are embedded
|
|
421
|
+
into the very same document, the embedding document can hence strip the
|
|
422
|
+
`<style>` elements off the diagrams and declare the union of their
|
|
423
|
+
rules (one per line) a single time on its own.
|
|
424
|
+
|
|
402
425
|
The `font-family` option is either a built-in font family, a plain font
|
|
403
426
|
family name, or the path to a WOFF2 file. The only built-in font family
|
|
404
427
|
is `Source Sans 3`, shipped as a variable WOFF2 file by the NPM
|
|
@@ -10,6 +10,9 @@ export declare const configDefaults: {
|
|
|
10
10
|
"color-node-ghost-name": string;
|
|
11
11
|
"color-node-ghost-box": string;
|
|
12
12
|
"color-node-ghost-border": string;
|
|
13
|
+
"color-node-self-name": string;
|
|
14
|
+
"color-node-self-box": string;
|
|
15
|
+
"color-node-self-border": string;
|
|
13
16
|
"color-group-name": string;
|
|
14
17
|
"color-group-box": string;
|
|
15
18
|
"color-group-border": string;
|
|
@@ -43,6 +46,7 @@ export declare const configDefaults: {
|
|
|
43
46
|
"hub-channel-width-min": number;
|
|
44
47
|
"hub-node-gap": number;
|
|
45
48
|
"hub-node-degree-max": number;
|
|
49
|
+
"hub-node-count-max": number;
|
|
46
50
|
"grid-columns-max": number;
|
|
47
51
|
"grid-columns-min": number;
|
|
48
52
|
"grid-gap-horizontal": number;
|
package/dst/gradia-api-config.js
CHANGED
|
@@ -20,6 +20,9 @@ export const configDefaults = {
|
|
|
20
20
|
"color-node-ghost-name": "#666666",
|
|
21
21
|
"color-node-ghost-box": "#f0f0f0",
|
|
22
22
|
"color-node-ghost-border": "#a0a0a0",
|
|
23
|
+
"color-node-self-name": "#444444",
|
|
24
|
+
"color-node-self-box": "#e0e0e0",
|
|
25
|
+
"color-node-self-border": "#707070",
|
|
23
26
|
"color-group-name": "#6699cc",
|
|
24
27
|
"color-group-box": "#f4f8fc",
|
|
25
28
|
"color-group-border": "#c0d0e0",
|
|
@@ -53,6 +56,7 @@ export const configDefaults = {
|
|
|
53
56
|
"hub-channel-width-min": 240, /* hub: min width of column channels */
|
|
54
57
|
"hub-node-gap": 20, /* hub: vertical gap of stacked nodes */
|
|
55
58
|
"hub-node-degree-max": 3, /* hub: max edges/side before growth */
|
|
59
|
+
"hub-node-count-max": 0, /* hub: max nodes/column before wrapping */
|
|
56
60
|
"grid-columns-max": 4, /* grid: max side-by-side tiles */
|
|
57
61
|
"grid-columns-min": 3, /* grid: min side-by-side tiles */
|
|
58
62
|
"grid-gap-horizontal": 40, /* grid: horizontal gap between tiles */
|
|
@@ -160,7 +164,7 @@ export const resolveFont = (config) => {
|
|
|
160
164
|
return { family };
|
|
161
165
|
}
|
|
162
166
|
else if (config["font-embed"])
|
|
163
|
-
throw new Error("option \"font-embed\" requires option \"font-family\" to be a built-in font " +
|
|
167
|
+
throw new Error("configuration option \"font-embed\" requires configuration option \"font-family\" to be a built-in font " +
|
|
164
168
|
`family (${Object.keys(fontsBuiltIn).map((name) => `"${name}"`).join(", ")}) ` +
|
|
165
169
|
"or the path to a WOFF2 file");
|
|
166
170
|
else
|
|
@@ -38,9 +38,9 @@ export const textWrap = (text, size, maxWidth) => {
|
|
|
38
38
|
lines.push(line);
|
|
39
39
|
return lines;
|
|
40
40
|
};
|
|
41
|
-
/* escape a string for use in XML/SVG content (control characters
|
|
42
|
-
stripped, as XML forbids them even in their escaped form) */
|
|
43
|
-
export const escapeXML = (text) => text.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/
|
|
41
|
+
/* escape a string for use in XML/SVG content (control and non-characters
|
|
42
|
+
are stripped, as XML forbids them even in their escaped form) */
|
|
43
|
+
export const escapeXML = (text) => text.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F\uFFFE\uFFFF\p{Cs}]/gu, "")
|
|
44
44
|
.replace(/&/g, "&")
|
|
45
45
|
.replace(/</g, "<")
|
|
46
46
|
.replace(/>/g, ">")
|
|
@@ -12,6 +12,7 @@ import { simplifyPoly } from "./gradia-api-render-edge.js";
|
|
|
12
12
|
container with one of its own members, and every "container"
|
|
13
13
|
annotation has to name a valid diagram type */
|
|
14
14
|
export const containmentOf = (graph, types) => {
|
|
15
|
+
/* collect the parent/children relations of the nodes */
|
|
15
16
|
const parentOfId = new Map();
|
|
16
17
|
const childrenOf = new Map();
|
|
17
18
|
for (const node of graph.nodes.values()) {
|
|
@@ -30,6 +31,7 @@ export const containmentOf = (graph, types) => {
|
|
|
30
31
|
else
|
|
31
32
|
children.push(node);
|
|
32
33
|
}
|
|
34
|
+
/* reject the containment cycles */
|
|
33
35
|
for (const id of parentOfId.keys()) {
|
|
34
36
|
const seen = new Set([id]);
|
|
35
37
|
for (let p = parentOfId.get(id); p !== undefined; p = parentOfId.get(p)) {
|
|
@@ -38,6 +40,7 @@ export const containmentOf = (graph, types) => {
|
|
|
38
40
|
seen.add(p);
|
|
39
41
|
}
|
|
40
42
|
}
|
|
43
|
+
/* reject the edges connecting a container with one of its own members */
|
|
41
44
|
const isAncestor = (a, id) => {
|
|
42
45
|
for (let p = parentOfId.get(id); p !== undefined; p = parentOfId.get(p))
|
|
43
46
|
if (p === a)
|
|
@@ -253,7 +256,7 @@ export const renderContained = async (graph, containment, type, types, config) =
|
|
|
253
256
|
const tt = topOf(edge.target);
|
|
254
257
|
if (ts === undefined && tt === undefined)
|
|
255
258
|
return;
|
|
256
|
-
if (ts !== undefined &&
|
|
259
|
+
if (ts !== undefined && ts === tt && inner.has(ts) && edge.source !== ts)
|
|
257
260
|
return;
|
|
258
261
|
let source;
|
|
259
262
|
let target;
|
|
@@ -18,7 +18,8 @@ export const simplifyPoly = (pts) => {
|
|
|
18
18
|
/* drop the remaining duplicate points */
|
|
19
19
|
const out = [];
|
|
20
20
|
for (const p of flat) {
|
|
21
|
-
|
|
21
|
+
const q = out.at(-1);
|
|
22
|
+
if (q !== undefined && q[0] === p[0] && q[1] === p[1])
|
|
22
23
|
continue;
|
|
23
24
|
out.push(p);
|
|
24
25
|
}
|
|
@@ -70,10 +71,11 @@ export const assignPorts = (edges, sides, cx, cy, boxW, boxH, portGap, approachY
|
|
|
70
71
|
const px = cx(id) + (side === "e" ? +1 : -1) * boxW.get(id) / 2;
|
|
71
72
|
/* place the fixed ports at their given offsets */
|
|
72
73
|
const fixedOf = (p) => fixedY?.(p.edge, p.role);
|
|
74
|
+
const place = (p, py) => portPos.set(`${p.edge}:${p.role}`, { x: px, y: py });
|
|
73
75
|
const taken = [];
|
|
74
76
|
for (const p of group.filter((p) => fixedOf(p) !== undefined)) {
|
|
75
77
|
const py = cy(id) + fixedOf(p);
|
|
76
|
-
|
|
78
|
+
place(p, py);
|
|
77
79
|
taken.push(py);
|
|
78
80
|
}
|
|
79
81
|
taken.sort((a, b) => a - b);
|
|
@@ -86,7 +88,6 @@ export const assignPorts = (edges, sides, cx, cy, boxW, boxH, portGap, approachY
|
|
|
86
88
|
free.sort((a, b) => (keyOf(a) - keyOf(b)) || (a.edge - b.edge));
|
|
87
89
|
const n = free.length + taken.length;
|
|
88
90
|
const spacing = Math.min(portGap, (boxH.get(id) - PORT_PAD) / n);
|
|
89
|
-
const place = (p, py) => portPos.set(`${p.edge}:${p.role}`, { x: px, y: py });
|
|
90
91
|
if (taken.length === 0) {
|
|
91
92
|
free.forEach((p, idx) => place(p, cy(id) + (idx - (n - 1) / 2) * spacing));
|
|
92
93
|
continue;
|
|
@@ -348,15 +349,15 @@ export const pathOf = (poly, hop, rounding, hopRadius) => {
|
|
|
348
349
|
their individual hops would be shorter than 14px) */
|
|
349
350
|
const groups = [];
|
|
350
351
|
for (const hx of xs) {
|
|
351
|
-
const g = groups
|
|
352
|
-
if (g !== undefined && Math.abs(hx - g
|
|
352
|
+
const g = groups.at(-1);
|
|
353
|
+
if (g !== undefined && Math.abs(hx - g.at(-1)) <= hopRadius * 2 + 14)
|
|
353
354
|
g.push(hx);
|
|
354
355
|
else
|
|
355
356
|
groups.push([hx]);
|
|
356
357
|
}
|
|
357
358
|
for (const g of groups) {
|
|
358
359
|
let x1 = g[0] - dir * hopRadius;
|
|
359
|
-
let x2 = g
|
|
360
|
+
let x2 = g.at(-1) + dir * hopRadius;
|
|
360
361
|
/* clamp the arc into the drawable segment part, so
|
|
361
362
|
crossings close to a corner still get their hop */
|
|
362
363
|
if ((x1 - p0[0]) * dir < 0)
|
|
@@ -17,9 +17,11 @@ export const groupOf = (node) => node.attrs.findLast((attr) => attr.key === "gro
|
|
|
17
17
|
deviating group of its own. Edges have to stay within a single
|
|
18
18
|
group, so reject any group-crossing edge */
|
|
19
19
|
export const partitionGroups = (graph, containment) => {
|
|
20
|
+
/* short-circuit when no node uses a group at all */
|
|
20
21
|
const nodes = Array.from(graph.nodes.values());
|
|
21
22
|
if (!nodes.some((node) => groupOf(node) !== undefined))
|
|
22
23
|
return null;
|
|
24
|
+
/* assign every node to the group of its outermost container */
|
|
23
25
|
const parts = new Map();
|
|
24
26
|
const nameOf = new Map();
|
|
25
27
|
for (const node of nodes) {
|
|
@@ -37,6 +39,7 @@ export const partitionGroups = (graph, containment) => {
|
|
|
37
39
|
}
|
|
38
40
|
part.nodes.set(node.id, node);
|
|
39
41
|
}
|
|
42
|
+
/* assign every edge to the common group of its endpoints */
|
|
40
43
|
for (const edge of graph.edges) {
|
|
41
44
|
const sg = nameOf.get(edge.source);
|
|
42
45
|
const tg = nameOf.get(edge.target);
|
|
@@ -45,6 +48,7 @@ export const partitionGroups = (graph, containment) => {
|
|
|
45
48
|
`crosses groups "${sg}" and "${tg}"`);
|
|
46
49
|
parts.get(sg).edges.push(edge);
|
|
47
50
|
}
|
|
51
|
+
/* provide the partitions in order of first appearance */
|
|
48
52
|
return Array.from(parts.entries()).map(([name, subgraph]) => ({ name, graph: subgraph }));
|
|
49
53
|
};
|
|
50
54
|
/* compose the per-group layouts into a single layout: the groups are
|
|
@@ -79,12 +79,11 @@ export const linesOfNode = (node, config) => {
|
|
|
79
79
|
attrs: attrsOfNode(node).flatMap((attr) => textWrap(`${attr.key}: ${attr.val}`, FS_ATTR, max))
|
|
80
80
|
};
|
|
81
81
|
};
|
|
82
|
-
/* determine node box sizes from their textual content
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
of a
|
|
87
|
-
of a containment level), which are taken as given */
|
|
82
|
+
/* determine node box sizes from their textual content (boxes are
|
|
83
|
+
scaled up in height by a per-node factor, but never below the
|
|
84
|
+
content height, to give the edges more attachment room), except
|
|
85
|
+
for the nodes of a fixed size (the container placeholders and gate
|
|
86
|
+
nodes of a containment level), which are taken as given */
|
|
88
87
|
export const measureNodes = (nodes, config, scaleOf, fixed) => {
|
|
89
88
|
/* the resulting node box dimensions */
|
|
90
89
|
const boxW = new Map();
|
|
@@ -20,11 +20,13 @@ const escapeCSS = (text) => text.replace(/[\\"']/g, "\\$&").replace(/[\r\n\f]/g,
|
|
|
20
20
|
const NS_GRADIA = new UUID(5, "ns:URL", "https://github.com/rse/gradia").format();
|
|
21
21
|
/* derive a per-document identifier prefix from a seed, as the SVG
|
|
22
22
|
identifiers are DOM-global and would collide once multiple diagrams
|
|
23
|
-
are embedded into the very same document. The prefix is
|
|
24
|
-
the seed and hence stays stable across regenerations
|
|
25
|
-
diagram (the Base16 format keeps it alphanumeric and
|
|
26
|
-
name and CSS/URL fragment
|
|
27
|
-
|
|
23
|
+
are embedded into the very same document. The prefix is derived from
|
|
24
|
+
the UUID v5 of the seed and hence stays stable across regenerations
|
|
25
|
+
of an unchanged diagram (the Base16 format keeps it alphanumeric and
|
|
26
|
+
thus a valid XML name and CSS/URL fragment, and its leading 12 digits
|
|
27
|
+
keep the identifiers short, as every edge references one, while
|
|
28
|
+
still making a collision among the diagrams of a document unlikely) */
|
|
29
|
+
const idPrefix = (seed) => `gradia-${new UUID(5, NS_GRADIA, seed).format("b16").toLowerCase().slice(0, 12)}`;
|
|
28
30
|
/* the placement of a group tag in the top-left corner of its group box */
|
|
29
31
|
const TAG_DX = 18; /* left offset of the group tag */
|
|
30
32
|
const TAG_DY = 12; /* top offset of the group tag */
|
|
@@ -66,12 +68,12 @@ const labelPlacer = (layout, hops, hopRadius) => {
|
|
|
66
68
|
const collisions = (boxes, box) => boxes.filter((o) => box[0] < o[2] && box[2] > o[0] && box[1] < o[3] && box[3] > o[1]).length;
|
|
67
69
|
const claim = (candidates) => {
|
|
68
70
|
let box = candidates[0];
|
|
69
|
-
let
|
|
71
|
+
let lowest = Infinity;
|
|
70
72
|
for (const c of candidates) {
|
|
71
73
|
const score = collisions(occupied, c) * (lines.length + 1) + collisions(lines, c);
|
|
72
|
-
if (score <
|
|
74
|
+
if (score < lowest) {
|
|
73
75
|
box = c;
|
|
74
|
-
|
|
76
|
+
lowest = score;
|
|
75
77
|
}
|
|
76
78
|
if (score === 0)
|
|
77
79
|
break;
|
|
@@ -83,7 +85,7 @@ const labelPlacer = (layout, hops, hopRadius) => {
|
|
|
83
85
|
};
|
|
84
86
|
/* generate the SVG fragments for a single node box (a node with a
|
|
85
87
|
"url" attribute becomes a hyperlink covering the whole box) */
|
|
86
|
-
const renderNode = (node, layout, style,
|
|
88
|
+
const renderNode = (node, layout, style, styler, config) => {
|
|
87
89
|
const { cx, cy, boxW, boxH, contentH } = layout;
|
|
88
90
|
const w = boxW.get(node.id);
|
|
89
91
|
const h = boxH.get(node.id);
|
|
@@ -93,7 +95,7 @@ const renderNode = (node, layout, style, font, color, config) => {
|
|
|
93
95
|
const url = urlOf(node);
|
|
94
96
|
const parts = [];
|
|
95
97
|
parts.push(`<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="6" ` +
|
|
96
|
-
`
|
|
98
|
+
`class="${styler.box(style.fill, style.stroke, 4)}"` +
|
|
97
99
|
`${style.dash !== undefined ? ` stroke-dasharray="${escapeXML(style.dash)}"` : ""}/>`);
|
|
98
100
|
/* vertically center the textual content block within the box, with
|
|
99
101
|
the optional type lines shifting the name and attributes down and
|
|
@@ -105,20 +107,18 @@ const renderNode = (node, layout, style, font, color, config) => {
|
|
|
105
107
|
ty + th + MIN_H / 2 + FS_NAME * 0.36 : cy(node.id) + th / 2 + FS_NAME * 0.36 - nh / 2;
|
|
106
108
|
lines.type.forEach((line, k) => {
|
|
107
109
|
parts.push(`<text x="${cx(node.id)}" ` +
|
|
108
|
-
`y="${nameY - TYPE_D - (lines.type.length - 1 - k) * TYPE_H}"
|
|
109
|
-
`
|
|
110
|
-
`style="font-family: ${font}; fill: ${color(style.text)}">${escapeXML(line)}</text>`);
|
|
110
|
+
`y="${nameY - TYPE_D - (lines.type.length - 1 - k) * TYPE_H}" ` +
|
|
111
|
+
`class="${styler.text(style.text, FS_TYPE, { middle: true })}">${escapeXML(line)}</text>`);
|
|
111
112
|
});
|
|
112
113
|
lines.name.forEach((line, k) => {
|
|
113
|
-
parts.push(`<text x="${cx(node.id)}" y="${nameY + k * NAME_H}"
|
|
114
|
-
`
|
|
115
|
-
|
|
114
|
+
parts.push(`<text x="${cx(node.id)}" y="${nameY + k * NAME_H}" ` +
|
|
115
|
+
`class="${styler.text(style.text, FS_NAME, { bold: true, middle: true })}">` +
|
|
116
|
+
`${escapeXML(line)}</text>`);
|
|
116
117
|
});
|
|
117
118
|
lines.attrs.forEach((line, k) => {
|
|
118
119
|
parts.push(`<text x="${cx(node.id)}" ` +
|
|
119
|
-
`y="${ty + th + nh + MIN_H + ATTR_P + k * ATTR_H}"
|
|
120
|
-
`
|
|
121
|
-
`style="font-family: ${font}; fill: ${color(style.text)}">${escapeXML(line)}</text>`);
|
|
120
|
+
`y="${ty + th + nh + MIN_H + ATTR_P + k * ATTR_H}" ` +
|
|
121
|
+
`class="${styler.text(style.text, FS_ATTR, { middle: true })}">${escapeXML(line)}</text>`);
|
|
122
122
|
});
|
|
123
123
|
if (url === undefined)
|
|
124
124
|
return parts;
|
|
@@ -157,11 +157,7 @@ const viewBoxOf = (layout, boxes, margin) => {
|
|
|
157
157
|
optional name, placed near the middle of the route, and its optional
|
|
158
158
|
arity, placed near the arrow head (both dodging into a collision-free
|
|
159
159
|
position through the "claim" of the label placer) */
|
|
160
|
-
const renderEdgeLabels = (edge, poly, claim,
|
|
161
|
-
/* the halo rendered behind the edge labels for readability
|
|
162
|
-
(its color is emitted into the style attribute below, as only
|
|
163
|
-
there the CSS custom property lookup can be resolved) */
|
|
164
|
-
const halo = "stroke-width=\"4.5\" paint-order=\"stroke\" stroke-linejoin=\"round\"";
|
|
160
|
+
const renderEdgeLabels = (edge, poly, claim, styler) => {
|
|
165
161
|
const parts = [];
|
|
166
162
|
if (edge.name !== undefined) {
|
|
167
163
|
const w = textWidth(edge.name, FS_EDGE);
|
|
@@ -178,11 +174,9 @@ const renderEdgeLabels = (edge, poly, claim, font, color) => {
|
|
|
178
174
|
}
|
|
179
175
|
}
|
|
180
176
|
const box = claim(candidates);
|
|
181
|
-
parts.push(`<text x="${(box[0] + box[2]) / 2}" y="${box[3] - 3}"
|
|
182
|
-
`
|
|
183
|
-
|
|
184
|
-
`stroke: ${color("color-edge-halo")}" ` +
|
|
185
|
-
`${halo}>${escapeXML(edge.name)}</text>`);
|
|
177
|
+
parts.push(`<text x="${(box[0] + box[2]) / 2}" y="${box[3] - 3}" ` +
|
|
178
|
+
`class="${styler.text("color-edge-name", FS_EDGE, { middle: true, halo: true })}">` +
|
|
179
|
+
`${escapeXML(edge.name)}</text>`);
|
|
186
180
|
}
|
|
187
181
|
if (edge.arity !== undefined) {
|
|
188
182
|
const w = textWidth(edge.arity, FS_ARITY);
|
|
@@ -211,43 +205,38 @@ const renderEdgeLabels = (edge, poly, claim, font, color) => {
|
|
|
211
205
|
];
|
|
212
206
|
}
|
|
213
207
|
const box = claim(candidates);
|
|
214
|
-
parts.push(`<text x="${(box[0] + box[2]) / 2}" y="${box[3] - 3}"
|
|
215
|
-
`
|
|
216
|
-
|
|
217
|
-
`stroke: ${color("color-edge-halo")}" ` +
|
|
218
|
-
`${halo}>${escapeXML(edge.arity)}</text>`);
|
|
208
|
+
parts.push(`<text x="${(box[0] + box[2]) / 2}" y="${box[3] - 3}" ` +
|
|
209
|
+
`class="${styler.text("color-edge-arity", FS_ARITY, { middle: true, halo: true })}">` +
|
|
210
|
+
`${escapeXML(edge.arity)}</text>`);
|
|
219
211
|
}
|
|
220
212
|
return parts;
|
|
221
213
|
};
|
|
222
214
|
/* generate the SVG fragments for a single group box and its tag
|
|
223
215
|
in the top-left corner */
|
|
224
|
-
const renderGroup = (group,
|
|
216
|
+
const renderGroup = (group, styler) => [
|
|
225
217
|
`<rect x="${group.x}" y="${group.y}" width="${group.w}" height="${group.h}" rx="12" ` +
|
|
226
|
-
`
|
|
227
|
-
`stroke: ${color("color-group-border")}" stroke-width="3.0"/>`,
|
|
218
|
+
`class="${styler.box("color-group-box", "color-group-border", 3)}"/>`,
|
|
228
219
|
`<text x="${group.x + TAG_DX}" y="${group.y + TAG_DY + FS_GROUP}" ` +
|
|
229
|
-
`
|
|
230
|
-
`style="font-family: ${font}; fill: ${color("color-group-name")}">${escapeXML(group.name)}</text>`
|
|
220
|
+
`class="${styler.text("color-group-name", FS_GROUP, { bold: true })}">${escapeXML(group.name)}</text>`
|
|
231
221
|
];
|
|
232
222
|
/* generate the SVG fragments for a single container box and its tag
|
|
233
223
|
in the top-left corner, with the optional type line above the name
|
|
234
224
|
(a container with a "url" attribute becomes a hyperlink covering
|
|
235
225
|
the whole box) */
|
|
236
|
-
const renderContainer = (c,
|
|
226
|
+
const renderContainer = (c, styler) => {
|
|
237
227
|
const type = typeOf(c.node);
|
|
238
228
|
const url = urlOf(c.node);
|
|
239
229
|
const parts = [
|
|
240
230
|
`<rect x="${c.x}" y="${c.y}" width="${c.w}" height="${c.h}" rx="12" ` +
|
|
241
|
-
`
|
|
242
|
-
|
|
231
|
+
`class="${styler.box("color-container-box", "color-container-border", 3)}" ` +
|
|
232
|
+
"stroke-dasharray=\"10 6\"/>",
|
|
243
233
|
...(type !== undefined ? [
|
|
244
234
|
`<text x="${c.x + TAG_DX}" y="${c.y + TAG_DY + FS_TYPE}" ` +
|
|
245
|
-
`
|
|
246
|
-
`style="font-family: ${font}; fill: ${color("color-container-name")}">${escapeXML(type)}</text>`
|
|
235
|
+
`class="${styler.text("color-container-name", FS_TYPE)}">${escapeXML(type)}</text>`
|
|
247
236
|
] : []),
|
|
248
237
|
`<text x="${c.x + TAG_DX}" y="${c.y + containerHead(c.node) - HEAD_H + TAG_DY + FS_GROUP}" ` +
|
|
249
|
-
`
|
|
250
|
-
|
|
238
|
+
`class="${styler.text("color-container-name", FS_GROUP, { bold: true })}">` +
|
|
239
|
+
`${escapeXML(c.node.name)}</text>`
|
|
251
240
|
];
|
|
252
241
|
if (url === undefined)
|
|
253
242
|
return parts;
|
|
@@ -264,7 +253,7 @@ export const renderSVG = (layout, config, explicit, seed) => {
|
|
|
264
253
|
values: explicitly configured values are hard-coded, while all
|
|
265
254
|
others are fetched at display time from the "--gradia-<option>"
|
|
266
255
|
CSS custom properties, falling back to the built-in defaults */
|
|
267
|
-
const color = (key) =>
|
|
256
|
+
const color = (key) => cssValueOf(explicit, key);
|
|
268
257
|
/* derive the collision-free identifiers of this SVG document */
|
|
269
258
|
const prefix = idPrefix(seed);
|
|
270
259
|
const idArrow = `${prefix}-arrow`;
|
|
@@ -273,7 +262,29 @@ export const renderSVG = (layout, config, explicit, seed) => {
|
|
|
273
262
|
const stack = Object.hasOwn(explicit, "font-family") ?
|
|
274
263
|
`'${escapeCSS(family)}'` :
|
|
275
264
|
`var(--gradia-font-family, '${escapeCSS(family)}')`;
|
|
276
|
-
const font =
|
|
265
|
+
const font = `${stack}, ui-sans-serif, -apple-system, Helvetica, Arial, sans-serif`;
|
|
266
|
+
/* collect the CSS classes the rendered elements reference (the
|
|
267
|
+
halo behind an edge label keeps it readable on top of a line) */
|
|
268
|
+
const rules = new Map();
|
|
269
|
+
const classOf = (decls) => {
|
|
270
|
+
let name = rules.get(decls);
|
|
271
|
+
if (name === undefined) {
|
|
272
|
+
name = `gradia-${new UUID(5, NS_GRADIA, decls).format("b16").toLowerCase().slice(0, 8)}`;
|
|
273
|
+
rules.set(decls, name);
|
|
274
|
+
}
|
|
275
|
+
return name;
|
|
276
|
+
};
|
|
277
|
+
const styler = {
|
|
278
|
+
text: (fill, size, options = {}) => classOf(`font-family: ${font}; font-size: ${size}px; ` +
|
|
279
|
+
(options.bold ? "font-weight: 600; " : "") +
|
|
280
|
+
(options.middle ? "text-anchor: middle; " : "") +
|
|
281
|
+
`fill: ${color(fill)}` +
|
|
282
|
+
(options.halo ? `; stroke: ${color("color-edge-halo")}; stroke-width: 4.5; ` +
|
|
283
|
+
"paint-order: stroke; stroke-linejoin: round" : "")),
|
|
284
|
+
box: (fill, stroke, width) => classOf(`fill: ${color(fill)}; stroke: ${color(stroke)}; stroke-width: ${width}`)
|
|
285
|
+
};
|
|
286
|
+
const classEdge = classOf(`fill: none; stroke: ${color("color-edge-line")}; stroke-width: 3`);
|
|
287
|
+
const classArrow = classOf(`fill: ${color("color-edge-line")}`);
|
|
277
288
|
/* detect the edge crossings requiring rendered hops */
|
|
278
289
|
const hops = computeHops(polys);
|
|
279
290
|
/* prepare the collision-free placement of the edge labels */
|
|
@@ -282,38 +293,39 @@ export const renderSVG = (layout, config, explicit, seed) => {
|
|
|
282
293
|
const svgEdges = [];
|
|
283
294
|
const svgLabels = [];
|
|
284
295
|
edges.forEach((edge, i) => {
|
|
285
|
-
svgEdges.push(`<path d="${pathOf(polys[i], hops[i], config["size-edge-corner-radius"], config["size-edge-hop-radius"])}"
|
|
286
|
-
`
|
|
287
|
-
svgLabels.push(...renderEdgeLabels(edge, polys[i], claim,
|
|
296
|
+
svgEdges.push(`<path d="${pathOf(polys[i], hops[i], config["size-edge-corner-radius"], config["size-edge-hop-radius"])}" ` +
|
|
297
|
+
`class="${classEdge}" marker-end="url(#${idArrow})"/>`);
|
|
298
|
+
svgLabels.push(...renderEdgeLabels(edge, polys[i], claim, styler));
|
|
288
299
|
});
|
|
289
300
|
/* generate the SVG fragments for the node boxes */
|
|
290
|
-
const svgNodes = nodes.flatMap((node) => renderNode(node, layout, styleOf(node),
|
|
301
|
+
const svgNodes = nodes.flatMap((node) => renderNode(node, layout, styleOf(node), styler, config));
|
|
291
302
|
/* generate the SVG fragments for the group boxes (drawn below
|
|
292
303
|
everything else) and the container boxes (drawn below the edges
|
|
293
304
|
and nodes, an outer box before its nested ones) */
|
|
294
|
-
const svgGroups = groups.flatMap((group) => renderGroup(group,
|
|
295
|
-
const svgContainers = containers.flatMap((c) => renderContainer(c,
|
|
305
|
+
const svgGroups = groups.flatMap((group) => renderGroup(group, styler));
|
|
306
|
+
const svgContainers = containers.flatMap((c) => renderContainer(c, styler));
|
|
296
307
|
/* determine the overall bounding box of all rendered elements */
|
|
297
308
|
const groupBoxes = groups.map((group) => [group.x, group.y, group.x + group.w, group.y + group.h]);
|
|
298
309
|
const containerBoxes = containers.map((c) => [c.x, c.y, c.x + c.w, c.y + c.h]);
|
|
299
310
|
const vb = viewBoxOf(layout, [...occupied, ...groupBoxes, ...containerBoxes], config["size-canvas-margin"]);
|
|
300
|
-
/* assemble the final SVG document */
|
|
311
|
+
/* assemble the final SVG document (with one CSS rule per line) */
|
|
301
312
|
return [
|
|
302
313
|
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>",
|
|
303
314
|
"<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" " +
|
|
304
315
|
`viewBox="${vb.x} ${vb.y} ${vb.w} ${vb.h}" width="${vb.w}" height="${vb.h}">`,
|
|
305
316
|
"<defs>",
|
|
317
|
+
"<style>",
|
|
306
318
|
...(embed !== undefined ? [
|
|
307
|
-
"<style>",
|
|
308
319
|
`@font-face { font-family: "${escapeXML(escapeCSS(family))}"; ` +
|
|
309
320
|
(weight !== undefined ? `font-weight: ${escapeXML(escapeCSS(weight))}; ` : "") +
|
|
310
|
-
`src: url(data:font/woff2;base64,${embed}) format("woff2"); }
|
|
311
|
-
"</style>"
|
|
321
|
+
`src: url(data:font/woff2;base64,${embed}) format("woff2"); }`
|
|
312
322
|
] : []),
|
|
323
|
+
...Array.from(rules, ([decls, name]) => `.${name} { ${escapeXML(decls)} }`),
|
|
324
|
+
"</style>",
|
|
313
325
|
`<marker id="${idArrow}" viewBox="0 0 10 10" refX="9" refY="5" ` +
|
|
314
326
|
"markerWidth=\"21\" markerHeight=\"21\" markerUnits=\"userSpaceOnUse\" " +
|
|
315
327
|
"orient=\"auto-start-reverse\">",
|
|
316
|
-
`<path d="M 0 1 L 9 5 L 0 9 z"
|
|
328
|
+
`<path d="M 0 1 L 9 5 L 0 9 z" class="${classArrow}"/>`,
|
|
317
329
|
"</marker>",
|
|
318
330
|
"</defs>",
|
|
319
331
|
...svgGroups,
|