@rse/gradia 0.9.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.
@@ -0,0 +1,329 @@
1
+ /*
2
+ ** Gradia -- Object Graph Diagram Rendering
3
+ ** Copyright (c) 2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
4
+ ** Distributed under MIT license <https://spdx.org/licenses/MIT.html>
5
+ */
6
+ /* external dependencies */
7
+ import { DagreLayout } from "@antv/layout";
8
+ import { SLOT, SCALE_H, MARGIN } from "./gradia-api-render-base.js";
9
+ import { measureNodes } from "./gradia-api-render-node.js";
10
+ import { simplifyPoly, assignPorts, assignTracks } from "./gradia-api-render-edge.js";
11
+ /* rendering geometry constants */
12
+ const CHANNEL = 140; /* max width of inter-column channels */
13
+ const GUTTER = 90; /* max height of inter-row gutters */
14
+ const MAXCOLS = 5; /* max number of side-by-side nodes */
15
+ const CLUSTX = 26; /* max X distance within a grid column */
16
+ const CLUSTY = 72; /* max Y distance within a grid row */
17
+ /* snap raw node positions onto a discrete grid by clustering the
18
+ distinct X coordinates into columns and Y coordinates into rows */
19
+ const snapToGrid = (nodes, rawX, rawY) => {
20
+ const cluster = (values, threshold) => {
21
+ const sorted = Array.from(new Set(values)).sort((a, b) => a - b);
22
+ const reps = [];
23
+ for (const v of sorted)
24
+ if (reps.length === 0 || v - reps[reps.length - 1] > threshold)
25
+ reps.push(v);
26
+ return reps;
27
+ };
28
+ const colReps = cluster(nodes.map((node) => rawX.get(node.id)), CLUSTX);
29
+ const rowReps = cluster(nodes.map((node) => rawY.get(node.id)), CLUSTY);
30
+ const indexOf = (reps, v) => {
31
+ let best = 0;
32
+ for (let i = 1; i < reps.length; i++)
33
+ if (Math.abs(reps[i] - v) < Math.abs(reps[best] - v))
34
+ best = i;
35
+ return best;
36
+ };
37
+ const col = new Map();
38
+ const row = new Map();
39
+ for (const node of nodes) {
40
+ col.set(node.id, indexOf(colReps, rawX.get(node.id)));
41
+ row.set(node.id, indexOf(rowReps, rawY.get(node.id)));
42
+ }
43
+ return { col, row, ncols: colReps.length, nrows: rowReps.length };
44
+ };
45
+ /* append a value to the array stored under a key of a multi-map */
46
+ const pushTo = (map, key, value) => {
47
+ const list = map.get(key);
48
+ if (list === undefined)
49
+ map.set(key, [value]);
50
+ else
51
+ list.push(value);
52
+ };
53
+ /* refine the row assignment: the raw Dagre ordering tends to fling
54
+ low-degree nodes far away from their neighbors, so pull every
55
+ node vertically toward the median row of its direct neighbors,
56
+ as far as free grid cells permit. The pull is deliberately gentle
57
+ to not compress the rows too aggressively: distant nodes are
58
+ pulled only half the way, and single-row moves are performed
59
+ only when they align the node with a direct neighbor in an
60
+ adjacent column (thereby straightening that edge) */
61
+ const refineRows = (nodes, edges, col, row) => {
62
+ const cells = new Set();
63
+ for (const node of nodes)
64
+ cells.add(`${col.get(node.id)}:${row.get(node.id)}`);
65
+ const neighborsOf = new Map();
66
+ for (const edge of edges) {
67
+ if (edge.source === edge.target)
68
+ continue;
69
+ pushTo(neighborsOf, edge.source, edge.target);
70
+ pushTo(neighborsOf, edge.target, edge.source);
71
+ }
72
+ /* pull every node toward the median row of its neighbors */
73
+ for (const node of nodes) {
74
+ const nb = neighborsOf.get(node.id);
75
+ if (nb === undefined)
76
+ continue;
77
+ const rows = nb.map((id) => row.get(id)).sort((a, b) => a - b);
78
+ const desired = rows[Math.floor((rows.length - 1) / 2)];
79
+ const cur = row.get(node.id);
80
+ const dist = Math.abs(desired - cur);
81
+ if (dist === 0)
82
+ continue;
83
+ const dir = Math.sign(desired - cur);
84
+ let target;
85
+ if (dist === 1) {
86
+ const aligns = nb.some((id) => row.get(id) === desired
87
+ && Math.abs(col.get(id) - col.get(node.id)) === 1);
88
+ if (!aligns)
89
+ continue;
90
+ target = desired;
91
+ }
92
+ else
93
+ target = cur + dir * Math.ceil(dist / 2);
94
+ for (let r = target; r !== cur; r -= dir) {
95
+ if (!cells.has(`${col.get(node.id)}:${r}`)) {
96
+ cells.delete(`${col.get(node.id)}:${cur}`);
97
+ cells.add(`${col.get(node.id)}:${r}`);
98
+ row.set(node.id, r);
99
+ break;
100
+ }
101
+ }
102
+ }
103
+ };
104
+ /* plan the coarse route of every edge: which inter-column channels
105
+ and inter-row gutters it occupies (based on grid indices only) */
106
+ const planRoutes = (edges, col, row) => {
107
+ const plans = edges.map((edge) => {
108
+ /* a self-loop is routed around its own node box and uses no channel */
109
+ if (edge.source === edge.target)
110
+ return { chans: [], guts: [] };
111
+ const sc = col.get(edge.source);
112
+ const tc = col.get(edge.target);
113
+ const sr = row.get(edge.source);
114
+ const tr = row.get(edge.target);
115
+ let chans;
116
+ let guts = [];
117
+ if (sc === tc)
118
+ chans = [sc];
119
+ else if (Math.abs(sc - tc) === 1)
120
+ chans = [Math.min(sc, tc)];
121
+ else {
122
+ chans = [sc < tc ? sc : sc - 1, sc < tc ? tc - 1 : tc];
123
+ guts = [tr > sr ? tr - 1 : tr];
124
+ }
125
+ return { chans, guts };
126
+ });
127
+ const chanCnt = new Map();
128
+ const gutCnt = new Map();
129
+ for (const plan of plans) {
130
+ for (const c of plan.chans)
131
+ chanCnt.set(c, (chanCnt.get(c) ?? 0) + 1);
132
+ for (const g of plan.guts)
133
+ gutCnt.set(g, (gutCnt.get(g) ?? 0) + 1);
134
+ }
135
+ return { plans, chanCnt, gutCnt };
136
+ };
137
+ /* determine grid cell sizes and final node center positions, with
138
+ channel widths and gutter heights sized by actual edge usage to
139
+ keep the overall layout compact */
140
+ const computeGrid = (nodes, col, row, boxW, boxH, ncols, nrows, chanCnt, gutCnt) => {
141
+ const colWidth = Array.from({ length: ncols }, () => 0);
142
+ const rowHeight = Array.from({ length: nrows }, () => 0);
143
+ for (const node of nodes) {
144
+ colWidth[col.get(node.id)] = Math.max(colWidth[col.get(node.id)], boxW.get(node.id));
145
+ rowHeight[row.get(node.id)] = Math.max(rowHeight[row.get(node.id)], boxH.get(node.id));
146
+ }
147
+ /* size the channels and gutters by their actual edge usage */
148
+ const chanW = Array.from({ length: ncols }, (_, c) => {
149
+ const cnt = chanCnt.get(c) ?? 0;
150
+ return cnt === 0 ? 24 : Math.min(CHANNEL, 28 + (cnt - 1) * SLOT);
151
+ });
152
+ const gutH = Array.from({ length: nrows }, (_, g) => {
153
+ const cnt = gutCnt.get(g) ?? 0;
154
+ return cnt === 0 ? 20 : Math.min(GUTTER, 24 + (cnt - 1) * SLOT);
155
+ });
156
+ /* derive the column and row center positions */
157
+ const colCX = [];
158
+ const rowCY = [];
159
+ let x = MARGIN;
160
+ for (let c = 0; c < colWidth.length; c++) {
161
+ colCX.push(x + colWidth[c] / 2);
162
+ x += colWidth[c] + chanW[c];
163
+ }
164
+ let y = MARGIN;
165
+ for (let r = 0; r < rowHeight.length; r++) {
166
+ rowCY.push(y + rowHeight[r] / 2);
167
+ y += rowHeight[r] + gutH[r];
168
+ }
169
+ return { colWidth, rowHeight, chanW, gutH, colCX, rowCY };
170
+ };
171
+ /* assign the parallel tracks within the channels and gutters and derive
172
+ the resulting track coordinate functions (see assignTracks for the
173
+ crossing-avoiding ordering scheme, with backward edges entering the
174
+ channel mirrored from the right side; the gutters use the very same
175
+ scheme, just transposed: edges dropping in from above enter from the
176
+ near side, edges rising from below are the mirrored users) */
177
+ const assignTrackCoords = (edges, plans, col, portPos, grid) => {
178
+ const { colWidth, rowHeight, chanW, gutH, colCX, rowCY } = grid;
179
+ const gutBase = (g) => rowCY[g] + rowHeight[g] / 2 + gutH[g] / 2;
180
+ /* assign the vertical tracks within the inter-column channels */
181
+ const chanUsers = new Map();
182
+ edges.forEach((edge, i) => {
183
+ const { chans, guts } = plans[i];
184
+ if (chans.length === 0)
185
+ return;
186
+ const mirror = col.get(edge.target) < col.get(edge.source);
187
+ const sp = portPos.get(`${i}:s`);
188
+ const tp = portPos.get(`${i}:t`);
189
+ if (chans.length === 1)
190
+ pushTo(chanUsers, chans[0], { edge: i, posIn: sp.y, posOut: tp.y, mirror });
191
+ else {
192
+ const gy = gutBase(guts[0]);
193
+ pushTo(chanUsers, chans[0], { edge: i, posIn: sp.y, posOut: gy, mirror });
194
+ pushTo(chanUsers, chans[1], { edge: i, posIn: gy, posOut: tp.y, mirror });
195
+ }
196
+ });
197
+ const chanOff = new Map();
198
+ for (const [c, users] of chanUsers)
199
+ for (const [edge, off] of assignTracks(users, chanW[c], 16))
200
+ chanOff.set(`${c}:${edge}`, off);
201
+ const chanX = (c, edge) => colCX[c] + colWidth[c] / 2 + chanW[c] / 2 +
202
+ chanOff.get(`${c}:${edge}`);
203
+ /* assign the horizontal tracks within the inter-row gutters */
204
+ const gutUsers = new Map();
205
+ edges.forEach((_, i) => {
206
+ const { chans, guts } = plans[i];
207
+ if (guts.length === 0)
208
+ return;
209
+ const sp = portPos.get(`${i}:s`);
210
+ pushTo(gutUsers, guts[0], {
211
+ edge: i,
212
+ posIn: chanX(chans[0], i),
213
+ posOut: chanX(chans[1], i),
214
+ mirror: sp.y > gutBase(guts[0])
215
+ });
216
+ });
217
+ const gutOff = new Map();
218
+ for (const [g, users] of gutUsers)
219
+ for (const [edge, off] of assignTracks(users, gutH[g], 12))
220
+ gutOff.set(`${g}:${edge}`, off);
221
+ const gutY = (g, edge) => gutBase(g) + gutOff.get(`${g}:${edge}`);
222
+ return { chanX, gutY };
223
+ };
224
+ /* route every edge as an orthogonal polyline through the channels
225
+ between columns and the gutters between rows */
226
+ const routePolys = (edges, plans, col, portPos, boxW, cx, chanX, gutY) => {
227
+ const loopUse = new Map();
228
+ return edges.map((edge, i) => {
229
+ const sc = col.get(edge.source);
230
+ const tc = col.get(edge.target);
231
+ const sp = portPos.get(`${i}:s`);
232
+ const tp = portPos.get(`${i}:t`);
233
+ const { chans, guts } = plans[i];
234
+ let pts;
235
+ if (edge.source === edge.target) {
236
+ const k = loopUse.get(edge.source) ?? 0;
237
+ loopUse.set(edge.source, k + 1);
238
+ const ox = cx(edge.source) + boxW.get(edge.source) / 2 + 24 + k * SLOT;
239
+ pts = [[sp.x, sp.y], [ox, sp.y], [ox, tp.y], [tp.x, tp.y]];
240
+ }
241
+ else if (chans.length === 1) {
242
+ const ch = chanX(chans[0], i);
243
+ if (Math.abs(sc - tc) === 1 && sp.y === tp.y)
244
+ pts = [[sp.x, sp.y], [tp.x, tp.y]];
245
+ else
246
+ pts = [[sp.x, sp.y], [ch, sp.y], [ch, tp.y], [tp.x, tp.y]];
247
+ }
248
+ else {
249
+ const ch1 = chanX(chans[0], i);
250
+ const ch2 = chanX(chans[1], i);
251
+ const gy = gutY(guts[0], i);
252
+ pts = [[sp.x, sp.y], [ch1, sp.y], [ch1, gy], [ch2, gy], [ch2, tp.y], [tp.x, tp.y]];
253
+ }
254
+ return simplifyPoly(pts);
255
+ });
256
+ };
257
+ /* lay out a directed graph model */
258
+ export const render = async (graph, _config) => {
259
+ const nodes = Array.from(graph.nodes.values());
260
+ const edges = graph.edges;
261
+ /* determine node box sizes from their textual content */
262
+ const { boxW, boxH, contentH } = measureNodes(nodes, () => SCALE_H);
263
+ /* determine raw node positions with the AntV Dagre layout */
264
+ const layout = new DagreLayout({
265
+ rankdir: "LR",
266
+ nodesep: 30,
267
+ ranksep: 60,
268
+ nodeSize: (node) => node.size
269
+ });
270
+ await layout.execute({
271
+ nodes: nodes.map((node) => ({ id: node.id, size: [boxW.get(node.id), boxH.get(node.id)] })),
272
+ edges: edges.map((edge, i) => ({ id: `e${i}`, source: edge.source, target: edge.target }))
273
+ });
274
+ const rawX = new Map();
275
+ const rawY = new Map();
276
+ layout.forEachNode((node) => {
277
+ rawX.set(String(node.id), node.x);
278
+ rawY.set(String(node.id), node.y);
279
+ });
280
+ /* snap the raw positions onto the discrete column/row grid */
281
+ const { col, row, ncols: gridCols, nrows: gridRows } = snapToGrid(nodes, rawX, rawY);
282
+ /* refine the row assignment by pulling every node vertically
283
+ toward the median row of its direct neighbors */
284
+ refineRows(nodes, edges, col, row);
285
+ /* fold the grid columns: constrain the diagram width to at most
286
+ MAXCOLS side-by-side nodes by wrapping excess columns into
287
+ additional row bands below, so wide graphs grow in height */
288
+ if (gridCols > MAXCOLS) {
289
+ for (const node of nodes) {
290
+ const c = col.get(node.id);
291
+ col.set(node.id, c % MAXCOLS);
292
+ row.set(node.id, row.get(node.id) + Math.floor(c / MAXCOLS) * gridRows);
293
+ }
294
+ }
295
+ const ncols = Math.min(gridCols, MAXCOLS);
296
+ /* drop the grid rows which are completely empty */
297
+ const usedRows = Array.from(new Set(nodes.map((node) => row.get(node.id)))).sort((a, b) => a - b);
298
+ const rowIdx = new Map(usedRows.map((r, idx) => [r, idx]));
299
+ for (const node of nodes)
300
+ row.set(node.id, rowIdx.get(row.get(node.id)));
301
+ const nrows = usedRows.length;
302
+ /* plan the coarse channel/gutter route of every edge */
303
+ const { plans, chanCnt, gutCnt } = planRoutes(edges, col, row);
304
+ /* determine grid cell sizes and final node center positions */
305
+ const grid = computeGrid(nodes, col, row, boxW, boxH, ncols, nrows, chanCnt, gutCnt);
306
+ const cx = (id) => grid.colCX[col.get(id)];
307
+ const cy = (id) => grid.rowCY[row.get(id)];
308
+ /* determine the attachment sides of every edge (east/west),
309
+ derived from the column relation of its endpoint nodes */
310
+ const sides = edges.map((edge) => {
311
+ const sc = col.get(edge.source);
312
+ const tc = col.get(edge.target);
313
+ if (sc < tc)
314
+ return { s: "e", t: "w" };
315
+ else if (sc > tc)
316
+ return { s: "w", t: "e" };
317
+ else
318
+ return { s: "e", t: "e" };
319
+ });
320
+ /* distribute the edge attachment ports along each node side */
321
+ const portPos = assignPorts(edges, sides, cx, cy, boxW, boxH);
322
+ /* assign the parallel tracks within the channels and gutters */
323
+ const { chanX, gutY } = assignTrackCoords(edges, plans, col, portPos, grid);
324
+ /* route every edge as an orthogonal polyline through the channels
325
+ between columns and the gutters between rows */
326
+ const polys = routePolys(edges, plans, col, portPos, boxW, cx, chanX, gutY);
327
+ /* hand over the laid out graph for SVG rendering */
328
+ return { nodes, edges, cx, cy, boxW, boxH, contentH, polys };
329
+ };
@@ -0,0 +1,4 @@
1
+ import { Graph } from "./gradia-api-model.js";
2
+ import { Config } from "./gradia-api-config.js";
3
+ import { Layout } from "./gradia-api-render-svg.js";
4
+ export declare const render: (graph: Graph, _config: Config) => Promise<Layout>;
@@ -0,0 +1,52 @@
1
+ /*
2
+ ** Gradia -- Object Graph Diagram Rendering
3
+ ** Copyright (c) 2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
4
+ ** Distributed under MIT license <https://spdx.org/licenses/MIT.html>
5
+ */
6
+ import { SCALE_H, MARGIN } from "./gradia-api-render-base.js";
7
+ import { measureNodes } from "./gradia-api-render-node.js";
8
+ /* rendering geometry constants */
9
+ const GAP_H = 40; /* horizontal gap between the tiles of the grid */
10
+ const GAP_V = 20; /* vertical gap between the tiles of the grid */
11
+ const MAXCOLS = 4; /* max number of side-by-side tiles */
12
+ /* lay out an edge-less graph model as a compact grid of tiles */
13
+ export const render = async (graph, _config) => {
14
+ const nodes = Array.from(graph.nodes.values());
15
+ /* ensure the graph is completely edge-less */
16
+ if (graph.edges.length > 0) {
17
+ const edge = graph.edges[0];
18
+ throw new Error("diagram type \"grid\" does not support edges " +
19
+ `(found ${graph.edges.length}, first is "${edge.source}" --> "${edge.target}")`);
20
+ }
21
+ /* determine node box sizes (keeping the individual widths)
22
+ and unify only their heights into a single tile height */
23
+ const { boxW, boxH, contentH } = measureNodes(nodes, () => SCALE_H / 2);
24
+ const tileH = nodes.reduce((a, node) => Math.max(a, boxH.get(node.id)), 0);
25
+ for (const node of nodes)
26
+ boxH.set(node.id, tileH);
27
+ /* place the nodes in declaration order onto a roughly square,
28
+ row-major grid (capped at MAXCOLS columns, so larger graphs
29
+ grow only in height), with each column as wide as its widest
30
+ tile and each tile left-aligned within its column */
31
+ const cols = Math.max(Math.min(Math.ceil(Math.sqrt(nodes.length)), MAXCOLS), 1);
32
+ const colWidth = Array.from({ length: cols }, () => 0);
33
+ nodes.forEach((node, i) => {
34
+ colWidth[i % cols] = Math.max(colWidth[i % cols], boxW.get(node.id));
35
+ });
36
+ const colLX = [];
37
+ let x = MARGIN;
38
+ for (let c = 0; c < cols; c++) {
39
+ colLX.push(x);
40
+ x += colWidth[c] + GAP_H;
41
+ }
42
+ const nodeCX = new Map();
43
+ const nodeCY = new Map();
44
+ nodes.forEach((node, i) => {
45
+ nodeCX.set(node.id, colLX[i % cols] + boxW.get(node.id) / 2);
46
+ nodeCY.set(node.id, MARGIN + Math.floor(i / cols) * (tileH + GAP_V) + tileH / 2);
47
+ });
48
+ const cx = (id) => nodeCX.get(id);
49
+ const cy = (id) => nodeCY.get(id);
50
+ /* hand over the laid out graph for SVG rendering */
51
+ return { nodes, edges: [], cx, cy, boxW, boxH, contentH, polys: [] };
52
+ };
@@ -0,0 +1,4 @@
1
+ import { Graph } from "./gradia-api-model.js";
2
+ import { Config } from "./gradia-api-config.js";
3
+ import { Layout } from "./gradia-api-render-svg.js";
4
+ export declare const render: (graph: Graph, config: Config) => Promise<Layout>;
@@ -0,0 +1,180 @@
1
+ /*
2
+ ** Gradia -- Object Graph Diagram Rendering
3
+ ** Copyright (c) 2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
4
+ ** Distributed under MIT license <https://spdx.org/licenses/MIT.html>
5
+ */
6
+ import { SLOT, SCALE_H, MARGIN } from "./gradia-api-render-base.js";
7
+ import { isPrimary, measureNodes, defaultStyleOf } from "./gradia-api-render-node.js";
8
+ import { simplifyPoly, assignPorts, assignTracks } from "./gradia-api-render-edge.js";
9
+ /* rendering geometry constants */
10
+ const CHANNEL = 340; /* max width of inter-column channels */
11
+ const CHANMIN = 240; /* min width of inter-column channels */
12
+ const STACK = 20; /* vertical gap between stacked nodes */
13
+ /* the separator between a node id and its placement suffix,
14
+ distinguishing the two clones of a twice-placed node */
15
+ const CLONE = "\u0000";
16
+ /* validate the constrained input topology and classify the declared
17
+ nodes: exactly one node is annotated with "primary: true" and every
18
+ edge connects the primary with another node. Each other node is
19
+ classified as input (edges point toward the primary) and/or output
20
+ (edges originate from the primary); a node referenced in both
21
+ directions is placed twice, once in the input column and once in
22
+ the output column, with its edges rewritten to attach to the
23
+ corresponding placement */
24
+ const classifyTopology = (graph) => {
25
+ const declared = Array.from(graph.nodes.values());
26
+ const primaries = declared.filter(isPrimary);
27
+ if (primaries.length !== 1)
28
+ throw new Error(`expected exactly one node annotated with "primary: true" (found ${primaries.length})`);
29
+ const center = primaries[0];
30
+ const inSet = new Set();
31
+ const outSet = new Set();
32
+ for (const edge of graph.edges) {
33
+ if (edge.source === center.id && edge.target === center.id)
34
+ throw new Error(`self-loop on primary node "${center.id}" not supported`);
35
+ else if (edge.target === center.id)
36
+ inSet.add(edge.source);
37
+ else if (edge.source === center.id)
38
+ outSet.add(edge.target);
39
+ else
40
+ throw new Error(`edge "${edge.source}" --> "${edge.target}" does not connect to primary node "${center.id}"`);
41
+ }
42
+ /* classify every non-primary node into the input and/or output column */
43
+ const dual = new Set();
44
+ const inputs = [];
45
+ const outputs = [];
46
+ for (const node of declared) {
47
+ if (node.id === center.id)
48
+ continue;
49
+ const isInput = inSet.has(node.id);
50
+ const isOutput = outSet.has(node.id);
51
+ if (!isInput && !isOutput)
52
+ throw new Error(`node "${node.id}" is neither input nor output of primary node "${center.id}"`);
53
+ if (isInput && isOutput) {
54
+ dual.add(node.id);
55
+ inputs.push({ ...node, id: node.id + CLONE + "in" });
56
+ outputs.push({ ...node, id: node.id + CLONE + "out" });
57
+ }
58
+ else if (isInput)
59
+ inputs.push(node);
60
+ else
61
+ outputs.push(node);
62
+ }
63
+ /* rewrite the edges of the twice-placed nodes onto their clones */
64
+ const edges = graph.edges.map((edge) => {
65
+ if (edge.target === center.id && dual.has(edge.source))
66
+ return { ...edge, source: edge.source + CLONE + "in" };
67
+ else if (edge.source === center.id && dual.has(edge.target))
68
+ return { ...edge, target: edge.target + CLONE + "out" };
69
+ else
70
+ return edge;
71
+ });
72
+ return { center, inputs, outputs, nodes: [center, ...inputs, ...outputs], edges };
73
+ };
74
+ /* lay out a hub graph model (N input nodes, one central hub node, M output nodes) */
75
+ export const render = async (graph, config) => {
76
+ /* validate the constrained input topology and classify the
77
+ declared nodes into the input and output columns */
78
+ const { center, inputs, outputs, nodes, edges } = classifyTopology(graph);
79
+ const inputSet = new Set(inputs.map((node) => node.id));
80
+ /* determine node box sizes from their textual content (the primary
81
+ box at full and all other boxes at half height scale) */
82
+ const { boxW, boxH, contentH } = measureNodes(nodes, (node) => node.id === center.id ? SCALE_H : SCALE_H / 2);
83
+ /* fixed three-column layout: stack the input nodes in the first
84
+ column and the output nodes in the third column (each stack
85
+ vertically centered), and place the center node in the second
86
+ column at the vertical center of the canvas */
87
+ const stackH = (list) => list.reduce((a, node) => a + boxH.get(node.id), 0) + Math.max(list.length - 1, 0) * STACK;
88
+ const totalH = Math.max(stackH(inputs), boxH.get(center.id), stackH(outputs));
89
+ const nodeCY = new Map();
90
+ const stack = (list) => {
91
+ let y = MARGIN + (totalH - stackH(list)) / 2;
92
+ for (const node of list) {
93
+ nodeCY.set(node.id, y + boxH.get(node.id) / 2);
94
+ y += boxH.get(node.id) + STACK;
95
+ }
96
+ };
97
+ stack(inputs);
98
+ stack(outputs);
99
+ nodeCY.set(center.id, MARGIN + totalH / 2);
100
+ /* determine column widths and left edge positions, with the two
101
+ inter-column channel widths sized by actual edge usage */
102
+ const colOf = (id) => id === center.id ? 1 : (inputSet.has(id) ? 0 : 2);
103
+ const colWidth = [
104
+ inputs.reduce((a, node) => Math.max(a, boxW.get(node.id)), 0),
105
+ boxW.get(center.id),
106
+ outputs.reduce((a, node) => Math.max(a, boxW.get(node.id)), 0)
107
+ ];
108
+ const chanOf = (edge) => Math.min(colOf(edge.source), colOf(edge.target));
109
+ const chanCnt = [
110
+ edges.filter((edge) => chanOf(edge) === 0).length,
111
+ edges.filter((edge) => chanOf(edge) === 1).length
112
+ ];
113
+ const chanW = chanCnt.map((cnt) => Math.min(CHANNEL, Math.max(CHANMIN, 28 + (cnt - 1) * SLOT)));
114
+ const colLX = [];
115
+ let x = MARGIN;
116
+ for (let c = 0; c < 3; c++) {
117
+ colLX.push(x);
118
+ x += colWidth[c] + (c < 2 ? chanW[c] : 0);
119
+ }
120
+ /* place the nodes within their column: input nodes right-aligned,
121
+ output nodes left-aligned, and the center node centered */
122
+ const cx = (id) => {
123
+ const c = colOf(id);
124
+ if (c === 0)
125
+ return colLX[0] + colWidth[0] - boxW.get(id) / 2;
126
+ else if (c === 2)
127
+ return colLX[2] + boxW.get(id) / 2;
128
+ else
129
+ return colLX[1] + colWidth[1] / 2;
130
+ };
131
+ const cy = (id) => nodeCY.get(id);
132
+ /* determine the attachment sides of every edge (east/west),
133
+ derived from the column relation of its endpoint nodes */
134
+ const sides = edges.map((edge) => colOf(edge.source) < colOf(edge.target) ? { s: "e", t: "w" } : { s: "w", t: "e" });
135
+ /* distribute the edge attachment ports along each node side */
136
+ const portPos = assignPorts(edges, sides, cx, cy, boxW, boxH);
137
+ /* assign the vertical tracks within each channel (see assignTracks
138
+ for the crossing-avoiding ordering scheme; an IOG has no backward
139
+ edges, hence no channel user is ever mirrored) */
140
+ const chanUsers = [[], []];
141
+ edges.forEach((edge, i) => {
142
+ chanUsers[chanOf(edge)].push({
143
+ edge: i,
144
+ posIn: portPos.get(`${i}:s`).y,
145
+ posOut: portPos.get(`${i}:t`).y,
146
+ mirror: false
147
+ });
148
+ });
149
+ const chanOff = new Map();
150
+ chanUsers.forEach((users, c) => {
151
+ for (const [edge, off] of assignTracks(users, chanW[c], 16))
152
+ chanOff.set(`${c}:${edge}`, off);
153
+ });
154
+ /* determine the X position of an edge's assigned track within a channel */
155
+ const chanX = (c, edge) => colLX[c] + colWidth[c] + chanW[c] / 2 +
156
+ chanOff.get(`${c}:${edge}`);
157
+ /* route every edge as an orthogonal polyline through its channel */
158
+ const polys = edges.map((edge, i) => {
159
+ const sp = portPos.get(`${i}:s`);
160
+ const tp = portPos.get(`${i}:t`);
161
+ const ch = chanX(chanOf(edge), i);
162
+ let pts;
163
+ if (sp.y === tp.y)
164
+ pts = [[sp.x, sp.y], [tp.x, tp.y]];
165
+ else
166
+ pts = [[sp.x, sp.y], [ch, sp.y], [ch, tp.y], [tp.x, tp.y]];
167
+ return simplifyPoly(pts);
168
+ });
169
+ /* hand over the laid out graph for SVG rendering (the output copy
170
+ of a twice-placed node is rendered as a dashed grey "ghost" box,
171
+ all other boxes get the default primary/regular coloring) */
172
+ const styleDefault = defaultStyleOf(config);
173
+ const styleOf = (node) => node.id.endsWith(CLONE + "out") ? {
174
+ fill: config["color-node-ghost-box"],
175
+ stroke: config["color-node-ghost-border"],
176
+ text: config["color-node-ghost-name"],
177
+ dash: "10 6"
178
+ } : styleDefault(node);
179
+ return { nodes, edges, cx, cy, boxW, boxH, contentH, polys, styleOf };
180
+ };
@@ -0,0 +1,26 @@
1
+ import { Graph } from "./gradia-api-model.js";
2
+ import { Config } from "./gradia-api-config.js";
3
+ import { Layout } from "./gradia-api-render-svg.js";
4
+ export type { Attr, Node, Edge, Graph } from "./gradia-api-model.js";
5
+ export type { Config } from "./gradia-api-config.js";
6
+ declare const renderers: {
7
+ graph: (graph: Graph, _config: Config) => Promise<Layout>;
8
+ hub: (graph: Graph, config: Config) => Promise<Layout>;
9
+ grid: (graph: Graph, _config: Config) => Promise<Layout>;
10
+ };
11
+ export type DiagramType = keyof typeof renderers;
12
+ export declare const diagramTypes: DiagramType[];
13
+ export declare const diagramTypeDefault: DiagramType;
14
+ export declare const diagramFormats: readonly ["svg:standalone", "svg:embedded", "url:xml", "url:base64"];
15
+ export type DiagramFormat = typeof diagramFormats[number];
16
+ export declare const diagramFormatDefault: DiagramFormat;
17
+ export interface DiagramOptions {
18
+ type?: DiagramType;
19
+ format?: DiagramFormat;
20
+ config?: Partial<Config>;
21
+ }
22
+ export declare class Gradia {
23
+ static parse(spec: string): Graph;
24
+ static generate(graph: Graph, options?: DiagramOptions): Promise<string>;
25
+ static render(spec: string, options?: DiagramOptions): Promise<string>;
26
+ }
@@ -0,0 +1,71 @@
1
+ /*
2
+ ** Gradia -- Object Graph Diagram Rendering
3
+ ** Copyright (c) 2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
4
+ ** Distributed under MIT license <https://spdx.org/licenses/MIT.html>
5
+ */
6
+ import { parse } from "./gradia-api-parser.js";
7
+ import { configDefaults, parseDirectives } from "./gradia-api-config.js";
8
+ import { partitionGroups, composeGroups } from "./gradia-api-render-group.js";
9
+ import { renderSVG } from "./gradia-api-render-svg.js";
10
+ import { render as renderGraph } from "./gradia-api-type-graph.js";
11
+ import { render as renderHub } from "./gradia-api-type-hub.js";
12
+ import { render as renderGrid } from "./gradia-api-type-grid.js";
13
+ /* the supported diagram types and their renderers */
14
+ const renderers = {
15
+ graph: renderGraph,
16
+ hub: renderHub,
17
+ grid: renderGrid
18
+ };
19
+ export const diagramTypes = Object.keys(renderers);
20
+ export const diagramTypeDefault = "graph";
21
+ /* the supported output formats */
22
+ export const diagramFormats = ["svg:standalone", "svg:embedded", "url:xml", "url:base64"];
23
+ export const diagramFormatDefault = "svg:standalone";
24
+ /* the Gradia API facade */
25
+ export class Gradia {
26
+ /* parse a graph description into the graph model */
27
+ static parse(spec) {
28
+ return parse(spec);
29
+ }
30
+ /* generate an SVG document or data URL from a graph model
31
+ (the rendering configuration is layered: defaults, then the
32
+ explicit config options) */
33
+ static async generate(graph, options = {}) {
34
+ /* determine and validate the diagram type and output format */
35
+ const type = options.type ?? diagramTypeDefault;
36
+ if (!Object.hasOwn(renderers, type))
37
+ throw new Error(`invalid diagram type "${type}"`);
38
+ const format = options.format ?? diagramFormatDefault;
39
+ if (!diagramFormats.includes(format))
40
+ throw new Error(`invalid output format "${format}"`);
41
+ /* layer the rendering configuration */
42
+ const config = { ...configDefaults, ...(options.config ?? {}) };
43
+ /* lay out the graph model: either as a whole, or partitioned
44
+ into its named groups which are laid out individually and
45
+ then stacked vertically as decorated group boxes */
46
+ const parts = partitionGroups(graph);
47
+ const layout = parts === null ?
48
+ await renderers[type](graph, config) :
49
+ composeGroups(parts, await Promise.all(parts.map((part) => renderers[type](part.graph, config))));
50
+ /* render the laid out graph into an SVG document */
51
+ const svg = renderSVG(layout, config);
52
+ /* convert the SVG document into the requested output format */
53
+ if (format === "svg:embedded")
54
+ return svg.replace(/^<\?xml[^?]*\?>\n/, "");
55
+ else if (format === "url:xml")
56
+ return `data:image/svg+xml,${encodeURIComponent(svg)}`;
57
+ else if (format === "url:base64")
58
+ return `data:image/svg+xml;base64,${Buffer.from(svg, "utf8").toString("base64")}`;
59
+ else
60
+ return svg;
61
+ }
62
+ /* render a graph description into an SVG document or data URL
63
+ (combines parse and generate, with the "#config <option> <value>"
64
+ directives from the spec layered between the defaults and the
65
+ explicit config options) */
66
+ static async render(spec, options = {}) {
67
+ const graph = Gradia.parse(spec);
68
+ const config = { ...parseDirectives(spec), ...(options.config ?? {}) };
69
+ return Gradia.generate(graph, { ...options, config });
70
+ }
71
+ }