@rse/gradia 1.1.8 → 1.2.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 +78 -36
- package/dst/gradia-api-config.d.ts +7 -0
- package/dst/gradia-api-config.js +54 -24
- package/dst/gradia-api-parser.js +10 -4
- package/dst/gradia-api-render-base.d.ts +28 -0
- package/dst/gradia-api-render-base.js +3 -2
- package/dst/gradia-api-render-container.d.ts +31 -0
- package/dst/gradia-api-render-container.js +300 -0
- package/dst/gradia-api-render-edge.d.ts +1 -1
- package/dst/gradia-api-render-edge.js +106 -37
- package/dst/gradia-api-render-group.d.ts +3 -2
- package/dst/gradia-api-render-group.js +16 -27
- package/dst/gradia-api-render-node.d.ts +9 -1
- package/dst/gradia-api-render-node.js +28 -14
- package/dst/gradia-api-render-svg.d.ts +1 -21
- package/dst/gradia-api-render-svg.js +82 -20
- package/dst/gradia-api-type-graph.d.ts +3 -2
- package/dst/gradia-api-type-graph.js +282 -126
- package/dst/gradia-api-type-grid.d.ts +3 -2
- package/dst/gradia-api-type-grid.js +5 -3
- package/dst/gradia-api-type-hub.d.ts +3 -2
- package/dst/gradia-api-type-hub.js +20 -10
- package/dst/gradia-api.d.ts +12 -4
- package/dst/gradia-api.js +33 -13
- package/dst/gradia-api.umd.js +2 -2
- package/dst/gradia-cli.js +4 -21
- package/dst/gradia-mcp.js +47 -53
- package/package.json +1 -1
|
@@ -18,16 +18,26 @@ 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
|
+
/* the special "parent" annotation (nesting the node into a container node) */
|
|
22
|
+
export const parentOf = (node) => node.attrs.findLast((attr) => attr.key === "parent")?.val;
|
|
23
|
+
/* the special "container" annotation (the diagram type laying out the
|
|
24
|
+
members of the node, marking even a member-less node as a container) */
|
|
25
|
+
export const containerTypeOf = (node) => node.attrs.findLast((attr) => attr.key === "container")?.val;
|
|
26
|
+
/* container box head geometry (the head holds the name tag and the
|
|
27
|
+
optional type line above it) */
|
|
28
|
+
export const HEAD_H = 34; /* height of the container box head */
|
|
29
|
+
export const HEAD_T = 20; /* extra head height of a type line */
|
|
30
|
+
export const containerHead = (node) => HEAD_H + (typeOf(node) !== undefined ? HEAD_T : 0);
|
|
21
31
|
/* the special attributes consumed by the renderers instead of being
|
|
22
32
|
rendered as node box text lines */
|
|
23
|
-
const ATTRS_SPECIAL = new Set(["url", "type", "primary", "group"]);
|
|
33
|
+
const ATTRS_SPECIAL = new Set(["url", "type", "primary", "group", "parent", "container"]);
|
|
24
34
|
/* split the attributes rendered as text lines from the special ones */
|
|
25
35
|
export const attrsOfNode = (node) => node.attrs.filter((attr) => !ATTRS_SPECIAL.has(attr.key));
|
|
26
36
|
/* the special "url" attribute (rendered as a hyperlink on the whole node
|
|
27
37
|
box, restricted to relative URLs and safe schemes, as the generated
|
|
28
38
|
SVG anchor would otherwise execute an injected "javascript:" URL) */
|
|
29
39
|
const URL_SCHEME = /^\s*([A-Za-z][A-Za-z0-9+.-]*):/;
|
|
30
|
-
const URL_SAFE = ["http", "https", "mailto"];
|
|
40
|
+
const URL_SAFE = new Set(["http", "https", "mailto"]);
|
|
31
41
|
export const urlOf = (node) => {
|
|
32
42
|
const raw = node.attrs.findLast((attr) => attr.key === "url")?.val;
|
|
33
43
|
if (raw === undefined)
|
|
@@ -36,7 +46,7 @@ export const urlOf = (node) => {
|
|
|
36
46
|
resolving the URL and they would otherwise hide an unsafe scheme */
|
|
37
47
|
const url = raw.replace(/[\u0000-\u001F\u007F]/g, "");
|
|
38
48
|
const scheme = URL_SCHEME.exec(url);
|
|
39
|
-
if (scheme !== null && !URL_SAFE.
|
|
49
|
+
if (scheme !== null && !URL_SAFE.has(scheme[1].toLowerCase()))
|
|
40
50
|
return undefined;
|
|
41
51
|
return url;
|
|
42
52
|
};
|
|
@@ -63,27 +73,31 @@ export const linesOfNode = (node, config) => {
|
|
|
63
73
|
};
|
|
64
74
|
};
|
|
65
75
|
/* determine node box sizes from their textual content
|
|
66
|
-
(boxes are scaled up in height by a per-node factor
|
|
67
|
-
give the edges more attachment
|
|
68
|
-
|
|
76
|
+
(boxes are scaled up in height by a per-node factor, but never
|
|
77
|
+
below the content height, to give the edges more attachment
|
|
78
|
+
room), except for the nodes
|
|
79
|
+
of a fixed size (the container placeholders and gate nodes
|
|
80
|
+
of a containment level), which are taken as given */
|
|
81
|
+
export const measureNodes = (nodes, config, scaleOf, fixed) => {
|
|
69
82
|
/* the resulting node box dimensions */
|
|
70
83
|
const boxW = new Map();
|
|
71
84
|
const boxH = new Map();
|
|
72
85
|
const contentH = new Map();
|
|
73
86
|
/* measure the text content of every node */
|
|
74
87
|
for (const node of nodes) {
|
|
88
|
+
const size = fixed?.get(node.id);
|
|
89
|
+
if (size !== undefined) {
|
|
90
|
+
boxW.set(node.id, size.w);
|
|
91
|
+
boxH.set(node.id, size.h);
|
|
92
|
+
contentH.set(node.id, size.h);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
75
95
|
const lines = linesOfNode(node, config);
|
|
76
|
-
|
|
77
|
-
for (const line of lines.name)
|
|
78
|
-
w = Math.max(w, textWidth(line, FS_NAME));
|
|
79
|
-
for (const line of lines.type)
|
|
80
|
-
w = Math.max(w, textWidth(line, FS_TYPE));
|
|
81
|
-
for (const line of lines.attrs)
|
|
82
|
-
w = Math.max(w, textWidth(line, FS_ATTR));
|
|
96
|
+
const w = Math.max(0, ...lines.name.map((line) => textWidth(line, FS_NAME)), ...lines.type.map((line) => textWidth(line, FS_TYPE)), ...lines.attrs.map((line) => textWidth(line, FS_ATTR)));
|
|
83
97
|
const h = MIN_H + (lines.name.length - 1) * NAME_H + lines.type.length * TYPE_H +
|
|
84
98
|
(lines.attrs.length > 0 ? ATTR_P + (lines.attrs.length - 1) * ATTR_H + ATTR_B : 0);
|
|
85
99
|
boxW.set(node.id, Math.max(config["size-node-width-min"], Math.ceil(w) + PAD_W * 2));
|
|
86
|
-
boxH.set(node.id, Math.ceil(h * scaleOf(node)));
|
|
100
|
+
boxH.set(node.id, Math.max(h, Math.ceil(h * scaleOf(node))));
|
|
87
101
|
contentH.set(node.id, h);
|
|
88
102
|
}
|
|
89
103
|
return { boxW, boxH, contentH };
|
|
@@ -1,23 +1,3 @@
|
|
|
1
|
-
import { Node, Edge } from "./gradia-api-model.js";
|
|
2
1
|
import { Config } from "./gradia-api-config.js";
|
|
3
|
-
import {
|
|
4
|
-
export interface GroupBox {
|
|
5
|
-
name: string;
|
|
6
|
-
x: number;
|
|
7
|
-
y: number;
|
|
8
|
-
w: number;
|
|
9
|
-
h: number;
|
|
10
|
-
}
|
|
11
|
-
export interface Layout {
|
|
12
|
-
nodes: Node[];
|
|
13
|
-
edges: Edge[];
|
|
14
|
-
cx: (id: string) => number;
|
|
15
|
-
cy: (id: string) => number;
|
|
16
|
-
boxW: Map<string, number>;
|
|
17
|
-
boxH: Map<string, number>;
|
|
18
|
-
contentH: Map<string, number>;
|
|
19
|
-
polys: Poly[];
|
|
20
|
-
styleOf?: (node: Node) => NodeStyle;
|
|
21
|
-
groups?: GroupBox[];
|
|
22
|
-
}
|
|
2
|
+
import { Layout } from "./gradia-api-render-base.js";
|
|
23
3
|
export declare const renderSVG: (layout: Layout, config: Config, explicit: Partial<Config>, seed: string) => string;
|
|
@@ -7,7 +7,7 @@
|
|
|
7
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, ARITY_OFF, textWidth, escapeXML } from "./gradia-api-render-base.js";
|
|
10
|
-
import { linesOfNode, urlOf, defaultStyleOf, MIN_H, NAME_H, ATTR_H, ATTR_P, TYPE_H, TYPE_D } from "./gradia-api-render-node.js";
|
|
10
|
+
import { linesOfNode, urlOf, typeOf, defaultStyleOf, containerHead, MIN_H, NAME_H, ATTR_H, ATTR_P, TYPE_H, TYPE_D, HEAD_H } 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
|
|
@@ -28,24 +28,54 @@ const idPrefix = (seed) => `gradia-${new UUID(5, NS_GRADIA, seed).format("b16").
|
|
|
28
28
|
/* the placement of a group tag in the top-left corner of its group box */
|
|
29
29
|
const TAG_DX = 18; /* left offset of the group tag */
|
|
30
30
|
const TAG_DY = 12; /* top offset of the group tag */
|
|
31
|
+
/* the half width of the box an edge line segment occupies */
|
|
32
|
+
const LINE_PAD = 2;
|
|
31
33
|
/* track occupied areas (node boxes and already placed labels) to
|
|
32
|
-
let subsequent labels dodge into a collision-free position
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
let subsequent labels dodge into a collision-free position, and the
|
|
35
|
+
edge lines (their segments and crossing hops) to let the labels at
|
|
36
|
+
least prefer the position covering the fewest lines (the returned
|
|
37
|
+
"occupied" array grows with every claimed label box and hence also
|
|
38
|
+
serves as the box input of the overall bounding box) */
|
|
39
|
+
const labelPlacer = (layout, hops, hopRadius) => {
|
|
36
40
|
const { nodes, cx, cy, boxW, boxH } = layout;
|
|
37
41
|
const occupied = nodes.map((node) => [
|
|
38
42
|
cx(node.id) - boxW.get(node.id) / 2, cy(node.id) - boxH.get(node.id) / 2,
|
|
39
43
|
cx(node.id) + boxW.get(node.id) / 2, cy(node.id) + boxH.get(node.id) / 2
|
|
40
44
|
]);
|
|
41
|
-
/* let the labels also dodge the group tags in the
|
|
45
|
+
/* let the labels also dodge the group and container tags in the box corners */
|
|
42
46
|
for (const group of layout.groups ?? [])
|
|
43
47
|
occupied.push([group.x + TAG_DX, group.y + TAG_DY,
|
|
44
48
|
group.x + TAG_DX + textWidth(group.name, FS_GROUP), group.y + TAG_DY + FS_GROUP * 1.2]);
|
|
45
|
-
|
|
46
|
-
|
|
49
|
+
for (const c of layout.containers ?? [])
|
|
50
|
+
occupied.push([c.x + TAG_DX, c.y + TAG_DY,
|
|
51
|
+
c.x + TAG_DX + textWidth(c.node.name, FS_GROUP), c.y + containerHead(c.node)]);
|
|
52
|
+
/* collect the edge line segments and the hops bulging above them */
|
|
53
|
+
const lines = [];
|
|
54
|
+
layout.polys.forEach((poly, i) => {
|
|
55
|
+
for (let k = 0; k < poly.length - 1; k++) {
|
|
56
|
+
const [a, b] = [poly[k], poly[k + 1]];
|
|
57
|
+
lines.push([Math.min(a[0], b[0]) - LINE_PAD, Math.min(a[1], b[1]) - LINE_PAD,
|
|
58
|
+
Math.max(a[0], b[0]) + LINE_PAD, Math.max(a[1], b[1]) + LINE_PAD]);
|
|
59
|
+
for (const hx of hops[i].get(k) ?? [])
|
|
60
|
+
lines.push([hx - hopRadius, a[1] - hopRadius, hx + hopRadius, a[1]]);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
/* claim the candidate box colliding with the fewest occupied
|
|
64
|
+
areas and, among those, with the fewest edge lines (earlier
|
|
65
|
+
candidates win ties, so the first collision-free one is taken) */
|
|
66
|
+
const collisions = (boxes, box) => boxes.filter((o) => box[0] < o[2] && box[2] > o[0] && box[1] < o[3] && box[3] > o[1]).length;
|
|
47
67
|
const claim = (candidates) => {
|
|
48
|
-
|
|
68
|
+
let box = candidates[0];
|
|
69
|
+
let worst = Infinity;
|
|
70
|
+
for (const c of candidates) {
|
|
71
|
+
const score = collisions(occupied, c) * (lines.length + 1) + collisions(lines, c);
|
|
72
|
+
if (score < worst) {
|
|
73
|
+
box = c;
|
|
74
|
+
worst = score;
|
|
75
|
+
}
|
|
76
|
+
if (score === 0)
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
49
79
|
occupied.push(box);
|
|
50
80
|
return box;
|
|
51
81
|
};
|
|
@@ -189,11 +219,46 @@ const renderEdgeLabels = (edge, poly, claim, font, color) => {
|
|
|
189
219
|
}
|
|
190
220
|
return parts;
|
|
191
221
|
};
|
|
222
|
+
/* generate the SVG fragments for a single group box and its tag
|
|
223
|
+
in the top-left corner */
|
|
224
|
+
const renderGroup = (group, font, color) => [
|
|
225
|
+
`<rect x="${group.x}" y="${group.y}" width="${group.w}" height="${group.h}" rx="12" ` +
|
|
226
|
+
`style="fill: ${color("color-group-box")}; ` +
|
|
227
|
+
`stroke: ${color("color-group-border")}" stroke-width="3.0"/>`,
|
|
228
|
+
`<text x="${group.x + TAG_DX}" y="${group.y + TAG_DY + FS_GROUP}" ` +
|
|
229
|
+
`font-size="${FS_GROUP}" font-weight="600" ` +
|
|
230
|
+
`style="font-family: ${font}; fill: ${color("color-group-name")}">${escapeXML(group.name)}</text>`
|
|
231
|
+
];
|
|
232
|
+
/* generate the SVG fragments for a single container box and its tag
|
|
233
|
+
in the top-left corner, with the optional type line above the name
|
|
234
|
+
(a container with a "url" attribute becomes a hyperlink covering
|
|
235
|
+
the whole box) */
|
|
236
|
+
const renderContainer = (c, font, color) => {
|
|
237
|
+
const type = typeOf(c.node);
|
|
238
|
+
const url = urlOf(c.node);
|
|
239
|
+
const parts = [
|
|
240
|
+
`<rect x="${c.x}" y="${c.y}" width="${c.w}" height="${c.h}" rx="12" ` +
|
|
241
|
+
`style="fill: ${color("color-container-box")}; ` +
|
|
242
|
+
`stroke: ${color("color-container-border")}" stroke-width="3.0" stroke-dasharray="10 6"/>`,
|
|
243
|
+
...(type !== undefined ? [
|
|
244
|
+
`<text x="${c.x + TAG_DX}" y="${c.y + TAG_DY + FS_TYPE}" ` +
|
|
245
|
+
`font-size="${FS_TYPE}" ` +
|
|
246
|
+
`style="font-family: ${font}; fill: ${color("color-container-name")}">${escapeXML(type)}</text>`
|
|
247
|
+
] : []),
|
|
248
|
+
`<text x="${c.x + TAG_DX}" y="${c.y + containerHead(c.node) - HEAD_H + TAG_DY + FS_GROUP}" ` +
|
|
249
|
+
`font-size="${FS_GROUP}" font-weight="600" ` +
|
|
250
|
+
`style="font-family: ${font}; fill: ${color("color-container-name")}">${escapeXML(c.node.name)}</text>`
|
|
251
|
+
];
|
|
252
|
+
if (url === undefined)
|
|
253
|
+
return parts;
|
|
254
|
+
return [`<a href="${escapeXML(url)}" xlink:href="${escapeXML(url)}">`, ...parts, "</a>"];
|
|
255
|
+
};
|
|
192
256
|
/* render a laid out graph into an SVG document (the seed determines the
|
|
193
257
|
identifier prefix of the document, see "idPrefix" above) */
|
|
194
258
|
export const renderSVG = (layout, config, explicit, seed) => {
|
|
195
259
|
const { nodes, edges, polys } = layout;
|
|
196
260
|
const groups = layout.groups ?? [];
|
|
261
|
+
const containers = layout.containers ?? [];
|
|
197
262
|
const styleOf = layout.styleOf ?? defaultStyleOf;
|
|
198
263
|
/* resolve the directly embedded configuration options into CSS
|
|
199
264
|
values: explicitly configured values are hard-coded, while all
|
|
@@ -212,7 +277,7 @@ export const renderSVG = (layout, config, explicit, seed) => {
|
|
|
212
277
|
/* detect the edge crossings requiring rendered hops */
|
|
213
278
|
const hops = computeHops(polys);
|
|
214
279
|
/* prepare the collision-free placement of the edge labels */
|
|
215
|
-
const { claim, occupied } = labelPlacer(layout);
|
|
280
|
+
const { claim, occupied } = labelPlacer(layout, hops, config["size-edge-hop-radius"]);
|
|
216
281
|
/* generate the SVG fragments for the edges (paths below, labels above) */
|
|
217
282
|
const svgEdges = [];
|
|
218
283
|
const svgLabels = [];
|
|
@@ -224,18 +289,14 @@ export const renderSVG = (layout, config, explicit, seed) => {
|
|
|
224
289
|
/* generate the SVG fragments for the node boxes */
|
|
225
290
|
const svgNodes = nodes.flatMap((node) => renderNode(node, layout, styleOf(node), font, color, config));
|
|
226
291
|
/* generate the SVG fragments for the group boxes (drawn below
|
|
227
|
-
everything else) and
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
`stroke: ${color("color-group-border")}" stroke-width="3.0"/>`,
|
|
232
|
-
`<text x="${group.x + TAG_DX}" y="${group.y + TAG_DY + FS_GROUP}" ` +
|
|
233
|
-
`font-size="${FS_GROUP}" font-weight="600" ` +
|
|
234
|
-
`style="font-family: ${font}; fill: ${color("color-group-name")}">${escapeXML(group.name)}</text>`
|
|
235
|
-
]);
|
|
292
|
+
everything else) and the container boxes (drawn below the edges
|
|
293
|
+
and nodes, an outer box before its nested ones) */
|
|
294
|
+
const svgGroups = groups.flatMap((group) => renderGroup(group, font, color));
|
|
295
|
+
const svgContainers = containers.flatMap((c) => renderContainer(c, font, color));
|
|
236
296
|
/* determine the overall bounding box of all rendered elements */
|
|
237
297
|
const groupBoxes = groups.map((group) => [group.x, group.y, group.x + group.w, group.y + group.h]);
|
|
238
|
-
const
|
|
298
|
+
const containerBoxes = containers.map((c) => [c.x, c.y, c.x + c.w, c.y + c.h]);
|
|
299
|
+
const vb = viewBoxOf(layout, [...occupied, ...groupBoxes, ...containerBoxes], config["size-canvas-margin"]);
|
|
239
300
|
/* assemble the final SVG document */
|
|
240
301
|
return [
|
|
241
302
|
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>",
|
|
@@ -256,6 +317,7 @@ export const renderSVG = (layout, config, explicit, seed) => {
|
|
|
256
317
|
"</marker>",
|
|
257
318
|
"</defs>",
|
|
258
319
|
...svgGroups,
|
|
320
|
+
...svgContainers,
|
|
259
321
|
...svgEdges,
|
|
260
322
|
...svgNodes,
|
|
261
323
|
...svgLabels,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Graph } from "./gradia-api-model.js";
|
|
2
2
|
import { Config } from "./gradia-api-config.js";
|
|
3
|
-
import { Layout } from "./gradia-api-render-
|
|
4
|
-
|
|
3
|
+
import { Layout } from "./gradia-api-render-base.js";
|
|
4
|
+
import { LevelContext } from "./gradia-api-render-container.js";
|
|
5
|
+
export declare const render: (graph: Graph, config: Config, level?: LevelContext) => Promise<Layout>;
|