@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.
- package/README.md +190 -0
- package/dst/gradia-api-config.d.ts +25 -0
- package/dst/gradia-api-config.js +63 -0
- package/dst/gradia-api-model.d.ts +19 -0
- package/dst/gradia-api-model.js +6 -0
- package/dst/gradia-api-parser.d.ts +2 -0
- package/dst/gradia-api-parser.js +164 -0
- package/dst/gradia-api-render-base.d.ts +20 -0
- package/dst/gradia-api-render-base.js +25 -0
- package/dst/gradia-api-render-edge.d.ts +25 -0
- package/dst/gradia-api-render-edge.js +211 -0
- package/dst/gradia-api-render-group.d.ts +9 -0
- package/dst/gradia-api-render-group.js +103 -0
- package/dst/gradia-api-render-node.d.ts +18 -0
- package/dst/gradia-api-render-node.js +74 -0
- package/dst/gradia-api-render-svg.d.ts +23 -0
- package/dst/gradia-api-render-svg.js +199 -0
- package/dst/gradia-api-type-graph.d.ts +4 -0
- package/dst/gradia-api-type-graph.js +329 -0
- package/dst/gradia-api-type-grid.d.ts +4 -0
- package/dst/gradia-api-type-grid.js +52 -0
- package/dst/gradia-api-type-hub.d.ts +4 -0
- package/dst/gradia-api-type-hub.js +180 -0
- package/dst/gradia-api.d.ts +26 -0
- package/dst/gradia-api.js +71 -0
- package/dst/gradia-cli.d.ts +7 -0
- package/dst/gradia-cli.js +52 -0
- package/package.json +52 -0
|
@@ -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,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,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
|
+
}
|