@rse/gradia 0.9.8 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +82 -16
- package/dst/gradia-api-config.d.ts +2 -0
- package/dst/gradia-api-config.js +12 -5
- package/dst/gradia-api-parser.js +8 -5
- package/dst/gradia-api-render-base.js +5 -3
- package/dst/gradia-api-render-edge.js +3 -2
- package/dst/gradia-api-render-group.js +1 -1
- package/dst/gradia-api-render-node.js +5 -4
- package/dst/gradia-api-render-svg.d.ts +1 -1
- package/dst/gradia-api-render-svg.js +70 -54
- package/dst/gradia-api-type-graph.js +20 -10
- package/dst/gradia-api-type-grid.js +14 -7
- package/dst/gradia-api-type-hub.js +7 -9
- package/dst/gradia-api.js +20 -4
- package/dst/gradia-api.umd.js +2 -2
- package/dst/gradia-cli.js +5 -3
- package/dst/gradia-mcp.js +85 -14
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -24,6 +24,23 @@ graphs:
|
|
|
24
24
|
output nodes.
|
|
25
25
|
- `grid`: a compact grid of tiles for an edge-less graph.
|
|
26
26
|
|
|
27
|
+
Examples
|
|
28
|
+
--------
|
|
29
|
+
|
|
30
|
+
Here are examples of the three graph diagram renderings:
|
|
31
|
+
|
|
32
|
+
- [broadcast-graph](smp/broadcast-graph.txt):
|
|
33
|
+
|
|
34
|
+

|
|
35
|
+
|
|
36
|
+
- [broadcast-hub](smp/broadcast-hub.txt):
|
|
37
|
+
|
|
38
|
+

|
|
39
|
+
|
|
40
|
+
- [broadcast-grid](smp/broadcast-grid.txt):
|
|
41
|
+
|
|
42
|
+

