@rse/gradia 1.1.9 → 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 +233 -122
- 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
|
|
@@ -147,6 +147,7 @@ const compactRows = (nodes, edges, col, row) => {
|
|
|
147
147
|
const cells = new Set();
|
|
148
148
|
for (const node of nodes)
|
|
149
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 */
|
|
150
151
|
const linked = new Set();
|
|
151
152
|
for (const edge of edges) {
|
|
152
153
|
linked.add(edge.source);
|
|
@@ -164,6 +165,8 @@ const compactRows = (nodes, edges, col, row) => {
|
|
|
164
165
|
break;
|
|
165
166
|
}
|
|
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 */
|
|
167
170
|
const byRow = new Map();
|
|
168
171
|
for (const node of nodes)
|
|
169
172
|
pushTo(byRow, row.get(node.id), node);
|
|
@@ -185,35 +188,98 @@ const compactRows = (nodes, edges, col, row) => {
|
|
|
185
188
|
row.set(node.id, rowIdx.get(row.get(node.id)));
|
|
186
189
|
return idx + 1;
|
|
187
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
|
+
};
|
|
188
246
|
/* plan the coarse route of every edge: which inter-column channels
|
|
189
|
-
and inter-row gutters it occupies (based on grid indices
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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) */
|
|
195
257
|
const sc = col.get(edge.source);
|
|
258
|
+
if (edge.source === edge.target)
|
|
259
|
+
return { chans: [sc], guts: [], flags: [flag("l", "l")] };
|
|
196
260
|
const tc = col.get(edge.target);
|
|
197
261
|
const sr = row.get(edge.source);
|
|
198
262
|
const tr = row.get(edge.target);
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
chans
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
+
};
|
|
210
275
|
});
|
|
211
276
|
/* count the edges occupying each channel and gutter and determine
|
|
212
277
|
the room the edge labels need there: a name is placed in the
|
|
213
278
|
single channel of its edge, or, for a two-channel edge, along
|
|
214
279
|
its gutter run, costing one label line there; an arity is always
|
|
215
280
|
placed in the channel through which its edge finally approaches
|
|
216
|
-
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) */
|
|
217
283
|
const chanCnt = new Map();
|
|
218
284
|
const gutCnt = new Map();
|
|
219
285
|
const chanLbl = new Map();
|
|
@@ -224,7 +290,9 @@ const planRoutes = (edges, col, row) => {
|
|
|
224
290
|
chanCnt.set(c, (chanCnt.get(c) ?? 0) + 1);
|
|
225
291
|
for (const g of plan.guts)
|
|
226
292
|
gutCnt.set(g, (gutCnt.get(g) ?? 0) + 1);
|
|
227
|
-
const { name, arity } = edges[i];
|
|
293
|
+
const { name, arity, source, target } = edges[i];
|
|
294
|
+
if (source === target)
|
|
295
|
+
return;
|
|
228
296
|
if (name !== undefined && plan.chans.length === 1)
|
|
229
297
|
chanNeed(plan.chans[0], textWidth(name, FS_EDGE) + 2 * CHAN_LBL);
|
|
230
298
|
else if (name !== undefined && plan.guts.length > 0)
|
|
@@ -232,22 +300,34 @@ const planRoutes = (edges, col, row) => {
|
|
|
232
300
|
if (arity !== undefined && plan.chans.length > 0)
|
|
233
301
|
chanNeed(plan.chans[plan.chans.length - 1], ARITY_OFF + textWidth(arity, FS_ARITY) + CHAN_LBL);
|
|
234
302
|
});
|
|
235
|
-
|
|
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 };
|
|
236
315
|
};
|
|
237
316
|
/* determine grid cell sizes and final node center positions, with
|
|
238
317
|
channel widths and gutter heights sized by actual edge usage to
|
|
239
318
|
keep the overall layout compact */
|
|
240
|
-
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) => {
|
|
241
320
|
const colWidth = Array.from({ length: ncols }, () => 0);
|
|
242
321
|
const rowHeight = Array.from({ length: nrows }, () => 0);
|
|
243
322
|
for (const node of nodes) {
|
|
244
323
|
colWidth[col.get(node.id)] = Math.max(colWidth[col.get(node.id)], boxW.get(node.id));
|
|
245
324
|
rowHeight[row.get(node.id)] = Math.max(rowHeight[row.get(node.id)], boxH.get(node.id));
|
|
246
325
|
}
|
|
247
|
-
/* size the channels and gutters by their actual edge usage,
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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 */
|
|
251
331
|
const chanW = Array.from({ length: ncols }, (_, c) => {
|
|
252
332
|
const cnt = chanCnt.get(c) ?? 0;
|
|
253
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));
|
|
@@ -255,7 +335,8 @@ const computeGrid = (nodes, col, row, boxW, boxH, ncols, nrows, chanCnt, gutCnt,
|
|
|
255
335
|
const gutH = Array.from({ length: nrows }, (_, g) => {
|
|
256
336
|
const cnt = gutCnt.get(g) ?? 0;
|
|
257
337
|
const lbl = gutLbl.get(g) ?? 0;
|
|
258
|
-
|
|
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));
|
|
259
340
|
});
|
|
260
341
|
/* derive the column and row center positions */
|
|
261
342
|
const colCX = [];
|
|
@@ -278,26 +359,26 @@ const computeGrid = (nodes, col, row, boxW, boxH, ncols, nrows, chanCnt, gutCnt,
|
|
|
278
359
|
channel mirrored from the right side; the gutters use the very same
|
|
279
360
|
scheme, just transposed: edges dropping in from above enter from the
|
|
280
361
|
near side, edges rising from below are the mirrored users) */
|
|
281
|
-
const assignTrackCoords = (edges, plans,
|
|
362
|
+
const assignTrackCoords = (edges, plans, portPos, grid, config) => {
|
|
282
363
|
const { colWidth, rowHeight, chanW, gutH, colCX, rowCY } = grid;
|
|
283
364
|
const gutBase = (g) => rowCY[g] + rowHeight[g] / 2 + gutH[g] / 2;
|
|
284
|
-
/* 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) */
|
|
285
367
|
const chanUsers = new Map();
|
|
286
368
|
edges.forEach((edge, i) => {
|
|
287
|
-
const { chans, guts } = plans[i];
|
|
369
|
+
const { chans, guts, flags } = plans[i];
|
|
288
370
|
if (chans.length === 0)
|
|
289
371
|
return;
|
|
290
|
-
const [sc, tc] = [col.get(edge.source), col.get(edge.target)];
|
|
291
|
-
const mirror = tc < sc;
|
|
292
|
-
const hairpin = tc === sc;
|
|
293
372
|
const sp = portPos.get(`${i}:s`);
|
|
294
373
|
const tp = portPos.get(`${i}:t`);
|
|
295
|
-
if (
|
|
296
|
-
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] });
|
|
297
378
|
else {
|
|
298
379
|
const gy = gutBase(guts[0]);
|
|
299
|
-
pushTo(chanUsers, chans[0], { edge: i, posIn: sp.y, posOut: gy,
|
|
300
|
-
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] });
|
|
301
382
|
}
|
|
302
383
|
});
|
|
303
384
|
const chanOff = new Map();
|
|
@@ -329,31 +410,33 @@ const assignTrackCoords = (edges, plans, col, portPos, grid, config) => {
|
|
|
329
410
|
};
|
|
330
411
|
/* route every edge as an orthogonal polyline through the channels
|
|
331
412
|
between columns and the gutters between rows */
|
|
332
|
-
const routePolys = (edges, plans,
|
|
333
|
-
|
|
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
|
+
});
|
|
334
420
|
return edges.map((edge, i) => {
|
|
335
|
-
const sc = col.get(edge.source);
|
|
336
|
-
const tc = col.get(edge.target);
|
|
337
421
|
const sp = portPos.get(`${i}:s`);
|
|
338
422
|
const tp = portPos.get(`${i}:t`);
|
|
339
|
-
const { chans, guts } = plans[i];
|
|
423
|
+
const { chans, guts, flags } = plans[i];
|
|
340
424
|
let pts;
|
|
341
425
|
if (edge.source === edge.target) {
|
|
342
426
|
/* route the self-loop counter-clockwise around the top-right
|
|
343
|
-
box corner: out on the east side, up
|
|
344
|
-
left above
|
|
345
|
-
detour above the box is the wider one, as
|
|
346
|
-
descent has to stay readable behind the arrow
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
const
|
|
350
|
-
const
|
|
351
|
-
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));
|
|
352
435
|
pts = [[sp.x, sp.y], [ox, sp.y], [ox, oy], [tp.x, oy], [tp.x, tp.y]];
|
|
353
436
|
}
|
|
354
437
|
else if (chans.length === 1) {
|
|
355
438
|
const ch = chanX(chans[0], i);
|
|
356
|
-
if (
|
|
439
|
+
if (!flags[0].hairpin && sp.y === tp.y)
|
|
357
440
|
pts = [[sp.x, sp.y], [tp.x, tp.y]];
|
|
358
441
|
else
|
|
359
442
|
pts = [[sp.x, sp.y], [ch, sp.y], [ch, tp.y], [tp.x, tp.y]];
|
|
@@ -377,13 +460,13 @@ const routePolys = (edges, plans, col, portPos, boxW, boxH, cx, cy, chanX, gutY,
|
|
|
377
460
|
tracks on top of the moved ports and finally route every edge as an
|
|
378
461
|
orthogonal polyline through the channels between the columns and
|
|
379
462
|
the gutters between the rows */
|
|
380
|
-
const routeEdges = (edges, plans,
|
|
381
|
-
const portPre = assignPorts(edges, sides, cx, cy, boxW, boxH, config["size-edge-port-gap"]);
|
|
382
|
-
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);
|
|
383
466
|
const portPos = assignPorts(edges, sides, cx, cy, boxW, boxH, config["size-edge-port-gap"], (edge) => plans[edge].guts.length > 0 ?
|
|
384
|
-
trackPre.gutY(plans[edge].guts[0], edge) : undefined);
|
|
385
|
-
const { chanX, gutY } = assignTrackCoords(edges, plans,
|
|
386
|
-
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);
|
|
387
470
|
const hops = computeHops(polys).reduce((sum, segs) => sum + Array.from(segs.values()).reduce((n, xs) => n + xs.length, 0), 0);
|
|
388
471
|
return { portPos, gutY, polys, hops };
|
|
389
472
|
};
|
|
@@ -392,18 +475,19 @@ const routeEdges = (edges, plans, col, sides, boxW, boxH, cx, cy, grid, config)
|
|
|
392
475
|
far as this improves the overall routing (fewer crossings, or as many
|
|
393
476
|
crossings but fewer jogged direct edges): a node may leave its row
|
|
394
477
|
band into an adjacent gutter only as far as no gutter track runs
|
|
395
|
-
across its column
|
|
396
|
-
box), and only as long as its own
|
|
397
|
-
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 */
|
|
398
481
|
const nudgeNodes = (nodes, edges, plans, col, row, boxH, grid, nrows, dy, route) => {
|
|
399
482
|
const gutterFree = (g, c) => !plans.some((plan) => plan.guts[0] === g
|
|
400
|
-
&& 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);
|
|
401
486
|
const shiftRange = (id) => {
|
|
402
487
|
const r = row.get(id);
|
|
403
488
|
const c = col.get(id);
|
|
404
489
|
const slack = (grid.rowHeight[r] - boxH.get(id)) / 2;
|
|
405
|
-
const
|
|
406
|
-
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;
|
|
407
491
|
const down = r < nrows - 1 && gutterFree(r, c) ? grid.gutH[r] - GUT_H0 : 0;
|
|
408
492
|
return [-(slack + up), slack + down];
|
|
409
493
|
};
|
|
@@ -415,8 +499,8 @@ const nudgeNodes = (nodes, edges, plans, col, row, boxH, grid, nrows, dy, route)
|
|
|
415
499
|
const gap = routing.portPos.get(`${i}:${role}`).y - routing.gutY(g, i);
|
|
416
500
|
return row.get(id) > g ? gap >= NUDGE_GAP : gap <= -NUDGE_GAP;
|
|
417
501
|
});
|
|
418
|
-
const direct = edges.map((edge, i) => plans[i].
|
|
419
|
-
&&
|
|
502
|
+
const direct = edges.map((edge, i) => plans[i].chans.length === 1
|
|
503
|
+
&& !plans[i].flags[0].hairpin
|
|
420
504
|
&& row.get(edge.source) === row.get(edge.target));
|
|
421
505
|
const jogs = (routing) => edges.filter((_, i) => direct[i]
|
|
422
506
|
&& routing.portPos.get(`${i}:s`).y !== routing.portPos.get(`${i}:t`).y).length;
|
|
@@ -462,91 +546,118 @@ const nudgeNodes = (nodes, edges, plans, col, row, boxH, grid, nrows, dy, route)
|
|
|
462
546
|
}
|
|
463
547
|
return current;
|
|
464
548
|
};
|
|
465
|
-
/*
|
|
466
|
-
|
|
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 = {}) => {
|
|
467
588
|
const nodes = Array.from(graph.nodes.values());
|
|
468
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));
|
|
469
595
|
/* determine node box sizes from their textual content
|
|
470
596
|
(all boxes at half height scale, grown by their edge
|
|
471
597
|
attachment needs once the attachment sides are known) */
|
|
472
|
-
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);
|
|
473
599
|
/* determine raw node positions with the AntV Dagre layout */
|
|
474
|
-
const layout = new DagreLayout({
|
|
475
|
-
rankdir: "LR",
|
|
476
|
-
nodesep: config["graph-node-separation"],
|
|
477
|
-
ranksep: config["graph-rank-separation"],
|
|
478
|
-
nodeSize: (node) => node.size
|
|
479
|
-
});
|
|
480
|
-
await layout.execute({
|
|
481
|
-
nodes: nodes.map((node) => ({ id: node.id, size: [boxW.get(node.id), boxH.get(node.id)] })),
|
|
482
|
-
edges: edges.map((edge, i) => ({ id: `e${i}`, source: edge.source, target: edge.target }))
|
|
483
|
-
});
|
|
484
600
|
const rawX = new Map();
|
|
485
601
|
const rawY = new Map();
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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
|
+
}
|
|
490
618
|
/* snap the raw positions onto the discrete column/row grid */
|
|
491
|
-
const { col, row, ncols: gridCols, nrows: gridRows } = snapToGrid(
|
|
619
|
+
const { col, row, ncols: gridCols, nrows: gridRows } = snapToGrid(real, rawX, rawY);
|
|
492
620
|
/* refine the row assignment by pulling every node vertically
|
|
493
621
|
toward the median row of its direct neighbors */
|
|
494
|
-
refineRows(
|
|
622
|
+
refineRows(real, realEdges, col, row);
|
|
495
623
|
/* fold the grid columns: constrain the diagram width to at most
|
|
496
624
|
the configured maximum of side-by-side nodes by wrapping excess
|
|
497
625
|
columns into additional row bands below, so wide graphs grow in
|
|
498
626
|
height */
|
|
499
627
|
const maxCols = Math.max(Math.floor(config["graph-columns-max"]), 1);
|
|
500
628
|
if (gridCols > maxCols) {
|
|
501
|
-
for (const node of
|
|
629
|
+
for (const node of real) {
|
|
502
630
|
const c = col.get(node.id);
|
|
503
631
|
col.set(node.id, c % maxCols);
|
|
504
632
|
row.set(node.id, row.get(node.id) + Math.floor(c / maxCols) * gridRows);
|
|
505
633
|
}
|
|
506
634
|
}
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
/*
|
|
511
|
-
const
|
|
512
|
-
/*
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
const
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
return { s: "e", t: "w" };
|
|
522
|
-
else if (sc > tc)
|
|
523
|
-
return { s: "w", t: "e" };
|
|
524
|
-
else
|
|
525
|
-
return { s: "e", t: "e" };
|
|
526
|
-
});
|
|
527
|
-
/* grow every box whose edge attachments exceed the configured
|
|
528
|
-
per-side maximum, step-wise by one port separation per
|
|
529
|
-
additional edge, so the edges keep enough attachment room
|
|
530
|
-
without a fixed height increase */
|
|
531
|
-
const portCnt = new Map();
|
|
532
|
-
edges.forEach((edge, i) => {
|
|
533
|
-
portCnt.set(`${sides[i].s}:${edge.source}`, (portCnt.get(`${sides[i].s}:${edge.source}`) ?? 0) + 1);
|
|
534
|
-
portCnt.set(`${sides[i].t}:${edge.target}`, (portCnt.get(`${sides[i].t}:${edge.target}`) ?? 0) + 1);
|
|
535
|
-
});
|
|
536
|
-
for (const node of nodes) {
|
|
537
|
-
const cnt = Math.max(portCnt.get(`w:${node.id}`) ?? 0, portCnt.get(`e:${node.id}`) ?? 0);
|
|
538
|
-
if (cnt > config["graph-node-degree-max"])
|
|
539
|
-
boxH.set(node.id, boxH.get(node.id) +
|
|
540
|
-
(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);
|
|
541
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);
|
|
542
653
|
/* determine grid cell sizes and node center positions (the nodes
|
|
543
654
|
centered in their rows, before any vertical nudging) */
|
|
544
|
-
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);
|
|
545
656
|
const dy = new Map();
|
|
546
657
|
const cx = (id) => grid.colCX[col.get(id)];
|
|
547
658
|
const cy = (id) => grid.rowCY[row.get(id)] + (dy.get(id) ?? 0);
|
|
548
659
|
/* route the edges for the current node positions */
|
|
549
|
-
const route = () => routeEdges(edges, plans,
|
|
660
|
+
const route = () => routeEdges(edges, plans, sides, boxW, boxH, cx, cy, grid, config, level);
|
|
550
661
|
/* nudge nodes vertically off their row centers where this
|
|
551
662
|
straightens edges and thereby simplifies the routing */
|
|
552
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>;
|