@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.
package/README.md CHANGED
@@ -7,7 +7,6 @@ Gradia
7
7
  <p/>
8
8
  <img src="https://nodei.co/npm/@rse/gradia.png?downloads=true&stars=true" alt=""/>
9
9
 
10
- <p/>
11
10
  [![github (author stars)](https://img.shields.io/github/stars/rse?logo=github&label=author%20stars&color=%233377aa)](https://github.com/rse)
12
11
  [![github (author followers)](https://img.shields.io/github/followers/rse?label=author%20followers&logo=github&color=%234477aa)](https://github.com/rse)
13
12
 
@@ -19,12 +18,19 @@ described in a concise textual input language, as SVG diagrams. Three
19
18
  diagram types are supported which fit the scenario of visualizing object
20
19
  graphs:
21
20
 
22
- - `graph`: a loosly grid-snapped layered layout of the whole graph,
21
+ - `graph`: a loosely grid-snapped layered layout of the whole graph,
23
22
  based on the Dagre algorithm of [@antv/layout](https://github.com/antvis/layout).
24
23
  - `hub`: a hub graph, placing one primary hub node between its input and
25
24
  output nodes.
26
25
  - `grid`: a compact grid of tiles for an edge-less graph.
27
26
 
27
+ Playground
28
+ ----------
29
+
30
+ See [playground.html](smp/playground.html) for a demo playground.
31
+
32
+ ![Playground](smp/playground.png)
33
+
28
34
  Installation
29
35
  ------------
30
36
 
@@ -39,12 +45,52 @@ Usage
39
45
  $ gradia [-t graph|hub|grid] [-f <format>] [-c <name>=<value>] -o <file>.svg <graph>.txt
40
46
  ```
41
47
 
48
+ The diagram type is selected with the `-t` command-line option or, when
49
+ this is absent, by the `#type` directive inside the input, and defaults
50
+ to `graph`.
51
+
42
52
  The output format is `svg:standalone` (a standalone SVG/XML document,
43
53
  the default), `svg:embedded` (the SVG without the `<?xml?>` declaration,
44
54
  for direct embedding into HTML), `url:xml` (a `data:image/svg+xml` URL
45
55
  with URL-encoded XML), or `url:base64` (a `data:image/svg+xml` URL with
46
56
  Base64-encoded XML).
47
57
 
58
+ MCP Service
59
+ -----------
60
+
61
+ ```sh
62
+ $ gradia --mcp [-t graph|hub|grid] [-f <format>] [-c <name>=<value>]
63
+ ```
64
+
65
+ With the `-m`/`--mcp` option, **Gradia** instead runs as a [Model Context
66
+ Protocol](https://modelcontextprotocol.io/) (MCP) service on stdio,
67
+ exposing a single tool `gradia_render` which renders a graph description
68
+ into an SVG diagram. The tool takes the arguments `input` (the graph
69
+ description), `type`, `format`, and `config` (an object of rendering
70
+ options), and returns the rendered SVG document or data URL as its text
71
+ result. Its description carries the entire input language grammar, so
72
+ an AI agent can author graph descriptions without further context. The
73
+ command-line options `--type`, `--format`, and `--config` act as the
74
+ defaults underlying the tool call arguments.
75
+
76
+ As the tool call arguments are treated as untrusted, the `config`
77
+ argument rejects the option `font-embed` and a `font-family` value
78
+ pointing to a WOFF2 file. Both remain available through the
79
+ (trusted) command-line options of the service.
80
+
81
+ Configure the service in an MCP client with:
82
+
83
+ ```json
84
+ {
85
+ "mcpServers": {
86
+ "gradia": {
87
+ "command": "npx",
88
+ "args": [ "-y", "@rse/gradia", "--mcp" ]
89
+ }
90
+ }
91
+ }
92
+ ```
93
+
48
94
  Input Syntax
49
95
  ------------
50
96
 
@@ -78,7 +124,9 @@ separated by `|`.
78
124
  <string-char> ::= ( <char> - ( "\"" | "\\" | <newline> ) ) | ( "\\" ( <char> - <newline> ) )
79
125
  <comment> ::= "#" { <char> - <newline> }
80
126
  <directive> ::= "#config" <whitespace> { <whitespace> } <option> <whitespace> { <whitespace> } ( <string> | <word> )
127
+ | "#type" <whitespace> { <whitespace> } <type>
81
128
  <option> ::= <letter> { <letter> | <digit> | "-" }
129
+ <type> ::= "graph" | "hub" | "grid"
82
130
  <word> ::= <char> - <whitespace> { <char> - <whitespace> }
83
131
  <newline> ::= "\n"
84
132
  <whitespace> ::= " " | "\t" | "\r"
@@ -104,10 +152,10 @@ separated by `|`.
104
152
  - A `<string>` cannot span lines. Inside it, `\` escapes the following
105
153
  character (especially `\"` and `\\`).
106
154
 
107
- - A `<directive>` is a `<comment>` which occupies its entire line,
108
- starts with the keyword `#config`, and whose `<option>` is one of the
109
- recognized rendering options (see below). All other comments are
110
- ignored.
155
+ - A `<directive>` is a `<comment>` which occupies its entire line and
156
+ starts with the keyword `#config` or `#type`. For `#config`, the
157
+ `<option>` has to be one of the recognized rendering options (see
158
+ below). All other comments are ignored.
111
159
 
112
160
  ### Semantic Rules
113
161
 
@@ -149,18 +197,54 @@ separated by `|`.
149
197
 
150
198
  ### Directives
151
199
 
152
- A `<directive>` sets one of the rendering options, which otherwise are
153
- settable through the corresponding `--config <option>=<value>`
200
+ A `#type` directive sets the diagram type (`graph`, `hub`, or `grid`),
201
+ which otherwise is settable through the `-t`/`--type` command-line
202
+ option (which takes precedence). The last occurrence wins and an invalid
203
+ type is silently ignored. Without both, the type defaults to `graph`.
204
+
205
+ A `#config` directive sets one of the rendering options, which otherwise
206
+ are settable through the corresponding `--config <option>=<value>`
154
207
  command-line options. The recognized options are:
155
208
 
156
209
  ```
157
- font-family color-node-ghost-name color-group-name
158
- color-node-ghost-box color-group-box
159
- color-node-regular-name color-node-ghost-border color-group-border
160
- color-node-regular-box
161
- color-node-regular-border color-node-primary-name color-edge-line
162
- color-node-primary-box color-edge-name
163
- color-node-primary-border color-edge-arity
210
+ font-family color-edge-line graph-columns-max
211
+ color-node-regular-name color-edge-name graph-channel-width-max
212
+ color-node-regular-box color-edge-arity graph-gutter-height-max
213
+ color-node-regular-border graph-node-separation
214
+ color-node-primary-name size-canvas-margin graph-rank-separation
215
+ color-node-primary-box size-node-width-min hub-channel-width-max
216
+ color-node-primary-border size-node-height-scale hub-channel-width-min
217
+ color-node-ghost-name size-edge-corner-radius hub-node-gap
218
+ color-node-ghost-box size-edge-hop-radius grid-columns-max
219
+ color-node-ghost-border size-edge-track-gap grid-gap-horizontal
220
+ color-group-name group-box-padding grid-gap-vertical
221
+ color-group-box group-box-gap
222
+ color-group-border
223
+ ```
224
+
225
+ The `size-*`, `group-*`, `graph-*`, `hub-*`, and `grid-*` options
226
+ control the rendering geometry (canvas margin, node box sizing, edge
227
+ routing, group box spacing, and the per-diagram-type layout) and take
228
+ non-negative numbers.
229
+
230
+ The `font-family` and `color-*` options are embedded directly into the
231
+ generated SVG. When such an option is *not* explicitly configured, the
232
+ SVG references the CSS custom property `--gradia-<option>` (definable by
233
+ the embedding document, e.g. for the `svg:embedded` output format) and
234
+ falls back to the built-in default. An explicitly configured value is
235
+ hard-coded into the SVG instead. The resulting precedence is: first
236
+ `#config`/`--config`, then the CSS custom property `--gradia-<option>`,
237
+ and finally the built-in default.
238
+
239
+ The `font-family` option is either a built-in font family, a plain font
240
+ family name, or the path to a WOFF2 file. The only built-in font family
241
+ is `Source Sans 3`, shipped as a variable WOFF2 file by the NPM
242
+ dependency [source-sans](https://github.com/adobe-fonts/source-sans),
243
+ and hence the only font which can be embedded without an external font
244
+ file:
245
+
246
+ ```sh
247
+ $ gradia -c font-family="Source Sans 3" -c font-embed=true -o graph.svg graph.txt
164
248
  ```
165
249
 
166
250
  The option `font-embed`, and a `font-family` value pointing to a WOFF2
@@ -170,6 +254,8 @@ as untrusted. Both are available on the command-line only.
170
254
  ### Example
171
255
 
172
256
  ```
257
+ #type graph
258
+
173
259
  #config color-node-primary-box #336699
174
260
  #config color-edge-line #999999
175
261
 
@@ -16,10 +16,34 @@ export declare const configDefaults: {
16
16
  "color-edge-line": string;
17
17
  "color-edge-name": string;
18
18
  "color-edge-arity": string;
19
+ "size-canvas-margin": number;
20
+ "size-node-width-min": number;
21
+ "size-node-height-scale": number;
22
+ "size-edge-corner-radius": number;
23
+ "size-edge-hop-radius": number;
24
+ "size-edge-track-gap": number;
25
+ "group-box-padding": number;
26
+ "group-box-gap": number;
27
+ "graph-columns-max": number;
28
+ "graph-channel-width-max": number;
29
+ "graph-gutter-height-max": number;
30
+ "graph-node-separation": number;
31
+ "graph-rank-separation": number;
32
+ "hub-channel-width-max": number;
33
+ "hub-channel-width-min": number;
34
+ "hub-node-gap": number;
35
+ "grid-columns-max": number;
36
+ "grid-gap-horizontal": number;
37
+ "grid-gap-vertical": number;
19
38
  };
20
39
  export type Config = typeof configDefaults;
40
+ export type ConfigEmbedded = {
41
+ [K in keyof Config]: Config[K] extends string ? K : never;
42
+ }[keyof Config];
43
+ export declare const cssValueOf: (explicit: Partial<Config>, key: ConfigEmbedded) => string;
21
44
  export declare const parseDirectives: (input: string) => Partial<Config>;
22
45
  export declare const resolveFont: (config: Config) => {
23
46
  family: string;
24
47
  embed?: string;
48
+ weight?: string;
25
49
  };
@@ -6,6 +6,7 @@
6
6
  /* built-in dependencies */
7
7
  import fs from "node:fs";
8
8
  import path from "node:path";
9
+ import url from "node:url";
9
10
  /* the rendering configuration and its default values */
10
11
  export const configDefaults = {
11
12
  "font-family": "Helvetica",
@@ -24,8 +25,36 @@ export const configDefaults = {
24
25
  "color-group-border": "#c0d0e0",
25
26
  "color-edge-line": "#999999",
26
27
  "color-edge-name": "#333333",
27
- "color-edge-arity": "#333333"
28
+ "color-edge-arity": "#333333",
29
+ "size-canvas-margin": 40, /* outer margin of the canvas */
30
+ "size-node-width-min": 220, /* minimum node box width */
31
+ "size-node-height-scale": 2.25, /* height scale of node boxes */
32
+ "size-edge-corner-radius": 20, /* corner rounding radius of edges */
33
+ "size-edge-hop-radius": 8, /* radius of edge crossing hops */
34
+ "size-edge-track-gap": 12, /* offset between parallel edge routes */
35
+ "group-box-padding": 30, /* inner padding of group boxes */
36
+ "group-box-gap": 40, /* vertical gap between group boxes */
37
+ "graph-columns-max": 5, /* graph: max side-by-side nodes */
38
+ "graph-channel-width-max": 140, /* graph: max width of column channels */
39
+ "graph-gutter-height-max": 90, /* graph: max height of row gutters */
40
+ "graph-node-separation": 30, /* graph: layered layout node separation */
41
+ "graph-rank-separation": 60, /* graph: layered layout rank separation */
42
+ "hub-channel-width-max": 340, /* hub: max width of column channels */
43
+ "hub-channel-width-min": 240, /* hub: min width of column channels */
44
+ "hub-node-gap": 20, /* hub: vertical gap of stacked nodes */
45
+ "grid-columns-max": 4, /* grid: max side-by-side tiles */
46
+ "grid-gap-horizontal": 40, /* grid: horizontal gap between tiles */
47
+ "grid-gap-vertical": 20 /* grid: vertical gap between tiles */
28
48
  };
49
+ /* resolve a directly embedded configuration option into a CSS value:
50
+ an explicitly configured value is hard-coded (stripped of the
51
+ characters which would end the CSS declaration, to prevent a CSS
52
+ injection from untrusted values), while otherwise the value is
53
+ fetched at display time from the CSS custom property
54
+ "--gradia-<option>", falling back to the built-in default */
55
+ export const cssValueOf = (explicit, key) => Object.hasOwn(explicit, key) ?
56
+ explicit[key].replace(/[;{}]/g, "") :
57
+ `var(--gradia-${key}, ${configDefaults[key]})`;
29
58
  /* parse "#config <option> <value>" configuration directives from a graph
30
59
  description (lines which are otherwise treated as plain comments) */
31
60
  export const parseDirectives = (input) => {
@@ -40,16 +69,45 @@ export const parseDirectives = (input) => {
40
69
  description, as they would let it read arbitrary local WOFF2 files */
41
70
  if (key === "font-embed" || (key === "font-family" && val.endsWith(".woff2")))
42
71
  continue;
43
- partial[key] = typeof configDefaults[key] === "boolean" ?
44
- val === "true" : val;
72
+ if (typeof configDefaults[key] === "boolean")
73
+ partial[key] = val === "true";
74
+ else if (typeof configDefaults[key] === "number") {
75
+ /* silently skip invalid numeric values, as directives are
76
+ lines of an untrusted input and never abort the rendering */
77
+ const num = Number(val);
78
+ if (val === "" || !Number.isFinite(num) || num < 0)
79
+ continue;
80
+ partial[key] = num;
81
+ }
82
+ else
83
+ partial[key] = val;
45
84
  }
46
85
  return partial;
47
86
  };
48
- /* resolve the configured font family: either a plain family name or a
49
- path to a WOFF2 file (its content optionally embedded as base64) */
87
+ /* the built-in font families, shipped as WOFF2 files by NPM
88
+ dependencies and hence embeddable without any external font file */
89
+ const fontsBuiltIn = {
90
+ "Source Sans 3": {
91
+ file: "source-sans/WOFF2/VF/SourceSans3VF-Upright.ttf.woff2",
92
+ weight: "200 900"
93
+ }
94
+ };
95
+ /* resolve the configured font family: either a built-in font family, a
96
+ plain font family name, or a path to a WOFF2 file (the content of
97
+ the two former optionally embedded as base64, together with the
98
+ weight range of the embedded, potentially variable, font) */
50
99
  export const resolveFont = (config) => {
51
100
  const spec = config["font-family"];
52
- if (spec.endsWith(".woff2")) {
101
+ if (Object.hasOwn(fontsBuiltIn, spec)) {
102
+ const builtIn = fontsBuiltIn[spec];
103
+ if (config["font-embed"]) {
104
+ const file = url.fileURLToPath(import.meta.resolve(builtIn.file));
105
+ return { family: spec, embed: fs.readFileSync(file).toString("base64"), weight: builtIn.weight };
106
+ }
107
+ else
108
+ return { family: spec };
109
+ }
110
+ else if (spec.endsWith(".woff2")) {
53
111
  const family = path.basename(spec, ".woff2");
54
112
  if (config["font-embed"])
55
113
  return { family, embed: fs.readFileSync(spec).toString("base64") };
@@ -57,7 +115,9 @@ export const resolveFont = (config) => {
57
115
  return { family };
58
116
  }
59
117
  else if (config["font-embed"])
60
- throw new Error("option \"font-embed\" requires option \"font-family\" to be the path to a WOFF2 file");
118
+ throw new Error("option \"font-embed\" requires option \"font-family\" to be a built-in font " +
119
+ `family (${Object.keys(fontsBuiltIn).map((name) => `"${name}"`).join(", ")}) ` +
120
+ "or the path to a WOFF2 file");
61
121
  else
62
122
  return { family: spec };
63
123
  };
@@ -133,7 +133,7 @@ export const parse = (input) => {
133
133
  /* a node has to stay in a single group, so reject any
134
134
  attempt to re-assign it to a different group */
135
135
  if (attr.key === "group" && node.attrs[k].val !== attr.val)
136
- throw new Error(`node "${id}" cannot be member of more than one group ` +
136
+ throw new Error(`node "${id}" cannot be a member of more than one group ` +
137
137
  `("${node.attrs[k].val}" vs "${attr.val}") ` +
138
138
  `at line ${start.line}, column ${start.col}`);
139
139
  node.attrs[k] = attr;
@@ -1,20 +1,16 @@
1
+ import { ConfigEmbedded } from "./gradia-api-config.js";
1
2
  export declare const FS_NAME = 30;
2
3
  export declare const FS_TYPE = 16;
3
4
  export declare const FS_ATTR = 22;
4
5
  export declare const FS_EDGE = 16;
5
6
  export declare const FS_ARITY = 16;
6
7
  export declare const FS_GROUP = 24;
7
- export declare const MARGIN = 40;
8
- export declare const SLOT = 12;
9
- export declare const RADIUS = 20;
10
- export declare const HOP = 8;
11
- export declare const SCALE_H = 2.25;
12
8
  export declare const textWidth: (text: string, size: number) => number;
13
9
  export declare const escapeXML: (text: string) => string;
14
10
  export type Poly = [number, number][];
15
11
  export interface NodeStyle {
16
- fill: string;
17
- stroke: string;
18
- text: string;
12
+ fill: ConfigEmbedded;
13
+ stroke: ConfigEmbedded;
14
+ text: ConfigEmbedded;
19
15
  dash?: string;
20
16
  }
@@ -3,18 +3,14 @@
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
- /* rendering geometry constants */
7
- export const FS_NAME = 30; /* font size of node names */
8
- export const FS_TYPE = 16; /* font size of node types */
9
- export const FS_ATTR = 22; /* font size of node attributes */
10
- export const FS_EDGE = 16; /* font size of edge labels */
11
- export const FS_ARITY = 16; /* font size of edge arities */
12
- export const FS_GROUP = 24; /* font size of group tags */
13
- export const MARGIN = 40; /* outer margin of the canvas */
14
- export const SLOT = 12; /* offset between parallel routes */
15
- export const RADIUS = 20; /* corner rounding radius of edges */
16
- export const HOP = 8; /* radius of edge crossing hops */
17
- export const SCALE_H = 2.25; /* height scale of node boxes */
6
+ /* rendering font size constants (the further geometry is configurable,
7
+ see the "size-*" and per-diagram-type options in the configuration) */
8
+ export const FS_NAME = 30; /* font size of node names */
9
+ export const FS_TYPE = 16; /* font size of node types */
10
+ export const FS_ATTR = 22; /* font size of node attributes */
11
+ export const FS_EDGE = 16; /* font size of edge labels */
12
+ export const FS_ARITY = 16; /* font size of edge arities */
13
+ export const FS_GROUP = 24; /* font size of group tags */
18
14
  /* estimate rendered text width (no canvas available under Node,
19
15
  factor tuned for the average glyph advance of Source Sans 3) */
20
16
  export const textWidth = (text, size) => text.length * size * 0.52;
@@ -15,9 +15,9 @@ export interface TrackUser {
15
15
  posOut: number;
16
16
  mirror: boolean;
17
17
  }
18
- export declare const assignTracks: (users: TrackUser[], width: number, pad: number) => Map<number, number>;
18
+ export declare const assignTracks: (users: TrackUser[], width: number, pad: number, gap: number) => Map<number, number>;
19
19
  export declare const computeHops: (polys: Poly[]) => Map<number, number[]>[];
20
- export declare const pathOf: (poly: Poly, hop: Map<number, number[]>) => string;
20
+ export declare const pathOf: (poly: Poly, hop: Map<number, number[]>, rounding: number, hopRadius: number) => string;
21
21
  export declare const pointAt: (poly: Poly, fraction: number) => {
22
22
  x: number;
23
23
  y: number;
@@ -3,7 +3,6 @@
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 { RADIUS, HOP, SLOT } from "./gradia-api-render-base.js";
7
6
  /* edge attachment port geometry */
8
7
  const PORT_SEP = 24; /* maximum separation of adjacent ports */
9
8
  const PORT_PAD = 10; /* padding of the port band inside the box */
@@ -66,7 +65,7 @@ export const assignPorts = (edges, sides, cx, cy, boxW, boxH) => {
66
65
  neighbor (back turners get near tracks entry-position-ordered,
67
66
  forward turners far tracks, reversed for mirrored users which
68
67
  enter the channel/gutter from the far side) */
69
- export const assignTracks = (users, width, pad) => {
68
+ export const assignTracks = (users, width, pad, gap) => {
70
69
  const order = (list, mirror) => {
71
70
  const dir = mirror ? -1 : +1;
72
71
  const backs = list.filter((u) => u.posOut < u.posIn)
@@ -79,7 +78,7 @@ export const assignTracks = (users, width, pad) => {
79
78
  ...order(users.filter((u) => !u.mirror), false),
80
79
  ...order(users.filter((u) => u.mirror), true)
81
80
  ];
82
- const step = Math.min(SLOT, (width - pad) / Math.max(ordered.length - 1, 1));
81
+ const step = Math.min(gap, (width - pad) / Math.max(ordered.length - 1, 1));
83
82
  const offsets = new Map();
84
83
  ordered.forEach((u, idx) => {
85
84
  offsets.set(u.edge, (idx - (ordered.length - 1) / 2) * step);
@@ -122,17 +121,17 @@ export const computeHops = (polys) => {
122
121
  };
123
122
  /* convert an edge polyline into an SVG path with rounded corners
124
123
  at the bends and semi-circular hops at the crossing points */
125
- export const pathOf = (poly, hop) => {
124
+ export const pathOf = (poly, hop, rounding, hopRadius) => {
126
125
  const len = (a, b) => Math.abs(b[0] - a[0]) + Math.abs(b[1] - a[1]);
127
126
  const radius = (k) => {
128
127
  if (k <= 0 || k >= poly.length - 1)
129
128
  return 0;
130
- let r = Math.min(RADIUS, len(poly[k - 1], poly[k]) / 2, len(poly[k], poly[k + 1]) / 2);
129
+ let r = Math.min(rounding, len(poly[k - 1], poly[k]) / 2, len(poly[k], poly[k + 1]) / 2);
131
130
  /* shrink the corner rounding when a crossing hop sits close
132
131
  to the bend, so the hop is never swallowed by the arc */
133
132
  for (const seg of [k - 1, k])
134
133
  for (const hx of hop.get(seg) ?? [])
135
- r = Math.min(r, Math.max(Math.abs(hx - poly[k][0]) - HOP - 2, 4));
134
+ r = Math.min(r, Math.max(Math.abs(hx - poly[k][0]) - hopRadius - 2, 4));
136
135
  return r;
137
136
  };
138
137
  const d = [];
@@ -159,14 +158,14 @@ export const pathOf = (poly, hop) => {
159
158
  const groups = [];
160
159
  for (const hx of xs) {
161
160
  const g = groups[groups.length - 1];
162
- if (g !== undefined && Math.abs(hx - g[g.length - 1]) <= HOP * 2 + 14)
161
+ if (g !== undefined && Math.abs(hx - g[g.length - 1]) <= hopRadius * 2 + 14)
163
162
  g.push(hx);
164
163
  else
165
164
  groups.push([hx]);
166
165
  }
167
166
  for (const g of groups) {
168
- let x1 = g[0] - dir * HOP;
169
- let x2 = g[g.length - 1] + dir * HOP;
167
+ let x1 = g[0] - dir * hopRadius;
168
+ let x2 = g[g.length - 1] + dir * hopRadius;
170
169
  /* clamp the arc into the drawable segment part, so
171
170
  crossings close to a corner still get their hop */
172
171
  if ((x1 - p0[0]) * dir < 0)
@@ -174,7 +173,7 @@ export const pathOf = (poly, hop) => {
174
173
  if ((p1[0] - x2) * dir < 0)
175
174
  x2 = p1[0];
176
175
  d.push(`L ${x1} ${a[1]}`);
177
- d.push(`A ${Math.abs(x2 - x1) / 2} ${HOP} 0 0 ${dir > 0 ? 1 : 0} ${x2} ${a[1]}`);
176
+ d.push(`A ${Math.abs(x2 - x1) / 2} ${hopRadius} 0 0 ${dir > 0 ? 1 : 0} ${x2} ${a[1]}`);
178
177
  }
179
178
  }
180
179
  d.push(`L ${p1[0]} ${p1[1]}`);
@@ -1,4 +1,5 @@
1
1
  import { Node, Graph } from "./gradia-api-model.js";
2
+ import { Config } from "./gradia-api-config.js";
2
3
  import { Layout } from "./gradia-api-render-svg.js";
3
4
  export declare const groupOf: (node: Node) => string | undefined;
4
5
  export interface GroupPart {
@@ -6,4 +7,4 @@ export interface GroupPart {
6
7
  graph: Graph;
7
8
  }
8
9
  export declare const partitionGroups: (graph: Graph) => GroupPart[] | null;
9
- export declare const composeGroups: (parts: GroupPart[], layouts: Layout[]) => Layout;
10
+ export declare const composeGroups: (parts: GroupPart[], layouts: Layout[], config: Config) => Layout;
@@ -3,11 +3,9 @@
3
3
  ** Copyright (c) 2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
4
4
  ** Distributed under MIT license <https://spdx.org/licenses/MIT.html>
5
5
  */
6
- import { FS_GROUP, MARGIN, textWidth } from "./gradia-api-render-base.js";
6
+ import { FS_GROUP, textWidth } from "./gradia-api-render-base.js";
7
7
  /* rendering geometry constants */
8
- const GROUP_PAD = 30; /* inner padding of group boxes */
9
- const GROUP_HEAD = 34; /* extra top space for the group tag */
10
- const GROUP_GAP = 40; /* vertical gap between stacked group boxes */
8
+ const GROUP_HEAD = 34; /* extra top space for the group tag */
11
9
  /* the special "group" annotation (assigning a node to a named group) */
12
10
  export const groupOf = (node) => node.attrs.findLast((attr) => attr.key === "group")?.val;
13
11
  /* partition the graph into its named groups (in order of first
@@ -65,24 +63,28 @@ const boundsOf = (layout) => {
65
63
  (a node id is unique across the groups, as every node is a member
66
64
  of exactly one group, so the maps can be merged and the coordinate
67
65
  functions can dispatch on the node id) */
68
- export const composeGroups = (parts, layouts) => {
66
+ export const composeGroups = (parts, layouts, config) => {
67
+ /* resolve the configurable rendering geometry */
68
+ const margin = config["size-canvas-margin"];
69
+ const pad = config["group-box-padding"];
70
+ const gap = config["group-box-gap"];
69
71
  /* stack the group boxes vertically and determine the coordinate
70
72
  offset which shifts each layout into its group box */
71
73
  const groups = [];
72
74
  const dxs = [];
73
75
  const dys = [];
74
76
  const groupIdx = new Map();
75
- let y = MARGIN;
77
+ let y = margin;
76
78
  layouts.forEach((layout, i) => {
77
79
  const b = boundsOf(layout);
78
- const w = Math.max(b.maxX - b.minX + GROUP_PAD * 2, textWidth(parts[i].name, FS_GROUP) + GROUP_PAD * 2);
79
- const h = b.maxY - b.minY + GROUP_PAD * 2 + GROUP_HEAD;
80
- dxs.push(MARGIN + GROUP_PAD - b.minX);
81
- dys.push(y + GROUP_HEAD + GROUP_PAD - b.minY);
80
+ const w = Math.max(b.maxX - b.minX + pad * 2, textWidth(parts[i].name, FS_GROUP) + pad * 2);
81
+ const h = b.maxY - b.minY + pad * 2 + GROUP_HEAD;
82
+ dxs.push(margin + pad - b.minX);
83
+ dys.push(y + GROUP_HEAD + pad - b.minY);
82
84
  for (const node of layout.nodes)
83
85
  groupIdx.set(node.id, i);
84
- groups.push({ name: parts[i].name, x: MARGIN, y, w, h });
85
- y += h + GROUP_GAP;
86
+ groups.push({ name: parts[i].name, x: margin, y, w, h });
87
+ y += h + gap;
86
88
  });
87
89
  /* merge the per-group layouts into the composed layout */
88
90
  const merge = (pick) => new Map(layouts.flatMap((layout) => Array.from(pick(layout).entries())));
@@ -2,7 +2,7 @@ import { Attr, Node } from "./gradia-api-model.js";
2
2
  import { Config } from "./gradia-api-config.js";
3
3
  import { NodeStyle } from "./gradia-api-render-base.js";
4
4
  export declare const isPrimary: (node: Node) => boolean;
5
- export declare const defaultStyleOf: (config: Config) => (node: Node) => NodeStyle;
5
+ export declare const defaultStyleOf: (node: Node) => NodeStyle;
6
6
  export declare const typeOf: (node: Node) => string | undefined;
7
7
  export declare const attrsOfNode: (node: Node) => Attr[];
8
8
  export declare const urlOf: (node: Node) => string | undefined;
@@ -11,7 +11,7 @@ export declare const ATTR_H = 30;
11
11
  export declare const ATTR_P = 8;
12
12
  export declare const TYPE_H = 26;
13
13
  export declare const TYPE_D = 36;
14
- export declare const measureNodes: (nodes: Node[], scaleOf: (node: Node) => number) => {
14
+ export declare const measureNodes: (nodes: Node[], config: Config, scaleOf: (node: Node) => number) => {
15
15
  boxW: Map<string, number>;
16
16
  boxH: Map<string, number>;
17
17
  contentH: Map<string, number>;
@@ -7,14 +7,14 @@ import { FS_NAME, FS_TYPE, FS_ATTR, textWidth } from "./gradia-api-render-base.j
7
7
  /* the special "primary" annotation (rendered as a distinct node coloring) */
8
8
  export const isPrimary = (node) => node.attrs.findLast((attr) => attr.key === "primary")?.val === "true";
9
9
  /* the default node box coloring (primary node boxes get a distinct one) */
10
- export const defaultStyleOf = (config) => (node) => isPrimary(node) ? {
11
- fill: config["color-node-primary-box"],
12
- stroke: config["color-node-primary-border"],
13
- text: config["color-node-primary-name"]
10
+ export const defaultStyleOf = (node) => isPrimary(node) ? {
11
+ fill: "color-node-primary-box",
12
+ stroke: "color-node-primary-border",
13
+ text: "color-node-primary-name"
14
14
  } : {
15
- fill: config["color-node-regular-box"],
16
- stroke: config["color-node-regular-border"],
17
- text: config["color-node-regular-name"]
15
+ fill: "color-node-regular-box",
16
+ stroke: "color-node-regular-border",
17
+ text: "color-node-regular-name"
18
18
  };
19
19
  /* the special "type" annotation (rendered above the node name) */
20
20
  export const typeOf = (node) => node.attrs.findLast((attr) => attr.key === "type")?.val;
@@ -48,8 +48,7 @@ export const TYPE_D = 36; /* baseline distance of the type to the name */
48
48
  /* determine node box sizes from their textual content
49
49
  (boxes are scaled up in height by a per-node factor to
50
50
  give the edges more attachment room) */
51
- export const measureNodes = (nodes, scaleOf) => {
52
- const MIN_W = 220; /* minimum node box width */
51
+ export const measureNodes = (nodes, config, scaleOf) => {
53
52
  const PAD_W = 18; /* left/right node box text padding */
54
53
  /* the resulting node box dimensions */
55
54
  const boxW = new Map();
@@ -66,7 +65,7 @@ export const measureNodes = (nodes, scaleOf) => {
66
65
  w = Math.max(w, textWidth(`${attr.key}: ${attr.val}`, FS_ATTR));
67
66
  const h = MIN_H + (type !== undefined ? TYPE_H : 0) +
68
67
  (attrs.length > 0 ? attrs.length * ATTR_H + ATTR_P : 0);
69
- boxW.set(node.id, Math.max(MIN_W, Math.ceil(w) + PAD_W * 2));
68
+ boxW.set(node.id, Math.max(config["size-node-width-min"], Math.ceil(w) + PAD_W * 2));
70
69
  boxH.set(node.id, Math.ceil(h * scaleOf(node)));
71
70
  contentH.set(node.id, h);
72
71
  }
@@ -20,4 +20,4 @@ export interface Layout {
20
20
  styleOf?: (node: Node) => NodeStyle;
21
21
  groups?: GroupBox[];
22
22
  }
23
- export declare const renderSVG: (layout: Layout, config: Config) => string;
23
+ export declare const renderSVG: (layout: Layout, config: Config, explicit: Partial<Config>) => string;