|
|
43
|
+
|
|
27
44
|
Playground
|
|
28
45
|
----------
|
|
29
46
|
|
|
@@ -179,7 +196,9 @@ separated by `|`.
|
|
|
179
196
|
- Four attribute keys are reserved and consumed by the renderer instead
|
|
180
197
|
of being displayed as a regular `<key>: <val>` line:
|
|
181
198
|
|
|
182
|
-
- `url: <url>` renders the node box as a hyperlink
|
|
199
|
+
- `url: <url>` renders the node box as a hyperlink. Only relative URLs
|
|
200
|
+
and the schemes `http`, `https`, and `mailto` are honored; any other
|
|
201
|
+
URL is silently dropped and the node box then simply stays unlinked,
|
|
183
202
|
- `type: <name>` renders `<name>` as a smaller text above the node
|
|
184
203
|
label inside the node box,
|
|
185
204
|
- `primary: true` renders the node in the primary node colors
|
|
@@ -195,6 +214,38 @@ separated by `|`.
|
|
|
195
214
|
- All remaining attributes are displayed as `<key>: <val>` lines inside
|
|
196
215
|
the node box.
|
|
197
216
|
|
|
217
|
+
### Diagram Type Constraints
|
|
218
|
+
|
|
219
|
+
The diagram type `graph` imposes no constraints on the topology: it
|
|
220
|
+
accepts self-loops and free-standing nodes alike, and hence is the safe
|
|
221
|
+
choice whenever the topology is not known to fit `hub` or `grid`.
|
|
222
|
+
|
|
223
|
+
The diagram type `hub` rejects the input with an error unless all of the
|
|
224
|
+
following conditions hold:
|
|
225
|
+
|
|
226
|
+
- Exactly one node carries the attribute `primary: true`.
|
|
227
|
+
|
|
228
|
+
- Every edge either points to or originates from that primary node. An
|
|
229
|
+
edge between two non-primary nodes is rejected.
|
|
230
|
+
|
|
231
|
+
- The primary node carries no self-loop.
|
|
232
|
+
|
|
233
|
+
- Every non-primary node is an input or an output of the primary node,
|
|
234
|
+
i.e., no node is free-standing.
|
|
235
|
+
|
|
236
|
+
A node which is both an input and an output of the primary node is placed
|
|
237
|
+
twice, once in the input column and once in the output column. The second
|
|
238
|
+
placement is rendered as a dashed "ghost" box, colored by the
|
|
239
|
+
`color-node-ghost-*` options.
|
|
240
|
+
|
|
241
|
+
The diagram type `grid` rejects the input with an error as soon as the
|
|
242
|
+
graph contains at least one edge.
|
|
243
|
+
|
|
244
|
+
As the groups of a grouped graph are laid out individually with the
|
|
245
|
+
selected diagram type, these constraints apply to every single group. For
|
|
246
|
+
the type `hub` this especially means that every group needs its own
|
|
247
|
+
primary node.
|
|
248
|
+
|
|
198
249
|
### Directives
|
|
199
250
|
|
|
200
251
|
A `#type` directive sets the diagram type (`graph`, `hub`, or `grid`),
|
|
@@ -204,28 +255,30 @@ type is silently ignored. Without both, the type defaults to `graph`.
|
|
|
204
255
|
|
|
205
256
|
A `#config` directive sets one of the rendering options, which otherwise
|
|
206
257
|
are settable through the corresponding `--config <option>=<value>`
|
|
207
|
-
command-line options
|
|
258
|
+
command-line options (`font-embed` being command-line only, see below).
|
|
259
|
+
The recognized options are:
|
|
208
260
|
|
|
209
261
|
```
|
|
210
|
-
font-family color-edge-line graph-
|
|
211
|
-
|
|
212
|
-
color-node-regular-
|
|
213
|
-
color-node-regular-
|
|
214
|
-
color-node-
|
|
215
|
-
color-node-primary-
|
|
216
|
-
color-node-primary-
|
|
217
|
-
color-node-
|
|
218
|
-
color-node-ghost-
|
|
219
|
-
color-node-ghost-
|
|
220
|
-
color-
|
|
221
|
-
color-group-
|
|
222
|
-
color-group-
|
|
262
|
+
font-family color-edge-line graph-channel-width-max
|
|
263
|
+
font-embed color-edge-name graph-gutter-height-max
|
|
264
|
+
color-node-regular-name color-edge-arity graph-node-separation
|
|
265
|
+
color-node-regular-box color-edge-halo graph-rank-separation
|
|
266
|
+
color-node-regular-border size-canvas-margin graph-node-degree-max
|
|
267
|
+
color-node-primary-name size-node-width-min hub-channel-width-max
|
|
268
|
+
color-node-primary-box size-node-width-max hub-channel-width-min
|
|
269
|
+
color-node-primary-border size-node-height-scale hub-node-gap
|
|
270
|
+
color-node-ghost-name size-edge-corner-radius hub-node-degree-max
|
|
271
|
+
color-node-ghost-box size-edge-hop-radius grid-columns-max
|
|
272
|
+
color-node-ghost-border size-edge-track-gap grid-columns-min
|
|
273
|
+
color-group-name group-box-padding grid-gap-horizontal
|
|
274
|
+
color-group-box group-box-gap grid-gap-vertical
|
|
275
|
+
color-group-border graph-columns-max grid-node-width-equal
|
|
223
276
|
```
|
|
224
277
|
|
|
225
278
|
The `size-*`, `group-*`, `graph-*`, `hub-*`, and `grid-*` options
|
|
226
279
|
control the rendering geometry (canvas margin, node box sizing, edge
|
|
227
280
|
routing, group box spacing, and the per-diagram-type layout) and take
|
|
228
|
-
non-negative numbers
|
|
281
|
+
non-negative numbers, except the boolean `grid-node-width-equal`.
|
|
229
282
|
|
|
230
283
|
The `size-node-width-max` option additionally enables the word-wrapping
|
|
231
284
|
of the node box texts: given a positive value, the node name, its type,
|
|
@@ -235,6 +288,19 @@ default `0` disables the wrapping entirely and hence lets a node box
|
|
|
235
288
|
become as wide as its longest text. A single word wider than the
|
|
236
289
|
maximum is never broken and still widens the box beyond the maximum.
|
|
237
290
|
|
|
291
|
+
The `grid-columns-min` and `grid-columns-max` options control the column
|
|
292
|
+
count of a `grid` diagram, which by default is derived from the node
|
|
293
|
+
count as a roughly square grid: `grid-columns-min` (default `3`) raises
|
|
294
|
+
the derived count, so that a few nodes still share a single row instead
|
|
295
|
+
of being stacked, while `grid-columns-max` (default `4`) caps it, so
|
|
296
|
+
that larger diagrams grow in height only. The column count never
|
|
297
|
+
exceeds the node count and the maximum always wins over the minimum.
|
|
298
|
+
|
|
299
|
+
The `grid-node-width-equal` option controls the tile widths of a `grid`
|
|
300
|
+
diagram: its default `true` forces all node boxes to the width of the
|
|
301
|
+
widest one and hence yields a strictly regular grid, while `false` lets
|
|
302
|
+
each column become only as wide as its own widest node box.
|
|
303
|
+
|
|
238
304
|
The `font-family` and `color-*` options are embedded directly into the
|
|
239
305
|
generated SVG. When such an option is *not* explicitly configured, the
|
|
240
306
|
SVG references the CSS custom property `--gradia-<option>` (definable by
|
|
@@ -37,8 +37,10 @@ export declare const configDefaults: {
|
|
|
37
37
|
"hub-node-gap": number;
|
|
38
38
|
"hub-node-degree-max": number;
|
|
39
39
|
"grid-columns-max": number;
|
|
40
|
+
"grid-columns-min": number;
|
|
40
41
|
"grid-gap-horizontal": number;
|
|
41
42
|
"grid-gap-vertical": number;
|
|
43
|
+
"grid-node-width-equal": boolean;
|
|
42
44
|
};
|
|
43
45
|
export type Config = typeof configDefaults;
|
|
44
46
|
export type ConfigEmbedded = {
|
package/dst/gradia-api-config.js
CHANGED
|
@@ -36,19 +36,21 @@ export const configDefaults = {
|
|
|
36
36
|
"size-edge-track-gap": 12, /* offset between parallel edge routes */
|
|
37
37
|
"group-box-padding": 30, /* inner padding of group boxes */
|
|
38
38
|
"group-box-gap": 40, /* vertical gap between group boxes */
|
|
39
|
-
"graph-columns-max":
|
|
39
|
+
"graph-columns-max": 4, /* graph: max side-by-side nodes */
|
|
40
40
|
"graph-channel-width-max": 140, /* graph: max width of column channels */
|
|
41
41
|
"graph-gutter-height-max": 90, /* graph: max height of row gutters */
|
|
42
42
|
"graph-node-separation": 30, /* graph: layered layout node separation */
|
|
43
43
|
"graph-rank-separation": 60, /* graph: layered layout rank separation */
|
|
44
|
-
"graph-node-degree-max": 3, /* graph: max edges
|
|
44
|
+
"graph-node-degree-max": 3, /* graph: max edges/side before growth */
|
|
45
45
|
"hub-channel-width-max": 340, /* hub: max width of column channels */
|
|
46
46
|
"hub-channel-width-min": 240, /* hub: min width of column channels */
|
|
47
47
|
"hub-node-gap": 20, /* hub: vertical gap of stacked nodes */
|
|
48
|
-
"hub-node-degree-max": 3, /* hub: max edges
|
|
48
|
+
"hub-node-degree-max": 3, /* hub: max edges/side before growth */
|
|
49
49
|
"grid-columns-max": 4, /* grid: max side-by-side tiles */
|
|
50
|
+
"grid-columns-min": 3, /* grid: min side-by-side tiles */
|
|
50
51
|
"grid-gap-horizontal": 40, /* grid: horizontal gap between tiles */
|
|
51
|
-
"grid-gap-vertical": 20 /* grid: vertical gap between tiles */
|
|
52
|
+
"grid-gap-vertical": 20, /* grid: vertical gap between tiles */
|
|
53
|
+
"grid-node-width-equal": true /* grid: force equal width of node boxes */
|
|
52
54
|
};
|
|
53
55
|
/* resolve a directly embedded configuration option into a CSS value:
|
|
54
56
|
an explicitly configured value is hard-coded (stripped of the
|
|
@@ -73,8 +75,13 @@ export const parseDirectives = (input) => {
|
|
|
73
75
|
description, as they would let it read arbitrary local WOFF2 files */
|
|
74
76
|
if (key === "font-embed" || (key === "font-family" && val.endsWith(".woff2")))
|
|
75
77
|
continue;
|
|
76
|
-
if (typeof configDefaults[key] === "boolean")
|
|
78
|
+
if (typeof configDefaults[key] === "boolean") {
|
|
79
|
+
/* silently skip invalid boolean values, as directives are
|
|
80
|
+
lines of an untrusted input and never abort the rendering */
|
|
81
|
+
if (val !== "true" && val !== "false")
|
|
82
|
+
continue;
|
|
77
83
|
partial[key] = val === "true";
|
|
84
|
+
}
|
|
78
85
|
else if (typeof configDefaults[key] === "number") {
|
|
79
86
|
/* silently skip invalid numeric values, as directives are
|
|
80
87
|
lines of an untrusted input and never abort the rendering */
|
package/dst/gradia-api-parser.js
CHANGED
|
@@ -126,17 +126,17 @@ export const parse = (input) => {
|
|
|
126
126
|
if (name !== null)
|
|
127
127
|
node.name = name;
|
|
128
128
|
for (const attr of attrs) {
|
|
129
|
-
const
|
|
130
|
-
if (
|
|
129
|
+
const idx = node.attrs.findIndex((a) => a.key === attr.key);
|
|
130
|
+
if (idx === -1)
|
|
131
131
|
node.attrs.push(attr);
|
|
132
132
|
else {
|
|
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
|
-
if (attr.key === "group" && node.attrs[
|
|
135
|
+
if (attr.key === "group" && node.attrs[idx].val !== attr.val)
|
|
136
136
|
throw new Error(`node "${id}" cannot be a member of more than one group ` +
|
|
137
|
-
`("${node.attrs[
|
|
137
|
+
`("${node.attrs[idx].val}" vs "${attr.val}") ` +
|
|
138
138
|
`at line ${start.line}, column ${start.col}`);
|
|
139
|
-
node.attrs[
|
|
139
|
+
node.attrs[idx] = attr;
|
|
140
140
|
}
|
|
141
141
|
}
|
|
142
142
|
graph.nodes.set(id, node);
|
|
@@ -144,10 +144,12 @@ export const parse = (input) => {
|
|
|
144
144
|
};
|
|
145
145
|
/* parse the statements (one node/edge chain per line) */
|
|
146
146
|
while (pos < tokens.length) {
|
|
147
|
+
/* skip the empty lines */
|
|
147
148
|
if (tokens[pos].type === "newline") {
|
|
148
149
|
pos++;
|
|
149
150
|
continue;
|
|
150
151
|
}
|
|
152
|
+
/* parse the chain of node references, connected by edge operators */
|
|
151
153
|
let source = parseNodeRef();
|
|
152
154
|
let t = peek();
|
|
153
155
|
while (t !== null && t.type === "edge") {
|
|
@@ -157,6 +159,7 @@ export const parse = (input) => {
|
|
|
157
159
|
source = target;
|
|
158
160
|
t = peek();
|
|
159
161
|
}
|
|
162
|
+
/* ensure the statement ends at the end of the line */
|
|
160
163
|
if (t !== null && t.type !== "newline")
|
|
161
164
|
bail("edge operator or end of line");
|
|
162
165
|
}
|
|
@@ -21,7 +21,7 @@ export const textWrap = (text, size, maxWidth) => {
|
|
|
21
21
|
const words = text.split(/\s+/).filter((word) => word !== "");
|
|
22
22
|
if (maxWidth <= 0 || words.length <= 1 || textWidth(text, size) <= maxWidth)
|
|
23
23
|
return [text];
|
|
24
|
-
const lines =
|
|
24
|
+
const lines = [];
|
|
25
25
|
let line = words[0];
|
|
26
26
|
for (const word of words.slice(1)) {
|
|
27
27
|
if (textWidth(`${line} ${word}`, size) > maxWidth) {
|
|
@@ -34,8 +34,10 @@ export const textWrap = (text, size, maxWidth) => {
|
|
|
34
34
|
lines.push(line);
|
|
35
35
|
return lines;
|
|
36
36
|
};
|
|
37
|
-
/* escape a string for use in XML/SVG content
|
|
38
|
-
|
|
37
|
+
/* escape a string for use in XML/SVG content (control characters are
|
|
38
|
+
stripped, as XML forbids them even in their escaped form) */
|
|
39
|
+
export const escapeXML = (text) => text.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "")
|
|
40
|
+
.replace(/&/g, "&")
|
|
39
41
|
.replace(/</g, "<")
|
|
40
42
|
.replace(/>/g, ">")
|
|
41
43
|
.replace(/"/g, """);
|
|
@@ -141,10 +141,11 @@ export const computeHops = (polys) => {
|
|
|
141
141
|
return result;
|
|
142
142
|
});
|
|
143
143
|
};
|
|
144
|
+
/* the Manhattan length of an orthogonal polyline segment */
|
|
145
|
+
const len = (a, b) => Math.abs(b[0] - a[0]) + Math.abs(b[1] - a[1]);
|
|
144
146
|
/* convert an edge polyline into an SVG path with rounded corners
|
|
145
147
|
at the bends and semi-circular hops at the crossing points */
|
|
146
148
|
export const pathOf = (poly, hop, rounding, hopRadius) => {
|
|
147
|
-
const len = (a, b) => Math.abs(b[0] - a[0]) + Math.abs(b[1] - a[1]);
|
|
148
149
|
const radius = (k) => {
|
|
149
150
|
if (k <= 0 || k >= poly.length - 1)
|
|
150
151
|
return 0;
|
|
@@ -214,7 +215,7 @@ export const pathOf = (poly, hop, rounding, hopRadius) => {
|
|
|
214
215
|
};
|
|
215
216
|
/* determine a point on a polyline at a given fraction of its length */
|
|
216
217
|
export const pointAt = (poly, fraction) => {
|
|
217
|
-
const lens = poly.slice(0, -1).map((p, k) =>
|
|
218
|
+
const lens = poly.slice(0, -1).map((p, k) => len(p, poly[k + 1]));
|
|
218
219
|
const total = lens.reduce((a, b) => a + b, 0);
|
|
219
220
|
let want = total * fraction;
|
|
220
221
|
for (let k = 0; k < lens.length; k++) {
|
|
@@ -36,7 +36,7 @@ export const partitionGroups = (graph) => {
|
|
|
36
36
|
`crosses groups "${sg}" and "${tg}"`);
|
|
37
37
|
parts.get(sg).edges.push(edge);
|
|
38
38
|
}
|
|
39
|
-
return Array.from(parts.entries()).map(([name,
|
|
39
|
+
return Array.from(parts.entries()).map(([name, subgraph]) => ({ name, graph: subgraph }));
|
|
40
40
|
};
|
|
41
41
|
/* determine the bounding box of a laid out group (nodes and edges) */
|
|
42
42
|
const boundsOf = (layout) => {
|
|
@@ -18,10 +18,11 @@ export const defaultStyleOf = (node) => isPrimary(node) ? {
|
|
|
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;
|
|
21
|
-
/*
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
21
|
+
/* the special attributes consumed by the renderers instead of being
|
|
22
|
+
rendered as node box text lines */
|
|
23
|
+
const ATTRS_SPECIAL = new Set(["url", "type", "primary", "group"]);
|
|
24
|
+
/* split the attributes rendered as text lines from the special ones */
|
|
25
|
+
export const attrsOfNode = (node) => node.attrs.filter((attr) => !ATTRS_SPECIAL.has(attr.key));
|
|
25
26
|
/* the special "url" attribute (rendered as a hyperlink on the whole node
|
|
26
27
|
box, restricted to relative URLs and safe schemes, as the generated
|
|
27
28
|
SVG anchor would otherwise execute an injected "javascript:" URL) */
|
|
@@ -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, explicit: Partial<Config
|
|
23
|
+
export declare const renderSVG: (layout: Layout, config: Config, explicit: Partial<Config>, seed: string) => string;
|
|
@@ -4,21 +4,27 @@
|
|
|
4
4
|
** Distributed under MIT license <https://spdx.org/licenses/MIT.html>
|
|
5
5
|
*/
|
|
6
6
|
/* external dependencies */
|
|
7
|
-
import
|
|
7
|
+
import UUID from "pure-uuid";
|
|
8
8
|
import { resolveFont, cssValueOf } from "./gradia-api-config.js";
|
|
9
9
|
import { FS_NAME, FS_TYPE, FS_ATTR, FS_EDGE, FS_ARITY, FS_GROUP, textWidth, escapeXML } from "./gradia-api-render-base.js";
|
|
10
10
|
import { linesOfNode, urlOf, defaultStyleOf, MIN_H, NAME_H, ATTR_H, ATTR_P, TYPE_H, TYPE_D } from "./gradia-api-render-node.js";
|
|
11
11
|
import { computeHops, pathOf, pointAt } from "./gradia-api-render-edge.js";
|
|
12
12
|
/* escape a string for use inside a CSS string literal (the XML escaping
|
|
13
13
|
is resolved by the parser before the CSS is parsed, so quotes and
|
|
14
|
-
backslashes have to be neutralized to prevent a CSS injection
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const
|
|
21
|
-
|
|
14
|
+
backslashes have to be neutralized to prevent a CSS injection, and
|
|
15
|
+
the CSS newline characters LF, CR and FF have to be replaced, as
|
|
16
|
+
they would end the string literal as an invalid "bad string") */
|
|
17
|
+
const escapeCSS = (text) => text.replace(/[\\"']/g, "\\$&").replace(/[\r\n\f]/g, " ");
|
|
18
|
+
/* the UUID namespace of Gradia, under which the per-document
|
|
19
|
+
identifier prefixes are derived */
|
|
20
|
+
const NS_GRADIA = new UUID(5, "ns:URL", "https://github.com/rse/gradia").format();
|
|
21
|
+
/* derive a per-document identifier prefix from a seed, as the SVG
|
|
22
|
+
identifiers are DOM-global and would collide once multiple diagrams
|
|
23
|
+
are embedded into the very same document. The prefix is a UUID v5 of
|
|
24
|
+
the seed and hence stays stable across regenerations of an unchanged
|
|
25
|
+
diagram (the Base16 format keeps it alphanumeric and thus a valid XML
|
|
26
|
+
name and CSS/URL fragment) */
|
|
27
|
+
const idPrefix = (seed) => `gradia-${new UUID(5, NS_GRADIA, seed).format("b16").toLowerCase()}`;
|
|
22
28
|
/* the placement of a group tag in the top-left corner of its group box */
|
|
23
29
|
const TAG_DX = 18; /* left offset of the group tag */
|
|
24
30
|
const TAG_DY = 12; /* top offset of the group tag */
|
|
@@ -117,8 +123,59 @@ const viewBoxOf = (layout, boxes, margin) => {
|
|
|
117
123
|
h: Math.ceil(maxY - minY + margin)
|
|
118
124
|
};
|
|
119
125
|
};
|
|
120
|
-
/*
|
|
121
|
-
|
|
126
|
+
/* generate the SVG fragments for the labels of a single edge: its
|
|
127
|
+
optional name, placed near the middle of the route, and its optional
|
|
128
|
+
arity, placed near the arrow head (both dodging into a collision-free
|
|
129
|
+
position through the "claim" of the label placer) */
|
|
130
|
+
const renderEdgeLabels = (edge, poly, claim, font, color) => {
|
|
131
|
+
/* the halo rendered behind the edge labels for readability
|
|
132
|
+
(its color is emitted into the style attribute below, as only
|
|
133
|
+
there the CSS custom property lookup can be resolved) */
|
|
134
|
+
const halo = "stroke-width=\"4.5\" paint-order=\"stroke\" stroke-linejoin=\"round\"";
|
|
135
|
+
const parts = [];
|
|
136
|
+
if (edge.name !== undefined) {
|
|
137
|
+
const w = textWidth(edge.name, FS_EDGE);
|
|
138
|
+
const candidates = [];
|
|
139
|
+
for (const f of [0.50, 0.40, 0.60, 0.30, 0.70, 0.20, 0.80]) {
|
|
140
|
+
const p = pointAt(poly, f);
|
|
141
|
+
if (p.horizontal) {
|
|
142
|
+
candidates.push([p.x - w / 2, p.y - 23, p.x + w / 2, p.y - 3]);
|
|
143
|
+
candidates.push([p.x - w / 2, p.y + 3, p.x + w / 2, p.y + 23]);
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
candidates.push([p.x + 5, p.y - 10, p.x + 5 + w, p.y + 10]);
|
|
147
|
+
candidates.push([p.x - 5 - w, p.y - 10, p.x - 5, p.y + 10]);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
const box = claim(candidates);
|
|
151
|
+
parts.push(`<text x="${(box[0] + box[2]) / 2}" y="${box[3] - 3}" text-anchor="middle" ` +
|
|
152
|
+
`font-size="${FS_EDGE}" ` +
|
|
153
|
+
`style="font-family: ${font}; fill: ${color("color-edge-name")}; ` +
|
|
154
|
+
`stroke: ${color("color-edge-halo")}" ` +
|
|
155
|
+
`${halo}>${escapeXML(edge.name)}</text>`);
|
|
156
|
+
}
|
|
157
|
+
if (edge.arity !== undefined) {
|
|
158
|
+
const w = textWidth(edge.arity, FS_ARITY);
|
|
159
|
+
const p = pointAt(poly, 1.0);
|
|
160
|
+
const prev = pointAt(poly, 0.999);
|
|
161
|
+
const dx = Math.sign(p.x - prev.x) || 1;
|
|
162
|
+
const ax = p.x - dx * (24 + w / 2);
|
|
163
|
+
const box = claim([
|
|
164
|
+
[ax - w / 2, p.y - 17, ax + w / 2, p.y - 4],
|
|
165
|
+
[ax - w / 2, p.y + 4, ax + w / 2, p.y + 17],
|
|
166
|
+
[ax - w / 2 - dx * 14, p.y - 17, ax + w / 2 - dx * 14, p.y - 4]
|
|
167
|
+
]);
|
|
168
|
+
parts.push(`<text x="${(box[0] + box[2]) / 2}" y="${box[3] - 3}" text-anchor="middle" ` +
|
|
169
|
+
`font-size="${FS_ARITY}" ` +
|
|
170
|
+
`style="font-family: ${font}; fill: ${color("color-edge-arity")}; ` +
|
|
171
|
+
`stroke: ${color("color-edge-halo")}" ` +
|
|
172
|
+
`${halo}>${escapeXML(edge.arity)}</text>`);
|
|
173
|
+
}
|
|
174
|
+
return parts;
|
|
175
|
+
};
|
|
176
|
+
/* render a laid out graph into an SVG document (the seed determines the
|
|
177
|
+
identifier prefix of the document, see "idPrefix" above) */
|
|
178
|
+
export const renderSVG = (layout, config, explicit, seed) => {
|
|
122
179
|
const { nodes, edges, polys } = layout;
|
|
123
180
|
const groups = layout.groups ?? [];
|
|
124
181
|
const styleOf = layout.styleOf ?? defaultStyleOf;
|
|
@@ -128,7 +185,7 @@ export const renderSVG = (layout, config, explicit) => {
|
|
|
128
185
|
CSS custom properties, falling back to the built-in defaults */
|
|
129
186
|
const color = (key) => escapeXML(cssValueOf(explicit, key));
|
|
130
187
|
/* derive the collision-free identifiers of this SVG document */
|
|
131
|
-
const prefix = idPrefix();
|
|
188
|
+
const prefix = idPrefix(seed);
|
|
132
189
|
const idArrow = `${prefix}-arrow`;
|
|
133
190
|
/* resolve the configured font into the rendered font family stack */
|
|
134
191
|
const { family, embed, weight } = resolveFont(config);
|
|
@@ -143,51 +200,10 @@ export const renderSVG = (layout, config, explicit) => {
|
|
|
143
200
|
/* generate the SVG fragments for the edges (paths below, labels above) */
|
|
144
201
|
const svgEdges = [];
|
|
145
202
|
const svgLabels = [];
|
|
146
|
-
/* the halo rendered behind the edge labels for readability
|
|
147
|
-
(its color is emitted into the style attribute below, as only
|
|
148
|
-
there the CSS custom property lookup can be resolved) */
|
|
149
|
-
const halo = "stroke-width=\"4.5\" paint-order=\"stroke\" stroke-linejoin=\"round\"";
|
|
150
203
|
edges.forEach((edge, i) => {
|
|
151
204
|
svgEdges.push(`<path d="${pathOf(polys[i], hops[i], config["size-edge-corner-radius"], config["size-edge-hop-radius"])}" fill="none" ` +
|
|
152
205
|
`style="stroke: ${color("color-edge-line")}" stroke-width="3.0" marker-end="url(#${idArrow})"/>`);
|
|
153
|
-
|
|
154
|
-
const w = textWidth(edge.name, FS_EDGE);
|
|
155
|
-
const candidates = [];
|
|
156
|
-
for (const f of [0.50, 0.40, 0.60, 0.30, 0.70, 0.20, 0.80]) {
|
|
157
|
-
const p = pointAt(polys[i], f);
|
|
158
|
-
if (p.horizontal) {
|
|
159
|
-
candidates.push([p.x - w / 2, p.y - 23, p.x + w / 2, p.y - 3]);
|
|
160
|
-
candidates.push([p.x - w / 2, p.y + 3, p.x + w / 2, p.y + 23]);
|
|
161
|
-
}
|
|
162
|
-
else {
|
|
163
|
-
candidates.push([p.x + 5, p.y - 10, p.x + 5 + w, p.y + 10]);
|
|
164
|
-
candidates.push([p.x - 5 - w, p.y - 10, p.x - 5, p.y + 10]);
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
const box = claim(candidates);
|
|
168
|
-
svgLabels.push(`<text x="${(box[0] + box[2]) / 2}" y="${box[3] - 3}" text-anchor="middle" ` +
|
|
169
|
-
`font-size="${FS_EDGE}" ` +
|
|
170
|
-
`style="font-family: ${font}; fill: ${color("color-edge-name")}; ` +
|
|
171
|
-
`stroke: ${color("color-edge-halo")}" ` +
|
|
172
|
-
`${halo}>${escapeXML(edge.name)}</text>`);
|
|
173
|
-
}
|
|
174
|
-
if (edge.arity !== undefined) {
|
|
175
|
-
const w = textWidth(edge.arity, FS_ARITY);
|
|
176
|
-
const p = pointAt(polys[i], 1.0);
|
|
177
|
-
const prev = pointAt(polys[i], 0.999);
|
|
178
|
-
const dx = Math.sign(p.x - prev.x) || 1;
|
|
179
|
-
const ax = p.x - dx * (24 + w / 2);
|
|
180
|
-
const box = claim([
|
|
181
|
-
[ax - w / 2, p.y - 17, ax + w / 2, p.y - 4],
|
|
182
|
-
[ax - w / 2, p.y + 4, ax + w / 2, p.y + 17],
|
|
183
|
-
[ax - w / 2 - dx * 14, p.y - 17, ax + w / 2 - dx * 14, p.y - 4]
|
|
184
|
-
]);
|
|
185
|
-
svgLabels.push(`<text x="${(box[0] + box[2]) / 2}" y="${box[3] - 3}" text-anchor="middle" ` +
|
|
186
|
-
`font-size="${FS_ARITY}" ` +
|
|
187
|
-
`style="font-family: ${font}; fill: ${color("color-edge-arity")}; ` +
|
|
188
|
-
`stroke: ${color("color-edge-halo")}" ` +
|
|
189
|
-
`${halo}>${escapeXML(edge.arity)}</text>`);
|
|
190
|
-
}
|
|
206
|
+
svgLabels.push(...renderEdgeLabels(edge, polys[i], claim, font, color));
|
|
191
207
|
});
|
|
192
208
|
/* generate the SVG fragments for the node boxes */
|
|
193
209
|
const svgNodes = nodes.flatMap((node) => renderNode(node, layout, styleOf(node), font, color, config));
|
|
@@ -8,8 +8,15 @@ import { DagreLayout } from "@antv/layout";
|
|
|
8
8
|
import { measureNodes } from "./gradia-api-render-node.js";
|
|
9
9
|
import { PORT_SEP, simplifyPoly, assignPorts, assignTracks } from "./gradia-api-render-edge.js";
|
|
10
10
|
/* rendering geometry constants */
|
|
11
|
-
const CLUSTX = 26; /* max X distance within a grid column
|
|
12
|
-
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 */
|
|
13
|
+
const CHAN_W0 = 24; /* width of an edge-less inter-column channel */
|
|
14
|
+
const CHAN_W1 = 28; /* width of a one-edge inter-column channel */
|
|
15
|
+
const CHAN_PAD = 16; /* cross-axis padding inside a channel */
|
|
16
|
+
const GUT_H0 = 20; /* height of an edge-less inter-row gutter */
|
|
17
|
+
const GUT_H1 = 24; /* height of a one-edge inter-row gutter */
|
|
18
|
+
const GUT_PAD = 12; /* cross-axis padding inside a gutter */
|
|
19
|
+
const LOOP_GAP = 24; /* detour of a self-loop right of its node box */
|
|
13
20
|
/* snap raw node positions onto a discrete grid by clustering the
|
|
14
21
|
distinct X coordinates into columns and Y coordinates into rows */
|
|
15
22
|
const snapToGrid = (nodes, rawX, rawY) => {
|
|
@@ -23,18 +30,19 @@ const snapToGrid = (nodes, rawX, rawY) => {
|
|
|
23
30
|
};
|
|
24
31
|
const colReps = cluster(nodes.map((node) => rawX.get(node.id)), CLUSTX);
|
|
25
32
|
const rowReps = cluster(nodes.map((node) => rawY.get(node.id)), CLUSTY);
|
|
26
|
-
const
|
|
33
|
+
const nearestIndexOf = (reps, v) => {
|
|
27
34
|
let best = 0;
|
|
28
35
|
for (let i = 1; i < reps.length; i++)
|
|
29
36
|
if (Math.abs(reps[i] - v) < Math.abs(reps[best] - v))
|
|
30
37
|
best = i;
|
|
31
38
|
return best;
|
|
32
39
|
};
|
|
40
|
+
/* assign every node to its nearest column and row */
|
|
33
41
|
const col = new Map();
|
|
34
42
|
const row = new Map();
|
|
35
43
|
for (const node of nodes) {
|
|
36
|
-
col.set(node.id,
|
|
37
|
-
row.set(node.id,
|
|
44
|
+
col.set(node.id, nearestIndexOf(colReps, rawX.get(node.id)));
|
|
45
|
+
row.set(node.id, nearestIndexOf(rowReps, rawY.get(node.id)));
|
|
38
46
|
}
|
|
39
47
|
return { col, row, ncols: colReps.length, nrows: rowReps.length };
|
|
40
48
|
};
|
|
@@ -120,6 +128,7 @@ const planRoutes = (edges, col, row) => {
|
|
|
120
128
|
}
|
|
121
129
|
return { chans, guts };
|
|
122
130
|
});
|
|
131
|
+
/* count the edges occupying each channel and gutter */
|
|
123
132
|
const chanCnt = new Map();
|
|
124
133
|
const gutCnt = new Map();
|
|
125
134
|
for (const plan of plans) {
|
|
@@ -143,11 +152,11 @@ const computeGrid = (nodes, col, row, boxW, boxH, ncols, nrows, chanCnt, gutCnt,
|
|
|
143
152
|
/* size the channels and gutters by their actual edge usage */
|
|
144
153
|
const chanW = Array.from({ length: ncols }, (_, c) => {
|
|
145
154
|
const cnt = chanCnt.get(c) ?? 0;
|
|
146
|
-
return cnt === 0 ?
|
|
155
|
+
return cnt === 0 ? CHAN_W0 : Math.min(config["graph-channel-width-max"], CHAN_W1 + (cnt - 1) * config["size-edge-track-gap"]);
|
|
147
156
|
});
|
|
148
157
|
const gutH = Array.from({ length: nrows }, (_, g) => {
|
|
149
158
|
const cnt = gutCnt.get(g) ?? 0;
|
|
150
|
-
return cnt === 0 ?
|
|
159
|
+
return cnt === 0 ? GUT_H0 : Math.min(config["graph-gutter-height-max"], GUT_H1 + (cnt - 1) * config["size-edge-track-gap"]);
|
|
151
160
|
});
|
|
152
161
|
/* derive the column and row center positions */
|
|
153
162
|
const colCX = [];
|
|
@@ -192,7 +201,7 @@ const assignTrackCoords = (edges, plans, col, portPos, grid, config) => {
|
|
|
192
201
|
});
|
|
193
202
|
const chanOff = new Map();
|
|
194
203
|
for (const [c, users] of chanUsers)
|
|
195
|
-
for (const [edge, off] of assignTracks(users, chanW[c],
|
|
204
|
+
for (const [edge, off] of assignTracks(users, chanW[c], CHAN_PAD, config["size-edge-track-gap"]))
|
|
196
205
|
chanOff.set(`${c}:${edge}`, off);
|
|
197
206
|
const chanX = (c, edge) => colCX[c] + colWidth[c] / 2 + chanW[c] / 2 +
|
|
198
207
|
chanOff.get(`${c}:${edge}`);
|
|
@@ -212,7 +221,7 @@ const assignTrackCoords = (edges, plans, col, portPos, grid, config) => {
|
|
|
212
221
|
});
|
|
213
222
|
const gutOff = new Map();
|
|
214
223
|
for (const [g, users] of gutUsers)
|
|
215
|
-
for (const [edge, off] of assignTracks(users, gutH[g],
|
|
224
|
+
for (const [edge, off] of assignTracks(users, gutH[g], GUT_PAD, config["size-edge-track-gap"]))
|
|
216
225
|
gutOff.set(`${g}:${edge}`, off);
|
|
217
226
|
const gutY = (g, edge) => gutBase(g) + gutOff.get(`${g}:${edge}`);
|
|
218
227
|
return { chanX, gutY };
|
|
@@ -231,7 +240,8 @@ const routePolys = (edges, plans, col, portPos, boxW, cx, chanX, gutY, config) =
|
|
|
231
240
|
if (edge.source === edge.target) {
|
|
232
241
|
const k = loopUse.get(edge.source) ?? 0;
|
|
233
242
|
loopUse.set(edge.source, k + 1);
|
|
234
|
-
const ox = cx(edge.source) + boxW.get(edge.source) / 2 +
|
|
243
|
+
const ox = cx(edge.source) + boxW.get(edge.source) / 2 + LOOP_GAP +
|
|
244
|
+
k * config["size-edge-track-gap"];
|
|
235
245
|
pts = [[sp.x, sp.y], [ox, sp.y], [ox, tp.y], [tp.x, tp.y]];
|
|
236
246
|
}
|
|
237
247
|
else if (chans.length === 1) {
|
|
@@ -12,23 +12,30 @@ export const render = async (graph, config) => {
|
|
|
12
12
|
const gapH = config["grid-gap-horizontal"];
|
|
13
13
|
const gapV = config["grid-gap-vertical"];
|
|
14
14
|
const maxCols = Math.max(Math.floor(config["grid-columns-max"]), 1);
|
|
15
|
+
const minCols = Math.max(Math.floor(config["grid-columns-min"]), 1);
|
|
15
16
|
/* ensure the graph is completely edge-less */
|
|
16
17
|
if (graph.edges.length > 0) {
|
|
17
18
|
const edge = graph.edges[0];
|
|
18
19
|
throw new Error("diagram type \"grid\" does not support edges " +
|
|
19
20
|
`(found ${graph.edges.length}, first is "${edge.source}" --> "${edge.target}")`);
|
|
20
21
|
}
|
|
21
|
-
/* determine node box sizes
|
|
22
|
-
and
|
|
22
|
+
/* determine node box sizes and unify their heights into a single
|
|
23
|
+
tile height and, if configured, their widths into a single tile width */
|
|
23
24
|
const { boxW, boxH, contentH } = measureNodes(nodes, config, () => config["size-node-height-scale"] / 2);
|
|
24
25
|
const tileH = nodes.reduce((a, node) => Math.max(a, boxH.get(node.id)), 0);
|
|
25
|
-
|
|
26
|
+
const tileW = nodes.reduce((a, node) => Math.max(a, boxW.get(node.id)), 0);
|
|
27
|
+
for (const node of nodes) {
|
|
26
28
|
boxH.set(node.id, tileH);
|
|
29
|
+
if (config["grid-node-width-equal"])
|
|
30
|
+
boxW.set(node.id, tileW);
|
|
31
|
+
}
|
|
27
32
|
/* place the nodes in declaration order onto a roughly square,
|
|
28
|
-
row-major grid (
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
33
|
+
row-major grid (raised to the configured minimum of columns, so
|
|
34
|
+
few nodes still share a row, and capped at the configured maximum
|
|
35
|
+
of columns and the node count, so larger graphs grow only in
|
|
36
|
+
height), with each column as wide as its widest tile and each
|
|
37
|
+
tile left-aligned within its column */
|
|
38
|
+
const cols = Math.max(Math.min(Math.max(Math.ceil(Math.sqrt(nodes.length)), minCols), maxCols, nodes.length), 1);
|
|
32
39
|
const colWidth = Array.from({ length: cols }, () => 0);
|
|
33
40
|
nodes.forEach((node, i) => {
|
|
34
41
|
colWidth[i % cols] = Math.max(colWidth[i % cols], boxW.get(node.id));
|