@rse/gradia 1.2.3 → 1.3.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.
@@ -26,6 +26,7 @@ const NUDGE_PASSES = 3; /* max number of node nudging passes
26
26
  /* snap raw node positions onto a discrete grid by clustering the
27
27
  distinct X coordinates into columns and Y coordinates into rows */
28
28
  const snapToGrid = (nodes, rawX, rawY) => {
29
+ /* cluster the distinct values into representatives more than a threshold apart */
29
30
  const cluster = (values, threshold) => {
30
31
  const sorted = Array.from(new Set(values)).sort((a, b) => a - b);
31
32
  const reps = [];
@@ -36,6 +37,7 @@ const snapToGrid = (nodes, rawX, rawY) => {
36
37
  };
37
38
  const colReps = cluster(nodes.map((node) => rawX.get(node.id)), CLUSTX);
38
39
  const rowReps = cluster(nodes.map((node) => rawY.get(node.id)), CLUSTY);
40
+ /* determine the index of the representative nearest to a value */
39
41
  const nearestIndexOf = (reps, v) => {
40
42
  let best = 0;
41
43
  for (let i = 1; i < reps.length; i++)
@@ -304,7 +306,7 @@ const planRoutes = (edges, col, row, sides, gates) => {
304
306
  chanNeed(plan.chans[0], textWidth(name, FS_EDGE) + 2 * CHAN_LBL);
305
307
  else if (name !== undefined && plan.guts.length > 0)
306
308
  gutLbl.set(plan.guts[0], (gutLbl.get(plan.guts[0]) ?? 0) + 1);
307
- if (arity !== undefined && chans.length > 0)
309
+ if (arity !== undefined)
308
310
  chanNeed(chans[chans.length - 1], ARITY_OFF + textWidth(arity, FS_ARITY) + CHAN_LBL);
309
311
  });
310
312
  /* count the self-loops of every node, as their detours stack up
@@ -375,8 +377,6 @@ const assignTrackCoords = (edges, plans, portPos, grid, config) => {
375
377
  const chanUsers = new Map();
376
378
  edges.forEach((edge, i) => {
377
379
  const { chans, guts, flags, gate } = plans[i];
378
- if (chans.length === 0)
379
- return;
380
380
  const sp = portPos.get(`${i}:s`);
381
381
  const tp = portPos.get(`${i}:t`);
382
382
  if (edge.source === edge.target)
@@ -433,7 +433,7 @@ const routePolys = (edges, plans, portPos, boxH, cy, chanX, gutY) => {
433
433
  return edges.map((edge, i) => {
434
434
  const sp = portPos.get(`${i}:s`);
435
435
  const tp = portPos.get(`${i}:t`);
436
- const { chans, guts, flags } = plans[i];
436
+ const { chans, guts, flags, gate } = plans[i];
437
437
  let pts;
438
438
  if (edge.source === edge.target) {
439
439
  /* route the self-loop counter-clockwise around the top-right
@@ -460,9 +460,9 @@ const routePolys = (edges, plans, portPos, boxH, cy, chanX, gutY) => {
460
460
  const ch1 = chanX(chans[0], i);
461
461
  const ch2 = chanX(chans[1], i);
462
462
  const gy = gutY(guts[0], i);
463
- if (plans[i].gate === "t")
463
+ if (gate === "t")
464
464
  pts = [[sp.x, sp.y], [ch1, sp.y], [ch1, gy], [tp.x, gy]];
465
- else if (plans[i].gate === "s")
465
+ else if (gate === "s")
466
466
  pts = [[sp.x, gy], [ch2, gy], [ch2, tp.y], [tp.x, tp.y]];
467
467
  else
468
468
  pts = [[sp.x, sp.y], [ch1, sp.y], [ch1, gy], [ch2, gy], [ch2, tp.y], [tp.x, tp.y]];
@@ -499,11 +499,13 @@ const routeEdges = (edges, plans, sides, boxW, boxH, cx, cy, grid, config, level
499
499
  rises above the box of its node), and only as long as its own
500
500
  gutter-routed edges keep approaching it from their gutter side */
501
501
  const nudgeNodes = (nodes, edges, plans, col, row, boxH, grid, nrows, dy, route) => {
502
+ /* whether neither a gutter track nor a self-loop detour crosses a column within a gutter */
502
503
  const gutterFree = (g, c) => !plans.some((plan) => plan.guts[0] === g
503
504
  && Math.min(...plan.chans) < c
504
505
  && c <= Math.max(...plan.chans) + (plan.gate === "t" ? 1 : 0))
505
506
  && !edges.some((edge) => edge.source === edge.target
506
507
  && row.get(edge.source) === g + 1 && col.get(edge.source) === c);
508
+ /* the vertical shift range of a node: its row slack plus the room of its free adjacent gutters */
507
509
  const shiftRange = (id) => {
508
510
  const r = row.get(id);
509
511
  const c = col.get(id);
@@ -512,6 +514,7 @@ const nudgeNodes = (nodes, edges, plans, col, row, boxH, grid, nrows, dy, route)
512
514
  const down = r < nrows - 1 && gutterFree(r, c) ? grid.gutH[r] - GUT_H0 : 0;
513
515
  return [-(slack + up), slack + down];
514
516
  };
517
+ /* whether the gutter-routed edges of a node still approach it from their gutter side */
515
518
  const approachesSanely = (id, routing) => edges.every((edge, i) => {
516
519
  const role = edge.source === id ? "s" : edge.target === id ? "t" : null;
517
520
  if (role === null || plans[i].guts.length === 0)
@@ -520,6 +523,7 @@ const nudgeNodes = (nodes, edges, plans, col, row, boxH, grid, nrows, dy, route)
520
523
  const gap = routing.portPos.get(`${i}:${role}`).y - routing.gutY(g, i);
521
524
  return row.get(id) > g ? gap >= NUDGE_GAP : gap <= -NUDGE_GAP;
522
525
  });
526
+ /* rank the routings: fewer crossings first, then fewer jogged direct same-row edges */
523
527
  const direct = edges.map((edge, i) => plans[i].chans.length === 1
524
528
  && !plans[i].flags[0].hairpin
525
529
  && row.get(edge.source) === row.get(edge.target));
@@ -10,6 +10,7 @@ import { measureNodes, orderOf } from "./gradia-api-render-node.js";
10
10
  declaration order, or in the order of their "order" attributes
11
11
  (the tiles without one trailing) */
12
12
  export const render = async (graph, config, level = {}) => {
13
+ /* determine the tiles in their rendering order */
13
14
  const nodes = Array.from(graph.nodes.values())
14
15
  .sort((a, b) => (orderOf(a) ?? Infinity) - (orderOf(b) ?? Infinity) || 0);
15
16
  /* resolve the configurable rendering geometry */
@@ -39,7 +40,7 @@ export const render = async (graph, config, level = {}) => {
39
40
  if (config["grid-node-width-equal"])
40
41
  boxW.set(node.id, tileW);
41
42
  }
42
- /* place the nodes in declaration order onto a roughly square,
43
+ /* place the nodes in their rendering order onto a roughly square,
43
44
  row-major grid (raised to the configured minimum of columns, so
44
45
  few nodes still share a row, and capped at the configured maximum
45
46
  of columns and the node count, so larger graphs grow only in
@@ -3,14 +3,19 @@
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 { FS_ARITY, ARITY_OFF, textWidth } from "./gradia-api-render-base.js";
6
7
  import { isPrimary, measureNodes, defaultStyleOf } from "./gradia-api-render-node.js";
7
8
  import { simplifyPoly, assignPorts, assignTracks } from "./gradia-api-render-edge.js";
8
9
  /* the separator between a node id and its placement suffix,
9
- distinguishing the two clones of a twice-placed node */
10
+ distinguishing the two clones of a twice-placed node and the
11
+ output clone of a self-referencing primary node */
10
12
  const CLONE = "\u0000";
11
13
  /* rendering geometry constants */
12
- const CHAN_W1 = 28; /* width of a one-edge inter-column channel */
13
- const CHAN_PAD = 16; /* cross-axis padding inside a channel */
14
+ const CHAN_W1 = 28; /* width of a one-edge inter-column channel */
15
+ const CHAN_PAD = 16; /* cross-axis padding inside a channel */
16
+ const WRAP_GAP = 40; /* min horizontal gap between two sub-columns */
17
+ const WRAP_PAD = 8; /* padding of an arity inside that gap */
18
+ const LANE_PAD = 12; /* clearance of an edge lane to a node box */
14
19
  /* validate the constrained input topology and classify the declared
15
20
  nodes: exactly one node is annotated with "primary: true" and every
16
21
  edge connects the primary with another node. Each other node is
@@ -18,8 +23,10 @@ const CHAN_PAD = 16; /* cross-axis padding inside a channel */
18
23
  (edges originate from the primary); a node referenced in both
19
24
  directions is placed twice, once in the input column and once in
20
25
  the output column, with its edges rewritten to attach to the
21
- corresponding placement */
26
+ corresponding placement. A self-loop on the primary is unrolled
27
+ likewise, onto a clone of the primary in the output column */
22
28
  const classifyTopology = (graph) => {
29
+ /* determine the primary node and the ids of its input and output nodes */
23
30
  const declared = Array.from(graph.nodes.values());
24
31
  for (const node of declared)
25
32
  if (node.id.includes(CLONE))
@@ -30,9 +37,10 @@ const classifyTopology = (graph) => {
30
37
  const center = primaries[0];
31
38
  const inSet = new Set();
32
39
  const outSet = new Set();
40
+ let selfLoop = false;
33
41
  for (const edge of graph.edges) {
34
42
  if (edge.source === center.id && edge.target === center.id)
35
- throw new Error(`self-loop on primary node "${center.id}" not supported`);
43
+ selfLoop = true;
36
44
  else if (edge.target === center.id)
37
45
  inSet.add(edge.source);
38
46
  else if (edge.source === center.id)
@@ -61,9 +69,16 @@ const classifyTopology = (graph) => {
61
69
  else
62
70
  outputs.push(node);
63
71
  }
72
+ /* place the clone of a self-referencing primary node (stripped of
73
+ its "primary" annotation) on top of the output column */
74
+ if (selfLoop)
75
+ outputs.unshift({ ...center, id: center.id + CLONE + "self",
76
+ attrs: center.attrs.filter((attr) => attr.key !== "primary") });
64
77
  /* rewrite the edges of the twice-placed nodes onto their clones */
65
78
  const edges = graph.edges.map((edge) => {
66
- if (edge.target === center.id && dual.has(edge.source))
79
+ if (edge.source === center.id && edge.target === center.id)
80
+ return { ...edge, target: edge.target + CLONE + "self" };
81
+ else if (edge.target === center.id && dual.has(edge.source))
67
82
  return { ...edge, source: edge.source + CLONE + "in" };
68
83
  else if (edge.source === center.id && dual.has(edge.target))
69
84
  return { ...edge, target: edge.target + CLONE + "out" };
@@ -111,30 +126,71 @@ export const render = async (graph, config, level = {}) => {
111
126
  boxH.set(node.id, boxH.get(node.id) +
112
127
  (cnt - config["hub-node-degree-max"]) * config["size-edge-port-gap"]);
113
128
  }
129
+ /* a stack of more nodes than the configured maximum wraps into two
130
+ staggered sub-columns: its nodes alternate between the outer
131
+ sub-column and the inner one (adjacent to the channel), and
132
+ every outer node is vertically centered onto a gap between two
133
+ inner nodes, through which its edges reach the channel on their
134
+ straight horizontal lanes (so the edge routing is unaffected) */
135
+ const countMax = config["hub-node-count-max"];
136
+ const outerSet = new Set();
137
+ for (const list of [inputs, outputs])
138
+ if (countMax > 0 && list.length > countMax)
139
+ list.filter((_, k) => k % 2 === 0).forEach((node) => outerSet.add(node.id));
140
+ /* the vertical half extent of the lane the edges of an outer node
141
+ occupy around its center: its ports at their maximum separation
142
+ plus the clearance to the inner node boxes (a fixed-size box
143
+ carries its ports at fixed positions anywhere along its side,
144
+ so its lane spans its entire height) */
145
+ const laneOf = (id) => {
146
+ const cnt = Math.max(portCnt.get(`w:${id}`) ?? 0, portCnt.get(`e:${id}`) ?? 0);
147
+ return level.fixedSize?.has(id) ? boxH.get(id) / 2 :
148
+ Math.min(boxH.get(id) / 2, (cnt - 1) * config["size-edge-port-gap"] / 2 + LANE_PAD);
149
+ };
114
150
  /* fixed three-column layout: stack the input nodes in the first
115
151
  column and the output nodes in the third column (each stack
116
152
  vertically centered), and place the center node in the second
117
- column at the vertical center of the canvas */
118
- const stackH = (list) => list.reduce((a, node) => a + boxH.get(node.id), 0) + Math.max(list.length - 1, 0) * gap;
119
- const totalH = Math.max(stackH(inputs), boxH.get(center.id), stackH(outputs));
120
- const nodeCY = new Map();
153
+ column at the vertical center of the canvas, where an outer node
154
+ keeps its lane clear of the inner nodes above and below it */
155
+ const stackY = new Map();
121
156
  const stack = (list) => {
122
- let y = margin + (totalH - stackH(list)) / 2;
157
+ let [innerB, outerB, laneB, bottom] = [-Infinity, -Infinity, -Infinity, 0];
123
158
  for (const node of list) {
124
- nodeCY.set(node.id, y + boxH.get(node.id) / 2);
125
- y += boxH.get(node.id) + gap;
159
+ const h = boxH.get(node.id);
160
+ let y = h / 2;
161
+ if (outerSet.has(node.id)) {
162
+ y = Math.max(y, outerB + gap + h / 2, innerB + laneOf(node.id));
163
+ outerB = y + h / 2;
164
+ laneB = y + laneOf(node.id);
165
+ }
166
+ else {
167
+ y = Math.max(y, innerB + gap + h / 2, laneB + h / 2);
168
+ innerB = y + h / 2;
169
+ }
170
+ stackY.set(node.id, y);
171
+ bottom = Math.max(bottom, y + h / 2);
126
172
  }
173
+ return bottom;
127
174
  };
128
- stack(inputs);
129
- stack(outputs);
175
+ const stackH = [stack(inputs), stack(outputs)];
176
+ const totalH = Math.max(stackH[0], boxH.get(center.id), stackH[1]);
177
+ const nodeCY = new Map();
178
+ for (const [c, list] of [inputs, outputs].entries())
179
+ for (const node of list)
180
+ nodeCY.set(node.id, margin + (totalH - stackH[c]) / 2 + stackY.get(node.id));
130
181
  nodeCY.set(center.id, margin + totalH / 2);
131
182
  /* determine column widths and left edge positions, with the two
132
- inter-column channel widths sized by actual edge usage */
133
- const colWidth = [
134
- inputs.reduce((a, node) => Math.max(a, boxW.get(node.id)), 0),
135
- boxW.get(center.id),
136
- outputs.reduce((a, node) => Math.max(a, boxW.get(node.id)), 0)
137
- ];
183
+ inter-column channel widths sized by actual edge usage (a
184
+ wrapped column spans its two sub-columns and their gap, which
185
+ holds the arities set back from the arrow heads at its outer
186
+ nodes, as the lanes between the inner nodes have no room) */
187
+ const subWidth = (list, outer) => list.filter((node) => outerSet.has(node.id) === outer)
188
+ .reduce((a, node) => Math.max(a, boxW.get(node.id)), 0);
189
+ const outerW = [subWidth(inputs, true), subWidth(outputs, true)];
190
+ const wrapGap = edges.reduce((a, edge) => outerSet.has(edge.target) && edge.arity !== undefined ?
191
+ Math.max(a, ARITY_OFF + textWidth(edge.arity, FS_ARITY) + WRAP_PAD) : a, WRAP_GAP);
192
+ const sideW = (list, c) => subWidth(list, false) + (outerW[c] > 0 ? (c === 1 ? wrapGap : WRAP_GAP) + outerW[c] : 0);
193
+ const colWidth = [sideW(inputs, 0), boxW.get(center.id), sideW(outputs, 1)];
138
194
  const chanOf = (edge) => Math.min(colOf(edge.source), colOf(edge.target));
139
195
  const chanCnt = [
140
196
  edges.filter((edge) => chanOf(edge) === 0).length,
@@ -147,14 +203,15 @@ export const render = async (graph, config, level = {}) => {
147
203
  colLX.push(x);
148
204
  x += colWidth[c] + (c < 2 ? chanW[c] : 0);
149
205
  }
150
- /* place the nodes within their column: input nodes right-aligned,
151
- output nodes left-aligned, and the center node centered */
206
+ /* place the nodes within their (sub-)column: input nodes
207
+ right-aligned, output nodes left-aligned, and the center node
208
+ centered */
152
209
  const cx = (id) => {
153
210
  const c = colOf(id);
154
211
  if (c === 0)
155
- return colLX[0] + colWidth[0] - boxW.get(id) / 2;
212
+ return colLX[0] + (outerSet.has(id) ? outerW[0] : colWidth[0]) - boxW.get(id) / 2;
156
213
  else if (c === 2)
157
- return colLX[2] + boxW.get(id) / 2;
214
+ return colLX[2] + (outerSet.has(id) ? colWidth[2] - outerW[1] : 0) + boxW.get(id) / 2;
158
215
  else
159
216
  return colLX[1] + colWidth[1] / 2;
160
217
  };
@@ -195,12 +252,19 @@ export const render = async (graph, config, level = {}) => {
195
252
  });
196
253
  /* hand over the laid out graph for SVG rendering (the output copy
197
254
  of a twice-placed node is rendered as a dashed grey "ghost" box,
198
- all other boxes get the default primary/regular coloring) */
255
+ the output clone of a self-referencing primary node as a dashed,
256
+ darker grey "self" box, and all other boxes get the default
257
+ primary/regular coloring) */
199
258
  const styleOf = (node) => node.id.endsWith(CLONE + "out") ? {
200
259
  fill: "color-node-ghost-box",
201
260
  stroke: "color-node-ghost-border",
202
261
  text: "color-node-ghost-name",
203
262
  dash: "10 6"
263
+ } : node.id.endsWith(CLONE + "self") ? {
264
+ fill: "color-node-self-box",
265
+ stroke: "color-node-self-border",
266
+ text: "color-node-self-name",
267
+ dash: "10 6"
204
268
  } : defaultStyleOf(node);
205
269
  return { nodes, edges, cx, cy, boxW, boxH, contentH, polys, styleOf };
206
270
  };
package/dst/gradia-api.js CHANGED
@@ -122,7 +122,7 @@ export class Gradia {
122
122
  static async render(spec, options = {}) {
123
123
  const graph = Gradia.parse(spec);
124
124
  const type = options.type ?? parseTypeDirective(spec);
125
- const config = { ...parseDirectives(spec), ...(options.config ?? {}) };
125
+ const config = { ...parseDirectives(spec), ...validateConfig(options.config ?? {}, { trusted: true }) };
126
126
  return Gradia.generate(graph, { ...options, type, config });
127
127
  }
128
128
  }