@rse/gradia 1.2.4 → 1.4.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.
@@ -3,15 +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 { ARITY_OFF, textWidth } from "./gradia-api-render-base.js";
6
7
  import { isPrimary, measureNodes, defaultStyleOf } from "./gradia-api-render-node.js";
7
- import { simplifyPoly, assignPorts, assignTracks } from "./gradia-api-render-edge.js";
8
+ import { simplifyPoly, assignPorts, assignTracks, portGapOf, PORT_PAD } from "./gradia-api-render-edge.js";
8
9
  /* the separator between a node id and its placement suffix,
9
10
  distinguishing the two clones of a twice-placed node and the
10
11
  output clone of a self-referencing primary node */
11
12
  const CLONE = "\u0000";
12
13
  /* rendering geometry constants */
13
- const CHAN_W1 = 28; /* width of a one-edge inter-column channel */
14
- 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 */
15
19
  /* validate the constrained input topology and classify the declared
16
20
  nodes: exactly one node is annotated with "primary: true" and every
17
21
  edge connects the primary with another node. Each other node is
@@ -102,13 +106,16 @@ export const render = async (graph, config, level = {}) => {
102
106
  from the input toward the output column (see classifyTopology),
103
107
  hence leaves on the east and enters on the west side */
104
108
  const sides = edges.map(() => ({ s: "e", t: "w" }));
105
- /* determine node box sizes from their textual content (all boxes at
106
- half height scale), then grow every box whose edge attachments
107
- exceed the configured per-side maximum, step-wise by one port
108
- separation per additional edge, so the edges keep enough
109
- attachment room without a fixed height increase (the fixed-size
110
- boxes of a containment level are exempt) */
109
+ /* determine node box sizes from their textual content (all boxes
110
+ at half height scale), then grow every box up to the height
111
+ hosting all of its edge attachments at the full port separation,
112
+ and the one whose attachments exceed the configured per-side
113
+ maximum additionally step-wise by one separation per additional
114
+ edge, so the ports of a node never pack tighter than configured,
115
+ however small its content height is (the fixed-size boxes of a
116
+ containment level are exempt) */
111
117
  const { boxW, boxH, contentH } = measureNodes(nodes, config, () => scale / 2, level.fixedSize);
118
+ const portGap = portGapOf(edges, config);
112
119
  const portCnt = new Map();
