@rse/gradia 1.2.0 → 1.2.2

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 CHANGED
@@ -197,7 +197,7 @@ separated by `|`.
197
197
  - The `<edge-name>` is rendered as the label of the edge and the
198
198
  `<edge-arity>` as its cardinality label.
199
199
 
200
- - Six attribute keys are reserved and consumed by the renderer instead
200
+ - Seven attribute keys are reserved and consumed by the renderer instead
201
201
  of being displayed as a regular `<key>: <val>` line:
202
202
 
203
203
  - `url: <url>` renders the node box as a hyperlink. Only relative URLs
@@ -213,7 +213,18 @@ separated by `|`.
213
213
  node which is implicitly declared if never referenced otherwise),
214
214
  - `container: <type>` marks the node as a container whose members
215
215
  are laid out with the diagram type `<type>` (`graph`, `hub`, or
216
- `grid`), even if it has no members at all.
216
+ `grid`), even if it has no members at all,
217
+ - `order: <number>` places the node explicitly (see below).
218
+
219
+ - A `graph` (a `graph` container level, too) with at least one node
220
+ carrying an `order` attribute is laid out in that explicit order
221
+ instead of by the layered algorithm: the distinct order values become
222
+ the rows, top-down in ascending order, the nodes of one value fill
223
+ their row left-to-right in declaration order (wrapped into further
224
+ rows beyond `graph-columns-max` nodes), and the nodes without an order
225
+ follow in the trailing rows. A `grid` places its tiles in the order of
226
+ the attribute, too, the tiles without one trailing. An invalid value
227
+ is ignored.
217
228
 
218
229
  - A node cannot be a member of more than one group. As soon as at least
219
230
  one node carries a `group` attribute, all nodes without one implicitly
@@ -236,8 +247,10 @@ separated by `|`.
236
247
  except a container with one of its own members. An edge crossing the
237
248
  boundary of a container laid out as `graph` is routed through the box
238
249
  border to the inner node (entering on the west and leaving on the east
239
- side), while an edge crossing the boundary of a container laid out as
240
- `hub` or `grid` ends at the box border instead.
250
+ side, and where other nodes lie between the inner node and the border,
251
+ around them through the gutter below, passing the border at the
252
+ height of that gutter), while an edge crossing the boundary of a
253
+ container laid out as `hub` or `grid` ends at the box border instead.
241
254
 
242
255
  - All remaining attributes are displayed as `<key>: <val>` lines inside
243
256
  the node box.
@@ -9,6 +9,7 @@ export declare const containerTypeOf: (node: Node) => string | undefined;
9
9
  export declare const HEAD_H = 34;
10
10
  export declare const HEAD_T = 20;
11
11
  export declare const containerHead: (node: Node) => number;
12
+ export declare const orderOf: (node: Node) => number | undefined;
12
13
  export declare const attrsOfNode: (node: Node) => Attr[];
13
14
  export declare const urlOf: (node: Node) => string | undefined;
14
15
  export declare const PAD_W = 18;
