@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 +25 -7
- package/dst/gradia-api-config.d.ts +1 -0
- package/dst/gradia-api-config.js +1 -0
- package/dst/gradia-api-render-svg.js +68 -56
- package/dst/gradia-api-type-hub.js +67 -21
- package/dst/gradia-api.umd.js +2 -2
- package/dst/gradia-cli.js +0 -0
- package/package.json +1 -1
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
|
|
331
|
-
color-edge-name #333333 grid-columns-
|
|
332
|
-
color-edge-arity #333333 grid-
|
|
333
|
-
color-edge-halo #ffffff grid-gap-
|
|
334
|
-
size-canvas-margin 40 grid-
|
|
335
|
-
size-node-width-min 220 grid-node-
|
|
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;
|
package/dst/gradia-api-config.js
CHANGED
|
@@ -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
|
|
24
|
-
the seed and hence stays stable across regenerations
|
|
25
|
-
diagram (the Base16 format keeps it alphanumeric and
|
|
26
|
-
name and CSS/URL fragment
|
|
27
|
-
|
|
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,
|
|
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
|
-
`
|
|
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}"
|
|
109
|
-
`
|
|
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}"
|
|
114
|
-
`
|
|
115
|
-
|
|
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}"
|
|
120
|
-
`
|
|
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,
|
|
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}"
|
|
182
|
-
`
|
|
183
|
-
|
|
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}"
|
|
215
|
-
`
|
|
216
|
-
|
|
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,
|
|
216
|
+
const renderGroup = (group, styler) => [
|
|
225
217
|
`<rect x="${group.x}" y="${group.y}" width="${group.w}" height="${group.h}" rx="12" ` +
|
|
226
|
-
`
|
|
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
|
-
`
|
|
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,
|
|
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
|
-
`
|
|
242
|
-
|
|
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
|
-
`
|
|
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
|
-
`
|
|
250
|
-
|
|
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) =>
|
|
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 =
|
|
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"])}"
|
|
286
|
-
`
|
|
287
|
-
svgLabels.push(...renderEdgeLabels(edge, polys[i], claim,
|
|
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),
|
|
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,
|
|
295
|
-
const svgContainers = containers.flatMap((c) => renderContainer(c,
|
|
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"
|
|
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
|
-
|
|
130
|
-
const
|
|
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
|
|
157
|
+
let [innerB, outerB, laneB, bottom] = [-Infinity, -Infinity, -Infinity, 0];
|
|
134
158
|
for (const node of list) {
|
|
135
|
-
|
|
136
|
-
y
|
|
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
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
|
162
|
-
output nodes left-aligned, and the center node
|
|
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
|
};
|