@rse/gradia 1.3.0 → 1.4.1

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.
@@ -5,8 +5,8 @@
5
5
  */
6
6
  /* external dependencies */
7
7
  import UUID from "pure-uuid";
8
- import { resolveFont, cssValueOf } from "./gradia-api-config.js";
9
- import { FS_NAME, FS_TYPE, FS_ATTR, FS_EDGE, FS_ARITY, FS_GROUP, ARITY_OFF, textWidth, escapeXML } from "./gradia-api-render-base.js";
8
+ import { resolveFont, cssValueOf, cssSizeOf } from "./gradia-api-config.js";
9
+ import { FS_GROUP, ARITY_OFF, ARITY_PAD, arityHeight, textWidth, escapeXML } from "./gradia-api-render-base.js";
10
10
  import { linesOfNode, urlOf, typeOf, defaultStyleOf, containerHead, MIN_H, NAME_H, ATTR_H, ATTR_P, TYPE_H, TYPE_D, HEAD_H } from "./gradia-api-render-node.js";
11
11
  import { computeHops, pathOf, pointAt } from "./gradia-api-render-edge.js";
12
12
  /* escape a string for use inside a CSS string literal (the XML escaping
@@ -32,13 +32,25 @@ const TAG_DX = 18; /* left offset of the group tag */
32
32
  const TAG_DY = 12; /* top offset of the group tag */
33
33
  /* the half width of the box an edge line segment occupies */
34
34
  const LINE_PAD = 2;
35
+ /* the clearance a label keeps from a foreign edge line, as a label
36
+ merely not overlapping such a line still reads as its annotation */
37
+ const LINE_GAP = 12;
38
+ /* the gap between two arity labels set back one behind the other along
39
+ one and the same edge line, and the number of such setbacks tried */
40
+ const ARITY_STEP = 8;
41
+ const ARITY_BACK = 3;
42
+ /* the maximum number of steps a name label is slid along a single
43
+ segment of its route, as a long segment would otherwise offer far
44
+ more candidate positions than a placement can ever need */
45
+ const SLIDE_MAX = 16;
35
46
  /* track occupied areas (node boxes and already placed labels) to
36
- let subsequent labels dodge into a collision-free position, and the
47
+ let subsequent labels dodge into a collision-free position, plus the
48
+ areas merely reserved for the labels still to be placed, and the
37
49
  edge lines (their segments and crossing hops) to let the labels at
38
50
  least prefer the position covering the fewest lines (the returned
39
51
  "occupied" array grows with every claimed label box and hence also
40
52
  serves as the box input of the overall bounding box) */