113
120
  edges.forEach((edge, i) => {
114
121
  portCnt.set(`${sides[i].s}:${edge.source}`, (portCnt.get(`${sides[i].s}:${edge.source}`) ?? 0) + 1);
@@ -118,34 +125,82 @@ export const render = async (graph, config, level = {}) => {
118
125
  if (level.fixedSize?.has(node.id))
119
126
  continue;
120
127
  const cnt = Math.max(portCnt.get(`w:${node.id}`) ?? 0, portCnt.get(`e:${node.id}`) ?? 0);
121
- if (cnt > config["hub-node-degree-max"])
122
- boxH.set(node.id, boxH.get(node.id) +
123
- (cnt - config["hub-node-degree-max"]) * config["size-edge-port-gap"]);
128
+ const extra = Math.max(0, cnt - config["hub-node-degree-max"]);
129
+ boxH.set(node.id, Math.max(boxH.get(node.id) + extra * portGap, cnt * portGap + PORT_PAD));
124
130
  }
131
+ /* a stack of more nodes than the configured maximum wraps into two
132
+ staggered sub-columns: its nodes alternate between the outer
133
+ sub-column and the inner one (adjacent to the channel), and
134
+ every outer node is vertically centered onto a gap between two
135
+ inner nodes, through which its edges reach the channel on their
136
+ straight horizontal lanes (so the edge routing is unaffected).
137
+ The configured maximum decides for the larger stack alone, as the
138
+ height of the diagram follows that one: the tallest (sub-)column
139
+ it ends up with implicitly caps the smaller stack, which hence
140
+ wraps exactly if that lowers the height of the diagram */
141
+ const countMax = config["hub-node-count-max"];
142
+ const bySize = inputs.length >= outputs.length ? [inputs, outputs] : [outputs, inputs];
143
+ const wrapped = [countMax > 0 && bySize[0].length > countMax, false];
144
+ const capped = wrapped[0] ? Math.ceil(bySize[0].length / 2) : bySize[0].length;
145
+ wrapped[1] = countMax > 0 && bySize[1].length > capped;
146
+ const outerSet = new Set();
147
+ for (const [c, list] of bySize.entries())
148
+ if (wrapped[c])
149
+ list.filter((_, k) => k % 2 === 0).forEach((node) => outerSet.add(node.id));
150
+ /* the vertical half extent of the lane the edges of an outer node
151
+ occupy around its center: its ports at their maximum separation
152
+ plus the clearance to the inner node boxes (a fixed-size box
153
+ carries its ports at fixed positions anywhere along its side,
154
+ so its lane spans its entire height) */
155
+ const laneOf = (id) => {
156
+ const cnt = Math.max(portCnt.get(`w:${id}`) ?? 0, portCnt.get(`e:${id}`) ?? 0);
157
+ return level.fixedSize?.has(id) ? boxH.get(id) / 2 :
158
+ Math.min(boxH.get(id) / 2, (cnt - 1) * portGap / 2 + LANE_PAD);
159
+ };
125
160
  /* fixed three-column layout: stack the input nodes in the first
126
161
  column and the output nodes in the third column (each stack
127
162
  vertically centered), and place the center node in the second
128
- column at the vertical center of the canvas */
129
- const stackH = (list) => list.reduce((a, node) => a + boxH.get(node.id), 0) + Math.max(list.length - 1, 0) * gap;
130
- const totalH = Math.max(stackH(inputs), boxH.get(center.id), stackH(outputs));
131
- const nodeCY = new Map();
163
+ column at the vertical center of the canvas, where an outer node
164
+ keeps its lane clear of the inner nodes above and below it */
165
+ const stackY = new Map();
132
166
  const stack = (list) => {
133
- let y = margin + (totalH - stackH(list)) / 2;
167
+ let [innerB, outerB, laneB, bottom] = [-Infinity, -Infinity, -Infinity, 0];
134
168
  for (const node of list) {
135
- nodeCY.set(node.id, y + boxH.get(node.id) / 2);
136
- y += boxH.get(node.id) + gap;
169
+ const h = boxH.get(node.id);
170
+ let y = h / 2;
171
+ if (outerSet.has(node.id)) {
172
+ y = Math.max(y, outerB + gap + h / 2, innerB + laneOf(node.id));
173
+ outerB = y + h / 2;
174
+ laneB = y + laneOf(node.id);
175
+ }
176
+ else {
177
+ y = Math.max(y, innerB + gap + h / 2, laneB + h / 2);
178
+ innerB = y + h / 2;
179
+ }
180
+ stackY.set(node.id, y);
181
+ bottom = Math.max(bottom, y + h / 2);
137
182
  }
183
+ return bottom;
138
184
  };
139
- stack(inputs);
140
- stack(outputs);
185
+ const stackH = [stack(inputs), stack(outputs)];
186
+ const totalH = Math.max(stackH[0], boxH.get(center.id), stackH[1]);
187
+ const nodeCY = new Map();
188
+ for (const [c, list] of [inputs, outputs].entries())
189
+ for (const node of list)
190
+ nodeCY.set(node.id, margin + (totalH - stackH[c]) / 2 + stackY.get(node.id));
141
191
  nodeCY.set(center.id, margin + totalH / 2);
142
192
  /* determine column widths and left edge positions, with the two
143
- inter-column channel widths sized by actual edge usage */
144
- const colWidth = [
145
- inputs.reduce((a, node) => Math.max(a, boxW.get(node.id)), 0),
146
- boxW.get(center.id),
147
- outputs.reduce((a, node) => Math.max(a, boxW.get(node.id)), 0)
148
- ];
193
+ inter-column channel widths sized by actual edge usage (a
194
+ wrapped column spans its two sub-columns and their gap, which
195
+ holds the arities set back from the arrow heads at its outer
196
+ nodes, as the lanes between the inner nodes have no room) */
197
+ const subWidth = (list, outer) => list.filter((node) => outerSet.has(node.id) === outer)
198
+ .reduce((a, node) => Math.max(a, boxW.get(node.id)), 0);
199
+ const outerW = [subWidth(inputs, true), subWidth(outputs, true)];
200
+ const wrapGap = edges.reduce((a, edge) => outerSet.has(edge.target) && edge.arity !== undefined ?
201
+ Math.max(a, ARITY_OFF + textWidth(edge.arity, config["size-font-arity"]) + WRAP_PAD) : a, WRAP_GAP);
202
+ const sideW = (list, c) => subWidth(list, false) + (outerW[c] > 0 ? (c === 1 ? wrapGap : WRAP_GAP) + outerW[c] : 0);
203
+ const colWidth = [sideW(inputs, 0), boxW.get(center.id), sideW(outputs, 1)];
149
204
  const chanOf = (edge) => Math.min(colOf(edge.source), colOf(edge.target));
150
205
  const chanCnt = [
151
206
  edges.filter((edge) => chanOf(edge) === 0).length,
@@ -158,20 +213,21 @@ export const render = async (graph, config, level = {}) => {
158
213
  colLX.push(x);
159
214
  x += colWidth[c] + (c < 2 ? chanW[c] : 0);
160
215
  }
161
- /* place the nodes within their column: input nodes right-aligned,
162
- output nodes left-aligned, and the center node centered */
216
+ /* place the nodes within their (sub-)column: input nodes
217
+ right-aligned, output nodes left-aligned, and the center node
218
+ centered */
163
219
  const cx = (id) => {
164
220
  const c = colOf(id);
165
221
  if (c === 0)
166
- return colLX[0] + colWidth[0] - boxW.get(id) / 2;
222
+ return colLX[0] + (outerSet.has(id) ? outerW[0] : colWidth[0]) - boxW.get(id) / 2;
167
223
  else if (c === 2)
168
- return colLX[2] + boxW.get(id) / 2;
224
+ return colLX[2] + (outerSet.has(id) ? colWidth[2] - outerW[1] : 0) + boxW.get(id) / 2;
169
225
  else
170
226
  return colLX[1] + colWidth[1] / 2;
171
227
  };
172
228
  const cy = (id) => nodeCY.get(id);
173
229
  /* distribute the edge attachment ports along each node side */
174
- const portPos = assignPorts(edges, sides, cx, cy, boxW, boxH, config["size-edge-port-gap"], undefined, level.fixedPort);
230
+ const portPos = assignPorts(edges, sides, cx, cy, boxW, boxH, portGap, undefined, level.fixedPort);
175
231
  /* assign the vertical tracks within each channel (see assignTracks
176
232
  for the crossing-avoiding ordering scheme; a hub graph has no backward
177
233
  edges, hence no channel user is ever mirrored) */