@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.
@@ -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 only) */
141
- const planRoutes = (edges, col, row) => {
142
- const plans = edges.map((edge) => {
143
- /* a self-loop is routed around its own node box and uses no channel */
144
- if (edge.source === edge.target)
145
- return { chans: [], guts: [] };
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
- let chans;
151
- let guts = [];
152
- if (sc === tc)
153
- chans = [sc];
154
- else if (Math.abs(sc - tc) === 1)
155
- chans = [Math.min(sc, tc)];
156
- else {
157
- chans = [sc < tc ? sc : sc - 1, sc < tc ? tc - 1 : tc];
158
- guts = [tr > sr ? tr - 1 : tr];
159
- }
160
- return { chans, guts };
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
- return { plans, chanCnt, gutCnt, chanLbl, gutLbl };
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, each
199
- additionally grown to hold the edge labels landing inside it (a
200
- channel by its widest label demand, a gutter by one line per
201
- label) and floored by the configured minimum width/height */
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
- 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));
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, col, portPos, grid, config) => {
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 (chans.length === 1)
247
- pushTo(chanUsers, chans[0], { edge: i, posIn: sp.y, posOut: tp.y, mirror, hairpin });
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, mirror });
251
- pushTo(chanUsers, chans[1], { edge: i, posIn: gy, posOut: tp.y, mirror });
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, col, portPos, boxW, boxH, cx, cy, chanX, gutY, config) => {
284
- const loopUse = new Map();
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 beside the box, back
295
- left above it, and down into the north side again (the
296
- detour above the box is the wider one, as its final
297
- descent has to stay readable behind the arrow head) */
298
- const k = loopUse.get(edge.source) ?? 0;
299
- loopUse.set(edge.source, k + 1);
300
- const off = k * config["size-edge-track-gap"];
301
- const ox = cx(edge.source) + boxW.get(edge.source) / 2 + LOOP_GAP + off;
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 (Math.abs(sc - tc) === 1 && sp.y === tp.y)
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, col, sides, boxW, boxH, cx, cy, grid, config) => {
332
- const portPre = assignPorts(edges, sides, cx, cy, boxW, boxH, config["size-edge-port-gap"]);
333
- const trackPre = assignTrackCoords(edges, plans, col, portPre, grid, config);
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, col, portPos, grid, config);
337
- const polys = routePolys(edges, plans, col, portPos, boxW, boxH, cx, cy, chanX, gutY, config);
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 (and it carries no self-loop, detouring above its
347
- box), and only as long as its own gutter-routed edges keep
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 loop = edges.some((edge) => edge.source === id && edge.target === id);
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].guts.length === 0
370
- && Math.abs(col.get(edge.source) - col.get(edge.target)) === 1
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
- /* lay out a directed graph model */
417
- export const render = async (graph, config) => {
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
- layout.forEachNode((node) => {
438
- rawX.set(String(node.id), node.x);
439
- rawY.set(String(node.id), node.y);
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(nodes, rawX, rawY);
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(nodes, edges, col, row);
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 nodes) {
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
- const ncols = Math.min(gridCols, maxCols);
459
- /* drop the grid rows which are completely empty */
460
- const usedRows = Array.from(new Set(nodes.map((node) => row.get(node.id)))).sort((a, b) => a - b);
461
- const rowIdx = new Map(usedRows.map((r, idx) => [r, idx]));
462
- for (const node of nodes)
463
- row.set(node.id, rowIdx.get(row.get(node.id)));
464
- const nrows = usedRows.length;
465
- /* plan the coarse channel/gutter route of every edge */
466
- const { plans, chanCnt, gutCnt, chanLbl, gutLbl } = planRoutes(edges, col, row);
467
- /* determine the attachment sides of every edge, derived from the
468
- column relation of its endpoint nodes (a self-loop leaves on the
469
- east side and re-enters on the north side of its own box) */
470
- const sides = edges.map((edge) => {
471
- const sc = col.get(edge.source);
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, col, sides, boxW, boxH, cx, cy, grid, config);
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-svg.js";
4
- export declare const render: (graph: Graph, config: Config) => Promise<Layout>;
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
- export const render = async (graph, config) => {
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-svg.js";
4
- export declare const render: (graph: Graph, config: Config) => Promise<Layout>;
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>;