41
- const labelPlacer = (layout, hops, hopRadius) => {
53
+ const labelPlacer = (layout, hops, hopRadius, config) => {
42
54
  const { nodes, cx, cy, boxW, boxH } = layout;
43
55
  const occupied = nodes.map((node) => [
44
56
  cx(node.id) - boxW.get(node.id) / 2, cy(node.id) - boxH.get(node.id) / 2,
@@ -50,27 +62,56 @@ const labelPlacer = (layout, hops, hopRadius) => {
50
62
  group.x + TAG_DX + textWidth(group.name, FS_GROUP), group.y + TAG_DY + FS_GROUP * 1.2]);
51
63
  for (const c of layout.containers ?? [])
52
64
  occupied.push([c.x + TAG_DX, c.y + TAG_DY,
53
- c.x + TAG_DX + textWidth(c.node.name, FS_GROUP), c.y + containerHead(c.node)]);
54
- /* collect the edge line segments and the hops bulging above them */
55
- const lines = [];
56
- layout.polys.forEach((poly, i) => {
65
+ c.x + TAG_DX + textWidth(c.node.name, FS_GROUP), c.y + containerHead(c.node, config)]);
66
+ /* collect the edge line segments and the hops bulging above them,
67
+ kept per edge, so a label can tell its own route from the foreign ones */
68
+ const lines = layout.polys.map((poly, i) => {
69
+ const boxes = [];
57
70
  for (let k = 0; k < poly.length - 1; k++) {
58
71
  const [a, b] = [poly[k], poly[k + 1]];
59
- lines.push([Math.min(a[0], b[0]) - LINE_PAD, Math.min(a[1], b[1]) - LINE_PAD,
72
+ boxes.push([Math.min(a[0], b[0]) - LINE_PAD, Math.min(a[1], b[1]) - LINE_PAD,
60
73
  Math.max(a[0], b[0]) + LINE_PAD, Math.max(a[1], b[1]) + LINE_PAD]);
61
74
  for (const hx of hops[i].get(k) ?? [])
62
- lines.push([hx - hopRadius, a[1] - hopRadius, hx + hopRadius, a[1]]);
75
+ boxes.push([hx - hopRadius, a[1] - hopRadius, hx + hopRadius, a[1]]);
63
76
  }
77
+ return boxes;
64
78
  });
79
+ const lineCount = lines.reduce((n, boxes) => n + boxes.length, 0);
65
80
  /* claim the candidate box colliding with the fewest occupied
66
81
  areas and, among those, with the fewest edge lines (earlier
67
- candidates win ties, so the first collision-free one is taken) */
82
+ candidates win ties, so the first collision-free one is taken),
83
+ where the own edge lines count on touch only, as the label sits
84
+ beside its own route by construction, while the foreign ones
85
+ count already within a clearance, so a label hugging a foreign
86
+ line loses against one staying with its own edge. A claim with
87
+ "dodge" unset weighs the lines not at all, as its candidates are
88
+ ordered by a proximity which matters more than a crossed line,
89
+ which the halo of the label keeps readable anyway */
68
90
  const collisions = (boxes, box) => boxes.filter((o) => box[0] < o[2] && box[2] > o[0] && box[1] < o[3] && box[3] > o[1]).length;
69
- const claim = (candidates) => {
91
+ const crossings = (box, edge) => {
92
+ const near = [box[0] - LINE_GAP, box[1] - LINE_GAP, box[2] + LINE_GAP, box[3] + LINE_GAP];
93
+ return lines.reduce((n, boxes, i) => n + collisions(boxes, i === edge ? box : near), 0);
94
+ };
95
+ const reserved = [];
96
+ const reserve = (box) => {
97
+ reserved.push(box);
98
+ };
99
+ /* a claim counts the areas reserved for the labels still to come
100
+ like the occupied ones, so an early label yields the position a
101
+ later one depends upon wherever it has an alternative of its
102
+ own, while the "own" reservation of the claiming label is
103
+ released, as that is the very area it is about to take */
104
+ const claim = (candidates, edge, dodge = true, own) => {
105
+ if (own !== undefined) {
106
+ const k = reserved.indexOf(own);
107
+ if (k >= 0)
108
+ reserved.splice(k, 1);
109
+ }
70
110
  let box = candidates[0];
71
111
  let lowest = Infinity;
72
112
  for (const c of candidates) {
73
- const score = collisions(occupied, c) * (lines.length + 1) + collisions(lines, c);
113
+ const score = (collisions(occupied, c) + collisions(reserved, c)) * (lineCount + 1) +
114
+ (dodge ? crossings(c, edge) : 0);
74
115
  if (score < lowest) {
75
116
  box = c;
76
117
  lowest = score;
@@ -81,7 +122,7 @@ const labelPlacer = (layout, hops, hopRadius) => {
81
122
  occupied.push(box);
82
123
  return box;
83
124
  };
84
- return { claim, occupied };
125
+ return { claim, reserve, occupied };
85
126
  };
86
127
  /* generate the SVG fragments for a single node box (a node with a
87
128
  "url" attribute becomes a hyperlink covering the whole box) */
@@ -101,24 +142,25 @@ const renderNode = (node, layout, style, styler, config) => {
101
142
  the optional type lines shifting the name and attributes down and
102
143
  the additional wrapped name lines shifting the attributes down */
103
144
  const ty = cy(node.id) - contentH.get(node.id) / 2;
104
- const th = lines.type.length * TYPE_H;
105
- const nh = (lines.name.length - 1) * NAME_H;
145
+ const th = lines.type.length * TYPE_H(config);
146
+ const nh = (lines.name.length - 1) * NAME_H(config);
147
+ const asc = config["size-font-node"] * 0.36;
106
148
  const nameY = lines.attrs.length > 0 ?
107
- ty + th + MIN_H / 2 + FS_NAME * 0.36 : cy(node.id) + th / 2 + FS_NAME * 0.36 - nh / 2;
149
+ ty + th + MIN_H(config) / 2 + asc : cy(node.id) + th / 2 + asc - nh / 2;
108
150
  lines.type.forEach((line, k) => {
109
151
  parts.push(`<text x="${cx(node.id)}" ` +
110
- `y="${nameY - TYPE_D - (lines.type.length - 1 - k) * TYPE_H}" ` +
111
- `class="${styler.text(style.text, FS_TYPE, { middle: true })}">${escapeXML(line)}</text>`);
152
+ `y="${nameY - TYPE_D(config) - (lines.type.length - 1 - k) * TYPE_H(config)}" ` +
153
+ `class="${styler.text(style.text, "size-font-type", { middle: true })}">${escapeXML(line)}</text>`);
112
154
  });
113
155
  lines.name.forEach((line, k) => {
114
- parts.push(`<text x="${cx(node.id)}" y="${nameY + k * NAME_H}" ` +
115
- `class="${styler.text(style.text, FS_NAME, { bold: true, middle: true })}">` +
156
+ parts.push(`<text x="${cx(node.id)}" y="${nameY + k * NAME_H(config)}" ` +
157
+ `class="${styler.text(style.text, "size-font-node", { bold: true, middle: true })}">` +
116
158
  `${escapeXML(line)}</text>`);
117
159
  });
118
160
  lines.attrs.forEach((line, k) => {
119
161
  parts.push(`<text x="${cx(node.id)}" ` +
120
- `y="${ty + th + nh + MIN_H + ATTR_P + k * ATTR_H}" ` +
121
- `class="${styler.text(style.text, FS_ATTR, { middle: true })}">${escapeXML(line)}</text>`);
162
+ `y="${ty + th + nh + MIN_H(config) + ATTR_P + k * ATTR_H(config)}" ` +
163
+ `class="${styler.text(style.text, "size-font-prop", { middle: true })}">${escapeXML(line)}</text>`);
122
164
  });
123
165
  if (url === undefined)
124
166
  return parts;
@@ -153,63 +195,100 @@ const viewBoxOf = (layout, boxes, margin) => {
153
195
  h: Math.ceil(maxY - minY + margin)
154
196
  };
155
197
  };
156
- /* generate the SVG fragments for the labels of a single edge: its
157
- optional name, placed near the middle of the route, and its optional
158
- arity, placed near the arrow head (both dodging into a collision-free
159
- position through the "claim" of the label placer) */
160
- const renderEdgeLabels = (edge, poly, claim, styler) => {
161
- const parts = [];
162
- if (edge.name !== undefined) {
163
- const w = textWidth(edge.name, FS_EDGE);
164
- const candidates = [];
165
- for (const f of [0.50, 0.40, 0.60, 0.30, 0.70, 0.20, 0.80]) {
166
- const p = pointAt(poly, f);
167
- if (p.horizontal) {
168
- candidates.push([p.x - w / 2, p.y - 23, p.x + w / 2, p.y - 3]);
169
- candidates.push([p.x - w / 2, p.y + 3, p.x + w / 2, p.y + 23]);
170
- }
171
- else {
172
- candidates.push([p.x + 5, p.y - 10, p.x + 5 + w, p.y + 10]);
173
- candidates.push([p.x - 5 - w, p.y - 10, p.x - 5, p.y + 10]);
174
- }
175
- }
176
- const box = claim(candidates);
177
- parts.push(`<text x="${(box[0] + box[2]) / 2}" y="${box[3] - 3}" ` +
178
- `class="${styler.text("color-edge-name", FS_EDGE, { middle: true, halo: true })}">` +
179
- `${escapeXML(edge.name)}</text>`);
180
- }
181
- if (edge.arity !== undefined) {
182
- const w = textWidth(edge.arity, FS_ARITY);
183
- const p = pointAt(poly, 1.0);
184
- const prev = pointAt(poly, 0.999);
185
- /* set the arity back from the arrow head along the final
186
- segment and place it beside the line, so an edge approaching
187
- vertically keeps its arity next to its own arrow */
188
- let candidates;
198
+ /* the candidate positions of the arity label of an edge: set back
199
+ from the arrow head along the final segment and placed beside the
200
+ line, so an edge approaching vertically keeps its arity next to its
201
+ own arrow, offering both sides at every further setback, which steps
202
+ by the label extent along the segment, as a shorter step would leave
203
+ the label on top of the very label it dodges */
204
+ const arityBoxes = (arity, poly, config) => {
205
+ const w = textWidth(arity, config["size-font-arity"]);
206
+ const h = arityHeight(config);
207
+ const p = pointAt(poly, 1.0);
208
+ const prev = pointAt(poly, 0.999);
209
+ const candidates = [];
210
+ for (let k = 0; k < ARITY_BACK; k++) {
211
+ const back = k * ((p.horizontal ? w : h) + ARITY_STEP);
189
212
  if (p.horizontal) {
190
213
  const dx = Math.sign(p.x - prev.x) || 1;
191
- const ax = p.x - dx * (ARITY_OFF + w / 2);
192
- candidates = [
193
- [ax - w / 2, p.y - 17, ax + w / 2, p.y - 4],
194
- [ax - w / 2, p.y + 4, ax + w / 2, p.y + 17],
195
- [ax - w / 2 - dx * 14, p.y - 17, ax + w / 2 - dx * 14, p.y - 4]
196
- ];
214
+ const ax = p.x - dx * (ARITY_OFF + w / 2 + back);
215
+ candidates.push([ax - w / 2, p.y - ARITY_PAD - h, ax + w / 2, p.y - ARITY_PAD]);
216
+ candidates.push([ax - w / 2, p.y + ARITY_PAD, ax + w / 2, p.y + ARITY_PAD + h]);
197
217
  }
198
218
  else {
199
219
  const dy = Math.sign(p.y - prev.y) || 1;
200
- const ay = p.y - dy * ARITY_OFF;
201
- candidates = [
202
- [p.x + 6, ay - 7, p.x + 6 + w, ay + 6],
203
- [p.x - 6 - w, ay - 7, p.x - 6, ay + 6],
204
- [p.x + 6, ay - 7 - dy * 14, p.x + 6 + w, ay + 6 - dy * 14]
205
- ];
220
+ const ay = p.y - dy * (ARITY_OFF + back);
221
+ candidates.push([p.x + 6, ay + 6 - h, p.x + 6 + w, ay + 6]);
222
+ candidates.push([p.x - 6 - w, ay + 6 - h, p.x - 6, ay + 6]);
223
+ }
224
+ }
225
+ return candidates;
226
+ };
227
+ /* generate the SVG fragment for the arity label of a single edge,
228
+ which claims one of its candidate positions without dodging the
229
+ edge lines, as the neighboring ports of a node run closer than any
230
+ clearance anyway and it is the very setback from its own arrow head
231
+ which attaches it to its edge, so a line crossed beneath its halo
232
+ weighs less than a drift away from that arrow */
233
+ const renderEdgeArity = (edge, candidates, claim, styler) => {
234
+ if (edge.arity === undefined)
235
+ return [];
236
+ const box = claim(candidates, false, candidates[0]);
237
+ return [`<text x="${(box[0] + box[2]) / 2}" y="${box[3] - 3}" ` +
238
+ `class="${styler.text("color-edge-arity", "size-font-arity", { middle: true, halo: true })}">` +
239
+ `${escapeXML(edge.arity)}</text>`];
240
+ };
241
+ /* generate the SVG fragment for the name label of a single edge,
242
+ placed near the middle of the route (dodging into a collision-free
243
+ position through the "claim" of the label placer) */
244
+ const renderEdgeName = (edge, poly, claim, styler, config) => {
245
+ if (edge.name === undefined)
246
+ return [];
247
+ const w = textWidth(edge.name, config["size-font-edge"]);
248
+ const h = config["size-font-edge"] + 4;
249
+ /* offer, along every segment of the own route and on both sides
250
+ of its line, the label centered on the segment plus the two
251
+ positions flush with its ends plus, where the segment takes the
252
+ label at all, the positions sliding between those ends in steps
253
+ of half the label extent, as the free space along a route is
254
+ regularly not where the segment centers are, and order the
255
+ candidates by the distance from the middle of the route, so the
256
+ label ends up as close to that middle as the collisions along
257
+ the way allow */
258
+ const slide = (lo, hi, extent) => {
259
+ const first = lo + extent / 2;
260
+ const last = hi - extent / 2;
261
+ const positions = [(lo + hi) / 2, first, last];
262
+ if (last > first) {
263
+ const steps = Math.min(Math.ceil((last - first) / (extent / 2)), SLIDE_MAX);
264
+ for (let k = 1; k < steps; k++)
265
+ positions.push(first + (last - first) * k / steps);
266
+ }
267
+ return positions;
268
+ };
269
+ const mid = pointAt(poly, 0.50);
270
+ const near = (box) => Math.abs((box[0] + box[2]) / 2 - mid.x) + Math.abs((box[1] + box[3]) / 2 - mid.y);
271
+ const candidates = [];
272
+ for (let k = 0; k < poly.length - 1; k++) {
273
+ const [a, b] = [poly[k], poly[k + 1]];
274
+ if (a[1] === b[1]) {
275
+ for (const x of slide(Math.min(a[0], b[0]), Math.max(a[0], b[0]), w)) {
276
+ candidates.push([x - w / 2, a[1] - 3 - h, x + w / 2, a[1] - 3]);
277
+ candidates.push([x - w / 2, a[1] + 3, x + w / 2, a[1] + 3 + h]);
278
+ }
279
+ }
280
+ else {
281
+ for (const y of slide(Math.min(a[1], b[1]), Math.max(a[1], b[1]), h)) {
282
+ candidates.push([a[0] + 5, y - h / 2, a[0] + 5 + w, y + h / 2]);
283
+ candidates.push([a[0] - 5 - w, y - h / 2, a[0] - 5, y + h / 2]);
284
+ }
206
285
  }
207
- const box = claim(candidates);
208
- parts.push(`<text x="${(box[0] + box[2]) / 2}" y="${box[3] - 3}" ` +
209
- `class="${styler.text("color-edge-arity", FS_ARITY, { middle: true, halo: true })}">` +
210
- `${escapeXML(edge.arity)}</text>`);
211
286
  }
212
- return parts;
287
+ candidates.sort((a, b) => near(a) - near(b));
288
+ const box = claim(candidates);
289
+ return [`<text x="${(box[0] + box[2]) / 2}" y="${box[3] - 3}" ` +
290
+ `class="${styler.text("color-edge-name", "size-font-edge", { middle: true, halo: true })}">` +
291
+ `${escapeXML(edge.name)}</text>`];
213
292
  };
214
293
  /* generate the SVG fragments for a single group box and its tag
215
294
  in the top-left corner */
@@ -223,7 +302,7 @@ const renderGroup = (group, styler) => [
223
302
  in the top-left corner, with the optional type line above the name
224
303
  (a container with a "url" attribute becomes a hyperlink covering
225
304
  the whole box) */
226
- const renderContainer = (c, styler) => {
305
+ const renderContainer = (c, styler, config) => {
227
306
  const type = typeOf(c.node);
228
307
  const url = urlOf(c.node);
229
308
  const parts = [
@@ -231,10 +310,10 @@ const renderContainer = (c, styler) => {
231
310
  `class="${styler.box("color-container-box", "color-container-border", 3)}" ` +
232
311
  "stroke-dasharray=\"10 6\"/>",
233
312
  ...(type !== undefined ? [
234
- `<text x="${c.x + TAG_DX}" y="${c.y + TAG_DY + FS_TYPE}" ` +
235
- `class="${styler.text("color-container-name", FS_TYPE)}">${escapeXML(type)}</text>`
313
+ `<text x="${c.x + TAG_DX}" y="${c.y + TAG_DY + config["size-font-type"]}" ` +
314
+ `class="${styler.text("color-container-name", "size-font-type")}">${escapeXML(type)}</text>`
236
315
  ] : []),
237
- `<text x="${c.x + TAG_DX}" y="${c.y + containerHead(c.node) - HEAD_H + TAG_DY + FS_GROUP}" ` +
316
+ `<text x="${c.x + TAG_DX}" y="${c.y + containerHead(c.node, config) - HEAD_H + TAG_DY + FS_GROUP}" ` +
238
317
  `class="${styler.text("color-container-name", FS_GROUP, { bold: true })}">` +
239
318
  `${escapeXML(c.node.name)}</text>`
240
319
  ];
@@ -275,7 +354,8 @@ export const renderSVG = (layout, config, explicit, seed) => {
275
354
  return name;
276
355
  };
277
356
  const styler = {
278
- text: (fill, size, options = {}) => classOf(`font-family: ${font}; font-size: ${size}px; ` +
357
+ text: (fill, size, options = {}) => classOf(`font-family: ${font}; ` +
358
+ `font-size: ${typeof size === "number" ? `${size}px` : cssSizeOf(config, size)}; ` +
279
359
  (options.bold ? "font-weight: 600; " : "") +
280
360
  (options.middle ? "text-anchor: middle; " : "") +
281
361
  `fill: ${color(fill)}` +
@@ -288,14 +368,30 @@ export const renderSVG = (layout, config, explicit, seed) => {
288
368
  /* detect the edge crossings requiring rendered hops */
289
369
  const hops = computeHops(polys);
290
370
  /* prepare the collision-free placement of the edge labels */
291
- const { claim, occupied } = labelPlacer(layout, hops, config["size-edge-hop-radius"]);
292
- /* generate the SVG fragments for the edges (paths below, labels above) */
293
- const svgEdges = [];
371
+ const { claim, reserve, occupied } = labelPlacer(layout, hops, config["size-edge-hop-radius"], config);
372
+ /* generate the SVG fragments for the edge paths */
373
+ const svgEdges = polys.map((poly, i) => `<path d="${pathOf(poly, hops[i], config["size-edge-corner-radius"], config["size-edge-hop-radius"])}" ` +
374
+ `class="${classEdge}" marker-end="url(#${idArrow})"/>`);
375
+ /* reserve the position every arity label prefers -- the one beside
376
+ its own arrow head -- before any name label is placed, as an
377
+ arity is bound to that arrow while a name may slide along the
378
+ whole route: a name hence yields the reserved area wherever it
379
+ has an alternative, and only a name left without one takes it
380
+ and pushes its arity onto the next setback */
381
+ const arities = edges.map((edge, i) => edge.arity !== undefined ? arityBoxes(edge.arity, polys[i], config) : undefined);
382
+ for (const candidates of arities)
383
+ if (candidates !== undefined)
384
+ reserve(candidates[0]);
385
+ /* generate the SVG fragments for the edge labels (the names
386
+ first, as their reservations keep the arity positions free) */
294
387
  const svgLabels = [];
295
388
  edges.forEach((edge, i) => {
296
- svgEdges.push(`<path d="${pathOf(polys[i], hops[i], config["size-edge-corner-radius"], config["size-edge-hop-radius"])}" ` +
297
- `class="${classEdge}" marker-end="url(#${idArrow})"/>`);
298
- svgLabels.push(...renderEdgeLabels(edge, polys[i], claim, styler));
389
+ svgLabels.push(...renderEdgeName(edge, polys[i], (c, dodge) => claim(c, i, dodge), styler, config));
390
+ });
391
+ edges.forEach((edge, i) => {
392
+ const candidates = arities[i];
393
+ if (candidates !== undefined)
394
+ svgLabels.push(...renderEdgeArity(edge, candidates, (c, dodge, own) => claim(c, i, dodge, own), styler));
299
395
  });
300
396
  /* generate the SVG fragments for the node boxes */
301
397
  const svgNodes = nodes.flatMap((node) => renderNode(node, layout, styleOf(node), styler, config));
@@ -303,7 +399,7 @@ export const renderSVG = (layout, config, explicit, seed) => {
303
399
  everything else) and the container boxes (drawn below the edges
304
400
  and nodes, an outer box before its nested ones) */
305
401
  const svgGroups = groups.flatMap((group) => renderGroup(group, styler));
306
- const svgContainers = containers.flatMap((c) => renderContainer(c, styler));
402
+ const svgContainers = containers.flatMap((c) => renderContainer(c, styler, config));
307
403
  /* determine the overall bounding box of all rendered elements */
308
404
  const groupBoxes = groups.map((group) => [group.x, group.y, group.x + group.w, group.y + group.h]);
309
405
  const containerBoxes = containers.map((c) => [c.x, c.y, c.x + c.w, c.y + c.h]);
@@ -5,9 +5,9 @@
5
5
  */
6
6
  /* external dependencies */
7
7
  import { DagreLayout } from "@antv/layout";
8
- import { FS_EDGE, FS_ARITY, ARITY_OFF, textWidth } from "./gradia-api-render-base.js";
8
+ import { ARITY_OFF, textWidth } from "./gradia-api-render-base.js";
9
9
  import { measureNodes, orderOf } from "./gradia-api-render-node.js";
10
- import { simplifyPoly, assignPorts, assignTracks, computeHops } from "./gradia-api-render-edge.js";
10
+ import { simplifyPoly, assignPorts, assignTracks, computeHops, portGapOf, PORT_PAD } from "./gradia-api-render-edge.js";
11
11
  /* rendering geometry constants */
12
12
  const CLUSTX = 26; /* max X distance within a grid column */
13
13
  const CLUSTY = 72; /* max Y distance within a grid row */
@@ -255,7 +255,7 @@ const trackChans = (plan) => plan.gate === "t" ? [plan.chans[0]] : plan.gate ===
255
255
  its one, and, if those differ, the gutter next to the target row
256
256
  connects them (an edge whose gate end lies beyond that gutter ends
257
257
  in the gutter itself, as the gate merely marks the border passage) */
258
- const planRoutes = (edges, col, row, sides, gates) => {
258
+ const planRoutes = (edges, col, row, sides, gates, config) => {
259
259
  const flag = (from, to) => ({ mirror: from === "r", hairpin: from === to });
260
260
  const plans = edges.map((edge, i) => {
261
261
  /* a self-loop detours as a hairpin through the channel beside
@@ -303,11 +303,11 @@ const planRoutes = (edges, col, row, sides, gates) => {
303
303
  if (source === target)
304
304
  return;
305
305
  if (name !== undefined && plan.chans.length === 1)
306
- chanNeed(plan.chans[0], textWidth(name, FS_EDGE) + 2 * CHAN_LBL);
306
+ chanNeed(plan.chans[0], textWidth(name, config["size-font-edge"]) + 2 * CHAN_LBL);
307
307
  else if (name !== undefined && plan.guts.length > 0)
308
308
  gutLbl.set(plan.guts[0], (gutLbl.get(plan.guts[0]) ?? 0) + 1);
309
309
  if (arity !== undefined)
310
- chanNeed(chans[chans.length - 1], ARITY_OFF + textWidth(arity, FS_ARITY) + CHAN_LBL);
310
+ chanNeed(chans[chans.length - 1], ARITY_OFF + textWidth(arity, config["size-font-arity"]) + CHAN_LBL);
311
311
  });
312
312
  /* count the self-loops of every node, as their detours stack up
313
313
  above its box and have to fit into the gutter above its row
@@ -481,9 +481,10 @@ const routePolys = (edges, plans, portPos, boxH, cy, chanX, gutY) => {
481
481
  orthogonal polyline through the channels between the columns and
482
482
  the gutters between the rows */
483
483
  const routeEdges = (edges, plans, sides, boxW, boxH, cx, cy, grid, config, level) => {
484
- const portPre = assignPorts(edges, sides, cx, cy, boxW, boxH, config["size-edge-port-gap"], undefined, level.fixedPort);
484
+ const portGap = portGapOf(edges, config);
485
+ const portPre = assignPorts(edges, sides, cx, cy, boxW, boxH, portGap, undefined, level.fixedPort);
485
486
  const trackPre = assignTrackCoords(edges, plans, portPre, grid, config);
486
- const portPos = assignPorts(edges, sides, cx, cy, boxW, boxH, config["size-edge-port-gap"], (edge) => plans[edge].guts.length > 0 ?
487
+ const portPos = assignPorts(edges, sides, cx, cy, boxW, boxH, portGap, (edge) => plans[edge].guts.length > 0 ?
487
488
  trackPre.gutY(plans[edge].guts[0], edge) : undefined, level.fixedPort);
488
489
  const { chanX, gutY } = assignTrackCoords(edges, plans, portPos, grid, config);
489
490
  const polys = routePolys(edges, plans, portPos, boxH, cy, chanX, gutY);
@@ -586,12 +587,14 @@ const attachSides = (edges, col, level) => edges.map((edge, i) => {
586
587
  const t = level.fixedPort?.(i, "t") !== undefined ? "w" : sc < tc ? "w" : "e";
587
588
  return { s, t };
588
589
  });
589
- /* grow every box whose edge attachments exceed the configured
590
- per-side maximum, step-wise by one port separation per
591
- additional edge, so the edges keep enough attachment room
592
- without a fixed height increase (the fixed-size boxes of a
593
- containment level are exempt) */
590
+ /* grow every box up to the height hosting all of its edge
591
+ attachments at the full port separation, and the one whose
592
+ attachments exceed the configured per-side maximum additionally
593
+ step-wise by one separation per additional edge, so the ports of a
594
+ node never pack tighter than configured, however small its content
595
+ height is (the fixed-size boxes of a containment level are exempt) */
594
596
  const growBoxes = (nodes, edges, sides, boxH, config, level) => {
597
+ const portGap = portGapOf(edges, config);
595
598
  const portCnt = new Map();
596
599
  edges.forEach((edge, i) => {
597
600
  portCnt.set(`${sides[i].s}:${edge.source}`, (portCnt.get(`${sides[i].s}:${edge.source}`) ?? 0) + 1);
@@ -601,9 +604,8 @@ const growBoxes = (nodes, edges, sides, boxH, config, level) => {
601
604
  if (level.fixedSize?.has(node.id))
602
605
  continue;
603
606
  const cnt = Math.max(portCnt.get(`w:${node.id}`) ?? 0, portCnt.get(`e:${node.id}`) ?? 0);
604
- if (cnt > config["graph-node-degree-max"])
605
- boxH.set(node.id, boxH.get(node.id) +
606
- (cnt - config["graph-node-degree-max"]) * config["size-edge-port-gap"]);
607
+ const extra = Math.max(0, cnt - config["graph-node-degree-max"]);
608
+ boxH.set(node.id, Math.max(boxH.get(node.id) + extra * portGap, cnt * portGap + PORT_PAD));
607
609
  }
608
610
  };
609
611
  /* lay out a directed graph model (for a containment level: with the
@@ -699,12 +701,12 @@ export const render = async (graph, config, level = {}) => {
699
701
  /* plan the coarse channel/gutter route of every edge (a west side
700
702
  attachment in the first column needs a channel left of it, so
701
703
  the whole grid is shifted one column to the right then) */
702
- let routes = planRoutes(edges, col, row, sides, gates);
704
+ let routes = planRoutes(edges, col, row, sides, gates, config);
703
705
  if (routes.plans.some((plan) => plan.chans.some((c) => c < 0))) {
704
706
  for (const node of nodes)
705
707
  col.set(node.id, col.get(node.id) + 1);
706
708
  ncols++;
707
- routes = planRoutes(edges, col, row, sides, gates);
709
+ routes = planRoutes(edges, col, row, sides, gates, config);
708
710
  }
709
711
  const { plans, chanCnt, gutCnt, chanLbl, gutLbl, gutLoop } = routes;
710
712
  /* grow the boxes by their edge attachment needs */
@@ -3,9 +3,9 @@
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
+ import { ARITY_OFF, textWidth } from "./gradia-api-render-base.js";
7
7
  import { isPrimary, measureNodes, defaultStyleOf } from "./gradia-api-render-node.js";
8
- import { simplifyPoly, assignPorts, assignTracks } from "./gradia-api-render-edge.js";
8
+ import { simplifyPoly, assignPorts, assignTracks, portGapOf, PORT_PAD } from "./gradia-api-render-edge.js";
9
9
  /* the separator between a node id and its placement suffix,
10
10
  distinguishing the two clones of a twice-placed node and the
11
11
  output clone of a self-referencing primary node */
@@ -106,13 +106,16 @@ export const render = async (graph, config, level = {}) => {
106
106
  from the input toward the output column (see classifyTopology),
107
107
  hence leaves on the east and enters on the west side */
108
108
  const sides = edges.map(() => ({ s: "e", t: "w" }));
109
- /* determine node box sizes from their textual content (all boxes at
110
- half height scale), then grow every box whose edge attachments
111
- exceed the configured per-side maximum, step-wise by one port
112
- separation per additional edge, so the edges keep enough
113
- attachment room without a fixed height increase (the fixed-size
114
- 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) */
115
117
  const { boxW, boxH, contentH } = measureNodes(nodes, config, () => scale / 2, level.fixedSize);
118
+ const portGap = portGapOf(edges, config);
116
119
  const portCnt = new Map();
117
120
  edges.forEach((edge, i) => {
118
121
  portCnt.set(`${sides[i].s}:${edge.source}`, (portCnt.get(`${sides[i].s}:${edge.source}`) ?? 0) + 1);
@@ -122,20 +125,27 @@ export const render = async (graph, config, level = {}) => {
122
125
  if (level.fixedSize?.has(node.id))
123
126
  continue;
124
127
  const cnt = Math.max(portCnt.get(`w:${node.id}`) ?? 0, portCnt.get(`e:${node.id}`) ?? 0);
125
- if (cnt > config["hub-node-degree-max"])
126
- boxH.set(node.id, boxH.get(node.id) +
127
- (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));
128
130
  }
129
131
  /* a stack of more nodes than the configured maximum wraps into two
130
132
  staggered sub-columns: its nodes alternate between the outer
131
133
  sub-column and the inner one (adjacent to the channel), and
132
134
  every outer node is vertically centered onto a gap between two
133
135
  inner nodes, through which its edges reach the channel on their
134
- straight horizontal lanes (so the edge routing is unaffected) */
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 */
135
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;
136
146
  const outerSet = new Set();
137
- for (const list of [inputs, outputs])
138
- if (countMax > 0 && list.length > countMax)
147
+ for (const [c, list] of bySize.entries())
148
+ if (wrapped[c])
139
149
  list.filter((_, k) => k % 2 === 0).forEach((node) => outerSet.add(node.id));
140
150
  /* the vertical half extent of the lane the edges of an outer node
141
151
  occupy around its center: its ports at their maximum separation
@@ -145,7 +155,7 @@ export const render = async (graph, config, level = {}) => {
145
155
  const laneOf = (id) => {
146
156
  const cnt = Math.max(portCnt.get(`w:${id}`) ?? 0, portCnt.get(`e:${id}`) ?? 0);
147
157
  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);
158
+ Math.min(boxH.get(id) / 2, (cnt - 1) * portGap / 2 + LANE_PAD);
149
159
  };
150
160
  /* fixed three-column layout: stack the input nodes in the first
151
161
  column and the output nodes in the third column (each stack
@@ -188,7 +198,7 @@ export const render = async (graph, config, level = {}) => {
188
198
  .reduce((a, node) => Math.max(a, boxW.get(node.id)), 0);
189
199
  const outerW = [subWidth(inputs, true), subWidth(outputs, true)];
190
200
  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);
201
+ Math.max(a, ARITY_OFF + textWidth(edge.arity, config["size-font-arity"]) + WRAP_PAD) : a, WRAP_GAP);
192
202
  const sideW = (list, c) => subWidth(list, false) + (outerW[c] > 0 ? (c === 1 ? wrapGap : WRAP_GAP) + outerW[c] : 0);
193
203
  const colWidth = [sideW(inputs, 0), boxW.get(center.id), sideW(outputs, 1)];
194
204
  const chanOf = (edge) => Math.min(colOf(edge.source), colOf(edge.target));
@@ -217,7 +227,7 @@ export const render = async (graph, config, level = {}) => {
217
227
  };
218
228
  const cy = (id) => nodeCY.get(id);
219
229
  /* distribute the edge attachment ports along each node side */
220
- 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);
221
231
  /* assign the vertical tracks within each channel (see assignTracks
222
232
  for the crossing-avoiding ordering scheme; a hub graph has no backward
223
233
  edges, hence no channel user is ever mirrored) */