@rse/gradia 1.2.2 → 1.2.4
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 +43 -32
- package/dst/gradia-api-config.d.ts +4 -0
- package/dst/gradia-api-config.js +6 -2
- 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 +3 -3
- package/dst/gradia-api-type-graph.js +10 -6
- package/dst/gradia-api-type-grid.js +26 -9
- package/dst/gradia-api-type-hub.js +23 -5
- package/dst/gradia-api.js +1 -1
- package/dst/gradia-api.umd.js +2 -2
- package/dst/gradia-mcp.js +9 -3
- 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,38 +307,40 @@ 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-node-
|
|
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 grid-columns-max 4
|
|
331
|
+
color-edge-name #333333 grid-columns-min 3
|
|
332
|
+
color-edge-arity #333333 grid-gap-horizontal 40
|
|
333
|
+
color-edge-halo #ffffff grid-gap-vertical 20
|
|
334
|
+
size-canvas-margin 40 grid-node-width-equal true
|
|
335
|
+
size-node-width-min 220 grid-node-height-equal true
|
|
336
|
+
size-node-width-max 0
|
|
332
337
|
```
|
|
333
338
|
|
|
334
339
|
The `size-*`, `group-*`, `container-*`, `graph-*`, `hub-*`, and
|
|
335
340
|
`grid-*` options control the rendering geometry (canvas margin, node
|
|
336
341
|
box sizing, edge routing, group and container box spacing, and the
|
|
337
342
|
per-diagram-type layout) and take non-negative numbers, except the
|
|
338
|
-
|
|
343
|
+
booleans `grid-node-width-equal` and `grid-node-height-equal`.
|
|
339
344
|
|
|
340
345
|
The `size-node-width-max` option additionally enables the word-wrapping
|
|
341
346
|
of the node box texts: given a positive value, the node name, its type,
|
|
@@ -379,10 +384,16 @@ stacked, while `grid-columns-max` caps it, so that larger diagrams grow
|
|
|
379
384
|
in height only. The column count never exceeds the node count and the
|
|
380
385
|
maximum always wins over the minimum.
|
|
381
386
|
|
|
382
|
-
The `grid-node-width-equal`
|
|
383
|
-
diagram: the value `true` forces all node
|
|
384
|
-
|
|
385
|
-
|
|
387
|
+
The `grid-node-width-equal` and `grid-node-height-equal` options control
|
|
388
|
+
the tile sizes of a `grid` diagram: the value `true` forces all node
|
|
389
|
+
boxes to the width of the widest one (or the height of the tallest one)
|
|
390
|
+
and hence yields a strictly regular grid, while `false` lets each column
|
|
391
|
+
become only as wide as its own widest node box (or each row only as tall
|
|
392
|
+
as its own tallest node box), with the tiles left-aligned within their
|
|
393
|
+
column (or top-aligned within their row). A nested container box takes
|
|
394
|
+
part in the width unification only, as its height is the one of its
|
|
395
|
+
content: it never grows to the height of the tallest tile of its row,
|
|
396
|
+
but keeps its row as tall as itself.
|
|
386
397
|
|
|
387
398
|
The `font-family` and `color-*` options are embedded directly into the
|
|
388
399
|
generated SVG. When such an option is *not* explicitly configured, the
|
|
@@ -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;
|
|
@@ -48,6 +51,7 @@ export declare const configDefaults: {
|
|
|
48
51
|
"grid-gap-horizontal": number;
|
|
49
52
|
"grid-gap-vertical": number;
|
|
50
53
|
"grid-node-width-equal": boolean;
|
|
54
|
+
"grid-node-height-equal": boolean;
|
|
51
55
|
};
|
|
52
56
|
export type Config = typeof configDefaults;
|
|
53
57
|
export type ConfigEmbedded = {
|
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",
|
|
@@ -57,7 +60,8 @@ export const configDefaults = {
|
|
|
57
60
|
"grid-columns-min": 3, /* grid: min side-by-side tiles */
|
|
58
61
|
"grid-gap-horizontal": 40, /* grid: horizontal gap between tiles */
|
|
59
62
|
"grid-gap-vertical": 20, /* grid: vertical gap between tiles */
|
|
60
|
-
"grid-node-width-equal": true /* grid: force equal width of node boxes */
|
|
63
|
+
"grid-node-width-equal": true, /* grid: force equal width of node boxes */
|
|
64
|
+
"grid-node-height-equal": true /* grid: force equal height of node boxes */
|
|
61
65
|
};
|
|
62
66
|
/* resolve a directly embedded configuration option into a CSS value:
|
|
63
67
|
an explicitly configured value is hard-coded (stripped of the
|
|
@@ -159,7 +163,7 @@ export const resolveFont = (config) => {
|
|
|
159
163
|
return { family };
|
|
160
164
|
}
|
|
161
165
|
else if (config["font-embed"])
|
|
162
|
-
throw new Error("option \"font-embed\" requires option \"font-family\" to be a built-in font " +
|
|
166
|
+
throw new Error("configuration option \"font-embed\" requires configuration option \"font-family\" to be a built-in font " +
|
|
163
167
|
`family (${Object.keys(fontsBuiltIn).map((name) => `"${name}"`).join(", ")}) ` +
|
|
164
168
|
"or the path to a WOFF2 file");
|
|
165
169
|
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();
|
|
@@ -66,12 +66,12 @@ const labelPlacer = (layout, hops, hopRadius) => {
|
|
|
66
66
|
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
67
|
const claim = (candidates) => {
|
|
68
68
|
let box = candidates[0];
|
|
69
|
-
let
|
|
69
|
+
let lowest = Infinity;
|
|
70
70
|
for (const c of candidates) {
|
|
71
71
|
const score = collisions(occupied, c) * (lines.length + 1) + collisions(lines, c);
|
|
72
|
-
if (score <
|
|
72
|
+
if (score < lowest) {
|
|
73
73
|
box = c;
|
|
74
|
-
|
|
74
|
+
lowest = score;
|
|
75
75
|
}
|
|
76
76
|
if (score === 0)
|
|
77
77
|
break;
|
|
@@ -26,6 +26,7 @@ const NUDGE_PASSES = 3; /* max number of node nudging passes
|
|
|
26
26
|
/* snap raw node positions onto a discrete grid by clustering the
|
|
27
27
|
distinct X coordinates into columns and Y coordinates into rows */
|
|
28
28
|
const snapToGrid = (nodes, rawX, rawY) => {
|
|
29
|
+
/* cluster the distinct values into representatives more than a threshold apart */
|
|
29
30
|
const cluster = (values, threshold) => {
|
|
30
31
|
const sorted = Array.from(new Set(values)).sort((a, b) => a - b);
|
|
31
32
|
const reps = [];
|
|
@@ -36,6 +37,7 @@ const snapToGrid = (nodes, rawX, rawY) => {
|
|
|
36
37
|
};
|
|
37
38
|
const colReps = cluster(nodes.map((node) => rawX.get(node.id)), CLUSTX);
|
|
38
39
|
const rowReps = cluster(nodes.map((node) => rawY.get(node.id)), CLUSTY);
|
|
40
|
+
/* determine the index of the representative nearest to a value */
|
|
39
41
|
const nearestIndexOf = (reps, v) => {
|
|
40
42
|
let best = 0;
|
|
41
43
|
for (let i = 1; i < reps.length; i++)
|
|
@@ -304,7 +306,7 @@ const planRoutes = (edges, col, row, sides, gates) => {
|
|
|
304
306
|
chanNeed(plan.chans[0], textWidth(name, FS_EDGE) + 2 * CHAN_LBL);
|
|
305
307
|
else if (name !== undefined && plan.guts.length > 0)
|
|
306
308
|
gutLbl.set(plan.guts[0], (gutLbl.get(plan.guts[0]) ?? 0) + 1);
|
|
307
|
-
if (arity !== undefined
|
|
309
|
+
if (arity !== undefined)
|
|
308
310
|
chanNeed(chans[chans.length - 1], ARITY_OFF + textWidth(arity, FS_ARITY) + CHAN_LBL);
|
|
309
311
|
});
|
|
310
312
|
/* count the self-loops of every node, as their detours stack up
|
|
@@ -375,8 +377,6 @@ const assignTrackCoords = (edges, plans, portPos, grid, config) => {
|
|
|
375
377
|
const chanUsers = new Map();
|
|
376
378
|
edges.forEach((edge, i) => {
|
|
377
379
|
const { chans, guts, flags, gate } = plans[i];
|
|
378
|
-
if (chans.length === 0)
|
|
379
|
-
return;
|
|
380
380
|
const sp = portPos.get(`${i}:s`);
|
|
381
381
|
const tp = portPos.get(`${i}:t`);
|
|
382
382
|
if (edge.source === edge.target)
|
|
@@ -433,7 +433,7 @@ const routePolys = (edges, plans, portPos, boxH, cy, chanX, gutY) => {
|
|
|
433
433
|
return edges.map((edge, i) => {
|
|
434
434
|
const sp = portPos.get(`${i}:s`);
|
|
435
435
|
const tp = portPos.get(`${i}:t`);
|
|
436
|
-
const { chans, guts, flags } = plans[i];
|
|
436
|
+
const { chans, guts, flags, gate } = plans[i];
|
|
437
437
|
let pts;
|
|
438
438
|
if (edge.source === edge.target) {
|
|
439
439
|
/* route the self-loop counter-clockwise around the top-right
|
|
@@ -460,9 +460,9 @@ const routePolys = (edges, plans, portPos, boxH, cy, chanX, gutY) => {
|
|
|
460
460
|
const ch1 = chanX(chans[0], i);
|
|
461
461
|
const ch2 = chanX(chans[1], i);
|
|
462
462
|
const gy = gutY(guts[0], i);
|
|
463
|
-
if (
|
|
463
|
+
if (gate === "t")
|
|
464
464
|
pts = [[sp.x, sp.y], [ch1, sp.y], [ch1, gy], [tp.x, gy]];
|
|
465
|
-
else if (
|
|
465
|
+
else if (gate === "s")
|
|
466
466
|
pts = [[sp.x, gy], [ch2, gy], [ch2, tp.y], [tp.x, tp.y]];
|
|
467
467
|
else
|
|
468
468
|
pts = [[sp.x, sp.y], [ch1, sp.y], [ch1, gy], [ch2, gy], [ch2, tp.y], [tp.x, tp.y]];
|
|
@@ -499,11 +499,13 @@ const routeEdges = (edges, plans, sides, boxW, boxH, cx, cy, grid, config, level
|
|
|
499
499
|
rises above the box of its node), and only as long as its own
|
|
500
500
|
gutter-routed edges keep approaching it from their gutter side */
|
|
501
501
|
const nudgeNodes = (nodes, edges, plans, col, row, boxH, grid, nrows, dy, route) => {
|
|
502
|
+
/* whether neither a gutter track nor a self-loop detour crosses a column within a gutter */
|
|
502
503
|
const gutterFree = (g, c) => !plans.some((plan) => plan.guts[0] === g
|
|
503
504
|
&& Math.min(...plan.chans) < c
|
|
504
505
|
&& c <= Math.max(...plan.chans) + (plan.gate === "t" ? 1 : 0))
|
|
505
506
|
&& !edges.some((edge) => edge.source === edge.target
|
|
506
507
|
&& row.get(edge.source) === g + 1 && col.get(edge.source) === c);
|
|
508
|
+
/* the vertical shift range of a node: its row slack plus the room of its free adjacent gutters */
|
|
507
509
|
const shiftRange = (id) => {
|
|
508
510
|
const r = row.get(id);
|
|
509
511
|
const c = col.get(id);
|
|
@@ -512,6 +514,7 @@ const nudgeNodes = (nodes, edges, plans, col, row, boxH, grid, nrows, dy, route)
|
|
|
512
514
|
const down = r < nrows - 1 && gutterFree(r, c) ? grid.gutH[r] - GUT_H0 : 0;
|
|
513
515
|
return [-(slack + up), slack + down];
|
|
514
516
|
};
|
|
517
|
+
/* whether the gutter-routed edges of a node still approach it from their gutter side */
|
|
515
518
|
const approachesSanely = (id, routing) => edges.every((edge, i) => {
|
|
516
519
|
const role = edge.source === id ? "s" : edge.target === id ? "t" : null;
|
|
517
520
|
if (role === null || plans[i].guts.length === 0)
|
|
@@ -520,6 +523,7 @@ const nudgeNodes = (nodes, edges, plans, col, row, boxH, grid, nrows, dy, route)
|
|
|
520
523
|
const gap = routing.portPos.get(`${i}:${role}`).y - routing.gutY(g, i);
|
|
521
524
|
return row.get(id) > g ? gap >= NUDGE_GAP : gap <= -NUDGE_GAP;
|
|
522
525
|
});
|
|
526
|
+
/* rank the routings: fewer crossings first, then fewer jogged direct same-row edges */
|
|
523
527
|
const direct = edges.map((edge, i) => plans[i].chans.length === 1
|
|
524
528
|
&& !plans[i].flags[0].hairpin
|
|
525
529
|
&& row.get(edge.source) === row.get(edge.target));
|
|
@@ -6,10 +6,11 @@
|
|
|
6
6
|
import { measureNodes, orderOf } from "./gradia-api-render-node.js";
|
|
7
7
|
/* lay out an edge-less graph model as a compact grid of tiles (for a
|
|
8
8
|
containment level: with the container placeholders at their fixed
|
|
9
|
-
sizes, which take part in the tile
|
|
9
|
+
sizes, which take part in the tile width unification only), the tiles in
|
|
10
10
|
declaration order, or in the order of their "order" attributes
|
|
11
11
|
(the tiles without one trailing) */
|
|
12
12
|
export const render = async (graph, config, level = {}) => {
|
|
13
|
+
/* determine the tiles in their rendering order */
|
|
13
14
|
const nodes = Array.from(graph.nodes.values())
|
|
14
15
|
.sort((a, b) => (orderOf(a) ?? Infinity) - (orderOf(b) ?? Infinity) || 0);
|
|
15
16
|
/* resolve the configurable rendering geometry */
|
|
@@ -24,26 +25,36 @@ export const render = async (graph, config, level = {}) => {
|
|
|
24
25
|
throw new Error("diagram type \"grid\" does not support edges " +
|
|
25
26
|
`(found ${graph.edges.length}, first is "${edge.source}" --> "${edge.target}")`);
|
|
26
27
|
}
|
|
27
|
-
/* determine node box sizes and unify their
|
|
28
|
-
|
|
28
|
+
/* determine node box sizes and, if configured, unify their widths
|
|
29
|
+
into a single tile width (the container placeholders included, so
|
|
30
|
+
the container boxes line up) and their heights into a single tile
|
|
31
|
+
height (the container placeholders excluded, as the height of a
|
|
32
|
+
container box is the one of its content) */
|
|
29
33
|
const { boxW, boxH, contentH } = measureNodes(nodes, config, () => config["size-node-height-scale"] / 2, level.fixedSize);
|
|
30
|
-
const
|
|
34
|
+
const isFixed = (node) => level.fixedSize?.has(node.id) ?? false;
|
|
35
|
+
const tileH = nodes.reduce((a, node) => isFixed(node) ? a : Math.max(a, boxH.get(node.id)), 0);
|
|
31
36
|
const tileW = nodes.reduce((a, node) => Math.max(a, boxW.get(node.id)), 0);
|
|
32
37
|
for (const node of nodes) {
|
|
33
|
-
|
|
38
|
+
if (config["grid-node-height-equal"] && !isFixed(node))
|
|
39
|
+
boxH.set(node.id, tileH);
|
|
34
40
|
if (config["grid-node-width-equal"])
|
|
35
41
|
boxW.set(node.id, tileW);
|
|
36
42
|
}
|
|
37
|
-
/* place the nodes in
|
|
43
|
+
/* place the nodes in their rendering order onto a roughly square,
|
|
38
44
|
row-major grid (raised to the configured minimum of columns, so
|
|
39
45
|
few nodes still share a row, and capped at the configured maximum
|
|
40
46
|
of columns and the node count, so larger graphs grow only in
|
|
41
47
|
height), with each column as wide as its widest tile and each
|
|
42
|
-
tile left-aligned
|
|
48
|
+
row as tall as its tallest tile, and each tile left-aligned
|
|
49
|
+
within its column and top-aligned within its row */
|
|
43
50
|
const cols = Math.max(Math.min(Math.max(Math.ceil(Math.sqrt(nodes.length)), minCols), maxCols, nodes.length), 1);
|
|
51
|
+
const rows = Math.ceil(nodes.length / cols);
|
|
44
52
|
const colWidth = Array.from({ length: cols }, () => 0);
|
|
53
|
+
const rowHeight = Array.from({ length: rows }, () => 0);
|
|
45
54
|
nodes.forEach((node, i) => {
|
|
46
|
-
|
|
55
|
+
const [c, r] = [i % cols, Math.floor(i / cols)];
|
|
56
|
+
colWidth[c] = Math.max(colWidth[c], boxW.get(node.id));
|
|
57
|
+
rowHeight[r] = Math.max(rowHeight[r], boxH.get(node.id));
|
|
47
58
|
});
|
|
48
59
|
const colLX = [];
|
|
49
60
|
let x = margin;
|
|
@@ -51,11 +62,17 @@ export const render = async (graph, config, level = {}) => {
|
|
|
51
62
|
colLX.push(x);
|
|
52
63
|
x += colWidth[c] + gapH;
|
|
53
64
|
}
|
|
65
|
+
const rowTY = [];
|
|
66
|
+
let y = margin;
|
|
67
|
+
for (let r = 0; r < rows; r++) {
|
|
68
|
+
rowTY.push(y);
|
|
69
|
+
y += rowHeight[r] + gapV;
|
|
70
|
+
}
|
|
54
71
|
const nodeCX = new Map();
|
|
55
72
|
const nodeCY = new Map();
|
|
56
73
|
nodes.forEach((node, i) => {
|
|
57
74
|
nodeCX.set(node.id, colLX[i % cols] + boxW.get(node.id) / 2);
|
|
58
|
-
nodeCY.set(node.id,
|
|
75
|
+
nodeCY.set(node.id, rowTY[Math.floor(i / cols)] + boxH.get(node.id) / 2);
|
|
59
76
|
});
|
|
60
77
|
const cx = (id) => nodeCX.get(id);
|
|
61
78
|
const cy = (id) => nodeCY.get(id);
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
import { isPrimary, measureNodes, defaultStyleOf } from "./gradia-api-render-node.js";
|
|
7
7
|
import { simplifyPoly, assignPorts, assignTracks } from "./gradia-api-render-edge.js";
|
|
8
8
|
/* the separator between a node id and its placement suffix,
|
|
9
|
-
distinguishing the two clones of a twice-placed node
|
|
9
|
+
distinguishing the two clones of a twice-placed node and the
|
|
10
|
+
output clone of a self-referencing primary node */
|
|
10
11
|
const CLONE = "\u0000";
|
|
11
12
|
/* rendering geometry constants */
|
|
12
13
|
const CHAN_W1 = 28; /* width of a one-edge inter-column channel */
|
|
@@ -18,8 +19,10 @@ const CHAN_PAD = 16; /* cross-axis padding inside a channel */
|
|
|
18
19
|
(edges originate from the primary); a node referenced in both
|
|
19
20
|
directions is placed twice, once in the input column and once in
|
|
20
21
|
the output column, with its edges rewritten to attach to the
|
|
21
|
-
corresponding placement
|
|
22
|
+
corresponding placement. A self-loop on the primary is unrolled
|
|
23
|
+
likewise, onto a clone of the primary in the output column */
|
|
22
24
|
const classifyTopology = (graph) => {
|
|
25
|
+
/* determine the primary node and the ids of its input and output nodes */
|
|
23
26
|
const declared = Array.from(graph.nodes.values());
|
|
24
27
|
for (const node of declared)
|
|
25
28
|
if (node.id.includes(CLONE))
|
|
@@ -30,9 +33,10 @@ const classifyTopology = (graph) => {
|
|
|
30
33
|
const center = primaries[0];
|
|
31
34
|
const inSet = new Set();
|
|
32
35
|
const outSet = new Set();
|
|
36
|
+
let selfLoop = false;
|
|
33
37
|
for (const edge of graph.edges) {
|
|
34
38
|
if (edge.source === center.id && edge.target === center.id)
|
|
35
|
-
|
|
39
|
+
selfLoop = true;
|
|
36
40
|
else if (edge.target === center.id)
|
|
37
41
|
inSet.add(edge.source);
|
|
38
42
|
else if (edge.source === center.id)
|
|
@@ -61,9 +65,16 @@ const classifyTopology = (graph) => {
|
|
|
61
65
|
else
|
|
62
66
|
outputs.push(node);
|
|
63
67
|
}
|
|
68
|
+
/* place the clone of a self-referencing primary node (stripped of
|
|
69
|
+
its "primary" annotation) on top of the output column */
|
|
70
|
+
if (selfLoop)
|
|
71
|
+
outputs.unshift({ ...center, id: center.id + CLONE + "self",
|
|
72
|
+
attrs: center.attrs.filter((attr) => attr.key !== "primary") });
|
|
64
73
|
/* rewrite the edges of the twice-placed nodes onto their clones */
|
|
65
74
|
const edges = graph.edges.map((edge) => {
|
|
66
|
-
if (edge.
|
|
75
|
+
if (edge.source === center.id && edge.target === center.id)
|
|
76
|
+
return { ...edge, target: edge.target + CLONE + "self" };
|
|
77
|
+
else if (edge.target === center.id && dual.has(edge.source))
|
|
67
78
|
return { ...edge, source: edge.source + CLONE + "in" };
|
|
68
79
|
else if (edge.source === center.id && dual.has(edge.target))
|
|
69
80
|
return { ...edge, target: edge.target + CLONE + "out" };
|
|
@@ -195,12 +206,19 @@ export const render = async (graph, config, level = {}) => {
|
|
|
195
206
|
});
|
|
196
207
|
/* hand over the laid out graph for SVG rendering (the output copy
|
|
197
208
|
of a twice-placed node is rendered as a dashed grey "ghost" box,
|
|
198
|
-
|
|
209
|
+
the output clone of a self-referencing primary node as a dashed,
|
|
210
|
+
darker grey "self" box, and all other boxes get the default
|
|
211
|
+
primary/regular coloring) */
|
|
199
212
|
const styleOf = (node) => node.id.endsWith(CLONE + "out") ? {
|
|
200
213
|
fill: "color-node-ghost-box",
|
|
201
214
|
stroke: "color-node-ghost-border",
|
|
202
215
|
text: "color-node-ghost-name",
|
|
203
216
|
dash: "10 6"
|
|
217
|
+
} : node.id.endsWith(CLONE + "self") ? {
|
|
218
|
+
fill: "color-node-self-box",
|
|
219
|
+
stroke: "color-node-self-border",
|
|
220
|
+
text: "color-node-self-name",
|
|
221
|
+
dash: "10 6"
|
|
204
222
|
} : defaultStyleOf(node);
|
|
205
223
|
return { nodes, edges, cx, cy, boxW, boxH, contentH, polys, styleOf };
|
|
206
224
|
};
|
package/dst/gradia-api.js
CHANGED
|
@@ -122,7 +122,7 @@ export class Gradia {
|
|
|
122
122
|
static async render(spec, options = {}) {
|
|
123
123
|
const graph = Gradia.parse(spec);
|
|
124
124
|
const type = options.type ?? parseTypeDirective(spec);
|
|
125
|
-
const config = { ...parseDirectives(spec), ...(options.config ?? {}) };
|
|
125
|
+
const config = { ...parseDirectives(spec), ...validateConfig(options.config ?? {}, { trusted: true }) };
|
|
126
126
|
return Gradia.generate(graph, { ...options, type, config });
|
|
127
127
|
}
|
|
128
128
|
}
|