@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.
@@ -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 only) */
190
- const planRoutes = (edges, col, row) => {
191
- const plans = edges.map((edge) => {
192
- /* a self-loop is routed around its own node box and uses no channel */
193
- if (edge.source === edge.target)
194
- 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) */
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
- let chans;
200
- let guts = [];
201
- if (sc === tc)
202
- chans = [sc];
203
- else if (Math.abs(sc - tc) === 1)
204
- chans = [Math.min(sc, tc)];
205
- else {
206
- chans = [sc < tc ? sc : sc - 1, sc < tc ? tc - 1 : tc];
207
- guts = [tr > sr ? tr - 1 : tr];
208
- }
209
- 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
+ };
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
- 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 };
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, each
248
- additionally grown to hold the edge labels landing inside it (a
249
- channel by its widest label demand, a gutter by one line per
250
- 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 */
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
- 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));
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, col, portPos, grid, config) => {
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 (chans.length === 1)
296
- 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] });
297
378
  else {
298
379
  const gy = gutBase(guts[0]);
299
- pushTo(chanUsers, chans[0], { edge: i, posIn: sp.y, posOut: gy, mirror });
300
- 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] });
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, col, portPos, boxW, boxH, cx, cy, chanX, gutY, config) => {
333
- 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
+ });
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 beside the box, back
344
- left above it, and down into the north side again (the
345
- detour above the box is the wider one, as its final
346
- descent has to stay readable behind the arrow head) */
347
- const k = loopUse.get(edge.source) ?? 0;
348
- loopUse.set(edge.source, k + 1);
349
- const off = k * config["size-edge-track-gap"];
350
- const ox = cx(edge.source) + boxW.get(edge.source) / 2 + LOOP_GAP + off;
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 (Math.abs(sc - tc) === 1 && sp.y === tp.y)
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, col, sides, boxW, boxH, cx, cy, grid, config) => {
381
- const portPre = assignPorts(edges, sides, cx, cy, boxW, boxH, config["size-edge-port-gap"]);
382
- 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);
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, col, portPos, grid, config);
386
- 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);
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 (and it carries no self-loop, detouring above its
396
- box), and only as long as its own gutter-routed edges keep
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 loop = edges.some((edge) => edge.source === id && edge.target === id);
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].guts.length === 0
419
- && 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
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
- /* lay out a directed graph model */
466
- 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 = {}) => {
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
- layout.forEachNode((node) => {
487
- rawX.set(String(node.id), node.x);
488
- rawY.set(String(node.id), node.y);
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(nodes, rawX, rawY);
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(nodes, edges, col, row);
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 nodes) {
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
- const ncols = Math.min(gridCols, maxCols);
508
- /* compact the sparsely occupied grid rows into fewer, denser ones */
509
- const nrows = compactRows(nodes, edges, col, row);
510
- /* plan the coarse channel/gutter route of every edge */
511
- const { plans, chanCnt, gutCnt, chanLbl, gutLbl } = planRoutes(edges, col, row);
512
- /* determine the attachment sides of every edge, derived from the
513
- column relation of its endpoint nodes (a self-loop leaves on the
514
- east side and re-enters on the north side of its own box) */
515
- const sides = edges.map((edge) => {
516
- const sc = col.get(edge.source);
517
- const tc = col.get(edge.target);
518
- if (edge.source === edge.target)
519
- return { s: "e", t: "n" };
520
- else if (sc < tc)
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, col, sides, boxW, boxH, cx, cy, grid, config);
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-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>;