@rse/gradia 0.9.0 → 0.9.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.
@@ -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
- import { resolveFont } from "./gradia-api-config.js";
7
- import { FS_NAME, FS_TYPE, FS_ATTR, FS_EDGE, FS_ARITY, FS_GROUP, MARGIN, textWidth, escapeXML } from "./gradia-api-render-base.js";
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
- `fill="${escapeXML(style.fill)}" stroke="${escapeXML(style.stroke)}" stroke-width="4.0"` +
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-family="${font}" font-size="${FS_TYPE}" ` +
62
- `fill="${escapeXML(style.text)}">${escapeXML(type)}</text>`);
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-family="${font}" font-size="${FS_NAME}" font-weight="600" ` +
65
- `fill="${escapeXML(style.text)}">${escapeXML(node.name)}</text>`);
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-family="${font}" font-size="${FS_ATTR}" ` +
69
- `fill="${escapeXML(style.text)}">${escapeXML(`${attr.key}: ${attr.val}`)}</text>`);
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 - MARGIN / 2),
99
- y: Math.floor(minY - MARGIN / 2),
100
- w: Math.ceil(maxX - minX + MARGIN),
101
- h: Math.ceil(maxY - minY + MARGIN)
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(config);
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 font = escapeXML(`'${escapeCSS(family)}', ui-sans-serif, -apple-system, Helvetica, Arial, sans-serif`);
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 */
@@ -119,8 +139,8 @@ export const renderSVG = (layout, config) => {
119
139
  /* the white halo rendered behind the edge labels for readability */
120
140
  const halo = "stroke=\"#ffffff\" stroke-width=\"4.5\" paint-order=\"stroke\" stroke-linejoin=\"round\"";
121
141
  edges.forEach((edge, i) => {
122
- svgEdges.push(`<path d="${pathOf(polys[i], hops[i])}" fill="none" ` +
123
- `stroke="${escapeXML(config["color-edge-line"])}" stroke-width="3" marker-end="url(#arrow)"/>`);
142
+ svgEdges.push(`<path d="${pathOf(polys[i], hops[i], config["size-edge-corner-radius"], config["size-edge-hop-radius"])}" fill="none" ` +
143
+ `style="stroke: ${color("color-edge-line")}" stroke-width="3.0" marker-end="url(#${idArrow})"/>`);
124
144
  if (edge.name !== undefined) {
125
145
  const w = textWidth(edge.name, FS_EDGE);
126
146
  const candidates = [];
@@ -137,8 +157,9 @@ export const renderSVG = (layout, config) => {
137
157
  }
138
158
  const box = claim(candidates);
139
159
  svgLabels.push(`<text x="${(box[0] + box[2]) / 2}" y="${box[3] - 3}" text-anchor="middle" ` +
140
- `font-family="${font}" font-size="${FS_EDGE}" ` +
141
- `fill="${escapeXML(config["color-edge-name"])}" ${halo}>${escapeXML(edge.name)}</text>`);
160
+ `font-size="${FS_EDGE}" ` +
161
+ `style="font-family: ${font}; fill: ${color("color-edge-name")}" ` +
162
+ `${halo}>${escapeXML(edge.name)}</text>`);
142
163
  }
143
164
  if (edge.arity !== undefined) {
144
165
  const w = textWidth(edge.arity, FS_ARITY);
@@ -152,25 +173,26 @@ export const renderSVG = (layout, config) => {
152
173
  [ax - w / 2 - dx * 14, p.y - 17, ax + w / 2 - dx * 14, p.y - 4]
153
174
  ]);
154
175
  svgLabels.push(`<text x="${(box[0] + box[2]) / 2}" y="${box[3] - 3}" text-anchor="middle" ` +
155
- `font-family="${font}" font-size="${FS_ARITY}" ` +
156
- `fill="${escapeXML(config["color-edge-arity"])}" ${halo}>${escapeXML(edge.arity)}</text>`);
176
+ `font-size="${FS_ARITY}" ` +
177
+ `style="font-family: ${font}; fill: ${color("color-edge-arity")}" ` +
178
+ `${halo}>${escapeXML(edge.arity)}</text>`);
157
179
  }
158
180
  });
159
181
  /* generate the SVG fragments for the node boxes */
160
- const svgNodes = nodes.flatMap((node) => renderNode(node, layout, styleOf(node), font));
182
+ const svgNodes = nodes.flatMap((node) => renderNode(node, layout, styleOf(node), font, color));
161
183
  /* generate the SVG fragments for the group boxes (drawn below
162
184
  everything else) and their tags in the top-left corners */
163
185
  const svgGroups = groups.flatMap((group) => [
164
186
  `<rect x="${group.x}" y="${group.y}" width="${group.w}" height="${group.h}" rx="12" ` +
165
- `fill="${escapeXML(config["color-group-box"])}" ` +
166
- `stroke="${escapeXML(config["color-group-border"])}" stroke-width="3.0"/>`,
187
+ `style="fill: ${color("color-group-box")}; ` +
188
+ `stroke: ${color("color-group-border")}" stroke-width="3.0"/>`,
167
189
  `<text x="${group.x + TAG_DX}" y="${group.y + TAG_DY + FS_GROUP}" ` +
