@rse/gradia 1.1.8 → 1.2.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 +78 -36
- package/dst/gradia-api-config.d.ts +7 -0
- package/dst/gradia-api-config.js +54 -24
- package/dst/gradia-api-parser.js +10 -4
- package/dst/gradia-api-render-base.d.ts +28 -0
- package/dst/gradia-api-render-base.js +3 -2
- package/dst/gradia-api-render-container.d.ts +31 -0
- package/dst/gradia-api-render-container.js +300 -0
- package/dst/gradia-api-render-edge.d.ts +1 -1
- package/dst/gradia-api-render-edge.js +106 -37
- package/dst/gradia-api-render-group.d.ts +3 -2
- package/dst/gradia-api-render-group.js +16 -27
- package/dst/gradia-api-render-node.d.ts +9 -1
- package/dst/gradia-api-render-node.js +28 -14
- package/dst/gradia-api-render-svg.d.ts +1 -21
- package/dst/gradia-api-render-svg.js +82 -20
- package/dst/gradia-api-type-graph.d.ts +3 -2
- package/dst/gradia-api-type-graph.js +282 -126
- package/dst/gradia-api-type-grid.d.ts +3 -2
- package/dst/gradia-api-type-grid.js +5 -3
- package/dst/gradia-api-type-hub.d.ts +3 -2
- package/dst/gradia-api-type-hub.js +20 -10
- package/dst/gradia-api.d.ts +12 -4
- package/dst/gradia-api.js +33 -13
- package/dst/gradia-api.umd.js +2 -2
- package/dst/gradia-cli.js +4 -21
- package/dst/gradia-mcp.js +47 -53
- package/package.json +1 -1
|
@@ -19,8 +19,8 @@ const GUT_H0 = 20; /* height of an edge-less inter-row gutter */
|
|
|
19
19
|
const GUT_H1 = 24; /* height of a one-edge inter-row gutter */
|
|
20
20
|
const GUT_PAD = 12; /* cross-axis padding inside a gutter */
|
|
21
21
|
const GUT_LBL = 22; /* clearance above/below a label in a gutter */
|
|
22
|
-
const LOOP_GAP = 24; /* detour of a self-loop beside its node box */
|
|
23
22
|
const LOOP_TOP = 52; /* detour of a self-loop above its node box */
|
|
23
|
+
/* node nudging constants */
|
|
24
24
|
const NUDGE_GAP = 24; /* min port distance of a nudged node from its gutter tracks */
|
|
25
25
|
const NUDGE_PASSES = 3; /* max number of node nudging passes */
|
|
26
26
|
/* snap raw node positions onto a discrete grid by clustering the
|
|
@@ -136,35 +136,150 @@ const refineRows = (nodes, edges, col, row) => {
|
|
|
136
136
|
}
|
|
137
137
|
}
|
|
138
138
|
};
|
|
139
|
+
/* compact the rows of the grid, as the layered layout and the column
|
|
140
|
+
folding leave many sparsely occupied rows behind: first lift every
|
|
141
|
+
edge-less node (bound by no ordering constraint at all) into the
|
|
142
|
+
topmost free cell of its column, then merge every row into the row
|
|
143
|
+
above it as long as no column is occupied in both (which keeps the
|
|
144
|
+
vertical order of all nodes and the row alignment of the nodes
|
|
145
|
+
sharing a row), and finally renumber the rows densely */
|
|
146
|
+
const compactRows = (nodes, edges, col, row) => {
|
|
147
|
+
const cells = new Set();
|
|
148
|
+
for (const node of nodes)
|
|
149
|
+
cells.add(`${col.get(node.id)}:${row.get(node.id)}`);
|
|
150
|
+
/* lift every edge-less node into the topmost free cell of its column */
|
|
151
|
+
const linked = new Set();
|
|
152
|
+
for (const edge of edges) {
|
|
153
|
+
linked.add(edge.source);
|
|
154
|
+
linked.add(edge.target);
|
|
155
|
+
}
|
|
156
|
+
const isolated = nodes.filter((node) => !linked.has(node.id))
|
|
157
|
+
.sort((a, b) => row.get(a.id) - row.get(b.id));
|
|
158
|
+
for (const node of isolated) {
|
|
159
|
+
const c = col.get(node.id);
|
|
160
|
+
for (let r = 0; r < row.get(node.id); r++)
|
|
161
|
+
if (!cells.has(`${c}:${r}`)) {
|
|
162
|
+
cells.delete(`${c}:${row.get(node.id)}`);
|
|
163
|
+
cells.add(`${c}:${r}`);
|
|
164
|
+
row.set(node.id, r);
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
/* merge every row into the row above it as long as no column is
|
|
169
|
+
occupied in both, then renumber the rows densely */
|
|
170
|
+
const byRow = new Map();
|
|
171
|
+
for (const node of nodes)
|
|
172
|
+
pushTo(byRow, row.get(node.id), node);
|
|
173
|
+
const rowMax = Math.max(...byRow.keys());
|
|
174
|
+
const rowIdx = new Map();
|
|
175
|
+
let idx = -1;
|
|
176
|
+
let occupied = new Set();
|
|
177
|
+
for (let r = 0; r <= rowMax; r++) {
|
|
178
|
+
const members = byRow.get(r) ?? [];
|
|
179
|
+
if (idx < 0 || members.some((node) => occupied.has(col.get(node.id)))) {
|
|
180
|
+
idx++;
|
|
181
|
+
occupied = new Set();
|
|
182
|
+
}
|
|
183
|
+
for (const node of members)
|
|
184
|
+
occupied.add(col.get(node.id));
|
|
185
|
+
rowIdx.set(r, idx);
|
|
186
|
+
}
|
|
187
|
+
for (const node of nodes)
|
|
188
|
+
row.set(node.id, rowIdx.get(row.get(node.id)));
|
|
189
|
+
return idx + 1;
|
|
190
|
+
};
|
|
191
|
+
/* place the gate nodes of a containment level: the "in" gates into an
|
|
192
|
+
additional first column and the "out" gates into an additional last
|
|
193
|
+
column (both reserved for the gates, so the stubs from the container
|
|
194
|
+
border to the gates never cross a node box), each gate into the row
|
|
195
|
+
of the node its edge connects to, else into the nearest free row of
|
|
196
|
+
the gate column, else into a row newly inserted below the wanted one */
|
|
197
|
+
const placeGates = (gates, edges, col, row, ncols, nrows) => {
|
|
198
|
+
const ins = Array.from(gates).filter(([, side]) => side === "in").map(([id]) => id);
|
|
199
|
+
const outs = Array.from(gates).filter(([, side]) => side === "out").map(([id]) => id);
|
|
200
|
+
if (ins.length > 0) {
|
|
201
|
+
for (const id of col.keys())
|
|
202
|
+
col.set(id, col.get(id) + 1);
|
|
203
|
+
ncols++;
|
|
204
|
+
}
|
|
205
|
+
if (outs.length > 0)
|
|
206
|
+
ncols++;
|
|
207
|
+
const place = (ids, c) => {
|
|
208
|
+
/* the gates are placed in the order of their wanted rows, so
|
|
209
|
+
the ones displaced into other rows never cross each other
|
|
210
|
+
(the wanted row is the current row of the connected node,
|
|
211
|
+
re-read per gate, as an inserted row shifts the nodes below) */
|
|
212
|
+
const otherOf = new Map();
|
|
213
|
+
for (const id of ids) {
|
|
214
|
+
const edge = edges.find((edge) => edge.source === id || edge.target === id);
|
|
215
|
+
otherOf.set(id, edge.source === id ? edge.target : edge.source);
|
|
216
|
+
}
|
|
217
|
+
const wantOf = (id) => row.get(otherOf.get(id));
|
|
218
|
+
ids.sort((a, b) => wantOf(a) - wantOf(b));
|
|
219
|
+
const placed = [];
|
|
220
|
+
for (const id of ids) {
|
|
221
|
+
const want = wantOf(id);
|
|
222
|
+
const taken = (r) => placed.some((other) => row.get(other) === r);
|
|
223
|
+
let r;
|
|
224
|
+
for (let d = 0; r === undefined && d < nrows; d++) {
|
|
225
|
+
if (want + d < nrows && !taken(want + d))
|
|
226
|
+
r = want + d;
|
|
227
|
+
else if (want - d >= 0 && !taken(want - d))
|
|
228
|
+
r = want - d;
|
|
229
|
+
}
|
|
230
|
+
if (r === undefined) {
|
|
231
|
+
for (const [other, otherRow] of row)
|
|
232
|
+
if (otherRow > want)
|
|
233
|
+
row.set(other, otherRow + 1);
|
|
234
|
+
nrows++;
|
|
235
|
+
r = want + 1;
|
|
236
|
+
}
|
|
237
|
+
col.set(id, c);
|
|
238
|
+
row.set(id, r);
|
|
239
|
+
placed.push(id);
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
place(ins, 0);
|
|
243
|
+
place(outs, ncols - 1);
|
|
244
|
+
return { ncols, nrows };
|
|
245
|
+
};
|
|
139
246
|
/* plan the coarse route of every edge: which inter-column channels
|
|
140
|
-
and inter-row gutters it occupies (based on grid indices
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
247
|
+
and inter-row gutters it occupies (based on the grid indices and
|
|
248
|
+
the attachment sides only): the source stub reaches the channel
|
|
249
|
+
beside its attachment side, the target stub the channel beside
|
|
250
|
+
its one, and, if those differ, the gutter next to the target row
|
|
251
|
+
connects them */
|
|
252
|
+
const planRoutes = (edges, col, row, sides) => {
|
|
253
|
+
const flag = (from, to) => ({ mirror: from === "r", hairpin: from === to });
|
|
254
|
+
const plans = edges.map((edge, i) => {
|
|
255
|
+
/* a self-loop detours as a hairpin through the channel beside
|
|
256
|
+
its own node box (in from the near side and out on it again) */
|
|
146
257
|
const sc = col.get(edge.source);
|
|
258
|
+
if (edge.source === edge.target)
|
|
259
|
+
return { chans: [sc], guts: [], flags: [flag("l", "l")] };
|
|
147
260
|
const tc = col.get(edge.target);
|
|
148
261
|
const sr = row.get(edge.source);
|
|
149
262
|
const tr = row.get(edge.target);
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
chans
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
263
|
+
const sChan = sides[i].s === "e" ? sc : sc - 1;
|
|
264
|
+
const tChan = sides[i].t === "w" ? tc - 1 : tc;
|
|
265
|
+
const sFrom = sides[i].s === "e" ? "l" : "r";
|
|
266
|
+
const tTo = sides[i].t === "w" ? "r" : "l";
|
|
267
|
+
if (sChan === tChan)
|
|
268
|
+
return { chans: [sChan], guts: [], flags: [flag(sFrom, tTo)] };
|
|
269
|
+
const dir = tChan > sChan ? "r" : "l";
|
|
270
|
+
return {
|
|
271
|
+
chans: [sChan, tChan],
|
|
272
|
+
guts: [tr > sr ? tr - 1 : tr],
|
|
273
|
+
flags: [flag(sFrom, dir), flag(dir === "r" ? "l" : "r", tTo)]
|
|
274
|
+
};
|
|
161
275
|
});
|
|
162
276
|
/* count the edges occupying each channel and gutter and determine
|
|
163
277
|
the room the edge labels need there: a name is placed in the
|
|
164
278
|
single channel of its edge, or, for a two-channel edge, along
|
|
165
279
|
its gutter run, costing one label line there; an arity is always
|
|
166
280
|
placed in the channel through which its edge finally approaches
|
|
167
|
-
the target node, set back from the arrow head
|
|
281
|
+
the target node, set back from the arrow head (the labels of a
|
|
282
|
+
self-loop land on its detour above the box and need no room) */
|
|
168
283
|
const chanCnt = new Map();
|
|
169
284
|
const gutCnt = new Map();
|
|
170
285
|
const chanLbl = new Map();
|
|
@@ -175,7 +290,9 @@ const planRoutes = (edges, col, row) => {
|
|
|
175
290
|
chanCnt.set(c, (chanCnt.get(c) ?? 0) + 1);
|
|
176
291
|
for (const g of plan.guts)
|
|
177
292
|
gutCnt.set(g, (gutCnt.get(g) ?? 0) + 1);
|
|
178
|
-
const { name, arity } = edges[i];
|
|
293
|
+
const { name, arity, source, target } = edges[i];
|
|
294
|
+
if (source === target)
|
|
295
|
+
return;
|
|
179
296
|
if (name !== undefined && plan.chans.length === 1)
|
|
180
297
|
chanNeed(plan.chans[0], textWidth(name, FS_EDGE) + 2 * CHAN_LBL);
|
|
181
298
|
else if (name !== undefined && plan.guts.length > 0)
|
|
@@ -183,22 +300,34 @@ const planRoutes = (edges, col, row) => {
|
|
|
183
300
|
if (arity !== undefined && plan.chans.length > 0)
|
|
184
301
|
chanNeed(plan.chans[plan.chans.length - 1], ARITY_OFF + textWidth(arity, FS_ARITY) + CHAN_LBL);
|
|
185
302
|
});
|
|
186
|
-
|
|
303
|
+
/* count the self-loops of every node, as their detours stack up
|
|
304
|
+
above its box and have to fit into the gutter above its row
|
|
305
|
+
(the demand of a gutter is the deepest stack of any node below it) */
|
|
306
|
+
const loopCnt = new Map();
|
|
307
|
+
const gutLoop = new Map();
|
|
308
|
+
for (const edge of edges.filter((edge) => edge.source === edge.target)) {
|
|
309
|
+
const cnt = (loopCnt.get(edge.source) ?? 0) + 1;
|
|
310
|
+
loopCnt.set(edge.source, cnt);
|
|
311
|
+
const g = row.get(edge.source) - 1;
|
|
312
|
+
gutLoop.set(g, Math.max(gutLoop.get(g) ?? 0, cnt));
|
|
313
|
+
}
|
|
314
|
+
return { plans, chanCnt, gutCnt, chanLbl, gutLbl, gutLoop };
|
|
187
315
|
};
|
|
188
316
|
/* determine grid cell sizes and final node center positions, with
|
|
189
317
|
channel widths and gutter heights sized by actual edge usage to
|
|
190
318
|
keep the overall layout compact */
|
|
191
|
-
const computeGrid = (nodes, col, row, boxW, boxH, ncols, nrows, chanCnt, gutCnt, chanLbl, gutLbl, config) => {
|
|
319
|
+
const computeGrid = (nodes, col, row, boxW, boxH, ncols, nrows, chanCnt, gutCnt, chanLbl, gutLbl, gutLoop, config) => {
|
|
192
320
|
const colWidth = Array.from({ length: ncols }, () => 0);
|
|
193
321
|
const rowHeight = Array.from({ length: nrows }, () => 0);
|
|
194
322
|
for (const node of nodes) {
|
|
195
323
|
colWidth[col.get(node.id)] = Math.max(colWidth[col.get(node.id)], boxW.get(node.id));
|
|
196
324
|
rowHeight[row.get(node.id)] = Math.max(rowHeight[row.get(node.id)], boxH.get(node.id));
|
|
197
325
|
}
|
|
198
|
-
/* size the channels and gutters by their actual edge usage,
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
326
|
+
/* size the channels and gutters by their actual edge usage, grown
|
|
327
|
+
to hold the edge labels landing inside (a channel by its widest
|
|
328
|
+
label demand, a gutter by one line per label) and the self-loop
|
|
329
|
+
detours rising into a gutter from the row below, and floored by
|
|
330
|
+
the configured minimum width/height */
|
|
202
331
|
const chanW = Array.from({ length: ncols }, (_, c) => {
|
|
203
332
|
const cnt = chanCnt.get(c) ?? 0;
|
|
204
333
|
return Math.min(config["graph-channel-width-max"], Math.max(config["graph-channel-width-min"], cnt === 0 ? CHAN_W0 : CHAN_W1 + (cnt - 1) * config["size-edge-track-gap"], chanLbl.get(c) ?? 0));
|
|
@@ -206,7 +335,8 @@ const computeGrid = (nodes, col, row, boxW, boxH, ncols, nrows, chanCnt, gutCnt,
|
|
|
206
335
|
const gutH = Array.from({ length: nrows }, (_, g) => {
|
|
207
336
|
const cnt = gutCnt.get(g) ?? 0;
|
|
208
337
|
const lbl = gutLbl.get(g) ?? 0;
|
|
209
|
-
|
|
338
|
+
const loop = gutLoop.get(g) ?? 0;
|
|
339
|
+
return Math.min(config["graph-gutter-height-max"], Math.max(config["graph-gutter-height-min"], cnt === 0 ? GUT_H0 : GUT_H1 + (cnt - 1) * config["size-edge-track-gap"], lbl > 0 ? GUT_H1 + lbl * GUT_LBL : 0, loop > 0 ? LOOP_TOP + (loop - 1) * config["size-edge-track-gap"] + GUT_PAD : 0));
|
|
210
340
|
});
|
|
211
341
|
/* derive the column and row center positions */
|
|
212
342
|
const colCX = [];
|
|
@@ -229,26 +359,26 @@ const computeGrid = (nodes, col, row, boxW, boxH, ncols, nrows, chanCnt, gutCnt,
|
|
|
229
359
|
channel mirrored from the right side; the gutters use the very same
|
|
230
360
|
scheme, just transposed: edges dropping in from above enter from the
|
|
231
361
|
near side, edges rising from below are the mirrored users) */
|
|
232
|
-
const assignTrackCoords = (edges, plans,
|
|
362
|
+
const assignTrackCoords = (edges, plans, portPos, grid, config) => {
|
|
233
363
|
const { colWidth, rowHeight, chanW, gutH, colCX, rowCY } = grid;
|
|
234
364
|
const gutBase = (g) => rowCY[g] + rowHeight[g] / 2 + gutH[g] / 2;
|
|
235
|
-
/* assign the vertical tracks within the inter-column channels
|
|
365
|
+
/* assign the vertical tracks within the inter-column channels (the
|
|
366
|
+
leg of a self-loop spans up to its detour above the box) */
|
|
236
367
|
const chanUsers = new Map();
|
|
237
368
|
edges.forEach((edge, i) => {
|
|
238
|
-
const { chans, guts } = plans[i];
|
|
369
|
+
const { chans, guts, flags } = plans[i];
|
|
239
370
|
if (chans.length === 0)
|
|
240
371
|
return;
|
|
241
|
-
const [sc, tc] = [col.get(edge.source), col.get(edge.target)];
|
|
242
|
-
const mirror = tc < sc;
|
|
243
|
-
const hairpin = tc === sc;
|
|
244
372
|
const sp = portPos.get(`${i}:s`);
|
|
245
373
|
const tp = portPos.get(`${i}:t`);
|
|
246
|
-
if (
|
|
247
|
-
pushTo(chanUsers, chans[0], { edge: i, posIn: sp.y, posOut: tp.y
|
|
374
|
+
if (edge.source === edge.target)
|
|
375
|
+
pushTo(chanUsers, chans[0], { edge: i, posIn: sp.y, posOut: tp.y - LOOP_TOP, ...flags[0] });
|
|
376
|
+
else if (chans.length === 1)
|
|
377
|
+
pushTo(chanUsers, chans[0], { edge: i, posIn: sp.y, posOut: tp.y, ...flags[0] });
|
|
248
378
|
else {
|
|
249
379
|
const gy = gutBase(guts[0]);
|
|
250
|
-
pushTo(chanUsers, chans[0], { edge: i, posIn: sp.y, posOut: gy,
|
|
251
|
-
pushTo(chanUsers, chans[1], { edge: i, posIn: gy, posOut: tp.y,
|
|
380
|
+
pushTo(chanUsers, chans[0], { edge: i, posIn: sp.y, posOut: gy, ...flags[0] });
|
|
381
|
+
pushTo(chanUsers, chans[1], { edge: i, posIn: gy, posOut: tp.y, ...flags[1] });
|
|
252
382
|
}
|
|
253
383
|
});
|
|
254
384
|
const chanOff = new Map();
|
|
@@ -280,31 +410,33 @@ const assignTrackCoords = (edges, plans, col, portPos, grid, config) => {
|
|
|
280
410
|
};
|
|
281
411
|
/* route every edge as an orthogonal polyline through the channels
|
|
282
412
|
between columns and the gutters between rows */
|
|
283
|
-
const routePolys = (edges, plans,
|
|
284
|
-
|
|
413
|
+
const routePolys = (edges, plans, portPos, boxH, cy, chanX, gutY) => {
|
|
414
|
+
/* the innermost channel track among the self-loops of each node */
|
|
415
|
+
const loopX = new Map();
|
|
416
|
+
edges.forEach((edge, i) => {
|
|
417
|
+
if (edge.source === edge.target)
|
|
418
|
+
loopX.set(edge.source, Math.min(loopX.get(edge.source) ?? Infinity, chanX(plans[i].chans[0], i)));
|
|
419
|
+
});
|
|
285
420
|
return edges.map((edge, i) => {
|
|
286
|
-
const sc = col.get(edge.source);
|
|
287
|
-
const tc = col.get(edge.target);
|
|
288
421
|
const sp = portPos.get(`${i}:s`);
|
|
289
422
|
const tp = portPos.get(`${i}:t`);
|
|
290
|
-
const { chans, guts } = plans[i];
|
|
423
|
+
const { chans, guts, flags } = plans[i];
|
|
291
424
|
let pts;
|
|
292
425
|
if (edge.source === edge.target) {
|
|
293
426
|
/* route the self-loop counter-clockwise around the top-right
|
|
294
|
-
box corner: out on the east side, up
|
|
295
|
-
left above
|
|
296
|
-
detour above the box is the wider one, as
|
|
297
|
-
descent has to stay readable behind the arrow
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
const
|
|
301
|
-
const
|
|
302
|
-
const oy = cy(edge.source) - boxH.get(edge.source) / 2 - LOOP_TOP - off;
|
|
427
|
+
box corner: out on the east side, up along its channel
|
|
428
|
+
track, back left above the box, and down into the north
|
|
429
|
+
side again (the detour above the box is the wider one, as
|
|
430
|
+
its final descent has to stay readable behind the arrow
|
|
431
|
+
head, and grows with the track distance from the innermost
|
|
432
|
+
loop of the node, so the nested loops stay apart) */
|
|
433
|
+
const ox = chanX(chans[0], i);
|
|
434
|
+
const oy = cy(edge.source) - boxH.get(edge.source) / 2 - LOOP_TOP - (ox - loopX.get(edge.source));
|
|
303
435
|
pts = [[sp.x, sp.y], [ox, sp.y], [ox, oy], [tp.x, oy], [tp.x, tp.y]];
|
|
304
436
|
}
|
|
305
437
|
else if (chans.length === 1) {
|
|
306
438
|
const ch = chanX(chans[0], i);
|
|
307
|
-
if (
|
|
439
|
+
if (!flags[0].hairpin && sp.y === tp.y)
|
|
308
440
|
pts = [[sp.x, sp.y], [tp.x, tp.y]];
|
|
309
441
|
else
|
|
310
442
|
pts = [[sp.x, sp.y], [ch, sp.y], [ch, tp.y], [tp.x, tp.y]];
|
|
@@ -328,13 +460,13 @@ const routePolys = (edges, plans, col, portPos, boxW, boxH, cx, cy, chanX, gutY,
|
|
|
328
460
|
tracks on top of the moved ports and finally route every edge as an
|
|
329
461
|
orthogonal polyline through the channels between the columns and
|
|
330
462
|
the gutters between the rows */
|
|
331
|
-
const routeEdges = (edges, plans,
|
|
332
|
-
const portPre = assignPorts(edges, sides, cx, cy, boxW, boxH, config["size-edge-port-gap"]);
|
|
333
|
-
const trackPre = assignTrackCoords(edges, plans,
|
|
463
|
+
const routeEdges = (edges, plans, sides, boxW, boxH, cx, cy, grid, config, level) => {
|
|
464
|
+
const portPre = assignPorts(edges, sides, cx, cy, boxW, boxH, config["size-edge-port-gap"], undefined, level.fixedPort);
|
|
465
|
+
const trackPre = assignTrackCoords(edges, plans, portPre, grid, config);
|
|
334
466
|
const portPos = assignPorts(edges, sides, cx, cy, boxW, boxH, config["size-edge-port-gap"], (edge) => plans[edge].guts.length > 0 ?
|
|
335
|
-
trackPre.gutY(plans[edge].guts[0], edge) : undefined);
|
|
336
|
-
const { chanX, gutY } = assignTrackCoords(edges, plans,
|
|
337
|
-
const polys = routePolys(edges, plans,
|
|
467
|
+
trackPre.gutY(plans[edge].guts[0], edge) : undefined, level.fixedPort);
|
|
468
|
+
const { chanX, gutY } = assignTrackCoords(edges, plans, portPos, grid, config);
|
|
469
|
+
const polys = routePolys(edges, plans, portPos, boxH, cy, chanX, gutY);
|
|
338
470
|
const hops = computeHops(polys).reduce((sum, segs) => sum + Array.from(segs.values()).reduce((n, xs) => n + xs.length, 0), 0);
|
|
339
471
|
return { portPos, gutY, polys, hops };
|
|
340
472
|
};
|
|
@@ -343,18 +475,19 @@ const routeEdges = (edges, plans, col, sides, boxW, boxH, cx, cy, grid, config)
|
|
|
343
475
|
far as this improves the overall routing (fewer crossings, or as many
|
|
344
476
|
crossings but fewer jogged direct edges): a node may leave its row
|
|
345
477
|
band into an adjacent gutter only as far as no gutter track runs
|
|
346
|
-
across its column
|
|
347
|
-
box), and only as long as its own
|
|
348
|
-
approaching it from their gutter side */
|
|
478
|
+
across its column and no self-loop detours through it there (a loop
|
|
479
|
+
rises above the box of its node), and only as long as its own
|
|
480
|
+
gutter-routed edges keep approaching it from their gutter side */
|
|
349
481
|
const nudgeNodes = (nodes, edges, plans, col, row, boxH, grid, nrows, dy, route) => {
|
|
350
482
|
const gutterFree = (g, c) => !plans.some((plan) => plan.guts[0] === g
|
|
351
|
-
&& Math.min(...plan.chans) < c && c <= Math.max(...plan.chans))
|
|
483
|
+
&& Math.min(...plan.chans) < c && c <= Math.max(...plan.chans))
|
|
484
|
+
&& !edges.some((edge) => edge.source === edge.target
|
|
485
|
+
&& row.get(edge.source) === g + 1 && col.get(edge.source) === c);
|
|
352
486
|
const shiftRange = (id) => {
|
|
353
487
|
const r = row.get(id);
|
|
354
488
|
const c = col.get(id);
|
|
355
489
|
const slack = (grid.rowHeight[r] - boxH.get(id)) / 2;
|
|
356
|
-
const
|
|
357
|
-
const up = r > 0 && gutterFree(r - 1, c) && !loop ? grid.gutH[r - 1] - GUT_H0 : 0;
|
|
490
|
+
const up = r > 0 && gutterFree(r - 1, c) ? grid.gutH[r - 1] - GUT_H0 : 0;
|
|
358
491
|
const down = r < nrows - 1 && gutterFree(r, c) ? grid.gutH[r] - GUT_H0 : 0;
|
|
359
492
|
return [-(slack + up), slack + down];
|
|
360
493
|
};
|
|
@@ -366,8 +499,8 @@ const nudgeNodes = (nodes, edges, plans, col, row, boxH, grid, nrows, dy, route)
|
|
|
366
499
|
const gap = routing.portPos.get(`${i}:${role}`).y - routing.gutY(g, i);
|
|
367
500
|
return row.get(id) > g ? gap >= NUDGE_GAP : gap <= -NUDGE_GAP;
|
|
368
501
|
});
|
|
369
|
-
const direct = edges.map((edge, i) => plans[i].
|
|
370
|
-
&&
|
|
502
|
+
const direct = edges.map((edge, i) => plans[i].chans.length === 1
|
|
503
|
+
&& !plans[i].flags[0].hairpin
|
|
371
504
|
&& row.get(edge.source) === row.get(edge.target));
|
|
372
505
|
const jogs = (routing) => edges.filter((_, i) => direct[i]
|
|
373
506
|
&& routing.portPos.get(`${i}:s`).y !== routing.portPos.get(`${i}:t`).y).length;
|
|
@@ -413,95 +546,118 @@ const nudgeNodes = (nodes, edges, plans, col, row, boxH, grid, nrows, dy, route)
|
|
|
413
546
|
}
|
|
414
547
|
return current;
|
|
415
548
|
};
|
|
416
|
-
/*
|
|
417
|
-
|
|
549
|
+
/* determine the attachment sides of every edge, derived from the
|
|
550
|
+
column relation of its endpoint nodes (a self-loop leaves on the
|
|
551
|
+
east side and re-enters on the north side of its own box), except
|
|
552
|
+
for an edge end attaching at a fixed port of a container box,
|
|
553
|
+
which always uses the side of the inner gate: the west side
|
|
554
|
+
when entering the container, the east side when leaving it */
|
|
555
|
+
const attachSides = (edges, col, level) => edges.map((edge, i) => {
|
|
556
|
+
const sc = col.get(edge.source);
|
|
557
|
+
const tc = col.get(edge.target);
|
|
558
|
+
if (edge.source === edge.target)
|
|
559
|
+
return { s: "e", t: "n" };
|
|
560
|
+
const s = level.fixedPort?.(i, "s") !== undefined ? "e" : sc <= tc ? "e" : "w";
|
|
561
|
+
const t = level.fixedPort?.(i, "t") !== undefined ? "w" : sc < tc ? "w" : "e";
|
|
562
|
+
return { s, t };
|
|
563
|
+
});
|
|
564
|
+
/* grow every box whose edge attachments exceed the configured
|
|
565
|
+
per-side maximum, step-wise by one port separation per
|
|
566
|
+
additional edge, so the edges keep enough attachment room
|
|
567
|
+
without a fixed height increase (the fixed-size boxes of a
|
|
568
|
+
containment level are exempt) */
|
|
569
|
+
const growBoxes = (nodes, edges, sides, boxH, config, level) => {
|
|
570
|
+
const portCnt = new Map();
|
|
571
|
+
edges.forEach((edge, i) => {
|
|
572
|
+
portCnt.set(`${sides[i].s}:${edge.source}`, (portCnt.get(`${sides[i].s}:${edge.source}`) ?? 0) + 1);
|
|
573
|
+
portCnt.set(`${sides[i].t}:${edge.target}`, (portCnt.get(`${sides[i].t}:${edge.target}`) ?? 0) + 1);
|
|
574
|
+
});
|
|
575
|
+
for (const node of nodes) {
|
|
576
|
+
if (level.fixedSize?.has(node.id))
|
|
577
|
+
continue;
|
|
578
|
+
const cnt = Math.max(portCnt.get(`w:${node.id}`) ?? 0, portCnt.get(`e:${node.id}`) ?? 0);
|
|
579
|
+
if (cnt > config["graph-node-degree-max"])
|
|
580
|
+
boxH.set(node.id, boxH.get(node.id) +
|
|
581
|
+
(cnt - config["graph-node-degree-max"]) * config["size-edge-port-gap"]);
|
|
582
|
+
}
|
|
583
|
+
};
|
|
584
|
+
/* lay out a directed graph model (for a containment level: with the
|
|
585
|
+
container placeholders at their fixed sizes, the edge ends attaching
|
|
586
|
+
to them at their fixed ports, and the gate nodes on the boundary) */
|
|
587
|
+
export const render = async (graph, config, level = {}) => {
|
|
418
588
|
const nodes = Array.from(graph.nodes.values());
|
|
419
589
|
const edges = graph.edges;
|
|
590
|
+
/* the gate nodes are kept out of the layered layout and the grid
|
|
591
|
+
refinement, as they are placed into their own columns afterwards */
|
|
592
|
+
const gates = level.gates ?? new Map();
|
|
593
|
+
const real = nodes.filter((node) => !gates.has(node.id));
|
|
594
|
+
const realEdges = edges.filter((edge) => !gates.has(edge.source) && !gates.has(edge.target));
|
|
420
595
|
/* determine node box sizes from their textual content
|
|
421
596
|
(all boxes at half height scale, grown by their edge
|
|
422
597
|
attachment needs once the attachment sides are known) */
|
|
423
|
-
const { boxW, boxH, contentH } = measureNodes(nodes, config, () => config["size-node-height-scale"] / 2);
|
|
598
|
+
const { boxW, boxH, contentH } = measureNodes(nodes, config, () => config["size-node-height-scale"] / 2, level.fixedSize);
|
|
424
599
|
/* determine raw node positions with the AntV Dagre layout */
|
|
425
|
-
const layout = new DagreLayout({
|
|
426
|
-
rankdir: "LR",
|
|
427
|
-
nodesep: config["graph-node-separation"],
|
|
428
|
-
ranksep: config["graph-rank-separation"],
|
|
429
|
-
nodeSize: (node) => node.size
|
|
430
|
-
});
|
|
431
|
-
await layout.execute({
|
|
432
|
-
nodes: nodes.map((node) => ({ id: node.id, size: [boxW.get(node.id), boxH.get(node.id)] })),
|
|
433
|
-
edges: edges.map((edge, i) => ({ id: `e${i}`, source: edge.source, target: edge.target }))
|
|
434
|
-
});
|
|
435
600
|
const rawX = new Map();
|
|
436
601
|
const rawY = new Map();
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
602
|
+
if (real.length > 0) {
|
|
603
|
+
const layout = new DagreLayout({
|
|
604
|
+
rankdir: "LR",
|
|
605
|
+
nodesep: config["graph-node-separation"],
|
|
606
|
+
ranksep: config["graph-rank-separation"],
|
|
607
|
+
nodeSize: (node) => node.size
|
|
608
|
+
});
|
|
609
|
+
await layout.execute({
|
|
610
|
+
nodes: real.map((node) => ({ id: node.id, size: [boxW.get(node.id), boxH.get(node.id)] })),
|
|
611
|
+
edges: realEdges.map((edge, i) => ({ id: `e${i}`, source: edge.source, target: edge.target }))
|
|
612
|
+
});
|
|
613
|
+
layout.forEachNode((node) => {
|
|
614
|
+
rawX.set(String(node.id), node.x);
|
|
615
|
+
rawY.set(String(node.id), node.y);
|
|
616
|
+
});
|
|
617
|
+
}
|
|
441
618
|
/* snap the raw positions onto the discrete column/row grid */
|
|
442
|
-
const { col, row, ncols: gridCols, nrows: gridRows } = snapToGrid(
|
|
619
|
+
const { col, row, ncols: gridCols, nrows: gridRows } = snapToGrid(real, rawX, rawY);
|
|
443
620
|
/* refine the row assignment by pulling every node vertically
|
|
444
621
|
toward the median row of its direct neighbors */
|
|
445
|
-
refineRows(
|
|
622
|
+
refineRows(real, realEdges, col, row);
|
|
446
623
|
/* fold the grid columns: constrain the diagram width to at most
|
|
447
624
|
the configured maximum of side-by-side nodes by wrapping excess
|
|
448
625
|
columns into additional row bands below, so wide graphs grow in
|
|
449
626
|
height */
|
|
450
627
|
const maxCols = Math.max(Math.floor(config["graph-columns-max"]), 1);
|
|
451
628
|
if (gridCols > maxCols) {
|
|
452
|
-
for (const node of
|
|
629
|
+
for (const node of real) {
|
|
453
630
|
const c = col.get(node.id);
|
|
454
631
|
col.set(node.id, c % maxCols);
|
|
455
632
|
row.set(node.id, row.get(node.id) + Math.floor(c / maxCols) * gridRows);
|
|
456
633
|
}
|
|
457
634
|
}
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
const tc = col.get(edge.target);
|
|
473
|
-
if (edge.source === edge.target)
|
|
474
|
-
return { s: "e", t: "n" };
|
|
475
|
-
else if (sc < tc)
|
|
476
|
-
return { s: "e", t: "w" };
|
|
477
|
-
else if (sc > tc)
|
|
478
|
-
return { s: "w", t: "e" };
|
|
479
|
-
else
|
|
480
|
-
return { s: "e", t: "e" };
|
|
481
|
-
});
|
|
482
|
-
/* grow every box whose edge attachments exceed the configured
|
|
483
|
-
per-side maximum, step-wise by one port separation per
|
|
484
|
-
additional edge, so the edges keep enough attachment room
|
|
485
|
-
without a fixed height increase */
|
|
486
|
-
const portCnt = new Map();
|
|
487
|
-
edges.forEach((edge, i) => {
|
|
488
|
-
portCnt.set(`${sides[i].s}:${edge.source}`, (portCnt.get(`${sides[i].s}:${edge.source}`) ?? 0) + 1);
|
|
489
|
-
portCnt.set(`${sides[i].t}:${edge.target}`, (portCnt.get(`${sides[i].t}:${edge.target}`) ?? 0) + 1);
|
|
490
|
-
});
|
|
491
|
-
for (const node of nodes) {
|
|
492
|
-
const cnt = Math.max(portCnt.get(`w:${node.id}`) ?? 0, portCnt.get(`e:${node.id}`) ?? 0);
|
|
493
|
-
if (cnt > config["graph-node-degree-max"])
|
|
494
|
-
boxH.set(node.id, boxH.get(node.id) +
|
|
495
|
-
(cnt - config["graph-node-degree-max"]) * config["size-edge-port-gap"]);
|
|
635
|
+
/* compact the sparsely occupied grid rows into fewer, denser ones,
|
|
636
|
+
then place the gate nodes into their own boundary columns */
|
|
637
|
+
let { ncols, nrows } = placeGates(gates, edges, col, row, Math.min(gridCols, maxCols), compactRows(real, realEdges, col, row));
|
|
638
|
+
/* determine the attachment sides of every edge */
|
|
639
|
+
const sides = attachSides(edges, col, level);
|
|
640
|
+
/* plan the coarse channel/gutter route of every edge (a west side
|
|
641
|
+
attachment in the first column needs a channel left of it, so
|
|
642
|
+
the whole grid is shifted one column to the right then) */
|
|
643
|
+
let routes = planRoutes(edges, col, row, sides);
|
|
644
|
+
if (routes.plans.some((plan) => plan.chans.some((c) => c < 0))) {
|
|
645
|
+
for (const node of nodes)
|
|
646
|
+
col.set(node.id, col.get(node.id) + 1);
|
|
647
|
+
ncols++;
|
|
648
|
+
routes = planRoutes(edges, col, row, sides);
|
|
496
649
|
}
|
|
650
|
+
const { plans, chanCnt, gutCnt, chanLbl, gutLbl, gutLoop } = routes;
|
|
651
|
+
/* grow the boxes by their edge attachment needs */
|
|
652
|
+
growBoxes(nodes, edges, sides, boxH, config, level);
|
|
497
653
|
/* determine grid cell sizes and node center positions (the nodes
|
|
498
654
|
centered in their rows, before any vertical nudging) */
|
|
499
|
-
const grid = computeGrid(nodes, col, row, boxW, boxH, ncols, nrows, chanCnt, gutCnt, chanLbl, gutLbl, config);
|
|
655
|
+
const grid = computeGrid(nodes, col, row, boxW, boxH, ncols, nrows, chanCnt, gutCnt, chanLbl, gutLbl, gutLoop, config);
|
|
500
656
|
const dy = new Map();
|
|
501
657
|
const cx = (id) => grid.colCX[col.get(id)];
|
|
502
658
|
const cy = (id) => grid.rowCY[row.get(id)] + (dy.get(id) ?? 0);
|
|
503
659
|
/* route the edges for the current node positions */
|
|
504
|
-
const route = () => routeEdges(edges, plans,
|
|
660
|
+
const route = () => routeEdges(edges, plans, sides, boxW, boxH, cx, cy, grid, config, level);
|
|
505
661
|
/* nudge nodes vertically off their row centers where this
|
|
506
662
|
straightens edges and thereby simplifies the routing */
|
|
507
663
|
const routing = nudgeNodes(nodes, edges, plans, col, row, boxH, grid, nrows, dy, route);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Graph } from "./gradia-api-model.js";
|
|
2
2
|
import { Config } from "./gradia-api-config.js";
|
|
3
|
-
import { Layout } from "./gradia-api-render-
|
|
4
|
-
|
|
3
|
+
import { Layout } from "./gradia-api-render-base.js";
|
|
4
|
+
import { LevelContext } from "./gradia-api-render-container.js";
|
|
5
|
+
export declare const render: (graph: Graph, config: Config, level?: LevelContext) => Promise<Layout>;
|
|
@@ -4,8 +4,10 @@
|
|
|
4
4
|
** Distributed under MIT license <https://spdx.org/licenses/MIT.html>
|
|
5
5
|
*/
|
|
6
6
|
import { measureNodes } from "./gradia-api-render-node.js";
|
|
7
|
-
/* lay out an edge-less graph model as a compact grid of tiles
|
|
8
|
-
|
|
7
|
+
/* lay out an edge-less graph model as a compact grid of tiles (for a
|
|
8
|
+
containment level: with the container placeholders at their fixed
|
|
9
|
+
sizes, which take part in the tile size unification) */
|
|
10
|
+
export const render = async (graph, config, level = {}) => {
|
|
9
11
|
const nodes = Array.from(graph.nodes.values());
|
|
10
12
|
/* resolve the configurable rendering geometry */
|
|
11
13
|
const margin = config["size-canvas-margin"];
|
|
@@ -21,7 +23,7 @@ export const render = async (graph, config) => {
|
|
|
21
23
|
}
|
|
22
24
|
/* determine node box sizes and unify their heights into a single
|
|
23
25
|
tile height and, if configured, their widths into a single tile width */
|
|
24
|
-
const { boxW, boxH, contentH } = measureNodes(nodes, config, () => config["size-node-height-scale"] / 2);
|
|
26
|
+
const { boxW, boxH, contentH } = measureNodes(nodes, config, () => config["size-node-height-scale"] / 2, level.fixedSize);
|
|
25
27
|
const tileH = nodes.reduce((a, node) => Math.max(a, boxH.get(node.id)), 0);
|
|
26
28
|
const tileW = nodes.reduce((a, node) => Math.max(a, boxW.get(node.id)), 0);
|
|
27
29
|
for (const node of nodes) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Graph } from "./gradia-api-model.js";
|
|
2
2
|
import { Config } from "./gradia-api-config.js";
|
|
3
|
-
import { Layout } from "./gradia-api-render-
|
|
4
|
-
|
|
3
|
+
import { Layout } from "./gradia-api-render-base.js";
|
|
4
|
+
import { LevelContext } from "./gradia-api-render-container.js";
|
|
5
|
+
export declare const render: (graph: Graph, config: Config, level?: LevelContext) => Promise<Layout>;
|