@rse/gradia 1.2.4 → 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.
package/README.md CHANGED
@@ -327,13 +327,13 @@ color-group-border #c0d0e0 hub-channel-width-max 340
327
327
  color-container-name #666666 hub-channel-width-min 240
328
328
  color-container-box #f6f6f6 hub-node-gap 20
329
329
  color-container-border #a0a0a0 hub-node-degree-max 3
330
- color-edge-line #999999 grid-columns-max 4
331
- color-edge-name #333333 grid-columns-min 3
332
- color-edge-arity #333333 grid-gap-horizontal 40
333
- color-edge-halo #ffffff grid-gap-vertical 20
334
- size-canvas-margin 40 grid-node-width-equal true
335
- size-node-width-min 220 grid-node-height-equal true
336
- size-node-width-max 0
330
+ color-edge-line #999999 hub-node-count-max 0
331
+ color-edge-name #333333 grid-columns-max 4
332
+ color-edge-arity #333333 grid-columns-min 3
333
+ color-edge-halo #ffffff grid-gap-horizontal 40
334
+ size-canvas-margin 40 grid-gap-vertical 20
335
+ size-node-width-min 220 grid-node-width-equal true
336
+ size-node-width-max 0 grid-node-height-equal true
337
337
  ```
338
338
 
339
339
  The `size-*`, `group-*`, `container-*`, `graph-*`, `hub-*`, and
@@ -376,6 +376,15 @@ unlabeled diagram can be spaced out, while the `-max` options cap it, so
376
376
  that neither many parallel edges nor a long label can push the nodes
377
377
  apart without bound.
378
378
 
379
+ The `hub-node-count-max` option controls the wrapping of the input and
380
+ output column of a `hub` diagram: given a positive value, a column of
381
+ more nodes than this wraps into two staggered sub-columns, whose nodes
382
+ alternate between the outer sub-column and the inner one, with every
383
+ outer node vertically centered onto a gap between two inner nodes,
384
+ through which its edges reach the primary node. This trades nearly
385
+ half of the height of a large diagram for additional width. The value
386
+ `0` disables the wrapping entirely.
387
+
379
388
  The `grid-columns-min` and `grid-columns-max` options control the column
380
389
  count of a `grid` diagram, which by default is derived from the node
381
390
  count as a roughly square grid: `grid-columns-min` raises the derived
@@ -404,6 +413,15 @@ hard-coded into the SVG instead. The resulting precedence is: first
404
413
  `#config`/`--config`, then the CSS custom property `--gradia-<option>`,
405
414
  and finally the built-in default.
406
415
 
416
+ The styling is not repeated on every element of the generated SVG, but
417
+ declared once as CSS classes in a `<style>` element, which the elements
418
+ reference. A class is named `gradia-<digest>` after the digest of its
419
+ own declarations, so equal declarations always yield the very same
420
+ class. As the class names are global once multiple diagrams are embedded
421
+ into the very same document, the embedding document can hence strip the
422
+ `<style>` elements off the diagrams and declare the union of their
423
+ rules (one per line) a single time on its own.
424
+
407
425
  The `font-family` option is either a built-in font family, a plain font
408
426
  family name, or the path to a WOFF2 file. The only built-in font family
409
427
  is `Source Sans 3`, shipped as a variable WOFF2 file by the NPM
