@rse/gradia 0.9.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.
@@ -0,0 +1,211 @@
1
+ /*
2
+ ** Gradia -- Object Graph Diagram Rendering
3
+ ** Copyright (c) 2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
4
+ ** Distributed under MIT license <https://spdx.org/licenses/MIT.html>
5
+ */
6
+ import { RADIUS, HOP, SLOT } from "./gradia-api-render-base.js";
7
+ /* edge attachment port geometry */
8
+ const PORT_SEP = 24; /* maximum separation of adjacent ports */
9
+ const PORT_PAD = 10; /* padding of the port band inside the box */
10
+ /* drop duplicate and collinear intermediate points of a polyline */
11
+ export const simplifyPoly = (pts) => {
12
+ /* drop the collinear intermediate points first, as collapsing
13
+ a backtracking triple leaves a duplicate point pair behind */
14
+ const flat = [...pts];
15
+ for (let k = flat.length - 2; k > 0; k--) {
16
+ const [a, b, c] = [flat[k - 1], flat[k], flat[k + 1]];
17
+ if ((a[0] === b[0] && b[0] === c[0]) || (a[1] === b[1] && b[1] === c[1]))
18
+ flat.splice(k, 1);
19
+ }
20
+ /* drop the remaining duplicate points */
21
+ const out = [];
22
+ for (const p of flat) {
23
+ if (out.length > 0 && out[out.length - 1][0] === p[0] && out[out.length - 1][1] === p[1])
24
+ continue;
25
+ out.push(p);
26
+ }
27
+ return out;
28
+ };
29
+ /* distribute the edge attachment ports along each node side,
30
+ ordered by the vertical position of the opposite endpoint */
31
+ export const assignPorts = (edges, sides, cx, cy, boxW, boxH) => {
32
+ /* group the edge endpoints by the node side they attach to */
33
+ const portGroups = new Map();
34
+ const addPort = (key, edge, role) => {
35
+ const group = portGroups.get(key);
36
+ if (group === undefined)
37
+ portGroups.set(key, [{ edge, role }]);
38
+ else
39
+ group.push({ edge, role });
40
+ };
41
+ edges.forEach((edge, i) => {
42
+ addPort(`${edge.source}:${sides[i].s}`, i, "s");
43
+ addPort(`${edge.target}:${sides[i].t}`, i, "t");
44
+ });
45
+ /* place the ports of each group, ordered by the opposite endpoint */
46
+ const portPos = new Map();
47
+ for (const [key, group] of portGroups) {
48
+ const sep = key.lastIndexOf(":"); /* ids may contain a colon themselves */
49
+ const id = key.slice(0, sep);
50
+ const side = key.slice(sep + 1);
51
+ const otherOf = (p) => p.role === "s" ? edges[p.edge].target : edges[p.edge].source;
52
+ group.sort((a, b) => (cy(otherOf(a)) - cy(otherOf(b))) || (a.edge - b.edge));
53
+ const spacing = Math.min(PORT_SEP, (boxH.get(id) - PORT_PAD) / group.length);
54
+ group.forEach((p, idx) => {
55
+ portPos.set(`${p.edge}:${p.role}`, {
56
+ x: cx(id) + (side === "e" ? +1 : -1) * boxW.get(id) / 2,
57
+ y: cy(id) + (idx - (group.length - 1) / 2) * spacing
58
+ });
59
+ });
60
+ }
61
+ return portPos;
62
+ };
63
+ /* assign the parallel tracks within a channel or gutter: the users are
64
+ ordered by their entry/exit positions, so that a track-following
65
+ segment never has to cross the entering segment of a parallel
66
+ neighbor (back turners get near tracks entry-position-ordered,
67
+ forward turners far tracks, reversed for mirrored users which
68
+ enter the channel/gutter from the far side) */
69
+ export const assignTracks = (users, width, pad) => {
70
+ const order = (list, mirror) => {
71
+ const dir = mirror ? -1 : +1;
72
+ const backs = list.filter((u) => u.posOut < u.posIn)
73
+ .sort((a, b) => dir * ((a.posIn - b.posIn) || (a.posOut - b.posOut)));
74
+ const fores = list.filter((u) => u.posOut >= u.posIn)
75
+ .sort((a, b) => dir * ((b.posIn - a.posIn) || (b.posOut - a.posOut)));
76
+ return [...backs, ...fores];
77
+ };
78
+ const ordered = [
79
+ ...order(users.filter((u) => !u.mirror), false),
80
+ ...order(users.filter((u) => u.mirror), true)
81
+ ];
82
+ const step = Math.min(SLOT, (width - pad) / Math.max(ordered.length - 1, 1));
83
+ const offsets = new Map();
84
+ ordered.forEach((u, idx) => {
85
+ offsets.set(u.edge, (idx - (ordered.length - 1) / 2) * step);
86
+ });
87
+ return offsets;
88
+ };
89
+ /* detect edge crossings: for every horizontal segment determine the
90
+ vertical segments of all other edges crossing it (hops are always
91
+ drawn on the horizontal segment of the crossing pair) */
92
+ export const computeHops = (polys) => {
93
+ /* collect all vertical segments once, instead of re-scanning
94
+ every polyline again for each single horizontal segment */
95
+ const verts = [];
96
+ polys.forEach((poly, j) => {
97
+ for (let m = 0; m < poly.length - 1; m++) {
98
+ const [c, d] = [poly[m], poly[m + 1]];
99
+ if (c[0] !== d[0])
100
+ continue;
101
+ verts.push({ owner: j, x: c[0], y1: Math.min(c[1], d[1]), y2: Math.max(c[1], d[1]) });
102
+ }
103
+ });
104
+ return polys.map((poly, i) => {
105
+ const result = new Map();
106
+ for (let k = 0; k < poly.length - 1; k++) {
107
+ const [a, b] = [poly[k], poly[k + 1]];
108
+ if (a[1] !== b[1])
109
+ continue;
110
+ const hy = a[1];
111
+ const hx1 = Math.min(a[0], b[0]);
112
+ const hx2 = Math.max(a[0], b[0]);
113
+ const xs = [];
114
+ for (const v of verts)
115
+ if (v.owner !== i && v.x > hx1 + 1 && v.x < hx2 - 1 && hy > v.y1 + 1 && hy < v.y2 - 1)
116
+ xs.push(v.x);
117
+ if (xs.length > 0)
118
+ result.set(k, xs);
119
+ }
120
+ return result;
121
+ });
122
+ };
123
+ /* convert an edge polyline into an SVG path with rounded corners
124
+ at the bends and semi-circular hops at the crossing points */
125
+ export const pathOf = (poly, hop) => {
126
+ const len = (a, b) => Math.abs(b[0] - a[0]) + Math.abs(b[1] - a[1]);
127
+ const radius = (k) => {
128
+ if (k <= 0 || k >= poly.length - 1)
129
+ return 0;
130
+ let r = Math.min(RADIUS, len(poly[k - 1], poly[k]) / 2, len(poly[k], poly[k + 1]) / 2);
131
+ /* shrink the corner rounding when a crossing hop sits close
132
+ to the bend, so the hop is never swallowed by the arc */
133
+ for (const seg of [k - 1, k])
134
+ for (const hx of hop.get(seg) ?? [])
135
+ r = Math.min(r, Math.max(Math.abs(hx - poly[k][0]) - HOP - 2, 4));
136
+ return r;
137
+ };
138
+ const d = [];
139
+ for (let k = 0; k < poly.length - 1; k++) {
140
+ const [a, b] = [poly[k], poly[k + 1]];
141
+ const l = len(a, b);
142
+ const ux = (b[0] - a[0]) / l;
143
+ const uy = (b[1] - a[1]) / l;
144
+ const r0 = radius(k);
145
+ const r1 = radius(k + 1);
146
+ const p0 = [a[0] + ux * r0, a[1] + uy * r0];
147
+ const p1 = [b[0] - ux * r1, b[1] - uy * r1];
148
+ if (k === 0)
149
+ d.push(`M ${p0[0]} ${p0[1]}`);
150
+ if (a[1] === b[1] && hop.has(k)) {
151
+ const dir = Math.sign(b[0] - a[0]);
152
+ const xs = hop.get(k)
153
+ .filter((hx) => (hx - p0[0]) * dir > 2 && (p1[0] - hx) * dir > 2)
154
+ .sort((m, n) => (m - n) * dir);
155
+ /* merge closely adjacent crossings into groups, each
156
+ rendered as one single wide "long hop" arc (crossings
157
+ are merged whenever the flat line piece left between
158
+ their individual hops would be shorter than 14px) */
159
+ const groups = [];
160
+ for (const hx of xs) {
161
+ const g = groups[groups.length - 1];
162
+ if (g !== undefined && Math.abs(hx - g[g.length - 1]) <= HOP * 2 + 14)
163
+ g.push(hx);
164
+ else
165
+ groups.push([hx]);
166
+ }
167
+ for (const g of groups) {
168
+ let x1 = g[0] - dir * HOP;
169
+ let x2 = g[g.length - 1] + dir * HOP;
170
+ /* clamp the arc into the drawable segment part, so
171
+ crossings close to a corner still get their hop */
172
+ if ((x1 - p0[0]) * dir < 0)
173
+ x1 = p0[0];
174
+ if ((p1[0] - x2) * dir < 0)
175
+ x2 = p1[0];
176
+ 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]}`);
178
+ }
179
+ }
180
+ d.push(`L ${p1[0]} ${p1[1]}`);
181
+ if (k < poly.length - 2) {
182
+ const c = poly[k + 1];
183
+ const n = poly[k + 2];
184
+ const nl = len(c, n);
185
+ const q = [
186
+ c[0] + (n[0] - c[0]) / nl * r1,
187
+ c[1] + (n[1] - c[1]) / nl * r1
188
+ ];
189
+ d.push(`Q ${b[0]} ${b[1]} ${q[0]} ${q[1]}`);
190
+ }
191
+ }
192
+ return d.join(" ");
193
+ };
194
+ /* determine a point on a polyline at a given fraction of its length */
195
+ export const pointAt = (poly, fraction) => {
196
+ const lens = poly.slice(0, -1).map((p, k) => Math.abs(poly[k + 1][0] - p[0]) + Math.abs(poly[k + 1][1] - p[1]));
197
+ const total = lens.reduce((a, b) => a + b, 0);
198
+ let want = total * fraction;
199
+ for (let k = 0; k < lens.length; k++) {
200
+ if (want <= lens[k] || k === lens.length - 1) {
201
+ const t = lens[k] > 0 ? want / lens[k] : 0;
202
+ return {
203
+ x: poly[k][0] + (poly[k + 1][0] - poly[k][0]) * Math.min(t, 1),
204
+ y: poly[k][1] + (poly[k + 1][1] - poly[k][1]) * Math.min(t, 1),
205
+ horizontal: poly[k][1] === poly[k + 1][1]
206
+ };
207
+ }
208
+ want -= lens[k];
209
+ }
210
+ return { x: poly[0][0], y: poly[0][1], horizontal: true };
211
+ };
@@ -0,0 +1,9 @@
1
+ import { Node, Graph } from "./gradia-api-model.js";
2
+ import { Layout } from "./gradia-api-render-svg.js";
3
+ export declare const groupOf: (node: Node) => string | undefined;
4
+ export interface GroupPart {
5
+ name: string;
6
+ graph: Graph;
7
+ }
8
+ export declare const partitionGroups: (graph: Graph) => GroupPart[] | null;
9
+ export declare const composeGroups: (parts: GroupPart[], layouts: Layout[]) => Layout;
@@ -0,0 +1,103 @@
1
+ /*
2
+ ** Gradia -- Object Graph Diagram Rendering
3
+ ** Copyright (c) 2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
4
+ ** Distributed under MIT license <https://spdx.org/licenses/MIT.html>
5
+ */
6
+ import { FS_GROUP, MARGIN, textWidth } from "./gradia-api-render-base.js";
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 */
11
+ /* the special "group" annotation (assigning a node to a named group) */
12
+ export const groupOf = (node) => node.attrs.findLast((attr) => attr.key === "group")?.val;
13
+ /* partition the graph into its named groups (in order of first
14
+ appearance), implying "group: default" for the nodes without one,
15
+ or return null when no node uses a group at all. Edges have to
16
+ stay within a single group, so reject any group-crossing edge */
17
+ export const partitionGroups = (graph) => {
18
+ const nodes = Array.from(graph.nodes.values());
19
+ if (!nodes.some((node) => groupOf(node) !== undefined))
20
+ return null;
21
+ const parts = new Map();
22
+ const nameOf = new Map();
23
+ for (const node of nodes) {
24
+ const name = groupOf(node) ?? "default";
25
+ nameOf.set(node.id, name);
26
+ let part = parts.get(name);
27
+ if (part === undefined) {
28
+ part = { nodes: new Map(), edges: [] };
29
+ parts.set(name, part);
30
+ }
31
+ part.nodes.set(node.id, node);
32
+ }
33
+ for (const edge of graph.edges) {
34
+ const sg = nameOf.get(edge.source);
35
+ const tg = nameOf.get(edge.target);
36
+ if (sg !== tg)
37
+ throw new Error(`edge "${edge.source}" --> "${edge.target}" ` +
38
+ `crosses groups "${sg}" and "${tg}"`);
39
+ parts.get(sg).edges.push(edge);
40
+ }
41
+ return Array.from(parts.entries()).map(([name, graph]) => ({ name, graph }));
42
+ };
43
+ /* determine the bounding box of a laid out group (nodes and edges) */
44
+ const boundsOf = (layout) => {
45
+ const { nodes, cx, cy, boxW, boxH, polys } = layout;
46
+ let [minX, minY, maxX, maxY] = [Infinity, Infinity, -Infinity, -Infinity];
47
+ for (const node of nodes) {
48
+ minX = Math.min(minX, cx(node.id) - boxW.get(node.id) / 2);
49
+ minY = Math.min(minY, cy(node.id) - boxH.get(node.id) / 2);
50
+ maxX = Math.max(maxX, cx(node.id) + boxW.get(node.id) / 2);
51
+ maxY = Math.max(maxY, cy(node.id) + boxH.get(node.id) / 2);
52
+ }
53
+ for (const poly of polys) {
54
+ for (const [px, py] of poly) {
55
+ minX = Math.min(minX, px);
56
+ minY = Math.min(minY, py);
57
+ maxX = Math.max(maxX, px);
58
+ maxY = Math.max(maxY, py);
59
+ }
60
+ }
61
+ return { minX, minY, maxX, maxY };
62
+ };
63
+ /* compose the per-group layouts into a single layout: the groups are
64
+ stacked vertically, each one shifted into its surrounding group box
65
+ (a node id is unique across the groups, as every node is a member
66
+ of exactly one group, so the maps can be merged and the coordinate
67
+ functions can dispatch on the node id) */
68
+ export const composeGroups = (parts, layouts) => {
69
+ /* stack the group boxes vertically and determine the coordinate
70
+ offset which shifts each layout into its group box */
71
+ const groups = [];
72
+ const dxs = [];
73
+ const dys = [];
74
+ const groupIdx = new Map();
75
+ let y = MARGIN;
76
+ layouts.forEach((layout, i) => {
77
+ 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);
82
+ for (const node of layout.nodes)
83
+ groupIdx.set(node.id, i);
84
+ groups.push({ name: parts[i].name, x: MARGIN, y, w, h });
85
+ y += h + GROUP_GAP;
86
+ });
87
+ /* merge the per-group layouts into the composed layout */
88
+ const merge = (pick) => new Map(layouts.flatMap((layout) => Array.from(pick(layout).entries())));
89
+ const groupIdxOf = (id) => groupIdx.get(id);
90
+ return {
91
+ nodes: layouts.flatMap((layout) => layout.nodes),
92
+ edges: layouts.flatMap((layout) => layout.edges),
93
+ cx: (id) => layouts[groupIdxOf(id)].cx(id) + dxs[groupIdxOf(id)],
94
+ cy: (id) => layouts[groupIdxOf(id)].cy(id) + dys[groupIdxOf(id)],
95
+ boxW: merge((layout) => layout.boxW),
96
+ boxH: merge((layout) => layout.boxH),
97
+ contentH: merge((layout) => layout.contentH),
98
+ polys: layouts.flatMap((layout, i) => layout.polys.map((poly) => poly.map(([px, py]) => [px + dxs[i], py + dys[i]]))),
99
+ styleOf: layouts.every((layout) => layout.styleOf !== undefined) ?
100
+ (node) => layouts[groupIdxOf(node.id)].styleOf(node) : undefined,
101
+ groups
102
+ };
103
+ };
@@ -0,0 +1,18 @@
1
+ import { Attr, Node } from "./gradia-api-model.js";
2
+ import { Config } from "./gradia-api-config.js";
3
+ import { NodeStyle } from "./gradia-api-render-base.js";
4
+ export declare const isPrimary: (node: Node) => boolean;
5
+ export declare const defaultStyleOf: (config: Config) => (node: Node) => NodeStyle;
6
+ export declare const typeOf: (node: Node) => string | undefined;
7
+ export declare const attrsOfNode: (node: Node) => Attr[];
8
+ export declare const urlOf: (node: Node) => string | undefined;
9
+ export declare const MIN_H = 64;
10
+ export declare const ATTR_H = 30;
11
+ export declare const ATTR_P = 8;
12
+ export declare const TYPE_H = 26;
13
+ export declare const TYPE_D = 36;
14
+ export declare const measureNodes: (nodes: Node[], scaleOf: (node: Node) => number) => {
15
+ boxW: Map<string, number>;
16
+ boxH: Map<string, number>;
17
+ contentH: Map<string, number>;
18
+ };
@@ -0,0 +1,74 @@
1
+ /*
2
+ ** Gradia -- Object Graph Diagram Rendering
3
+ ** Copyright (c) 2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
4
+ ** Distributed under MIT license <https://spdx.org/licenses/MIT.html>
5
+ */
6
+ import { FS_NAME, FS_TYPE, FS_ATTR, textWidth } from "./gradia-api-render-base.js";
7
+ /* the special "primary" annotation (rendered as a distinct node coloring) */
8
+ export const isPrimary = (node) => node.attrs.findLast((attr) => attr.key === "primary")?.val === "true";
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"]
14
+ } : {
15
+ fill: config["color-node-regular-box"],
16
+ stroke: config["color-node-regular-border"],
17
+ text: config["color-node-regular-name"]
18
+ };
19
+ /* the special "type" annotation (rendered above the node name) */
20
+ export const typeOf = (node) => node.attrs.findLast((attr) => attr.key === "type")?.val;
21
+ /* split the attributes rendered as text lines from the special
22
+ "url", "type", "primary", and "group" attributes consumed by the renderers */
23
+ export const attrsOfNode = (node) => node.attrs.filter((attr) => attr.key !== "url" && attr.key !== "type"
24
+ && attr.key !== "primary" && attr.key !== "group");
25
+ /* the special "url" attribute (rendered as a hyperlink on the whole node
26
+ box, restricted to relative URLs and safe schemes, as the generated
27
+ SVG anchor would otherwise execute an injected "javascript:" URL) */
28
+ const URL_SCHEME = /^\s*([A-Za-z][A-Za-z0-9+.-]*):/;
29
+ const URL_SAFE = ["http", "https", "mailto"];
30
+ export const urlOf = (node) => {
31
+ const raw = node.attrs.findLast((attr) => attr.key === "url")?.val;
32
+ if (raw === undefined)
33
+ return undefined;
34
+ /* strip control characters, as browsers remove them before
35
+ resolving the URL and they would otherwise hide an unsafe scheme */
36
+ const url = raw.replace(/[\u0000-\u001F\u007F]/g, "");
37
+ const scheme = URL_SCHEME.exec(url);
38
+ if (scheme !== null && !URL_SAFE.includes(scheme[1].toLowerCase()))
39
+ return undefined;
40
+ return url;
41
+ };
42
+ /* node box text metrics (shared by the measuring and the text placement) */
43
+ export const MIN_H = 64; /* minimum node box height */
44
+ export const ATTR_H = 30; /* height of a single attribute line */
45
+ export const ATTR_P = 8; /* top padding of the attribute block */
46
+ export const TYPE_H = 26; /* extra height of the type line */
47
+ export const TYPE_D = 36; /* baseline distance of the type to the name */
48
+ /* determine node box sizes from their textual content
49
+ (boxes are scaled up in height by a per-node factor to
50
+ give the edges more attachment room) */
51
+ export const measureNodes = (nodes, scaleOf) => {
52
+ const MIN_W = 220; /* minimum node box width */
53
+ const PAD_W = 18; /* left/right node box text padding */
54
+ /* the resulting node box dimensions */
55
+ const boxW = new Map();
56
+ const boxH = new Map();
57
+ const contentH = new Map();
58
+ /* measure the text content of every node */
59
+ for (const node of nodes) {
60
+ const attrs = attrsOfNode(node);
61
+ const type = typeOf(node);
62
+ let w = textWidth(node.name, FS_NAME);
63
+ if (type !== undefined)
64
+ w = Math.max(w, textWidth(type, FS_TYPE));
65
+ for (const attr of attrs)
66
+ w = Math.max(w, textWidth(`${attr.key}: ${attr.val}`, FS_ATTR));
67
+ const h = MIN_H + (type !== undefined ? TYPE_H : 0) +
68
+ (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));
70
+ boxH.set(node.id, Math.ceil(h * scaleOf(node)));
71
+ contentH.set(node.id, h);
72
+ }
73
+ return { boxW, boxH, contentH };
74
+ };
@@ -0,0 +1,23 @@
1
+ import { Node, Edge } from "./gradia-api-model.js";
2
+ import { Config } from "./gradia-api-config.js";
3
+ import { Poly, NodeStyle } from "./gradia-api-render-base.js";
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
+ }
23
+ export declare const renderSVG: (layout: Layout, config: Config) => string;
@@ -0,0 +1,199 @@
1
+ /*
2
+ ** Gradia -- Object Graph Diagram Rendering
3
+ ** Copyright (c) 2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
4
+ ** Distributed under MIT license <https://spdx.org/licenses/MIT.html>
5
+ */
6
+ import { resolveFont } from "./gradia-api-config.js";
7
+ import { FS_NAME, FS_TYPE, FS_ATTR, FS_EDGE, FS_ARITY, FS_GROUP, MARGIN, textWidth, escapeXML } from "./gradia-api-render-base.js";
8
+ import { attrsOfNode, typeOf, urlOf, defaultStyleOf, MIN_H, ATTR_H, ATTR_P, TYPE_H, TYPE_D } from "./gradia-api-render-node.js";
9
+ import { computeHops, pathOf, pointAt } from "./gradia-api-render-edge.js";
10
+ /* escape a string for use inside a CSS string literal (the XML escaping
11
+ is resolved by the parser before the CSS is parsed, so quotes and
12
+ backslashes have to be neutralized to prevent a CSS injection) */
13
+ const escapeCSS = (text) => text.replace(/[\\"']/g, "\\$&").replace(/[\r\n]/g, " ");
14
+ /* the placement of a group tag in the top-left corner of its group box */
15
+ const TAG_DX = 18; /* left offset of the group tag */
16
+ const TAG_DY = 12; /* top offset of the group tag */
17
+ /* track occupied areas (node boxes and already placed labels) to
18
+ let subsequent labels dodge into a collision-free position (the
19
+ returned "occupied" array grows with every claimed label box and
20
+ hence also serves as the box input of the overall bounding box) */
21
+ const labelPlacer = (layout) => {
22
+ const { nodes, cx, cy, boxW, boxH } = layout;
23
+ const occupied = nodes.map((node) => [
24
+ cx(node.id) - boxW.get(node.id) / 2, cy(node.id) - boxH.get(node.id) / 2,
25
+ cx(node.id) + boxW.get(node.id) / 2, cy(node.id) + boxH.get(node.id) / 2
26
+ ]);
27
+ /* let the labels also dodge the group tags in the group box corners */
28
+ for (const group of layout.groups ?? [])
29
+ occupied.push([group.x + TAG_DX, group.y + TAG_DY,
30
+ group.x + TAG_DX + textWidth(group.name, FS_GROUP), group.y + TAG_DY + FS_GROUP * 1.2]);
31
+ const collides = (box) => occupied.some((o) => box[0] < o[2] && box[2] > o[0] && box[1] < o[3] && box[3] > o[1]);
32
+ const claim = (candidates) => {
33
+ const box = candidates.find((c) => !collides(c)) ?? candidates[0];
34
+ occupied.push(box);
35
+ return box;
36
+ };
37
+ return { claim, occupied };
38
+ };
39
+ /* generate the SVG fragments for a single node box (a node with a
40
+ "url" attribute becomes a hyperlink covering the whole box) */
41
+ const renderNode = (node, layout, style, font) => {
42
+ const { cx, cy, boxW, boxH, contentH } = layout;
43
+ const w = boxW.get(node.id);
44
+ const h = boxH.get(node.id);
45
+ const x = cx(node.id) - w / 2;
46
+ const y = cy(node.id) - h / 2;
47
+ const attrs = attrsOfNode(node);
48
+ const type = typeOf(node);
49
+ const url = urlOf(node);
50
+ const parts = [];
51
+ parts.push(`<rect x="${x}" y="${y}" width="${w}" height="${h}" rx="6" ` +
52
+ `fill="${escapeXML(style.fill)}" stroke="${escapeXML(style.stroke)}" stroke-width="4.0"` +
53
+ `${style.dash !== undefined ? ` stroke-dasharray="${escapeXML(style.dash)}"` : ""}/>`);
54
+ /* vertically center the textual content block within the box, with
55
+ the optional type line shifting the name and attributes down */
56
+ const ty = cy(node.id) - contentH.get(node.id) / 2;
57
+ const th = type !== undefined ? TYPE_H : 0;
58
+ const nameY = attrs.length > 0 ? ty + th + 42 : cy(node.id) + th / 2 + FS_NAME * 0.36;
59
+ if (type !== undefined)
60
+ parts.push(`<text x="${cx(node.id)}" y="${nameY - TYPE_D}" text-anchor="middle" ` +
61
+ `font-family="${font}" font-size="${FS_TYPE}" ` +
62
+ `fill="${escapeXML(style.text)}">${escapeXML(type)}</text>`);
63
+ parts.push(`<text x="${cx(node.id)}" y="${nameY}" text-anchor="middle" ` +
64
+ `font-family="${font}" font-size="${FS_NAME}" font-weight="600" ` +
65
+ `fill="${escapeXML(style.text)}">${escapeXML(node.name)}</text>`);
66
+ attrs.forEach((attr, k) => {
67
+ parts.push(`<text x="${cx(node.id)}" y="${ty + th + MIN_H + ATTR_P + k * ATTR_H}" text-anchor="middle" ` +
68
+ `font-family="${font}" font-size="${FS_ATTR}" ` +
69
+ `fill="${escapeXML(style.text)}">${escapeXML(`${attr.key}: ${attr.val}`)}</text>`);
70
+ });
71
+ if (url === undefined)
72
+ return parts;
73
+ return [`<a href="${escapeXML(url)}" xlink:href="${escapeXML(url)}">`, ...parts, "</a>"];
74
+ };
75
+ /* determine the overall bounding box of all rendered elements (the
76
+ node boxes and the placed labels are handed over as already
77
+ computed areas, the edge polylines are scanned here) */
78
+ const viewBoxOf = (layout, boxes) => {
79
+ const { polys } = layout;
80
+ let [minX, minY, maxX, maxY] = [Infinity, Infinity, -Infinity, -Infinity];
81
+ for (const [bx1, by1, bx2, by2] of boxes) {
82
+ minX = Math.min(minX, bx1);
83
+ minY = Math.min(minY, by1);
84
+ maxX = Math.max(maxX, bx2);
85
+ maxY = Math.max(maxY, by2);
86
+ }
87
+ for (const poly of polys) {
88
+ for (const [px, py] of poly) {
89
+ minX = Math.min(minX, px);
90
+ minY = Math.min(minY, py);
91
+ maxX = Math.max(maxX, px);
92
+ maxY = Math.max(maxY, py);
93
+ }
94
+ }
95
+ if (!Number.isFinite(minX))
96
+ [minX, minY, maxX, maxY] = [0, 0, 0, 0];
97
+ return {
98
+ x: Math.floor(minX - MARGIN / 2),
99
+ y: Math.floor(minY - MARGIN / 2),
100
+ w: Math.ceil(maxX - minX + MARGIN),
101
+ h: Math.ceil(maxY - minY + MARGIN)
102
+ };
103
+ };
104
+ /* render a laid out graph into an SVG document */
105
+ export const renderSVG = (layout, config) => {
106
+ const { nodes, edges, polys } = layout;
107
+ const groups = layout.groups ?? [];
108
+ const styleOf = layout.styleOf ?? defaultStyleOf(config);
109
+ /* resolve the configured font into the rendered font family stack */
110
+ const { family, embed } = resolveFont(config);
111
+ const font = escapeXML(`'${escapeCSS(family)}', ui-sans-serif, -apple-system, Helvetica, Arial, sans-serif`);
112
+ /* detect the edge crossings requiring rendered hops */
113
+ const hops = computeHops(polys);
114
+ /* prepare the collision-free placement of the edge labels */
115
+ const { claim, occupied } = labelPlacer(layout);
116
+ /* generate the SVG fragments for the edges (paths below, labels above) */
117
+ const svgEdges = [];
118
+ const svgLabels = [];
119
+ /* the white halo rendered behind the edge labels for readability */
120
+ const halo = "stroke=\"#ffffff\" stroke-width=\"4.5\" paint-order=\"stroke\" stroke-linejoin=\"round\"";
121
+ edges.forEach((edge, i) => {
122
+ svgEdges.push(`<path d="${pathOf(polys[i], hops[i])}" fill="none" ` +
123
+ `stroke="${escapeXML(config["color-edge-line"])}" stroke-width="3" marker-end="url(#arrow)"/>`);
124
+ if (edge.name !== undefined) {
125
+ const w = textWidth(edge.name, FS_EDGE);
126
+ const candidates = [];
127
+ for (const f of [0.50, 0.40, 0.60, 0.30, 0.70, 0.20, 0.80]) {
128
+ const p = pointAt(polys[i], f);
129
+ if (p.horizontal) {
130
+ candidates.push([p.x - w / 2, p.y - 23, p.x + w / 2, p.y - 3]);
131
+ candidates.push([p.x - w / 2, p.y + 3, p.x + w / 2, p.y + 23]);
132
+ }
133
+ else {
134
+ candidates.push([p.x + 5, p.y - 10, p.x + 5 + w, p.y + 10]);
135
+ candidates.push([p.x - 5 - w, p.y - 10, p.x - 5, p.y + 10]);
136
+ }
137
+ }
138
+ const box = claim(candidates);
139
+ svgLabels.push(`<text x="${(box[0] + box[2]) / 2}" y="${box[3] - 3}" text-anchor="middle" ` +
140
+ `font-family="${font}" font-size="${FS_EDGE}" ` +
141
+ `fill="${escapeXML(config["color-edge-name"])}" ${halo}>${escapeXML(edge.name)}</text>`);
142
+ }
143
+ if (edge.arity !== undefined) {
144
+ const w = textWidth(edge.arity, FS_ARITY);
145
+ const p = pointAt(polys[i], 1.0);
146
+ const prev = pointAt(polys[i], 0.999);
147
+ const dx = Math.sign(p.x - prev.x) || 1;
148
+ const ax = p.x - dx * (24 + w / 2);
149
+ const box = claim([
150
+ [ax - w / 2, p.y - 17, ax + w / 2, p.y - 4],
151
+ [ax - w / 2, p.y + 4, ax + w / 2, p.y + 17],
152
+ [ax - w / 2 - dx * 14, p.y - 17, ax + w / 2 - dx * 14, p.y - 4]
153
+ ]);
154
+ svgLabels.push(`<text x="${(box[0] + box[2]) / 2}" y="${box[3] - 3}" text-anchor="middle" ` +
155
+ `font-family="${font}" font-size="${FS_ARITY}" ` +
156
+ `fill="${escapeXML(config["color-edge-arity"])}" ${halo}>${escapeXML(edge.arity)}</text>`);
157
+ }
158
+ });
159
+ /* generate the SVG fragments for the node boxes */
160
+ const svgNodes = nodes.flatMap((node) => renderNode(node, layout, styleOf(node), font));
161
+ /* generate the SVG fragments for the group boxes (drawn below
162
+ everything else) and their tags in the top-left corners */
163
+ const svgGroups = groups.flatMap((group) => [
164
+ `<rect x="${group.x}" y="${group.y}" width="${group.w}" height="${group.h}" rx="12" ` +
165
+ `fill="${escapeXML(config["color-group-box"])}" ` +
166
+ `stroke="${escapeXML(config["color-group-border"])}" stroke-width="3.0"/>`,
167
+ `<text x="${group.x + TAG_DX}" y="${group.y + TAG_DY + FS_GROUP}" ` +
168
+ `font-family="${font}" font-size="${FS_GROUP}" font-weight="600" ` +
169
+ `fill="${escapeXML(config["color-group-name"])}">${escapeXML(group.name)}</text>`
170
+ ]);
171
+ /* determine the overall bounding box of all rendered elements */
172
+ const groupBoxes = groups.map((group) => [group.x, group.y, group.x + group.w, group.y + group.h]);
173
+ const vb = viewBoxOf(layout, [...occupied, ...groupBoxes]);
174
+ /* assemble the final SVG document */
175
+ return [
176
+ "<?xml version=\"1.0\" encoding=\"UTF-8\"?>",
177
+ "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" " +
178
+ `viewBox="${vb.x} ${vb.y} ${vb.w} ${vb.h}" width="${vb.w}" height="${vb.h}">`,
179
+ "<defs>",
180
+ ...(embed !== undefined ? [
181
+ "<style>",
182
+ `@font-face { font-family: "${escapeXML(escapeCSS(family))}"; ` +
183
+ `src: url(data:font/woff2;base64,${embed}) format("woff2"); }`,
184
+ "</style>"
185
+ ] : []),
186
+ "<marker id=\"arrow\" viewBox=\"0 0 10 10\" refX=\"9\" refY=\"5\" " +
187
+ "markerWidth=\"21\" markerHeight=\"21\" markerUnits=\"userSpaceOnUse\" " +
188
+ "orient=\"auto-start-reverse\">",
189
+ `<path d="M 0 1 L 9 5 L 0 9 z" fill="${escapeXML(config["color-edge-line"])}"/>`,
190
+ "</marker>",
191
+ "</defs>",
192
+ ...svgGroups,
193
+ ...svgEdges,
194
+ ...svgNodes,
195
+ ...svgLabels,
196
+ "</svg>",
197
+ ""
198
+ ].join("\n");
199
+ };
@@ -0,0 +1,4 @@
1
+ import { Graph } from "./gradia-api-model.js";
2
+ import { Config } from "./gradia-api-config.js";
3
+ import { Layout } from "./gradia-api-render-svg.js";
4
+ export declare const render: (graph: Graph, _config: Config) => Promise<Layout>;