168
- `font-family="${font}" font-size="${FS_GROUP}" font-weight="600" ` +
169
- `fill="${escapeXML(config["color-group-name"])}">${escapeXML(group.name)}</text>`
190
+ `font-size="${FS_GROUP}" font-weight="600" ` +
191
+ `style="font-family: ${font}; fill: ${color("color-group-name")}">${escapeXML(group.name)}</text>`
170
192
  ]);
171
193
  /* determine the overall bounding box of all rendered elements */
172
194
  const groupBoxes = groups.map((group) => [group.x, group.y, group.x + group.w, group.y + group.h]);
173
- const vb = viewBoxOf(layout, [...occupied, ...groupBoxes]);
195
+ const vb = viewBoxOf(layout, [...occupied, ...groupBoxes], config["size-canvas-margin"]);
174
196
  /* assemble the final SVG document */
175
197
  return [
176
198
  "<?xml version=\"1.0\" encoding=\"UTF-8\"?>",
@@ -180,13 +202,14 @@ export const renderSVG = (layout, config) => {
180
202
  ...(embed !== undefined ? [
181
203
  "<style>",
182
204
  `@font-face { font-family: "${escapeXML(escapeCSS(family))}"; ` +
205
+ (weight !== undefined ? `font-weight: ${escapeXML(escapeCSS(weight))}; ` : "") +
183
206
  `src: url(data:font/woff2;base64,${embed}) format("woff2"); }`,
184
207
  "</style>"
185
208
  ] : []),
186
- "<marker id=\"arrow\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\" " +
209
+ `<marker id="${idArrow}" viewBox="0 0 10 10" refX="9" refY="5" ` +
187
210
  "markerWidth=\"21\" markerHeight=\"21\" markerUnits=\"userSpaceOnUse\" " +
188
211
  "orient=\"auto-start-reverse\">",
189
- `<path d="M 0 1 L 9 5 L 0 9 z" fill="${escapeXML(config["color-edge-line"])}"/>`,
212
+ `<path d="M 0 1 L 9 5 L 0 9 z" style="fill: ${color("color-edge-line")}"/>`,
190
213
  "</marker>",
191
214
  "</defs>",
192
215
  ...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, _config: Config) => Promise<Layout>;
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 CHANNEL = 140; /* max width of inter-column channels */
13
- const GUTTER = 90; /* max height of inter-row gutters */
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(CHANNEL, 28 + (cnt - 1) * SLOT);
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(GUTTER, 24 + (cnt - 1) * SLOT);
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 = MARGIN;
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 = MARGIN;
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 * SLOT;
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, _config) => {
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, () => SCALE_H);
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: 30,
267
- ranksep: 60,
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
- MAXCOLS side-by-side nodes by wrapping excess columns into
287
- additional row bands below, so wide graphs grow in height */
288
- if (gridCols > MAXCOLS) {
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 % MAXCOLS);
292
- row.set(node.id, row.get(node.id) + Math.floor(c / MAXCOLS) * gridRows);
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, MAXCOLS);
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, _config: Config) => Promise<Layout>;
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, _config) => {
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, () => SCALE_H / 2);
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 MAXCOLS columns, so larger graphs
29
- grow only in height), with each column as wide as its widest
30
- tile and each tile left-aligned within its column */
31
- const cols = Math.max(Math.min(Math.ceil(Math.sqrt(nodes.length)), MAXCOLS), 1);
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 = MARGIN;
37
+ let x = margin;
38
38
  for (let c = 0; c < cols; c++) {
39
39
  colLX.push(x);
40
- x += colWidth[c] + GAP_H;
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, MARGIN + Math.floor(i / cols) * (tileH + GAP_V) + tileH / 2);
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 ? SCALE_H : SCALE_H / 2);
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) * STACK;
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 = MARGIN + (totalH - stackH(list)) / 2;
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) + STACK;
96
+ y += boxH.get(node.id) + gap;
95
97
  }
96
98
  };
97
99
  stack(inputs);
98
100
  stack(outputs);
99
- nodeCY.set(center.id, MARGIN + totalH / 2);
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(CHANNEL, Math.max(CHANMIN, 28 + (cnt - 1) * SLOT)));
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 = MARGIN;
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; an IOG has no backward
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: config["color-node-ghost-box"],
175
- stroke: config["color-node-ghost-border"],
176
- text: config["color-node-ghost-name"],
175
+ fill: "color-node-ghost-box",
176
+ stroke: "color-node-ghost-border",
177
+ text: "color-node-ghost-name",
177
178
  dash: "10 6"
178
- } : styleDefault(node);
179
+ } : defaultStyleOf(node);
179
180
  return { nodes, edges, cx, cy, boxW, boxH, contentH, polys, styleOf };
180
181
  };
@@ -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, _config: Config) => Promise<Layout>;
7
+ graph: (graph: Graph, config: Config) => Promise<Layout>;
8
8
  hub: (graph: Graph, config: Config) => Promise<Layout>;
9
- grid: (graph: Graph, _config: Config) => Promise<Layout>;
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 config = { ...configDefaults, ...(options.config ?? {}) };
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 "#config <option> <value>"
64
- directives from the spec layered between the defaults and the
65
- explicit config options) */
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
  }