@rse/gradia 0.9.0 → 0.9.2
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 +101 -15
- package/dst/gradia-api-config.d.ts +25 -0
- package/dst/gradia-api-config.js +68 -7
- package/dst/gradia-api-parser.js +1 -1
- package/dst/gradia-api-render-base.d.ts +4 -8
- package/dst/gradia-api-render-base.js +8 -12
- package/dst/gradia-api-render-edge.d.ts +2 -2
- package/dst/gradia-api-render-edge.js +9 -10
- package/dst/gradia-api-render-group.d.ts +2 -1
- package/dst/gradia-api-render-group.js +14 -12
- package/dst/gradia-api-render-node.d.ts +2 -2
- package/dst/gradia-api-render-node.js +9 -10
- package/dst/gradia-api-render-svg.d.ts +1 -1
- package/dst/gradia-api-render-svg.js +62 -35
- package/dst/gradia-api-type-graph.d.ts +1 -1
- package/dst/gradia-api-type-graph.js +27 -29
- package/dst/gradia-api-type-grid.d.ts +1 -1
- package/dst/gradia-api-type-grid.js +14 -14
- package/dst/gradia-api-type-hub.js +20 -19
- package/dst/gradia-api.d.ts +2 -2
- package/dst/gradia-api.js +26 -8
- package/dst/gradia-api.umd.js +5 -0
- package/dst/gradia-cli.js +35 -5
- package/dst/gradia-mcp.d.ts +10 -0
- package/dst/gradia-mcp.js +231 -0
- package/package.json +9 -3
|
@@ -3,14 +3,22 @@
|
|
|
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
|
-
|
|
7
|
-
import {
|
|
6
|
+
/* external dependencies */
|
|
7
|
+
import { customAlphabet } from "nanoid";
|
|
8
|
+
import { resolveFont, cssValueOf } from "./gradia-api-config.js";
|
|
9
|
+
import { FS_NAME, FS_TYPE, FS_ATTR, FS_EDGE, FS_ARITY, FS_GROUP, textWidth, escapeXML } from "./gradia-api-render-base.js";
|
|
8
10
|
import { attrsOfNode, typeOf, urlOf, defaultStyleOf, MIN_H, ATTR_H, ATTR_P, TYPE_H, TYPE_D } from "./gradia-api-render-node.js";
|
|
9
11
|
import { computeHops, pathOf, pointAt } from "./gradia-api-render-edge.js";
|
|
10
12
|
/* escape a string for use inside a CSS string literal (the XML escaping
|
|
11
13
|
is resolved by the parser before the CSS is parsed, so quotes and
|
|
12
14
|
backslashes have to be neutralized to prevent a CSS injection) */
|
|
13
15
|
const escapeCSS = (text) => text.replace(/[\\"']/g, "\\$&").replace(/[\r\n]/g, " ");
|
|
16
|
+
/* generate a per-document identifier prefix, as the SVG identifiers
|
|
17
|
+
are DOM-global and would collide once multiple diagrams are embedded
|
|
18
|
+
into the very same document (the alphabet is restricted to
|
|
19
|
+
alphanumerics to stay a valid XML name and CSS/URL fragment) */
|
|
20
|
+
const nanoid = customAlphabet("0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ", 12);
|
|
21
|
+
const idPrefix = () => `gradia-${nanoid()}`;
|
|
14
22
|
/* the placement of a group tag in the top-left corner of its group box */
|
|
15
23
|
const TAG_DX = 18; /* left offset of the group tag */
|
|
16
24
|
const TAG_DY = 12; /* top offset of the group tag */
|
|
@@ -28,6 +36,7 @@ const labelPlacer = (layout) => {
|
|
|
28
36
|
for (const group of layout.groups ?? [])
|
|
29
37
|
occupied.push([group.x + TAG_DX, group.y + TAG_DY,
|
|
30
38
|
group.x + TAG_DX + textWidth(group.name, FS_GROUP), group.y + TAG_DY + FS_GROUP * 1.2]);
|
|
39
|
+
/* claim the first collision-free candidate box */
|
|
31
40
|
const collides = (box) => occupied.some((o) => box[0] < o[2] && box[2] > o[0] && box[1] < o[3] && box[3] > o[1]);
|
|
32
41
|
const claim = (candidates) => {
|
|
33
42
|
const box = candidates.find((c) => !collides(c)) ?? candidates[0];
|
|
@@ -38,7 +47,7 @@ const labelPlacer = (layout) => {
|
|
|
38
47
|
};
|
|
39
48
|
/* generate the SVG fragments for a single node box (a node with a
|
|
40
49
|
"url" attribute becomes a hyperlink covering the whole box) */
|
|
41
|
-
const renderNode = (node, layout, style, font) => {
|
|
50
|
+
const renderNode = (node, layout, style, font, color) => {
|
|
42
51
|
const { cx, cy, boxW, boxH, contentH } = layout;
|
|
43
52
|
const w = boxW.get(node.id);
|
|
44
53
|
const h = boxH.get(node.id);
|
|
@@ -49,7 +58,7 @@ const renderNode = (node, layout, style, font) => {
|
|
|
49
58
|
const url = urlOf(node);
|
|
50
59
|
const parts = [];
|
|
51
60
|
parts.push(`<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="6" ` +
|
|
52
|
-
`
|
|
61
|
+
`style="fill: ${color(style.fill)}; stroke: ${color(style.stroke)}" stroke-width="4.0"` +
|
|
53
62
|
`${style.dash !== undefined ? ` stroke-dasharray="${escapeXML(style.dash)}"` : ""}/>`);
|
|
54
63
|
/* vertically center the textual content block within the box, with
|
|
55
64
|
the optional type line shifting the name and attributes down */
|
|
@@ -58,15 +67,15 @@ const renderNode = (node, layout, style, font) => {
|
|
|
58
67
|
const nameY = attrs.length > 0 ? ty + th + 42 : cy(node.id) + th / 2 + FS_NAME * 0.36;
|
|
59
68
|
if (type !== undefined)
|
|
60
69
|
parts.push(`<text x="${cx(node.id)}" y="${nameY - TYPE_D}" text-anchor="middle" ` +
|
|
61
|
-
`font-
|
|
62
|
-
`
|
|
70
|
+
`font-size="${FS_TYPE}" ` +
|
|
71
|
+
`style="font-family: ${font}; fill: ${color(style.text)}">${escapeXML(type)}</text>`);
|
|
63
72
|
parts.push(`<text x="${cx(node.id)}" y="${nameY}" text-anchor="middle" ` +
|
|
64
|
-
`font-
|
|
65
|
-
`
|
|
73
|
+
`font-size="${FS_NAME}" font-weight="600" ` +
|
|
74
|
+
`style="font-family: ${font}; fill: ${color(style.text)}">${escapeXML(node.name)}</text>`);
|
|
66
75
|
attrs.forEach((attr, k) => {
|
|
67
76
|
parts.push(`<text x="${cx(node.id)}" y="${ty + th + MIN_H + ATTR_P + k * ATTR_H}" text-anchor="middle" ` +
|
|
68
|
-
`font-
|
|
69
|
-
`
|
|
77
|
+
`font-size="${FS_ATTR}" ` +
|
|
78
|
+
`style="font-family: ${font}; fill: ${color(style.text)}">${escapeXML(`${attr.key}: ${attr.val}`)}</text>`);
|
|
70
79
|
});
|
|
71
80
|
if (url === undefined)
|
|
72
81
|
return parts;
|
|
@@ -75,7 +84,7 @@ const renderNode = (node, layout, style, font) => {
|
|
|
75
84
|
/* determine the overall bounding box of all rendered elements (the
|
|
76
85
|
node boxes and the placed labels are handed over as already
|
|
77
86
|
computed areas, the edge polylines are scanned here) */
|
|
78
|
-
const viewBoxOf = (layout, boxes) => {
|
|
87
|
+
const viewBoxOf = (layout, boxes, margin) => {
|
|
79
88
|
const { polys } = layout;
|
|
80
89
|
let [minX, minY, maxX, maxY] = [Infinity, Infinity, -Infinity, -Infinity];
|
|
81
90
|
for (const [bx1, by1, bx2, by2] of boxes) {
|
|
@@ -95,20 +104,31 @@ const viewBoxOf = (layout, boxes) => {
|
|
|
95
104
|
if (!Number.isFinite(minX))
|
|
96
105
|
[minX, minY, maxX, maxY] = [0, 0, 0, 0];
|
|
97
106
|
return {
|
|
98
|
-
x: Math.floor(minX -
|
|
99
|
-
y: Math.floor(minY -
|
|
100
|
-
w: Math.ceil(maxX - minX +
|
|
101
|
-
h: Math.ceil(maxY - minY +
|
|
107
|
+
x: Math.floor(minX - margin / 2),
|
|
108
|
+
y: Math.floor(minY - margin / 2),
|
|
109
|
+
w: Math.ceil(maxX - minX + margin),
|
|
110
|
+
h: Math.ceil(maxY - minY + margin)
|
|
102
111
|
};
|
|
103
112
|
};
|
|
104
113
|
/* render a laid out graph into an SVG document */
|
|
105
|
-
export const renderSVG = (layout, config) => {
|
|
114
|
+
export const renderSVG = (layout, config, explicit) => {
|
|
106
115
|
const { nodes, edges, polys } = layout;
|
|
107
116
|
const groups = layout.groups ?? [];
|
|
108
|
-
const styleOf = layout.styleOf ?? defaultStyleOf
|
|
117
|
+
const styleOf = layout.styleOf ?? defaultStyleOf;
|
|
118
|
+
/* resolve the directly embedded configuration options into CSS
|
|
119
|
+
values: explicitly configured values are hard-coded, while all
|
|
120
|
+
others are fetched at display time from the "--gradia-<option>"
|
|
121
|
+
CSS custom properties, falling back to the built-in defaults */
|
|
122
|
+
const color = (key) => escapeXML(cssValueOf(explicit, key));
|
|
123
|
+
/* derive the collision-free identifiers of this SVG document */
|
|
124
|
+
const prefix = idPrefix();
|
|
125
|
+
const idArrow = `${prefix}-arrow`;
|
|
109
126
|
/* resolve the configured font into the rendered font family stack */
|
|
110
|
-
const { family, embed } = resolveFont(config);
|
|
111
|
-
const
|
|
127
|
+
const { family, embed, weight } = resolveFont(config);
|
|
128
|
+
const stack = Object.hasOwn(explicit, "font-family") ?
|
|
129
|
+
`'${escapeCSS(family)}'` :
|
|
130
|
+
`var(--gradia-font-family, '${escapeCSS(family)}')`;
|
|
131
|
+
const font = escapeXML(`${stack}, ui-sans-serif, -apple-system, Helvetica, Arial, sans-serif`);
|
|
112
132
|
/* detect the edge crossings requiring rendered hops */
|
|
113
133
|
const hops = computeHops(polys);
|
|
114
134
|
/* prepare the collision-free placement of the edge labels */
|
|
@@ -116,11 +136,13 @@ export const renderSVG = (layout, config) => {
|
|
|
116
136
|
/* generate the SVG fragments for the edges (paths below, labels above) */
|
|
117
137
|
const svgEdges = [];
|
|
118
138
|
const svgLabels = [];
|
|
119
|
-
/* the
|
|
120
|
-
|
|
139
|
+
/* the halo rendered behind the edge labels for readability
|
|
140
|
+
(its color is emitted into the style attribute below, as only
|
|
141
|
+
there the CSS custom property lookup can be resolved) */
|
|
142
|
+
const halo = "stroke-width=\"4.5\" paint-order=\"stroke\" stroke-linejoin=\"round\"";
|
|
121
143
|
edges.forEach((edge, i) => {
|
|
122
|
-
svgEdges.push(`<path d="${pathOf(polys[i], hops[i])}" fill="none" ` +
|
|
123
|
-
`
|
|
144
|
+
svgEdges.push(`<path d="${pathOf(polys[i], hops[i], config["size-edge-corner-radius"], config["size-edge-hop-radius"])}" fill="none" ` +
|
|
145
|
+
`style="stroke: ${color("color-edge-line")}" stroke-width="3.0" marker-end="url(#${idArrow})"/>`);
|
|
124
146
|
if (edge.name !== undefined) {
|
|
125
147
|
const w = textWidth(edge.name, FS_EDGE);
|
|
126
148
|
const candidates = [];
|
|
@@ -137,8 +159,10 @@ export const renderSVG = (layout, config) => {
|
|
|
137
159
|
}
|
|
138
160
|
const box = claim(candidates);
|
|
139
161
|
svgLabels.push(`<text x="${(box[0] + box[2]) / 2}" y="${box[3] - 3}" text-anchor="middle" ` +
|
|
140
|
-
`font-
|
|
141
|
-
`
|
|
162
|
+
`font-size="${FS_EDGE}" ` +
|
|
163
|
+
`style="font-family: ${font}; fill: ${color("color-edge-name")}; ` +
|
|
164
|
+
`stroke: ${color("color-edge-halo")}" ` +
|
|
165
|
+
`${halo}>${escapeXML(edge.name)}</text>`);
|
|
142
166
|
}
|
|
143
167
|
if (edge.arity !== undefined) {
|
|
144
168
|
const w = textWidth(edge.arity, FS_ARITY);
|
|
@@ -152,25 +176,27 @@ export const renderSVG = (layout, config) => {
|
|
|
152
176
|
[ax - w / 2 - dx * 14, p.y - 17, ax + w / 2 - dx * 14, p.y - 4]
|
|
153
177
|
]);
|
|
154
178
|
svgLabels.push(`<text x="${(box[0] + box[2]) / 2}" y="${box[3] - 3}" text-anchor="middle" ` +
|
|
155
|
-
`font-
|
|
156
|
-
`
|
|
179
|
+
`font-size="${FS_ARITY}" ` +
|
|
180
|
+
`style="font-family: ${font}; fill: ${color("color-edge-arity")}; ` +
|
|
181
|
+
`stroke: ${color("color-edge-halo")}" ` +
|
|
182
|
+
`${halo}>${escapeXML(edge.arity)}</text>`);
|
|
157
183
|
}
|
|
158
184
|
});
|
|
159
185
|
/* generate the SVG fragments for the node boxes */
|
|
160
|
-
const svgNodes = nodes.flatMap((node) => renderNode(node, layout, styleOf(node), font));
|
|
186
|
+
const svgNodes = nodes.flatMap((node) => renderNode(node, layout, styleOf(node), font, color));
|
|
161
187
|
/* generate the SVG fragments for the group boxes (drawn below
|
|
162
188
|
everything else) and their tags in the top-left corners */
|
|
163
189
|
const svgGroups = groups.flatMap((group) => [
|
|
164
190
|
`<rect x="${group.x}" y="${group.y}" width="${group.w}" height="${group.h}" rx="12" ` +
|
|
165
|
-
`
|
|
166
|
-
`stroke
|
|
191
|
+
`style="fill: ${color("color-group-box")}; ` +
|
|
192
|
+
`stroke: ${color("color-group-border")}" stroke-width="3.0"/>`,
|
|
167
193
|
`<text x="${group.x + TAG_DX}" y="${group.y + TAG_DY + FS_GROUP}" ` +
|
|
168
|
-
`font-
|
|
169
|
-
`
|
|
194
|
+
`font-size="${FS_GROUP}" font-weight="600" ` +
|
|
195
|
+
`style="font-family: ${font}; fill: ${color("color-group-name")}">${escapeXML(group.name)}</text>`
|
|
170
196
|
]);
|
|
171
197
|
/* determine the overall bounding box of all rendered elements */
|
|
172
198
|
const groupBoxes = groups.map((group) => [group.x, group.y, group.x + group.w, group.y + group.h]);
|
|
173
|
-
const vb = viewBoxOf(layout, [...occupied, ...groupBoxes]);
|
|
199
|
+
const vb = viewBoxOf(layout, [...occupied, ...groupBoxes], config["size-canvas-margin"]);
|
|
174
200
|
/* assemble the final SVG document */
|
|
175
201
|
return [
|
|
176
202
|
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>",
|
|
@@ -180,13 +206,14 @@ export const renderSVG = (layout, config) => {
|
|
|
180
206
|
...(embed !== undefined ? [
|
|
181
207
|
"<style>",
|
|
182
208
|
`@font-face { font-family: "${escapeXML(escapeCSS(family))}"; ` +
|
|
209
|
+
(weight !== undefined ? `font-weight: ${escapeXML(escapeCSS(weight))}; ` : "") +
|
|
183
210
|
`src: url(data:font/woff2;base64,${embed}) format("woff2"); }`,
|
|
184
211
|
"</style>"
|
|
185
212
|
] : []),
|
|
186
|
-
|
|
213
|
+
`<marker id="${idArrow}" viewBox="0 0 10 10" refX="9" refY="5" ` +
|
|
187
214
|
"markerWidth=\"21\" markerHeight=\"21\" markerUnits=\"userSpaceOnUse\" " +
|
|
188
215
|
"orient=\"auto-start-reverse\">",
|
|
189
|
-
`<path d="M 0 1 L 9 5 L 0 9 z"
|
|
216
|
+
`<path d="M 0 1 L 9 5 L 0 9 z" style="fill: ${color("color-edge-line")}"/>`,
|
|
190
217
|
"</marker>",
|
|
191
218
|
"</defs>",
|
|
192
219
|
...svgGroups,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import { Graph } from "./gradia-api-model.js";
|
|
2
2
|
import { Config } from "./gradia-api-config.js";
|
|
3
3
|
import { Layout } from "./gradia-api-render-svg.js";
|
|
4
|
-
export declare const render: (graph: Graph,
|
|
4
|
+
export declare const render: (graph: Graph, config: Config) => Promise<Layout>;
|
|
@@ -5,15 +5,11 @@
|
|
|
5
5
|
*/
|
|
6
6
|
/* external dependencies */
|
|
7
7
|
import { DagreLayout } from "@antv/layout";
|
|
8
|
-
import { SLOT, SCALE_H, MARGIN } from "./gradia-api-render-base.js";
|
|
9
8
|
import { measureNodes } from "./gradia-api-render-node.js";
|
|
10
9
|
import { simplifyPoly, assignPorts, assignTracks } from "./gradia-api-render-edge.js";
|
|
11
10
|
/* rendering geometry constants */
|
|
12
|
-
const
|
|
13
|
-
const
|
|
14
|
-
const MAXCOLS = 5; /* max number of side-by-side nodes */
|
|
15
|
-
const CLUSTX = 26; /* max X distance within a grid column */
|
|
16
|
-
const CLUSTY = 72; /* max Y distance within a grid row */
|
|
11
|
+
const CLUSTX = 26; /* max X distance within a grid column */
|
|
12
|
+
const CLUSTY = 72; /* max Y distance within a grid row */
|
|
17
13
|
/* snap raw node positions onto a discrete grid by clustering the
|
|
18
14
|
distinct X coordinates into columns and Y coordinates into rows */
|
|
19
15
|
const snapToGrid = (nodes, rawX, rawY) => {
|
|
@@ -137,7 +133,7 @@ const planRoutes = (edges, col, row) => {
|
|
|
137
133
|
/* determine grid cell sizes and final node center positions, with
|
|
138
134
|
channel widths and gutter heights sized by actual edge usage to
|
|
139
135
|
keep the overall layout compact */
|
|
140
|
-
const computeGrid = (nodes, col, row, boxW, boxH, ncols, nrows, chanCnt, gutCnt) => {
|
|
136
|
+
const computeGrid = (nodes, col, row, boxW, boxH, ncols, nrows, chanCnt, gutCnt, config) => {
|
|
141
137
|
const colWidth = Array.from({ length: ncols }, () => 0);
|
|
142
138
|
const rowHeight = Array.from({ length: nrows }, () => 0);
|
|
143
139
|
for (const node of nodes) {
|
|
@@ -147,21 +143,21 @@ const computeGrid = (nodes, col, row, boxW, boxH, ncols, nrows, chanCnt, gutCnt)
|
|
|
147
143
|
/* size the channels and gutters by their actual edge usage */
|
|
148
144
|
const chanW = Array.from({ length: ncols }, (_, c) => {
|
|
149
145
|
const cnt = chanCnt.get(c) ?? 0;
|
|
150
|
-
return cnt === 0 ? 24 : Math.min(
|
|
146
|
+
return cnt === 0 ? 24 : Math.min(config["graph-channel-width-max"], 28 + (cnt - 1) * config["size-edge-track-gap"]);
|
|
151
147
|
});
|
|
152
148
|
const gutH = Array.from({ length: nrows }, (_, g) => {
|
|
153
149
|
const cnt = gutCnt.get(g) ?? 0;
|
|
154
|
-
return cnt === 0 ? 20 : Math.min(
|
|
150
|
+
return cnt === 0 ? 20 : Math.min(config["graph-gutter-height-max"], 24 + (cnt - 1) * config["size-edge-track-gap"]);
|
|
155
151
|
});
|
|
156
152
|
/* derive the column and row center positions */
|
|
157
153
|
const colCX = [];
|
|
158
154
|
const rowCY = [];
|
|
159
|
-
let x =
|
|
155
|
+
let x = config["size-canvas-margin"];
|
|
160
156
|
for (let c = 0; c < colWidth.length; c++) {
|
|
161
157
|
colCX.push(x + colWidth[c] / 2);
|
|
162
158
|
x += colWidth[c] + chanW[c];
|
|
163
159
|
}
|
|
164
|
-
let y =
|
|
160
|
+
let y = config["size-canvas-margin"];
|
|
165
161
|
for (let r = 0; r < rowHeight.length; r++) {
|
|
166
162
|
rowCY.push(y + rowHeight[r] / 2);
|
|
167
163
|
y += rowHeight[r] + gutH[r];
|
|
@@ -174,7 +170,7 @@ const computeGrid = (nodes, col, row, boxW, boxH, ncols, nrows, chanCnt, gutCnt)
|
|
|
174
170
|
channel mirrored from the right side; the gutters use the very same
|
|
175
171
|
scheme, just transposed: edges dropping in from above enter from the
|
|
176
172
|
near side, edges rising from below are the mirrored users) */
|
|
177
|
-
const assignTrackCoords = (edges, plans, col, portPos, grid) => {
|
|
173
|
+
const assignTrackCoords = (edges, plans, col, portPos, grid, config) => {
|
|
178
174
|
const { colWidth, rowHeight, chanW, gutH, colCX, rowCY } = grid;
|
|
179
175
|
const gutBase = (g) => rowCY[g] + rowHeight[g] / 2 + gutH[g] / 2;
|
|
180
176
|
/* assign the vertical tracks within the inter-column channels */
|
|
@@ -196,7 +192,7 @@ const assignTrackCoords = (edges, plans, col, portPos, grid) => {
|
|
|
196
192
|
});
|
|
197
193
|
const chanOff = new Map();
|
|
198
194
|
for (const [c, users] of chanUsers)
|
|
199
|
-
for (const [edge, off] of assignTracks(users, chanW[c], 16))
|
|
195
|
+
for (const [edge, off] of assignTracks(users, chanW[c], 16, config["size-edge-track-gap"]))
|
|
200
196
|
chanOff.set(`${c}:${edge}`, off);
|
|
201
197
|
const chanX = (c, edge) => colCX[c] + colWidth[c] / 2 + chanW[c] / 2 +
|
|
202
198
|
chanOff.get(`${c}:${edge}`);
|
|
@@ -216,14 +212,14 @@ const assignTrackCoords = (edges, plans, col, portPos, grid) => {
|
|
|
216
212
|
});
|
|
217
213
|
const gutOff = new Map();
|
|
218
214
|
for (const [g, users] of gutUsers)
|
|
219
|
-
for (const [edge, off] of assignTracks(users, gutH[g], 12))
|
|
215
|
+
for (const [edge, off] of assignTracks(users, gutH[g], 12, config["size-edge-track-gap"]))
|
|
220
216
|
gutOff.set(`${g}:${edge}`, off);
|
|
221
217
|
const gutY = (g, edge) => gutBase(g) + gutOff.get(`${g}:${edge}`);
|
|
222
218
|
return { chanX, gutY };
|
|
223
219
|
};
|
|
224
220
|
/* route every edge as an orthogonal polyline through the channels
|
|
225
221
|
between columns and the gutters between rows */
|
|
226
|
-
const routePolys = (edges, plans, col, portPos, boxW, cx, chanX, gutY) => {
|
|
222
|
+
const routePolys = (edges, plans, col, portPos, boxW, cx, chanX, gutY, config) => {
|
|
227
223
|
const loopUse = new Map();
|
|
228
224
|
return edges.map((edge, i) => {
|
|
229
225
|
const sc = col.get(edge.source);
|
|
@@ -235,7 +231,7 @@ const routePolys = (edges, plans, col, portPos, boxW, cx, chanX, gutY) => {
|
|
|
235
231
|
if (edge.source === edge.target) {
|
|
236
232
|
const k = loopUse.get(edge.source) ?? 0;
|
|
237
233
|
loopUse.set(edge.source, k + 1);
|
|
238
|
-
const ox = cx(edge.source) + boxW.get(edge.source) / 2 + 24 + k *
|
|
234
|
+
const ox = cx(edge.source) + boxW.get(edge.source) / 2 + 24 + k * config["size-edge-track-gap"];
|
|
239
235
|
pts = [[sp.x, sp.y], [ox, sp.y], [ox, tp.y], [tp.x, tp.y]];
|
|
240
236
|
}
|
|
241
237
|
else if (chans.length === 1) {
|
|
@@ -255,16 +251,16 @@ const routePolys = (edges, plans, col, portPos, boxW, cx, chanX, gutY) => {
|
|
|
255
251
|
});
|
|
256
252
|
};
|
|
257
253
|
/* lay out a directed graph model */
|
|
258
|
-
export const render = async (graph,
|
|
254
|
+
export const render = async (graph, config) => {
|
|
259
255
|
const nodes = Array.from(graph.nodes.values());
|
|
260
256
|
const edges = graph.edges;
|
|
261
257
|
/* determine node box sizes from their textual content */
|
|
262
|
-
const { boxW, boxH, contentH } = measureNodes(nodes, () =>
|
|
258
|
+
const { boxW, boxH, contentH } = measureNodes(nodes, config, () => config["size-node-height-scale"]);
|
|
263
259
|
/* determine raw node positions with the AntV Dagre layout */
|
|
264
260
|
const layout = new DagreLayout({
|
|
265
261
|
rankdir: "LR",
|
|
266
|
-
nodesep:
|
|
267
|
-
ranksep:
|
|
262
|
+
nodesep: config["graph-node-separation"],
|
|
263
|
+
ranksep: config["graph-rank-separation"],
|
|
268
264
|
nodeSize: (node) => node.size
|
|
269
265
|
});
|
|
270
266
|
await layout.execute({
|
|
@@ -283,16 +279,18 @@ export const render = async (graph, _config) => {
|
|
|
283
279
|
toward the median row of its direct neighbors */
|
|
284
280
|
refineRows(nodes, edges, col, row);
|
|
285
281
|
/* fold the grid columns: constrain the diagram width to at most
|
|
286
|
-
|
|
287
|
-
additional row bands below, so wide graphs grow in
|
|
288
|
-
|
|
282
|
+
the configured maximum of side-by-side nodes by wrapping excess
|
|
283
|
+
columns into additional row bands below, so wide graphs grow in
|
|
284
|
+
height */
|
|
285
|
+
const maxCols = Math.max(Math.floor(config["graph-columns-max"]), 1);
|
|
286
|
+
if (gridCols > maxCols) {
|
|
289
287
|
for (const node of nodes) {
|
|
290
288
|
const c = col.get(node.id);
|
|
291
|
-
col.set(node.id, c %
|
|
292
|
-
row.set(node.id, row.get(node.id) + Math.floor(c /
|
|
289
|
+
col.set(node.id, c % maxCols);
|
|
290
|
+
row.set(node.id, row.get(node.id) + Math.floor(c / maxCols) * gridRows);
|
|
293
291
|
}
|
|
294
292
|
}
|
|
295
|
-
const ncols = Math.min(gridCols,
|
|
293
|
+
const ncols = Math.min(gridCols, maxCols);
|
|
296
294
|
/* drop the grid rows which are completely empty */
|
|
297
295
|
const usedRows = Array.from(new Set(nodes.map((node) => row.get(node.id)))).sort((a, b) => a - b);
|
|
298
296
|
const rowIdx = new Map(usedRows.map((r, idx) => [r, idx]));
|
|
@@ -302,7 +300,7 @@ export const render = async (graph, _config) => {
|
|
|
302
300
|
/* plan the coarse channel/gutter route of every edge */
|
|
303
301
|
const { plans, chanCnt, gutCnt } = planRoutes(edges, col, row);
|
|
304
302
|
/* determine grid cell sizes and final node center positions */
|
|
305
|
-
const grid = computeGrid(nodes, col, row, boxW, boxH, ncols, nrows, chanCnt, gutCnt);
|
|
303
|
+
const grid = computeGrid(nodes, col, row, boxW, boxH, ncols, nrows, chanCnt, gutCnt, config);
|
|
306
304
|
const cx = (id) => grid.colCX[col.get(id)];
|
|
307
305
|
const cy = (id) => grid.rowCY[row.get(id)];
|
|
308
306
|
/* determine the attachment sides of every edge (east/west),
|
|
@@ -320,10 +318,10 @@ export const render = async (graph, _config) => {
|
|
|
320
318
|
/* distribute the edge attachment ports along each node side */
|
|
321
319
|
const portPos = assignPorts(edges, sides, cx, cy, boxW, boxH);
|
|
322
320
|
/* assign the parallel tracks within the channels and gutters */
|
|
323
|
-
const { chanX, gutY } = assignTrackCoords(edges, plans, col, portPos, grid);
|
|
321
|
+
const { chanX, gutY } = assignTrackCoords(edges, plans, col, portPos, grid, config);
|
|
324
322
|
/* route every edge as an orthogonal polyline through the channels
|
|
325
323
|
between columns and the gutters between rows */
|
|
326
|
-
const polys = routePolys(edges, plans, col, portPos, boxW, cx, chanX, gutY);
|
|
324
|
+
const polys = routePolys(edges, plans, col, portPos, boxW, cx, chanX, gutY, config);
|
|
327
325
|
/* hand over the laid out graph for SVG rendering */
|
|
328
326
|
return { nodes, edges, cx, cy, boxW, boxH, contentH, polys };
|
|
329
327
|
};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import { Graph } from "./gradia-api-model.js";
|
|
2
2
|
import { Config } from "./gradia-api-config.js";
|
|
3
3
|
import { Layout } from "./gradia-api-render-svg.js";
|
|
4
|
-
export declare const render: (graph: Graph,
|
|
4
|
+
export declare const render: (graph: Graph, config: Config) => Promise<Layout>;
|
|
@@ -3,15 +3,15 @@
|
|
|
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 { SCALE_H, MARGIN } from "./gradia-api-render-base.js";
|
|
7
6
|
import { measureNodes } from "./gradia-api-render-node.js";
|
|
8
|
-
/* rendering geometry constants */
|
|
9
|
-
const GAP_H = 40; /* horizontal gap between the tiles of the grid */
|
|
10
|
-
const GAP_V = 20; /* vertical gap between the tiles of the grid */
|
|
11
|
-
const MAXCOLS = 4; /* max number of side-by-side tiles */
|
|
12
7
|
/* lay out an edge-less graph model as a compact grid of tiles */
|
|
13
|
-
export const render = async (graph,
|
|
8
|
+
export const render = async (graph, config) => {
|
|
14
9
|
const nodes = Array.from(graph.nodes.values());
|
|
10
|
+
/* resolve the configurable rendering geometry */
|
|
11
|
+
const margin = config["size-canvas-margin"];
|
|
12
|
+
const gapH = config["grid-gap-horizontal"];
|
|
13
|
+
const gapV = config["grid-gap-vertical"];
|
|
14
|
+
const maxCols = Math.max(Math.floor(config["grid-columns-max"]), 1);
|
|
15
15
|
/* ensure the graph is completely edge-less */
|
|
16
16
|
if (graph.edges.length > 0) {
|
|
17
17
|
const edge = graph.edges[0];
|
|
@@ -20,30 +20,30 @@ export const render = async (graph, _config) => {
|
|
|
20
20
|
}
|
|
21
21
|
/* determine node box sizes (keeping the individual widths)
|
|
22
22
|
and unify only their heights into a single tile height */
|
|
23
|
-
const { boxW, boxH, contentH } = measureNodes(nodes, () =>
|
|
23
|
+
const { boxW, boxH, contentH } = measureNodes(nodes, config, () => config["size-node-height-scale"] / 2);
|
|
24
24
|
const tileH = nodes.reduce((a, node) => Math.max(a, boxH.get(node.id)), 0);
|
|
25
25
|
for (const node of nodes)
|
|
26
26
|
boxH.set(node.id, tileH);
|
|
27
27
|
/* place the nodes in declaration order onto a roughly square,
|
|
28
|
-
row-major grid (capped at
|
|
29
|
-
grow only in height), with each column as wide as
|
|
30
|
-
tile and each tile left-aligned within its column */
|
|
31
|
-
const cols = Math.max(Math.min(Math.ceil(Math.sqrt(nodes.length)),
|
|
28
|
+
row-major grid (capped at the configured maximum of columns, so
|
|
29
|
+
larger graphs grow only in height), with each column as wide as
|
|
30
|
+
its widest tile and each tile left-aligned within its column */
|
|
31
|
+
const cols = Math.max(Math.min(Math.ceil(Math.sqrt(nodes.length)), maxCols), 1);
|
|
32
32
|
const colWidth = Array.from({ length: cols }, () => 0);
|
|
33
33
|
nodes.forEach((node, i) => {
|
|
34
34
|
colWidth[i % cols] = Math.max(colWidth[i % cols], boxW.get(node.id));
|
|
35
35
|
});
|
|
36
36
|
const colLX = [];
|
|
37
|
-
let x =
|
|
37
|
+
let x = margin;
|
|
38
38
|
for (let c = 0; c < cols; c++) {
|
|
39
39
|
colLX.push(x);
|
|
40
|
-
x += colWidth[c] +
|
|
40
|
+
x += colWidth[c] + gapH;
|
|
41
41
|
}
|
|
42
42
|
const nodeCX = new Map();
|
|
43
43
|
const nodeCY = new Map();
|
|
44
44
|
nodes.forEach((node, i) => {
|
|
45
45
|
nodeCX.set(node.id, colLX[i % cols] + boxW.get(node.id) / 2);
|
|
46
|
-
nodeCY.set(node.id,
|
|
46
|
+
nodeCY.set(node.id, margin + Math.floor(i / cols) * (tileH + gapV) + tileH / 2);
|
|
47
47
|
});
|
|
48
48
|
const cx = (id) => nodeCX.get(id);
|
|
49
49
|
const cy = (id) => nodeCY.get(id);
|
|
@@ -3,13 +3,8 @@
|
|
|
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 { SLOT, SCALE_H, MARGIN } from "./gradia-api-render-base.js";
|
|
7
6
|
import { isPrimary, measureNodes, defaultStyleOf } from "./gradia-api-render-node.js";
|
|
8
7
|
import { simplifyPoly, assignPorts, assignTracks } from "./gradia-api-render-edge.js";
|
|
9
|
-
/* rendering geometry constants */
|
|
10
|
-
const CHANNEL = 340; /* max width of inter-column channels */
|
|
11
|
-
const CHANMIN = 240; /* min width of inter-column channels */
|
|
12
|
-
const STACK = 20; /* vertical gap between stacked nodes */
|
|
13
8
|
/* the separator between a node id and its placement suffix,
|
|
14
9
|
distinguishing the two clones of a twice-placed node */
|
|
15
10
|
const CLONE = "\u0000";
|
|
@@ -23,6 +18,9 @@ const CLONE = "\u0000";
|
|
|
23
18
|
corresponding placement */
|
|
24
19
|
const classifyTopology = (graph) => {
|
|
25
20
|
const declared = Array.from(graph.nodes.values());
|
|
21
|
+
for (const node of declared)
|
|
22
|
+
if (node.id.includes(CLONE))
|
|
23
|
+
throw new Error(`node id ${JSON.stringify(node.id)} contains a reserved control character`);
|
|
26
24
|
const primaries = declared.filter(isPrimary);
|
|
27
25
|
if (primaries.length !== 1)
|
|
28
26
|
throw new Error(`expected exactly one node annotated with "primary: true" (found ${primaries.length})`);
|
|
@@ -77,26 +75,30 @@ export const render = async (graph, config) => {
|
|
|
77
75
|
declared nodes into the input and output columns */
|
|
78
76
|
const { center, inputs, outputs, nodes, edges } = classifyTopology(graph);
|
|
79
77
|
const inputSet = new Set(inputs.map((node) => node.id));
|
|
78
|
+
/* resolve the configurable rendering geometry */
|
|
79
|
+
const margin = config["size-canvas-margin"];
|
|
80
|
+
const gap = config["hub-node-gap"];
|
|
81
|
+
const scale = config["size-node-height-scale"];
|
|
80
82
|
/* determine node box sizes from their textual content (the primary
|
|
81
83
|
box at full and all other boxes at half height scale) */
|
|
82
|
-
const { boxW, boxH, contentH } = measureNodes(nodes, (node) => node.id === center.id ?
|
|
84
|
+
const { boxW, boxH, contentH } = measureNodes(nodes, config, (node) => node.id === center.id ? scale : scale / 2);
|
|
83
85
|
/* fixed three-column layout: stack the input nodes in the first
|
|
84
86
|
column and the output nodes in the third column (each stack
|
|
85
87
|
vertically centered), and place the center node in the second
|
|
86
88
|
column at the vertical center of the canvas */
|
|
87
|
-
const stackH = (list) => list.reduce((a, node) => a + boxH.get(node.id), 0) + Math.max(list.length - 1, 0) *
|
|
89
|
+
const stackH = (list) => list.reduce((a, node) => a + boxH.get(node.id), 0) + Math.max(list.length - 1, 0) * gap;
|
|
88
90
|
const totalH = Math.max(stackH(inputs), boxH.get(center.id), stackH(outputs));
|
|
89
91
|
const nodeCY = new Map();
|
|
90
92
|
const stack = (list) => {
|
|
91
|
-
let y =
|
|
93
|
+
let y = margin + (totalH - stackH(list)) / 2;
|
|
92
94
|
for (const node of list) {
|
|
93
95
|
nodeCY.set(node.id, y + boxH.get(node.id) / 2);
|
|
94
|
-
y += boxH.get(node.id) +
|
|
96
|
+
y += boxH.get(node.id) + gap;
|
|
95
97
|
}
|
|
96
98
|
};
|
|
97
99
|
stack(inputs);
|
|
98
100
|
stack(outputs);
|
|
99
|
-
nodeCY.set(center.id,
|
|
101
|
+
nodeCY.set(center.id, margin + totalH / 2);
|
|
100
102
|
/* determine column widths and left edge positions, with the two
|
|
101
103
|
inter-column channel widths sized by actual edge usage */
|
|
102
104
|
const colOf = (id) => id === center.id ? 1 : (inputSet.has(id) ? 0 : 2);
|
|
@@ -110,9 +112,9 @@ export const render = async (graph, config) => {
|
|
|
110
112
|
edges.filter((edge) => chanOf(edge) === 0).length,
|
|
111
113
|
edges.filter((edge) => chanOf(edge) === 1).length
|
|
112
114
|
];
|
|
113
|
-
const chanW = chanCnt.map((cnt) => Math.min(
|
|
115
|
+
const chanW = chanCnt.map((cnt) => Math.min(config["hub-channel-width-max"], Math.max(config["hub-channel-width-min"], 28 + (cnt - 1) * config["size-edge-track-gap"])));
|
|
114
116
|
const colLX = [];
|
|
115
|
-
let x =
|
|
117
|
+
let x = margin;
|
|
116
118
|
for (let c = 0; c < 3; c++) {
|
|
117
119
|
colLX.push(x);
|
|
118
120
|
x += colWidth[c] + (c < 2 ? chanW[c] : 0);
|
|
@@ -135,7 +137,7 @@ export const render = async (graph, config) => {
|
|
|
135
137
|
/* distribute the edge attachment ports along each node side */
|
|
136
138
|
const portPos = assignPorts(edges, sides, cx, cy, boxW, boxH);
|
|
137
139
|
/* assign the vertical tracks within each channel (see assignTracks
|
|
138
|
-
for the crossing-avoiding ordering scheme;
|
|
140
|
+
for the crossing-avoiding ordering scheme; a hub graph has no backward
|
|
139
141
|
edges, hence no channel user is ever mirrored) */
|
|
140
142
|
const chanUsers = [[], []];
|
|
141
143
|
edges.forEach((edge, i) => {
|
|
@@ -148,7 +150,7 @@ export const render = async (graph, config) => {
|
|
|
148
150
|
});
|
|
149
151
|
const chanOff = new Map();
|
|
150
152
|
chanUsers.forEach((users, c) => {
|
|
151
|
-
for (const [edge, off] of assignTracks(users, chanW[c], 16))
|
|
153
|
+
for (const [edge, off] of assignTracks(users, chanW[c], 16, config["size-edge-track-gap"]))
|
|
152
154
|
chanOff.set(`${c}:${edge}`, off);
|
|
153
155
|
});
|
|
154
156
|
/* determine the X position of an edge's assigned track within a channel */
|
|
@@ -169,12 +171,11 @@ export const render = async (graph, config) => {
|
|
|
169
171
|
/* hand over the laid out graph for SVG rendering (the output copy
|
|
170
172
|
of a twice-placed node is rendered as a dashed grey "ghost" box,
|
|
171
173
|
all other boxes get the default primary/regular coloring) */
|
|
172
|
-
const styleDefault = defaultStyleOf(config);
|
|
173
174
|
const styleOf = (node) => node.id.endsWith(CLONE + "out") ? {
|
|
174
|
-
fill:
|
|
175
|
-
stroke:
|
|
176
|
-
text:
|
|
175
|
+
fill: "color-node-ghost-box",
|
|
176
|
+
stroke: "color-node-ghost-border",
|
|
177
|
+
text: "color-node-ghost-name",
|
|
177
178
|
dash: "10 6"
|
|
178
|
-
} :
|
|
179
|
+
} : defaultStyleOf(node);
|
|
179
180
|
return { nodes, edges, cx, cy, boxW, boxH, contentH, polys, styleOf };
|
|
180
181
|
};
|
package/dst/gradia-api.d.ts
CHANGED
|
@@ -4,9 +4,9 @@ import { Layout } from "./gradia-api-render-svg.js";
|
|
|
4
4
|
export type { Attr, Node, Edge, Graph } from "./gradia-api-model.js";
|
|
5
5
|
export type { Config } from "./gradia-api-config.js";
|
|
6
6
|
declare const renderers: {
|
|
7
|
-
graph: (graph: Graph,
|
|
7
|
+
graph: (graph: Graph, config: Config) => Promise<Layout>;
|
|
8
8
|
hub: (graph: Graph, config: Config) => Promise<Layout>;
|
|
9
|
-
grid: (graph: Graph,
|
|
9
|
+
grid: (graph: Graph, config: Config) => Promise<Layout>;
|
|
10
10
|
};
|
|
11
11
|
export type DiagramType = keyof typeof renderers;
|
|
12
12
|
export declare const diagramTypes: DiagramType[];
|
package/dst/gradia-api.js
CHANGED
|
@@ -18,6 +18,20 @@ const renderers = {
|
|
|
18
18
|
};
|
|
19
19
|
export const diagramTypes = Object.keys(renderers);
|
|
20
20
|
export const diagramTypeDefault = "graph";
|
|
21
|
+
/* parse the "#type <type>" directive from a graph description (a line
|
|
22
|
+
which is otherwise treated as a plain comment): the last occurrence
|
|
23
|
+
wins and an invalid type is silently skipped, as the directives are
|
|
24
|
+
lines of an untrusted input and never abort the rendering */
|
|
25
|
+
const parseTypeDirective = (input) => {
|
|
26
|
+
let type;
|
|
27
|
+
for (const line of input.split(/\r?\n/)) {
|
|
28
|
+
const m = /^[ \t]*#type[ \t]+([a-z][a-z0-9-]*)[ \t]*$/.exec(line);
|
|
29
|
+
if (m === null || !Object.hasOwn(renderers, m[1]))
|
|
30
|
+
continue;
|
|
31
|
+
type = m[1];
|
|
32
|
+
}
|
|
33
|
+
return type;
|
|
34
|
+
};
|
|
21
35
|
/* the supported output formats */
|
|
22
36
|
export const diagramFormats = ["svg:standalone", "svg:embedded", "url:xml", "url:base64"];
|
|
23
37
|
export const diagramFormatDefault = "svg:standalone";
|
|
@@ -29,7 +43,9 @@ export class Gradia {
|
|
|
29
43
|
}
|
|
30
44
|
/* generate an SVG document or data URL from a graph model
|
|
31
45
|
(the rendering configuration is layered: defaults, then the
|
|
32
|
-
explicit config options
|
|
46
|
+
explicit config options; the directly embedded options become
|
|
47
|
+
hard-coded values when explicitly configured and CSS custom
|
|
48
|
+
property lookups "--gradia-<option>" otherwise) */
|
|
33
49
|
static async generate(graph, options = {}) {
|
|
34
50
|
/* determine and validate the diagram type and output format */
|
|
35
51
|
const type = options.type ?? diagramTypeDefault;
|
|
@@ -39,16 +55,17 @@ export class Gradia {
|
|
|
39
55
|
if (!diagramFormats.includes(format))
|
|
40
56
|
throw new Error(`invalid output format "${format}"`);
|
|
41
57
|
/* layer the rendering configuration */
|
|
42
|
-
const
|
|
58
|
+
const explicit = options.config ?? {};
|
|
59
|
+
const config = { ...configDefaults, ...explicit };
|
|
43
60
|
/* lay out the graph model: either as a whole, or partitioned
|
|
44
61
|
into its named groups which are laid out individually and
|
|
45
62
|
then stacked vertically as decorated group boxes */
|
|
46
63
|
const parts = partitionGroups(graph);
|
|
47
64
|
const layout = parts === null ?
|
|
48
65
|
await renderers[type](graph, config) :
|
|
49
|
-
composeGroups(parts, await Promise.all(parts.map((part) => renderers[type](part.graph, config))));
|
|
66
|
+
composeGroups(parts, await Promise.all(parts.map((part) => renderers[type](part.graph, config))), config);
|
|
50
67
|
/* render the laid out graph into an SVG document */
|
|
51
|
-
const svg = renderSVG(layout, config);
|
|
68
|
+
const svg = renderSVG(layout, config, explicit);
|
|
52
69
|
/* convert the SVG document into the requested output format */
|
|
53
70
|
if (format === "svg:embedded")
|
|
54
71
|
return svg.replace(/^<\?xml[^?]*\?>\n/, "");
|
|
@@ -60,12 +77,13 @@ export class Gradia {
|
|
|
60
77
|
return svg;
|
|
61
78
|
}
|
|
62
79
|
/* render a graph description into an SVG document or data URL
|
|
63
|
-
(combines parse and generate, with the "#
|
|
64
|
-
directives from the spec layered
|
|
65
|
-
explicit
|
|
80
|
+
(combines parse and generate, with the "#type <type>" and
|
|
81
|
+
"#config <option> <value>" directives from the spec layered
|
|
82
|
+
between the defaults and the explicit options) */
|
|
66
83
|
static async render(spec, options = {}) {
|
|
67
84
|
const graph = Gradia.parse(spec);
|
|
85
|
+
const type = options.type ?? parseTypeDirective(spec);
|
|
68
86
|
const config = { ...parseDirectives(spec), ...(options.config ?? {}) };
|
|
69
|
-
return Gradia.generate(graph, { ...options, config });
|
|
87
|
+
return Gradia.generate(graph, { ...options, type, config });
|
|
70
88
|
}
|
|
71
89
|
}
|