@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
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
|
[](https://github.com/rse)
|
|
12
11
|
[](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
|
|
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
|
+

|
|
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
|
|
109
|
-
recognized rendering options (see
|
|
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
|
|
153
|
-
settable through the
|
|
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-
|
|
158
|
-
|
|
159
|
-
color-node-regular-
|
|
160
|
-
color-node-regular-
|
|
161
|
-
color-node-
|
|
162
|
-
|
|
163
|
-
|
|
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 color-edge-halo 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,35 @@ export declare const configDefaults: {
|
|
|
16
16
|
"color-edge-line": string;
|
|
17
17
|
"color-edge-name": string;
|
|
18
18
|
"color-edge-arity": string;
|
|
19
|
+
"color-edge-halo": string;
|
|
20
|
+
"size-canvas-margin": number;
|
|
21
|
+
"size-node-width-min": number;
|
|
22
|
+
"size-node-height-scale": number;
|
|
23
|
+
"size-edge-corner-radius": number;
|
|
24
|
+
"size-edge-hop-radius": number;
|
|
25
|
+
"size-edge-track-gap": number;
|
|
26
|
+
"group-box-padding": number;
|
|
27
|
+
"group-box-gap": number;
|
|
28
|
+
"graph-columns-max": number;
|
|
29
|
+
"graph-channel-width-max": number;
|
|
30
|
+
"graph-gutter-height-max": number;
|
|
31
|
+
"graph-node-separation": number;
|
|
32
|
+
"graph-rank-separation": number;
|
|
33
|
+
"hub-channel-width-max": number;
|
|
34
|
+
"hub-channel-width-min": number;
|
|
35
|
+
"hub-node-gap": number;
|
|
36
|
+
"grid-columns-max": number;
|
|
37
|
+
"grid-gap-horizontal": number;
|
|
38
|
+
"grid-gap-vertical": number;
|
|
19
39
|
};
|
|
20
40
|
export type Config = typeof configDefaults;
|
|
41
|
+
export type ConfigEmbedded = {
|
|
42
|
+
[K in keyof Config]: Config[K] extends string ? K : never;
|
|
43
|
+
}[keyof Config];
|
|
44
|
+
export declare const cssValueOf: (explicit: Partial<Config>, key: ConfigEmbedded) => string;
|
|
21
45
|
export declare const parseDirectives: (input: string) => Partial<Config>;
|
|
22
46
|
export declare const resolveFont: (config: Config) => {
|
|
23
47
|
family: string;
|
|
24
48
|
embed?: string;
|
|
49
|
+
weight?: string;
|
|
25
50
|
};
|
package/dst/gradia-api-config.js
CHANGED
|
@@ -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,37 @@ 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
|
+
"color-edge-halo": "#ffffff",
|
|
30
|
+
"size-canvas-margin": 40, /* outer margin of the canvas */
|
|
31
|
+
"size-node-width-min": 220, /* minimum node box width */
|
|
32
|
+
"size-node-height-scale": 2.25, /* height scale of node boxes */
|
|
33
|
+
"size-edge-corner-radius": 20, /* corner rounding radius of edges */
|
|
34
|
+
"size-edge-hop-radius": 8, /* radius of edge crossing hops */
|
|
35
|
+
"size-edge-track-gap": 12, /* offset between parallel edge routes */
|
|
36
|
+
"group-box-padding": 30, /* inner padding of group boxes */
|
|
37
|
+
"group-box-gap": 40, /* vertical gap between group boxes */
|
|
38
|
+
"graph-columns-max": 5, /* graph: max side-by-side nodes */
|
|
39
|
+
"graph-channel-width-max": 140, /* graph: max width of column channels */
|
|
40
|
+
"graph-gutter-height-max": 90, /* graph: max height of row gutters */
|
|
41
|
+
"graph-node-separation": 30, /* graph: layered layout node separation */
|
|
42
|
+
"graph-rank-separation": 60, /* graph: layered layout rank separation */
|
|
43
|
+
"hub-channel-width-max": 340, /* hub: max width of column channels */
|
|
44
|
+
"hub-channel-width-min": 240, /* hub: min width of column channels */
|
|
45
|
+
"hub-node-gap": 20, /* hub: vertical gap of stacked nodes */
|
|
46
|
+
"grid-columns-max": 4, /* grid: max side-by-side tiles */
|
|
47
|
+
"grid-gap-horizontal": 40, /* grid: horizontal gap between tiles */
|
|
48
|
+
"grid-gap-vertical": 20 /* grid: vertical gap between tiles */
|
|
28
49
|
};
|
|
50
|
+
/* resolve a directly embedded configuration option into a CSS value:
|
|
51
|
+
an explicitly configured value is hard-coded (stripped of the
|
|
52
|
+
characters which would end the CSS declaration, to prevent a CSS
|
|
53
|
+
injection from untrusted values), while otherwise the value is
|
|
54
|
+
fetched at display time from the CSS custom property
|
|
55
|
+
"--gradia-<option>", falling back to the built-in default */
|
|
56
|
+
export const cssValueOf = (explicit, key) => Object.hasOwn(explicit, key) ?
|
|
57
|
+
explicit[key].replace(/[;{}]/g, "") :
|
|
58
|
+
`var(--gradia-${key}, ${configDefaults[key]})`;
|
|
29
59
|
/* parse "#config <option> <value>" configuration directives from a graph
|
|
30
60
|
description (lines which are otherwise treated as plain comments) */
|
|
31
61
|
export const parseDirectives = (input) => {
|
|
@@ -40,16 +70,45 @@ export const parseDirectives = (input) => {
|
|
|
40
70
|
description, as they would let it read arbitrary local WOFF2 files */
|
|
41
71
|
if (key === "font-embed" || (key === "font-family" && val.endsWith(".woff2")))
|
|
42
72
|
continue;
|
|
43
|
-
|
|
44
|
-
val === "true"
|
|
73
|
+
if (typeof configDefaults[key] === "boolean")
|
|
74
|
+
partial[key] = val === "true";
|
|
75
|
+
else if (typeof configDefaults[key] === "number") {
|
|
76
|
+
/* silently skip invalid numeric values, as directives are
|
|
77
|
+
lines of an untrusted input and never abort the rendering */
|
|
78
|
+
const num = Number(val);
|
|
79
|
+
if (val === "" || !Number.isFinite(num) || num < 0)
|
|
80
|
+
continue;
|
|
81
|
+
partial[key] = num;
|
|
82
|
+
}
|
|
83
|
+
else
|
|
84
|
+
partial[key] = val;
|
|
45
85
|
}
|
|
46
86
|
return partial;
|
|
47
87
|
};
|
|
48
|
-
/*
|
|
49
|
-
|
|
88
|
+
/* the built-in font families, shipped as WOFF2 files by NPM
|
|
89
|
+
dependencies and hence embeddable without any external font file */
|
|
90
|
+
const fontsBuiltIn = {
|
|
91
|
+
"Source Sans 3": {
|
|
92
|
+
file: "source-sans/WOFF2/VF/SourceSans3VF-Upright.ttf.woff2",
|
|
93
|
+
weight: "200 900"
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
/* resolve the configured font family: either a built-in font family, a
|
|
97
|
+
plain font family name, or a path to a WOFF2 file (the content of
|
|
98
|
+
the two former optionally embedded as base64, together with the
|
|
99
|
+
weight range of the embedded, potentially variable, font) */
|
|
50
100
|
export const resolveFont = (config) => {
|
|
51
101
|
const spec = config["font-family"];
|
|
52
|
-
if (
|
|
102
|
+
if (Object.hasOwn(fontsBuiltIn, spec)) {
|
|
103
|
+
const builtIn = fontsBuiltIn[spec];
|
|
104
|
+
if (config["font-embed"]) {
|
|
105
|
+
const file = url.fileURLToPath(import.meta.resolve(builtIn.file));
|
|
106
|
+
return { family: spec, embed: fs.readFileSync(file).toString("base64"), weight: builtIn.weight };
|
|
107
|
+
}
|
|
108
|
+
else
|
|
109
|
+
return { family: spec };
|
|
110
|
+
}
|
|
111
|
+
else if (spec.endsWith(".woff2")) {
|
|
53
112
|
const family = path.basename(spec, ".woff2");
|
|
54
113
|
if (config["font-embed"])
|
|
55
114
|
return { family, embed: fs.readFileSync(spec).toString("base64") };
|
|
@@ -57,7 +116,9 @@ export const resolveFont = (config) => {
|
|
|
57
116
|
return { family };
|
|
58
117
|
}
|
|
59
118
|
else if (config["font-embed"])
|
|
60
|
-
throw new Error("option \"font-embed\" requires option \"font-family\" to be
|
|
119
|
+
throw new Error("option \"font-embed\" requires option \"font-family\" to be a built-in font " +
|
|
120
|
+
`family (${Object.keys(fontsBuiltIn).map((name) => `"${name}"`).join(", ")}) ` +
|
|
121
|
+
"or the path to a WOFF2 file");
|
|
61
122
|
else
|
|
62
123
|
return { family: spec };
|
|
63
124
|
};
|
package/dst/gradia-api-parser.js
CHANGED
|
@@ -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:
|
|
17
|
-
stroke:
|
|
18
|
-
text:
|
|
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
|
|
7
|
-
|
|
8
|
-
export const
|
|
9
|
-
export const
|
|
10
|
-
export const
|
|
11
|
-
export const
|
|
12
|
-
export const
|
|
13
|
-
export const
|
|
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[]
|
|
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(
|
|
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(
|
|
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]) -
|
|
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]) <=
|
|
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 *
|
|
169
|
-
let x2 = g[g.length - 1] + dir *
|
|
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} ${
|
|
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,
|
|
6
|
+
import { FS_GROUP, textWidth } from "./gradia-api-render-base.js";
|
|
7
7
|
/* rendering geometry constants */
|
|
8
|
-
const
|
|
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 =
|
|
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 +
|
|
79
|
-
const h = b.maxY - b.minY +
|
|
80
|
-
dxs.push(
|
|
81
|
-
dys.push(y + GROUP_HEAD +
|
|
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:
|
|
85
|
-
y += h +
|
|
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: (
|
|
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 = (
|
|
11
|
-
fill:
|
|
12
|
-
stroke:
|
|
13
|
-
text:
|
|
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:
|
|
16
|
-
stroke:
|
|
17
|
-
text:
|
|
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(
|
|
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;
|