@@ -46,6 +46,7 @@ export declare const configDefaults: {
46
46
  "hub-channel-width-min": number;
47
47
  "hub-node-gap": number;
48
48
  "hub-node-degree-max": number;
49
+ "hub-node-count-max": number;
49
50
  "grid-columns-max": number;
50
51
  "grid-columns-min": number;
51
52
  "grid-gap-horizontal": number;
@@ -56,6 +56,7 @@ export const configDefaults = {
56
56
  "hub-channel-width-min": 240, /* hub: min width of column channels */
57
57
  "hub-node-gap": 20, /* hub: vertical gap of stacked nodes */
58
58
  "hub-node-degree-max": 3, /* hub: max edges/side before growth */
59
+ "hub-node-count-max": 0, /* hub: max nodes/column before wrapping */
59
60
  "grid-columns-max": 4, /* grid: max side-by-side tiles */
60
61
  "grid-columns-min": 3, /* grid: min side-by-side tiles */
61
62
  "grid-gap-horizontal": 40, /* grid: horizontal gap between tiles */
@@ -20,11 +20,13 @@ const escapeCSS = (text) => text.replace(/[\\"']/g, "\\$&").replace(/[\r\n\f]/g,
20
20
  const NS_GRADIA = new UUID(5, "ns:URL", "https://github.com/rse/gradia").format();
21
21
  /* derive a per-document identifier prefix from a seed, as the SVG
22
22
  identifiers are DOM-global and would collide once multiple diagrams
23
- are embedded into the very same document. The prefix is a UUID v5 of
24
- the seed and hence stays stable across regenerations of an unchanged
25
- diagram (the Base16 format keeps it alphanumeric and thus a valid XML
26
- name and CSS/URL fragment) */
27
- const idPrefix = (seed) => `gradia-${new UUID(5, NS_GRADIA, seed).format("b16").toLowerCase()}`;
23
+ are embedded into the very same document. The prefix is derived from
24
+ the UUID v5 of the seed and hence stays stable across regenerations
25
+ of an unchanged diagram (the Base16 format keeps it alphanumeric and
26
+ thus a valid XML name and CSS/URL fragment, and its leading 12 digits
27
+ keep the identifiers short, as every edge references one, while
28
+ still making a collision among the diagrams of a document unlikely) */
29
+ const idPrefix = (seed) => `gradia-${new UUID(5, NS_GRADIA, seed).format("b16").toLowerCase().slice(0, 12)}`;
28
30
  /* the placement of a group tag in the top-left corner of its group box */
29
31
  const TAG_DX = 18; /* left offset of the group tag */
30
32
  const TAG_DY = 12; /* top offset of the group tag */
@@ -83,7 +85,7 @@ const labelPlacer = (layout, hops, hopRadius) => {
83
85
  };
84
86
  /* generate the SVG fragments for a single node box (a node with a
85
87
  "url" attribute becomes a hyperlink covering the whole box) */
86
- const renderNode = (node, layout, style, font, color, config) => {
88
+ const renderNode = (node, layout, style, styler, config) => {
87
89
  const { cx, cy, boxW, boxH, contentH } = layout;
88
90
  const w = boxW.get(node.id);
89
91
  const h = boxH.get(node.id);
@@ -93,7 +95,7 @@ const renderNode = (node, layout, style, font, color, config) => {
93
95
  const url = urlOf(node);
94
96
  const parts = [];
95
97
  parts.push(`<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="6" ` +
96
- `style="fill: ${color(style.fill)}; stroke: ${color(style.stroke)}" stroke-width="4.0"` +
98
+ `class="${styler.box(style.fill, style.stroke, 4)}"` +
97
99
  `${style.dash !== undefined ? ` stroke-dasharray="${escapeXML(style.dash)}"` : ""}/>`);
98
100
  /* vertically center the textual content block within the box, with
99
101
  the optional type lines shifting the name and attributes down and
@@ -105,20 +107,18 @@ const renderNode = (node, layout, style, font, color, config) => {
105
107
  ty + th + MIN_H / 2 + FS_NAME * 0.36 : cy(node.id) + th / 2 + FS_NAME * 0.36 - nh / 2;
106
108
  lines.type.forEach((line, k) => {
107
109
  parts.push(`<text x="${cx(node.id)}" ` +
108
- `y="${nameY - TYPE_D - (lines.type.length - 1 - k) * TYPE_H}" text-anchor="middle" ` +
109
- `font-size="${FS_TYPE}" ` +
110
- `style="font-family: ${font}; fill: ${color(style.text)}">${escapeXML(line)}</text>`);
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>`);
111
112
  });
112
113
  lines.name.forEach((line, k) => {
113
- parts.push(`<text x="${cx(node.id)}" y="${nameY + k * NAME_H}" text-anchor="middle" ` +
114
- `font-size="${FS_NAME}" font-weight="600" ` +
115
- `style="font-family: ${font}; fill: ${color(style.text)}">${escapeXML(line)}</text>`);
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 })}">` +
116
+ `${escapeXML(line)}</text>`);
116
117
  });
117
118
  lines.attrs.forEach((line, k) => {
118
119
  parts.push(`<text x="${cx(node.id)}" ` +
119
- `y="${ty + th + nh + MIN_H + ATTR_P + k * ATTR_H}" text-anchor="middle" ` +
120
- `font-size="${FS_ATTR}" ` +
121
- `style="font-family: ${font}; fill: ${color(style.text)}">${escapeXML(line)}</text>`);
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>`);
122
122
  });
123
123
  if (url === undefined)
124
124
  return parts;
@@ -157,11 +157,7 @@ const viewBoxOf = (layout, boxes, margin) => {
157
157
  optional name, placed near the middle of the route, and its optional
158
158
  arity, placed near the arrow head (both dodging into a collision-free
159
159
  position through the "claim" of the label placer) */
160
- const renderEdgeLabels = (edge, poly, claim, font, color) => {
161
- /* the halo rendered behind the edge labels for readability
162
- (its color is emitted into the style attribute below, as only
163
- there the CSS custom property lookup can be resolved) */
164
- const halo = "stroke-width=\"4.5\" paint-order=\"stroke\" stroke-linejoin=\"round\"";
160
+ const renderEdgeLabels = (edge, poly, claim, styler) => {
165
161
  const parts = [];
166
162
  if (edge.name !== undefined) {
167
163
  const w = textWidth(edge.name, FS_EDGE);
@@ -178,11 +174,9 @@ const renderEdgeLabels = (edge, poly, claim, font, color) => {
178
174
  }
179
175
  }
180
176
  const box = claim(candidates);
181
- parts.push(`<text x="${(box[0] + box[2]) / 2}" y="${box[3] - 3}" text-anchor="middle" ` +
182
- `font-size="${FS_EDGE}" ` +
183
- `style="font-family: ${font}; fill: ${color("color-edge-name")}; ` +
184
- `stroke: ${color("color-edge-halo")}" ` +
185
- `${halo}>${escapeXML(edge.name)}</text>`);
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>`);
186
180
  }
187
181
  if (edge.arity !== undefined) {
188
182
  const w = textWidth(edge.arity, FS_ARITY);
@@ -211,43 +205,38 @@ const renderEdgeLabels = (edge, poly, claim, font, color) => {
211
205
  ];
212
206
  }
213
207
  const box = claim(candidates);
214
- parts.push(`<text x="${(box[0] + box[2]) / 2}" y="${box[3] - 3}" text-anchor="middle" ` +
215
- `font-size="${FS_ARITY}" ` +
216
- `style="font-family: ${font}; fill: ${color("color-edge-arity")}; ` +
217
- `stroke: ${color("color-edge-halo")}" ` +
218
- `${halo}>${escapeXML(edge.arity)}</text>`);
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>`);
219
211
  }
220
212
  return parts;
221
213
  };
222
214
  /* generate the SVG fragments for a single group box and its tag
223
215
  in the top-left corner */
224
- const renderGroup = (group, font, color) => [
216
+ const renderGroup = (group, styler) => [
225
217
  `<rect x="${group.x}" y="${group.y}" width="${group.w}" height="${group.h}" rx="12" ` +
226
- `style="fill: ${color("color-group-box")}; ` +
227
- `stroke: ${color("color-group-border")}" stroke-width="3.0"/>`,
218
+ `class="${styler.box("color-group-box", "color-group-border", 3)}"/>`,
228
219
  `<text x="${group.x + TAG_DX}" y="${group.y + TAG_DY + FS_GROUP}" ` +
229
- `font-size="${FS_GROUP}" font-weight="600" ` +
230
- `style="font-family: ${font}; fill: ${color("color-group-name")}">${escapeXML(group.name)}</text>`
220
+ `class="${styler.text("color-group-name", FS_GROUP, { bold: true })}">${escapeXML(group.name)}</text>`
231
221
  ];
232
222
  /* generate the SVG fragments for a single container box and its tag
233
223
  in the top-left corner, with the optional type line above the name
234
224
  (a container with a "url" attribute becomes a hyperlink covering
235
225
  the whole box) */
236
- const renderContainer = (c, font, color) => {
226
+ const renderContainer = (c, styler) => {
237
227
  const type = typeOf(c.node);
238
228
  const url = urlOf(c.node);
239
229
  const parts = [
240
230
  `<rect x="${c.x}" y="${c.y}" width="${c.w}" height="${c.h}" rx="12" ` +
241
- `style="fill: ${color("color-container-box")}; ` +
242
- `stroke: ${color("color-container-border")}" stroke-width="3.0" stroke-dasharray="10 6"/>`,
231
+ `class="${styler.box("color-container-box", "color-container-border", 3)}" ` +
232
+ "stroke-dasharray=\"10 6\"/>",
243
233
  ...(type !== undefined ? [
244
234
  `<text x="${c.x + TAG_DX}" y="${c.y + TAG_DY + FS_TYPE}" ` +
245
- `font-size="${FS_TYPE}" ` +
246
- `style="font-family: ${font}; fill: ${color("color-container-name")}">${escapeXML(type)}</text>`
235
+ `class="${styler.text("color-container-name", FS_TYPE)}">${escapeXML(type)}</text>`
247
236
  ] : []),
248
237
  `<text x="${c.x + TAG_DX}" y="${c.y + containerHead(c.node) - HEAD_H + TAG_DY + FS_GROUP}" ` +
249
- `font-size="${FS_GROUP}" font-weight="600" ` +
250
- `style="font-family: ${font}; fill: ${color("color-container-name")}">${escapeXML(c.node.name)}</text>`
238
+ `class="${styler.text("color-container-name", FS_GROUP, { bold: true })}">` +
239
+ `${escapeXML(c.node.name)}</text>`
251
240
  ];
252
241
  if (url === undefined)
253
242
  return parts;
@@ -264,7 +253,7 @@ export const renderSVG = (layout, config, explicit, seed) => {
264
253
  values: explicitly configured values are hard-coded, while all
265
254
  others are fetched at display time from the "--gradia-<option>"
266
255
  CSS custom properties, falling back to the built-in defaults */
267
- const color = (key) => escapeXML(cssValueOf(explicit, key));
256
+ const color = (key) => cssValueOf(explicit, key);
268
257
  /* derive the collision-free identifiers of this SVG document */
269
258
  const prefix = idPrefix(seed);
270
259
  const idArrow = `${prefix}-arrow`;
@@ -273,7 +262,29 @@ export const renderSVG = (layout, config, explicit, seed) => {
273
262
  const stack = Object.hasOwn(explicit, "font-family") ?
274
263
  `'${escapeCSS(family)}'` :
275
264
  `var(--gradia-font-family, '${escapeCSS(family)}')`;
276
- const font = escapeXML(`${stack}, ui-sans-serif, -apple-system, Helvetica, Arial, sans-serif`);
265
+ const font = `${stack}, ui-sans-serif, -apple-system, Helvetica, Arial, sans-serif`;
266
+ /* collect the CSS classes the rendered elements reference (the
267
+ halo behind an edge label keeps it readable on top of a line) */
268
+ const rules = new Map();
269
+ const classOf = (decls) => {
270
+ let name = rules.get(decls);
271
+ if (name === undefined) {
272
+ name = `gradia-${new UUID(5, NS_GRADIA, decls).format("b16").toLowerCase().slice(0, 8)}`;
273
+ rules.set(decls, name);
274
+ }
275
+ return name;
276
+ };
277
+ const styler = {
278
+ text: (fill, size, options = {}) => classOf(`font-family: ${font}; font-size: ${size}px; ` +
279
+ (options.bold ? "font-weight: 600; " : "") +
280
+ (options.middle ? "text-anchor: middle; " : "") +
281
+ `fill: ${color(fill)}` +
282
+ (options.halo ? `; stroke: ${color("color-edge-halo")}; stroke-width: 4.5; ` +
283
+ "paint-order: stroke; stroke-linejoin: round" : "")),
284
+ box: (fill, stroke, width) => classOf(`fill: ${color(fill)}; stroke: ${color(stroke)}; stroke-width: ${width}`)
285
+ };
286
+ const classEdge = classOf(`fill: none; stroke: ${color("color-edge-line")}; stroke-width: 3`);
287
+ const classArrow = classOf(`fill: ${color("color-edge-line")}`);
277
288
  /* detect the edge crossings requiring rendered hops */
278
289
  const hops = computeHops(polys);
279
290
  /* prepare the collision-free placement of the edge labels */
@@ -282,38 +293,39 @@ export const renderSVG = (layout, config, explicit, seed) => {
282
293
  const svgEdges = [];
283
294
  const svgLabels = [];
284
295
  edges.forEach((edge, i) => {
285
- svgEdges.push(`<path d="${pathOf(polys[i], hops[i], config["size-edge-corner-radius"], config["size-edge-hop-radius"])}" fill="none" ` +
286
- `style="stroke: ${color("color-edge-line")}" stroke-width="3.0" marker-end="url(#${idArrow})"/>`);
287
- svgLabels.push(...renderEdgeLabels(edge, polys[i], claim, font, color));
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));
288
299
  });
289
300
  /* generate the SVG fragments for the node boxes */
290
- const svgNodes = nodes.flatMap((node) => renderNode(node, layout, styleOf(node), font, color, config));
301
+ const svgNodes = nodes.flatMap((node) => renderNode(node, layout, styleOf(node), styler, config));
291
302
  /* generate the SVG fragments for the group boxes (drawn below
292
303
  everything else) and the container boxes (drawn below the edges
293
304
  and nodes, an outer box before its nested ones) */
294
- const svgGroups = groups.flatMap((group) => renderGroup(group, font, color));
295
- const svgContainers = containers.flatMap((c) => renderContainer(c, font, color));
305
+ const svgGroups = groups.flatMap((group) => renderGroup(group, styler));
306
+ const svgContainers = containers.flatMap((c) => renderContainer(c, styler));
296
307
  /* determine the overall bounding box of all rendered elements */
297
308
  const groupBoxes = groups.map((group) => [group.x, group.y, group.x + group.w, group.y + group.h]);
298
309
  const containerBoxes = containers.map((c) => [c.x, c.y, c.x + c.w, c.y + c.h]);
299
310
  const vb = viewBoxOf(layout, [...occupied, ...groupBoxes, ...containerBoxes], config["size-canvas-margin"]);
300
- /* assemble the final SVG document */
311
+ /* assemble the final SVG document (with one CSS rule per line) */
301
312
  return [
302
313
  "<?xml version=\"1.0\" encoding=\"UTF-8\"?>",
303
314
  "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" " +
304
315
  `viewBox="${vb.x} ${vb.y} ${vb.w} ${vb.h}" width="${vb.w}" height="${vb.h}">`,
305
316
  "<defs>",
317
+ "<style>",
306
318
  ...(embed !== undefined ? [
307
- "<style>",
308
319
  `@font-face { font-family: "${escapeXML(escapeCSS(family))}"; ` +
309
320
  (weight !== undefined ? `font-weight: ${escapeXML(escapeCSS(weight))}; ` : "") +
310
- `src: url(data:font/woff2;base64,${embed}) format("woff2"); }`,
311
- "</style>"
321
+ `src: url(data:font/woff2;base64,${embed}) format("woff2"); }`
312
322
  ] : []),
323
+ ...Array.from(rules, ([decls, name]) => `.${name} { ${escapeXML(decls)} }`),
324
+ "</style>",
313
325
  `<marker id="${idArrow}" viewBox="0 0 10 10" refX="9" refY="5" ` +
314
326
  "markerWidth=\"21\" markerHeight=\"21\" markerUnits=\"userSpaceOnUse\" " +
315
327
  "orient=\"auto-start-reverse\">",
316
- `<path d="M 0 1 L 9 5 L 0 9 z" style="fill: ${color("color-edge-line")}"/>`,
328
+ `<path d="M 0 1 L 9 5 L 0 9 z" class="${classArrow}"/>`,
317
329
  "</marker>",
318
330
  "</defs>",
319
331
  ...svgGroups,
@@ -3,6 +3,7 @@
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,
@@ -10,8 +11,11 @@ import { simplifyPoly, assignPorts, assignTracks } from "./gradia-api-render-edg
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
@@ -122,30 +126,71 @@ export const render = async (graph, config, level = {}) => {
122
126
  boxH.set(node.id, boxH.get(node.id) +
123
127
  (cnt - config["hub-node-degree-max"]) * config["size-edge-port-gap"]);
124
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
+ };
125
150
  /* fixed three-column layout: stack the input nodes in the first
126
151
  column and the output nodes in the third column (each stack
127
152
  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();
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();
132
156
  const stack = (list) => {
133
- let y = margin + (totalH - stackH(list)) / 2;
157
+ let [innerB, outerB, laneB, bottom] = [-Infinity, -Infinity, -Infinity, 0];
134
158
  for (const node of list) {
135
- nodeCY.set(node.id, y + boxH.get(node.id) / 2);
136
- 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);
137
172
  }
173
+ return bottom;
138
174
  };
139
- stack(inputs);
140
- 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));
141
181
  nodeCY.set(center.id, margin + totalH / 2);
142
182
  /* 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
- ];
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)];
149
194
  const chanOf = (edge) => Math.min(colOf(edge.source), colOf(edge.target));
150
195
  const chanCnt = [
151
196
  edges.filter((edge) => chanOf(edge) === 0).length,
@@ -158,14 +203,15 @@ export const render = async (graph, config, level = {}) => {
158
203
  colLX.push(x);
159
204
  x += colWidth[c] + (c < 2 ? chanW[c] : 0);
160
205
  }
161
- /* place the nodes within their column: input nodes right-aligned,
162
- 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 */
163
209
  const cx = (id) => {
164
210
  const c = colOf(id);
165
211
  if (c === 0)
166
- return colLX[0] + colWidth[0] - boxW.get(id) / 2;
212
+ return colLX[0] + (outerSet.has(id) ? outerW[0] : colWidth[0]) - boxW.get(id) / 2;
167
213
  else if (c === 2)
168
- return colLX[2] + boxW.get(id) / 2;
214
+ return colLX[2] + (outerSet.has(id) ? colWidth[2] - outerW[1] : 0) + boxW.get(id) / 2;
169
215
  else
170
216
  return colLX[1] + colWidth[1] / 2;
171
217
  };