@@ -28,9 +28,16 @@ export const containerTypeOf = (node) => node.attrs.findLast((attr) => attr.key
28
28
  export const HEAD_H = 34; /* height of the container box head */
29
29
  export const HEAD_T = 20; /* extra head height of a type line */
30
30
  export const containerHead = (node) => HEAD_H + (typeOf(node) !== undefined ? HEAD_T : 0);
31
+ /* the special "order" annotation (the explicit top-down placement
32
+ order of the node, a finite number, an invalid value being ignored) */
33
+ export const orderOf = (node) => {
34
+ const val = node.attrs.findLast((attr) => attr.key === "order")?.val;
35
+ const num = val !== undefined && val.trim() !== "" ? Number(val) : NaN;
36
+ return Number.isFinite(num) ? num : undefined;
37
+ };
31
38
  /* the special attributes consumed by the renderers instead of being
32
39
  rendered as node box text lines */
33
- const ATTRS_SPECIAL = new Set(["url", "type", "primary", "group", "parent", "container"]);
40
+ const ATTRS_SPECIAL = new Set(["url", "type", "primary", "group", "parent", "container", "order"]);
34
41
  /* split the attributes rendered as text lines from the special ones */
35
42
  export const attrsOfNode = (node) => node.attrs.filter((attr) => !ATTRS_SPECIAL.has(attr.key));
36
43
  /* the special "url" attribute (rendered as a hyperlink on the whole node
@@ -6,7 +6,7 @@
6
6
  /* external dependencies */
7
7
  import { DagreLayout } from "@antv/layout";
8
8
  import { FS_EDGE, FS_ARITY, ARITY_OFF, textWidth } from "./gradia-api-render-base.js";
9
- import { measureNodes } from "./gradia-api-render-node.js";
9
+ import { measureNodes, orderOf } from "./gradia-api-render-node.js";
10
10
  import { simplifyPoly, assignPorts, assignTracks, computeHops } from "./gradia-api-render-edge.js";
11
11
  /* rendering geometry constants */
12
12
  const CLUSTX = 26; /* max X distance within a grid column */
@@ -243,13 +243,17 @@ const placeGates = (gates, edges, col, row, ncols, nrows) => {
243
243
  place(outs, ncols - 1);
244
244
  return { ncols, nrows };
245
245
  };
246
+ /* the channels an edge runs a vertical track in (the gate-side
247
+ channel of a gutter-ending edge is merely crossed horizontally) */
248
+ const trackChans = (plan) => plan.gate === "t" ? [plan.chans[0]] : plan.gate === "s" ? [plan.chans[1]] : plan.chans;
246
249
  /* plan the coarse route of every edge: which inter-column channels
247
250
  and inter-row gutters it occupies (based on the grid indices and
248
251
  the attachment sides only): the source stub reaches the channel
249
252
  beside its attachment side, the target stub the channel beside
250
253
  its one, and, if those differ, the gutter next to the target row
251
- connects them */
252
- const planRoutes = (edges, col, row, sides) => {
254
+ connects them (an edge whose gate end lies beyond that gutter ends
255
+ in the gutter itself, as the gate merely marks the border passage) */
256
+ const planRoutes = (edges, col, row, sides, gates) => {
253
257
  const flag = (from, to) => ({ mirror: from === "r", hairpin: from === to });
254
258
  const plans = edges.map((edge, i) => {
255
259
  /* a self-loop detours as a hairpin through the channel beside
@@ -267,10 +271,12 @@ const planRoutes = (edges, col, row, sides) => {
267
271
  if (sChan === tChan)
268
272
  return { chans: [sChan], guts: [], flags: [flag(sFrom, tTo)] };
269
273
  const dir = tChan > sChan ? "r" : "l";
274
+ const gate = gates.has(edge.target) ? "t" : gates.has(edge.source) ? "s" : undefined;
270
275
  return {
271
276
  chans: [sChan, tChan],
272
277
  guts: [tr > sr ? tr - 1 : tr],
273
- flags: [flag(sFrom, dir), flag(dir === "r" ? "l" : "r", tTo)]
278
+ flags: [flag(sFrom, dir), flag(dir === "r" ? "l" : "r", tTo)],
279
+ gate
274
280
  };
275
281
  });
276
282
  /* count the edges occupying each channel and gutter and determine
@@ -286,7 +292,8 @@ const planRoutes = (edges, col, row, sides) => {
286
292
  const gutLbl = new Map();
287
293
  const chanNeed = (c, width) => chanLbl.set(c, Math.max(chanLbl.get(c) ?? 0, width));
288
294
  plans.forEach((plan, i) => {
289
- for (const c of plan.chans)
295
+ const chans = trackChans(plan);
296
+ for (const c of chans)
290
297
  chanCnt.set(c, (chanCnt.get(c) ?? 0) + 1);
291
298
  for (const g of plan.guts)
292
299
  gutCnt.set(g, (gutCnt.get(g) ?? 0) + 1);
@@ -297,8 +304,8 @@ const planRoutes = (edges, col, row, sides) => {
297
304
  chanNeed(plan.chans[0], textWidth(name, FS_EDGE) + 2 * CHAN_LBL);
298
305
  else if (name !== undefined && plan.guts.length > 0)
299
306
  gutLbl.set(plan.guts[0], (gutLbl.get(plan.guts[0]) ?? 0) + 1);
300
- if (arity !== undefined && plan.chans.length > 0)
301
- chanNeed(plan.chans[plan.chans.length - 1], ARITY_OFF + textWidth(arity, FS_ARITY) + CHAN_LBL);
307
+ if (arity !== undefined && chans.length > 0)
308
+ chanNeed(chans[chans.length - 1], ARITY_OFF + textWidth(arity, FS_ARITY) + CHAN_LBL);
302
309
  });
303
310
  /* count the self-loops of every node, as their detours stack up
304
311
  above its box and have to fit into the gutter above its row
@@ -363,10 +370,11 @@ const assignTrackCoords = (edges, plans, portPos, grid, config) => {
363
370
  const { colWidth, rowHeight, chanW, gutH, colCX, rowCY } = grid;
364
371
  const gutBase = (g) => rowCY[g] + rowHeight[g] / 2 + gutH[g] / 2;
365
372
  /* assign the vertical tracks within the inter-column channels (the
366
- leg of a self-loop spans up to its detour above the box) */
373
+ leg of a self-loop spans up to its detour above the box, and a
374
+ gutter-ending edge takes no track in its gate-side channel) */
367
375
  const chanUsers = new Map();
368
376
  edges.forEach((edge, i) => {
369
- const { chans, guts, flags } = plans[i];
377
+ const { chans, guts, flags, gate } = plans[i];
370
378
  if (chans.length === 0)
371
379
  return;
372
380
  const sp = portPos.get(`${i}:s`);
@@ -377,8 +385,10 @@ const assignTrackCoords = (edges, plans, portPos, grid, config) => {
377
385
  pushTo(chanUsers, chans[0], { edge: i, posIn: sp.y, posOut: tp.y, ...flags[0] });
378
386
  else {
379
387
  const gy = gutBase(guts[0]);
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] });
388
+ if (gate !== "s")
389
+ pushTo(chanUsers, chans[0], { edge: i, posIn: sp.y, posOut: gy, ...flags[0] });
390
+ if (gate !== "t")
391
+ pushTo(chanUsers, chans[1], { edge: i, posIn: gy, posOut: tp.y, ...flags[1] });
382
392
  }
383
393
  });
384
394
  const chanOff = new Map();
@@ -387,17 +397,20 @@ const assignTrackCoords = (edges, plans, portPos, grid, config) => {
387
397
  chanOff.set(`${c}:${edge}`, off);
388
398
  const chanX = (c, edge) => colCX[c] + colWidth[c] / 2 + chanW[c] / 2 +
389
399
  chanOff.get(`${c}:${edge}`);
390
- /* assign the horizontal tracks within the inter-row gutters */
400
+ /* assign the horizontal tracks within the inter-row gutters (the
401
+ run of a gutter-ending edge extends up to its gate, where its
402
+ stub continues straight and hence crosses no other track) */
391
403
  const gutUsers = new Map();
392
404
  edges.forEach((_, i) => {
393
- const { chans, guts } = plans[i];
405
+ const { chans, guts, gate } = plans[i];
394
406
  if (guts.length === 0)
395
407
  return;
396
408
  const sp = portPos.get(`${i}:s`);
409
+ const tp = portPos.get(`${i}:t`);
397
410
  pushTo(gutUsers, guts[0], {
398
411
  edge: i,
399
- posIn: chanX(chans[0], i),
400
- posOut: chanX(chans[1], i),
412
+ posIn: gate === "s" ? sp.x : chanX(chans[0], i),
413
+ posOut: gate === "t" ? tp.x : chanX(chans[1], i),
401
414
  mirror: sp.y > gutBase(guts[0])
402
415
  });
403
416
  });
@@ -442,10 +455,17 @@ const routePolys = (edges, plans, portPos, boxH, cy, chanX, gutY) => {
442
455
  pts = [[sp.x, sp.y], [ch, sp.y], [ch, tp.y], [tp.x, tp.y]];
443
456
  }
444
457
  else {
458
+ /* a gutter-ending edge leaves the gutter straight into its
459
+ gate instead of turning into the gate-side channel */
445
460
  const ch1 = chanX(chans[0], i);
446
461
  const ch2 = chanX(chans[1], i);
447
462
  const gy = gutY(guts[0], i);
448
- pts = [[sp.x, sp.y], [ch1, sp.y], [ch1, gy], [ch2, gy], [ch2, tp.y], [tp.x, tp.y]];
463
+ if (plans[i].gate === "t")
464
+ pts = [[sp.x, sp.y], [ch1, sp.y], [ch1, gy], [tp.x, gy]];
465
+ else if (plans[i].gate === "s")
466
+ pts = [[sp.x, gy], [ch2, gy], [ch2, tp.y], [tp.x, tp.y]];
467
+ else
468
+ pts = [[sp.x, sp.y], [ch1, sp.y], [ch1, gy], [ch2, gy], [ch2, tp.y], [tp.x, tp.y]];
449
469
  }
450
470
  return simplifyPoly(pts);
451
471
  });
@@ -480,7 +500,8 @@ const routeEdges = (edges, plans, sides, boxW, boxH, cx, cy, grid, config, level
480
500
  gutter-routed edges keep approaching it from their gutter side */
481
501
  const nudgeNodes = (nodes, edges, plans, col, row, boxH, grid, nrows, dy, route) => {
482
502
  const gutterFree = (g, c) => !plans.some((plan) => plan.guts[0] === g
483
- && Math.min(...plan.chans) < c && c <= Math.max(...plan.chans))
503
+ && Math.min(...plan.chans) < c
504
+ && c <= Math.max(...plan.chans) + (plan.gate === "t" ? 1 : 0))
484
505
  && !edges.some((edge) => edge.source === edge.target
485
506
  && row.get(edge.source) === g + 1 && col.get(edge.source) === c);
486
507
  const shiftRange = (id) => {
@@ -596,56 +617,90 @@ export const render = async (graph, config, level = {}) => {
596
617
  (all boxes at half height scale, grown by their edge
597
618
  attachment needs once the attachment sides are known) */
598
619
  const { boxW, boxH, contentH } = measureNodes(nodes, config, () => config["size-node-height-scale"] / 2, level.fixedSize);
599
- /* determine raw node positions with the AntV Dagre layout */
600
- const rawX = new Map();
601
- const rawY = new Map();
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
- }
618
- /* snap the raw positions onto the discrete column/row grid */
619
- const { col, row, ncols: gridCols, nrows: gridRows } = snapToGrid(real, rawX, rawY);
620
- /* refine the row assignment by pulling every node vertically
621
- toward the median row of its direct neighbors */
622
- refineRows(real, realEdges, col, row);
623
- /* fold the grid columns: constrain the diagram width to at most
624
- the configured maximum of side-by-side nodes by wrapping excess
625
- columns into additional row bands below, so wide graphs grow in
626
- height */
627
620
  const maxCols = Math.max(Math.floor(config["graph-columns-max"]), 1);
628
- if (gridCols > maxCols) {
629
- for (const node of real) {
630
- const c = col.get(node.id);
631
- col.set(node.id, c % maxCols);
632
- row.set(node.id, row.get(node.id) + Math.floor(c / maxCols) * gridRows);
621
+ const col = new Map();
622
+ const row = new Map();
623
+ let ncols;
624
+ let nrows;
625
+ if (real.some((node) => orderOf(node) !== undefined)) {
626
+ /* an explicitly ordered graph (at least one node carrying an
627
+ "order" attribute) bypasses the layered layout: the distinct
628
+ order values become the rows, top-down in ascending order,
629
+ the nodes of one value fill their row left-to-right in
630
+ declaration order (wrapped into further rows beyond the
631
+ configured maximum of side-by-side nodes), and the nodes
632
+ without an order follow in the trailing rows */
633
+ const orderOrLast = (node) => orderOf(node) ?? Infinity;
634
+ const values = Array.from(new Set(real.map(orderOrLast))).sort((a, b) => a - b);
635
+ ncols = 1;
636
+ nrows = 0;
637
+ for (const value of values) {
638
+ const members = real.filter((node) => orderOrLast(node) === value);
639
+ members.forEach((node, i) => {
640
+ col.set(node.id, i % maxCols);
641
+ row.set(node.id, nrows + Math.floor(i / maxCols));
642
+ });
643
+ ncols = Math.max(ncols, Math.min(members.length, maxCols));
644
+ nrows += Math.ceil(members.length / maxCols);
645
+ }
646
+ }
647
+ else {
648
+ /* determine raw node positions with the AntV Dagre layout */
649
+ const rawX = new Map();
650
+ const rawY = new Map();
651
+ if (real.length > 0) {
652
+ const layout = new DagreLayout({
653
+ rankdir: "LR",
654
+ nodesep: config["graph-node-separation"],
655
+ ranksep: config["graph-rank-separation"],
656
+ nodeSize: (node) => node.size
657
+ });
658
+ await layout.execute({
659
+ nodes: real.map((node) => ({ id: node.id, size: [boxW.get(node.id), boxH.get(node.id)] })),
660
+ edges: realEdges.map((edge, i) => ({ id: `e${i}`, source: edge.source, target: edge.target }))
661
+ });
662
+ layout.forEachNode((node) => {
663
+ rawX.set(String(node.id), node.x);
664
+ rawY.set(String(node.id), node.y);
665
+ });
666
+ }
667
+ /* snap the raw positions onto the discrete column/row grid */
668
+ const snapped = snapToGrid(real, rawX, rawY);
669
+ for (const [id, c] of snapped.col)
670
+ col.set(id, c);
671
+ for (const [id, r] of snapped.row)
672
+ row.set(id, r);
673
+ /* refine the row assignment by pulling every node vertically
674
+ toward the median row of its direct neighbors */
675
+ refineRows(real, realEdges, col, row);
676
+ /* fold the grid columns: constrain the diagram width to at most
677
+ the configured maximum of side-by-side nodes by wrapping excess
678
+ columns into additional row bands below, so wide graphs grow in
679
+ height */
680
+ if (snapped.ncols > maxCols) {
681
+ for (const node of real) {
682
+ const c = col.get(node.id);
683
+ col.set(node.id, c % maxCols);
684
+ row.set(node.id, row.get(node.id) + Math.floor(c / maxCols) * snapped.nrows);
685
+ }
633
686
  }
687
+ /* compact the sparsely occupied grid rows into fewer, denser ones */
688
+ ncols = Math.min(snapped.ncols, maxCols);
689
+ nrows = compactRows(real, realEdges, col, row);
634
690
  }
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));
691
+ /* place the gate nodes into their own boundary columns */
692
+ ({ ncols, nrows } = placeGates(gates, edges, col, row, ncols, nrows));
638
693
  /* determine the attachment sides of every edge */
639
694
  const sides = attachSides(edges, col, level);
640
695
  /* plan the coarse channel/gutter route of every edge (a west side
641
696
  attachment in the first column needs a channel left of it, so
642
697
  the whole grid is shifted one column to the right then) */
643
- let routes = planRoutes(edges, col, row, sides);
698
+ let routes = planRoutes(edges, col, row, sides, gates);
644
699
  if (routes.plans.some((plan) => plan.chans.some((c) => c < 0))) {
645
700
  for (const node of nodes)
646
701
  col.set(node.id, col.get(node.id) + 1);
647
702
  ncols++;
648
- routes = planRoutes(edges, col, row, sides);
703
+ routes = planRoutes(edges, col, row, sides, gates);
649
704
  }
650
705
  const { plans, chanCnt, gutCnt, chanLbl, gutLbl, gutLoop } = routes;
651
706
  /* grow the boxes by their edge attachment needs */
@@ -3,12 +3,15 @@
3
3
  ** Copyright (c) 2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
4
4
  ** Distributed under MIT license <https://spdx.org/licenses/MIT.html>
5
5
  */
6
- import { measureNodes } from "./gradia-api-render-node.js";
6
+ import { measureNodes, orderOf } from "./gradia-api-render-node.js";
7
7
  /* lay out an edge-less graph model as a compact grid of tiles (for a
8
8
  containment level: with the container placeholders at their fixed
9
- sizes, which take part in the tile size unification) */
9
+ sizes, which take part in the tile size unification), the tiles in
10
+ declaration order, or in the order of their "order" attributes
11
+ (the tiles without one trailing) */
10
12
  export const render = async (graph, config, level = {}) => {
11
- const nodes = Array.from(graph.nodes.values());
13
+ const nodes = Array.from(graph.nodes.values())
14
+ .sort((a, b) => (orderOf(a) ?? Infinity) - (orderOf(b) ?? Infinity) || 0);
12
15
  /* resolve the configurable rendering geometry */
13
16
  const margin = config["size-canvas-margin"];
14
17
  const gapH = config["grid-gap-horizontal"];