@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
|
@@ -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));
|
|
@@ -10,6 +10,7 @@ import { measureNodes, orderOf } from "./gradia-api-render-node.js";
|
|
|
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 */
|
|
@@ -39,7 +40,7 @@ export const render = async (graph, config, level = {}) => {
|
|
|
39
40
|
if (config["grid-node-width-equal"])
|
|
40
41
|
boxW.set(node.id, tileW);
|
|
41
42
|
}
|
|
42
|
-
/* place the nodes in
|
|
43
|
+
/* place the nodes in their rendering order onto a roughly square,
|
|
43
44
|
row-major grid (raised to the configured minimum of columns, so
|
|
44
45
|
few nodes still share a row, and capped at the configured maximum
|
|
45
46
|
of columns and the node count, so larger graphs grow only in
|
|
@@ -3,14 +3,19 @@
|
|
|
3
3
|
** Copyright (c) 2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
|
|
4
4
|
** Distributed under MIT license <https://spdx.org/licenses/MIT.html>
|
|
5
5
|
*/
|
|
6
|
+
import { FS_ARITY, ARITY_OFF, textWidth } from "./gradia-api-render-base.js";
|
|
6
7
|
import { isPrimary, measureNodes, defaultStyleOf } from "./gradia-api-render-node.js";
|
|
7
8
|
import { simplifyPoly, assignPorts, assignTracks } from "./gradia-api-render-edge.js";
|
|
8
9
|
/* the separator between a node id and its placement suffix,
|
|
9
|
-
distinguishing the two clones of a twice-placed node
|
|
10
|
+
distinguishing the two clones of a twice-placed node and the
|
|
11
|
+
output clone of a self-referencing primary node */
|
|
10
12
|
const CLONE = "\u0000";
|
|
11
13
|
/* rendering geometry constants */
|
|
12
|
-
const CHAN_W1 = 28; /* width of a one-edge inter-column channel
|
|
13
|
-
const CHAN_PAD = 16; /* cross-axis padding inside a channel
|
|
14
|
+
const CHAN_W1 = 28; /* width of a one-edge inter-column channel */
|
|
15
|
+
const CHAN_PAD = 16; /* cross-axis padding inside a channel */
|
|
16
|
+
const WRAP_GAP = 40; /* min horizontal gap between two sub-columns */
|
|
17
|
+
const WRAP_PAD = 8; /* padding of an arity inside that gap */
|
|
18
|
+
const LANE_PAD = 12; /* clearance of an edge lane to a node box */
|
|
14
19
|
/* validate the constrained input topology and classify the declared
|
|
15
20
|
nodes: exactly one node is annotated with "primary: true" and every
|
|
16
21
|
edge connects the primary with another node. Each other node is
|
|
@@ -18,8 +23,10 @@ const CHAN_PAD = 16; /* cross-axis padding inside a channel */
|
|
|
18
23
|
(edges originate from the primary); a node referenced in both
|
|
19
24
|
directions is placed twice, once in the input column and once in
|
|
20
25
|
the output column, with its edges rewritten to attach to the
|
|
21
|
-
corresponding placement
|
|
26
|
+
corresponding placement. A self-loop on the primary is unrolled
|
|
27
|
+
likewise, onto a clone of the primary in the output column */
|
|
22
28
|
const classifyTopology = (graph) => {
|
|
29
|
+
/* determine the primary node and the ids of its input and output nodes */
|
|
23
30
|
const declared = Array.from(graph.nodes.values());
|
|
24
31
|
for (const node of declared)
|
|
25
32
|
if (node.id.includes(CLONE))
|
|
@@ -30,9 +37,10 @@ const classifyTopology = (graph) => {
|
|
|
30
37
|
const center = primaries[0];
|
|
31
38
|
const inSet = new Set();
|
|
32
39
|
const outSet = new Set();
|
|
40
|
+
let selfLoop = false;
|
|
33
41
|
for (const edge of graph.edges) {
|
|
34
42
|
if (edge.source === center.id && edge.target === center.id)
|
|
35
|
-
|
|
43
|
+
selfLoop = true;
|
|
36
44
|
else if (edge.target === center.id)
|
|
37
45
|
inSet.add(edge.source);
|
|
38
46
|
else if (edge.source === center.id)
|
|
@@ -61,9 +69,16 @@ const classifyTopology = (graph) => {
|
|
|
61
69
|
else
|
|
62
70
|
outputs.push(node);
|
|
63
71
|
}
|
|
72
|
+
/* place the clone of a self-referencing primary node (stripped of
|
|
73
|
+
its "primary" annotation) on top of the output column */
|
|
74
|
+
if (selfLoop)
|
|
75
|
+
outputs.unshift({ ...center, id: center.id + CLONE + "self",
|
|
76
|
+
attrs: center.attrs.filter((attr) => attr.key !== "primary") });
|
|
64
77
|
/* rewrite the edges of the twice-placed nodes onto their clones */
|
|
65
78
|
const edges = graph.edges.map((edge) => {
|
|
66
|
-
if (edge.
|
|
79
|
+
if (edge.source === center.id && edge.target === center.id)
|
|
80
|
+
return { ...edge, target: edge.target + CLONE + "self" };
|
|
81
|
+
else if (edge.target === center.id && dual.has(edge.source))
|
|
67
82
|
return { ...edge, source: edge.source + CLONE + "in" };
|
|
68
83
|
else if (edge.source === center.id && dual.has(edge.target))
|
|
69
84
|
return { ...edge, target: edge.target + CLONE + "out" };
|
|
@@ -111,30 +126,71 @@ export const render = async (graph, config, level = {}) => {
|
|
|
111
126
|
boxH.set(node.id, boxH.get(node.id) +
|
|
112
127
|
(cnt - config["hub-node-degree-max"]) * config["size-edge-port-gap"]);
|
|
113
128
|
}
|
|
129
|
+
/* a stack of more nodes than the configured maximum wraps into two
|
|
130
|
+
staggered sub-columns: its nodes alternate between the outer
|
|
131
|
+
sub-column and the inner one (adjacent to the channel), and
|
|
132
|
+
every outer node is vertically centered onto a gap between two
|
|
133
|
+
inner nodes, through which its edges reach the channel on their
|
|
134
|
+
straight horizontal lanes (so the edge routing is unaffected) */
|
|
135
|
+
const countMax = config["hub-node-count-max"];
|
|
136
|
+
const outerSet = new Set();
|
|
137
|
+
for (const list of [inputs, outputs])
|
|
138
|
+
if (countMax > 0 && list.length > countMax)
|
|
139
|
+
list.filter((_, k) => k % 2 === 0).forEach((node) => outerSet.add(node.id));
|
|
140
|
+
/* the vertical half extent of the lane the edges of an outer node
|
|
141
|
+
occupy around its center: its ports at their maximum separation
|
|
142
|
+
plus the clearance to the inner node boxes (a fixed-size box
|
|
143
|
+
carries its ports at fixed positions anywhere along its side,
|
|
144
|
+
so its lane spans its entire height) */
|
|
145
|
+
const laneOf = (id) => {
|
|
146
|
+
const cnt = Math.max(portCnt.get(`w:${id}`) ?? 0, portCnt.get(`e:${id}`) ?? 0);
|
|
147
|
+
return level.fixedSize?.has(id) ? boxH.get(id) / 2 :
|
|
148
|
+
Math.min(boxH.get(id) / 2, (cnt - 1) * config["size-edge-port-gap"] / 2 + LANE_PAD);
|
|
149
|
+
};
|
|
114
150
|
/* fixed three-column layout: stack the input nodes in the first
|
|
115
151
|
column and the output nodes in the third column (each stack
|
|
116
152
|
vertically centered), and place the center node in the second
|
|
117
|
-
column at the vertical center of the canvas
|
|
118
|
-
|
|
119
|
-
const
|
|
120
|
-
const nodeCY = new Map();
|
|
153
|
+
column at the vertical center of the canvas, where an outer node
|
|
154
|
+
keeps its lane clear of the inner nodes above and below it */
|
|
155
|
+
const stackY = new Map();
|
|
121
156
|
const stack = (list) => {
|
|
122
|
-
let
|
|
157
|
+
let [innerB, outerB, laneB, bottom] = [-Infinity, -Infinity, -Infinity, 0];
|
|
123
158
|
for (const node of list) {
|
|
124
|
-
|
|
125
|
-
y
|
|
159
|
+
const h = boxH.get(node.id);
|
|
160
|
+
let y = h / 2;
|
|
161
|
+
if (outerSet.has(node.id)) {
|
|
162
|
+
y = Math.max(y, outerB + gap + h / 2, innerB + laneOf(node.id));
|
|
163
|
+
outerB = y + h / 2;
|
|
164
|
+
laneB = y + laneOf(node.id);
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
y = Math.max(y, innerB + gap + h / 2, laneB + h / 2);
|
|
168
|
+
innerB = y + h / 2;
|
|
169
|
+
}
|
|
170
|
+
stackY.set(node.id, y);
|
|
171
|
+
bottom = Math.max(bottom, y + h / 2);
|
|
126
172
|
}
|
|
173
|
+
return bottom;
|
|
127
174
|
};
|
|
128
|
-
stack(inputs);
|
|
129
|
-
|
|
175
|
+
const stackH = [stack(inputs), stack(outputs)];
|
|
176
|
+
const totalH = Math.max(stackH[0], boxH.get(center.id), stackH[1]);
|
|
177
|
+
const nodeCY = new Map();
|
|
178
|
+
for (const [c, list] of [inputs, outputs].entries())
|
|
179
|
+
for (const node of list)
|
|
180
|
+
nodeCY.set(node.id, margin + (totalH - stackH[c]) / 2 + stackY.get(node.id));
|
|
130
181
|
nodeCY.set(center.id, margin + totalH / 2);
|
|
131
182
|
/* determine column widths and left edge positions, with the two
|
|
132
|
-
inter-column channel widths sized by actual edge usage
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
183
|
+
inter-column channel widths sized by actual edge usage (a
|
|
184
|
+
wrapped column spans its two sub-columns and their gap, which
|
|
185
|
+
holds the arities set back from the arrow heads at its outer
|
|
186
|
+
nodes, as the lanes between the inner nodes have no room) */
|
|
187
|
+
const subWidth = (list, outer) => list.filter((node) => outerSet.has(node.id) === outer)
|
|
188
|
+
.reduce((a, node) => Math.max(a, boxW.get(node.id)), 0);
|
|
189
|
+
const outerW = [subWidth(inputs, true), subWidth(outputs, true)];
|
|
190
|
+
const wrapGap = edges.reduce((a, edge) => outerSet.has(edge.target) && edge.arity !== undefined ?
|
|
191
|
+
Math.max(a, ARITY_OFF + textWidth(edge.arity, FS_ARITY) + WRAP_PAD) : a, WRAP_GAP);
|
|
192
|
+
const sideW = (list, c) => subWidth(list, false) + (outerW[c] > 0 ? (c === 1 ? wrapGap : WRAP_GAP) + outerW[c] : 0);
|
|
193
|
+
const colWidth = [sideW(inputs, 0), boxW.get(center.id), sideW(outputs, 1)];
|
|
138
194
|
const chanOf = (edge) => Math.min(colOf(edge.source), colOf(edge.target));
|
|
139
195
|
const chanCnt = [
|
|
140
196
|
edges.filter((edge) => chanOf(edge) === 0).length,
|
|
@@ -147,14 +203,15 @@ export const render = async (graph, config, level = {}) => {
|
|
|
147
203
|
colLX.push(x);
|
|
148
204
|
x += colWidth[c] + (c < 2 ? chanW[c] : 0);
|
|
149
205
|
}
|
|
150
|
-
/* place the nodes within their column: input nodes
|
|
151
|
-
output nodes left-aligned, and the center node
|
|
206
|
+
/* place the nodes within their (sub-)column: input nodes
|
|
207
|
+
right-aligned, output nodes left-aligned, and the center node
|
|
208
|
+
centered */
|
|
152
209
|
const cx = (id) => {
|
|
153
210
|
const c = colOf(id);
|
|
154
211
|
if (c === 0)
|
|
155
|
-
return colLX[0] + colWidth[0] - boxW.get(id) / 2;
|
|
212
|
+
return colLX[0] + (outerSet.has(id) ? outerW[0] : colWidth[0]) - boxW.get(id) / 2;
|
|
156
213
|
else if (c === 2)
|
|
157
|
-
return colLX[2] + boxW.get(id) / 2;
|
|
214
|
+
return colLX[2] + (outerSet.has(id) ? colWidth[2] - outerW[1] : 0) + boxW.get(id) / 2;
|
|
158
215
|
else
|
|
159
216
|
return colLX[1] + colWidth[1] / 2;
|
|
160
217
|
};
|
|
@@ -195,12 +252,19 @@ export const render = async (graph, config, level = {}) => {
|
|
|
195
252
|
});
|
|
196
253
|
/* hand over the laid out graph for SVG rendering (the output copy
|
|
197
254
|
of a twice-placed node is rendered as a dashed grey "ghost" box,
|
|
198
|
-
|
|
255
|
+
the output clone of a self-referencing primary node as a dashed,
|
|
256
|
+
darker grey "self" box, and all other boxes get the default
|
|
257
|
+
primary/regular coloring) */
|
|
199
258
|
const styleOf = (node) => node.id.endsWith(CLONE + "out") ? {
|
|
200
259
|
fill: "color-node-ghost-box",
|
|
201
260
|
stroke: "color-node-ghost-border",
|
|
202
261
|
text: "color-node-ghost-name",
|
|
203
262
|
dash: "10 6"
|
|
263
|
+
} : node.id.endsWith(CLONE + "self") ? {
|
|
264
|
+
fill: "color-node-self-box",
|
|
265
|
+
stroke: "color-node-self-border",
|
|
266
|
+
text: "color-node-self-name",
|
|
267
|
+
dash: "10 6"
|
|
204
268
|
} : defaultStyleOf(node);
|
|
205
269
|
return { nodes, edges, cx, cy, boxW, boxH, contentH, polys, styleOf };
|
|
206
270
|
};
|
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
|
}
|