@matchina/viz-svg 0.1.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,210 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.runElkLayout = runElkLayout;
7
+ var _elkBundled = _interopRequireDefault(require("elkjs/lib/elk.bundled.js"));
8
+ function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
9
+ const elk = new _elkBundled.default();
10
+ function toElkId(fullKey) {
11
+ return fullKey.replace(/\./g, "|");
12
+ }
13
+ function fromElkId(id) {
14
+ return id.replace(/\|/g, ".");
15
+ }
16
+ function textWidth(text, charW = 7.2, pad = 24) {
17
+ return Math.max(60, Math.ceil(text.length * charW) + pad);
18
+ }
19
+ function lcaPath(a, b) {
20
+ const out = [];
21
+ const n = Math.min(a.length, b.length);
22
+ for (let i = 0; i < n; i++) {
23
+ if (a[i] === b[i]) out.push(a[i]);else break;
24
+ }
25
+ return out;
26
+ }
27
+ function buildElkNode(fullKey, shape, nodeSpacing, layerSpacing, direction) {
28
+ const stateNode = shape.states.get(fullKey);
29
+ const label = stateNode.key;
30
+ const isCompound = stateNode.isCompound;
31
+ const path = fullKey.split(".");
32
+ const node = {
33
+ id: toElkId(fullKey),
34
+ labels: [{
35
+ text: label,
36
+ width: textWidth(label, 7.5, 16),
37
+ height: 18
38
+ }],
39
+ layoutOptions: {
40
+ "elk.algorithm": "layered",
41
+ "elk.direction": direction,
42
+ "elk.layered.spacing.nodeNodeBetweenLayers": String(layerSpacing),
43
+ "elk.spacing.nodeNode": String(nodeSpacing),
44
+ "elk.spacing.edgeNode": String(nodeSpacing * 0.5),
45
+ "elk.layered.cycleBreaking.strategy": "MODEL_ORDER",
46
+ "elk.layered.considerModelOrder": "NODES_AND_EDGES"
47
+ },
48
+ _meta: {
49
+ path,
50
+ isCompound
51
+ }
52
+ };
53
+ if (!isCompound) {
54
+ node.width = Math.max(textWidth(label, 8, 32), 92);
55
+ node.height = 44;
56
+ } else {
57
+ node.layoutOptions["elk.hierarchyHandling"] = "INCLUDE_CHILDREN";
58
+ node.layoutOptions["elk.padding"] = `[top=36,left=${nodeSpacing * 0.6},bottom=${nodeSpacing * 0.6},right=${nodeSpacing * 0.6}]`;
59
+ const children = [];
60
+ for (const [childKey, parentKey] of shape.hierarchy) {
61
+ if (parentKey === fullKey) children.push(childKey);
62
+ }
63
+ node.children = children.map(childKey => buildElkNode(childKey, shape, nodeSpacing, layerSpacing, direction));
64
+ }
65
+ return node;
66
+ }
67
+ function indexNodes(node, byId) {
68
+ byId.set(node.id, node);
69
+ for (const child of node.children ?? []) indexNodes(child, byId);
70
+ }
71
+ async function runElkLayout(shape, opts = {}) {
72
+ const nodeSpacing = opts.nodeSpacing ?? 40;
73
+ const layerSpacing = opts.layerSpacing ?? nodeSpacing + 20;
74
+ const direction = opts.direction ?? "DOWN";
75
+ const edgeRouting = opts.edgeRouting ?? "ORTHOGONAL";
76
+ const rootKeys = [];
77
+ for (const [fullKey, parentKey] of shape.hierarchy) {
78
+ if (parentKey === void 0 && fullKey !== "") rootKeys.push(fullKey);
79
+ }
80
+ const rootChildren = rootKeys.map(key => buildElkNode(key, shape, nodeSpacing, layerSpacing, direction));
81
+ const byId = /* @__PURE__ */new Map();
82
+ for (const child of rootChildren) indexNodes(child, byId);
83
+ const rootEdges = [];
84
+ for (const [sourceFullKey, eventMap] of shape.transitions) {
85
+ for (const [event, targetFullKey] of eventMap) {
86
+ if (!shape.states.has(targetFullKey)) continue;
87
+ if (!byId.has(toElkId(sourceFullKey)) || !byId.has(toElkId(targetFullKey))) continue;
88
+ const sourcePath = sourceFullKey.split(".");
89
+ const targetPath = targetFullKey.split(".");
90
+ const edge = {
91
+ id: `e:${toElkId(sourceFullKey)}->${toElkId(targetFullKey)}:${event}`,
92
+ sources: [toElkId(sourceFullKey)],
93
+ targets: [toElkId(targetFullKey)],
94
+ labels: [{
95
+ text: event,
96
+ width: textWidth(event, 6.6, 14),
97
+ height: 16
98
+ }],
99
+ _meta: {
100
+ event,
101
+ sourcePath,
102
+ targetPath
103
+ }
104
+ };
105
+ const lca = lcaPath(sourcePath, targetPath);
106
+ const lcaKey = lca.join(".");
107
+ const owner = lcaKey ? byId.get(toElkId(lcaKey)) : null;
108
+ if (owner?.children) {
109
+ owner.edges = owner.edges ?? [];
110
+ owner.edges.push(edge);
111
+ } else {
112
+ rootEdges.push(edge);
113
+ }
114
+ }
115
+ }
116
+ const elkInput = {
117
+ id: "root",
118
+ layoutOptions: {
119
+ "elk.algorithm": "layered",
120
+ "elk.direction": direction,
121
+ "elk.hierarchyHandling": "INCLUDE_CHILDREN",
122
+ "elk.edgeRouting": edgeRouting,
123
+ "elk.layered.spacing.nodeNodeBetweenLayers": String(layerSpacing),
124
+ "elk.spacing.nodeNode": String(nodeSpacing),
125
+ // Keep parallel edges visually separate — don't merge or collapse them.
126
+ "elk.layered.mergeEdges": "false",
127
+ "elk.layered.mergeHierarchyEdges": "false",
128
+ "elk.layered.cycleBreaking.strategy": "MODEL_ORDER",
129
+ "elk.layered.considerModelOrder": "NODES_AND_EDGES",
130
+ "elk.padding": `[top=${nodeSpacing * 0.6},left=${nodeSpacing * 0.6},bottom=${nodeSpacing * 0.6},right=${nodeSpacing * 0.6}]`
131
+ },
132
+ children: rootChildren,
133
+ edges: rootEdges
134
+ };
135
+ const result = await elk.layout(elkInput);
136
+ const nodes = [];
137
+ const edges = [];
138
+ const absById = /* @__PURE__ */new Map();
139
+ absById.set("root", {
140
+ x: 0,
141
+ y: 0
142
+ });
143
+ function walkNodes(node, ox, oy) {
144
+ const ax = (node.x ?? 0) + ox;
145
+ const ay = (node.y ?? 0) + oy;
146
+ absById.set(node.id, {
147
+ x: ax,
148
+ y: ay
149
+ });
150
+ if (node.id !== "root" && node._meta) {
151
+ nodes.push({
152
+ id: fromElkId(node.id),
153
+ x: ax,
154
+ y: ay,
155
+ width: node.width ?? 92,
156
+ height: node.height ?? 44,
157
+ label: node.labels?.[0]?.text ?? node.id,
158
+ isCompound: node._meta.isCompound,
159
+ path: node._meta.path
160
+ });
161
+ }
162
+ for (const child of node.children ?? []) walkNodes(child, ax, ay);
163
+ }
164
+ walkNodes(result, 0, 0);
165
+ function walkEdges(node) {
166
+ for (const edge of node.edges ?? []) {
167
+ const containerId = edge.container ?? node.id ?? "root";
168
+ const off = absById.get(containerId) ?? {
169
+ x: 0,
170
+ y: 0
171
+ };
172
+ const label = edge.labels?.[0];
173
+ edges.push({
174
+ id: edge.id,
175
+ event: edge._meta?.event ?? "",
176
+ sourcePath: edge._meta?.sourcePath ?? [],
177
+ targetPath: edge._meta?.targetPath ?? [],
178
+ sections: (edge.sections ?? []).map(s => ({
179
+ startPoint: {
180
+ x: s.startPoint.x + off.x,
181
+ y: s.startPoint.y + off.y
182
+ },
183
+ endPoint: {
184
+ x: s.endPoint.x + off.x,
185
+ y: s.endPoint.y + off.y
186
+ },
187
+ bendPoints: (s.bendPoints ?? []).map(b => ({
188
+ x: b.x + off.x,
189
+ y: b.y + off.y
190
+ }))
191
+ })),
192
+ label: label ? {
193
+ text: label.text,
194
+ x: (label.x ?? 0) + off.x,
195
+ y: (label.y ?? 0) + off.y,
196
+ width: label.width ?? 60,
197
+ height: label.height ?? 16
198
+ } : null
199
+ });
200
+ }
201
+ for (const child of node.children ?? []) walkEdges(child);
202
+ }
203
+ walkEdges(result);
204
+ return {
205
+ nodes,
206
+ edges,
207
+ width: result.width ?? 800,
208
+ height: result.height ?? 600
209
+ };
210
+ }
@@ -0,0 +1,175 @@
1
+ import ELKConstructor from "elkjs/lib/elk.bundled.js";
2
+ const elk = new ELKConstructor();
3
+ function toElkId(fullKey) {
4
+ return fullKey.replace(/\./g, "|");
5
+ }
6
+ function fromElkId(id) {
7
+ return id.replace(/\|/g, ".");
8
+ }
9
+ function textWidth(text, charW = 7.2, pad = 24) {
10
+ return Math.max(60, Math.ceil(text.length * charW) + pad);
11
+ }
12
+ function lcaPath(a, b) {
13
+ const out = [];
14
+ const n = Math.min(a.length, b.length);
15
+ for (let i = 0; i < n; i++) {
16
+ if (a[i] === b[i]) out.push(a[i]);
17
+ else break;
18
+ }
19
+ return out;
20
+ }
21
+ function buildElkNode(fullKey, shape, nodeSpacing, layerSpacing, direction) {
22
+ const stateNode = shape.states.get(fullKey);
23
+ const label = stateNode.key;
24
+ const isCompound = stateNode.isCompound;
25
+ const path = fullKey.split(".");
26
+ const node = {
27
+ id: toElkId(fullKey),
28
+ labels: [{ text: label, width: textWidth(label, 7.5, 16), height: 18 }],
29
+ layoutOptions: {
30
+ "elk.algorithm": "layered",
31
+ "elk.direction": direction,
32
+ "elk.layered.spacing.nodeNodeBetweenLayers": String(layerSpacing),
33
+ "elk.spacing.nodeNode": String(nodeSpacing),
34
+ "elk.spacing.edgeNode": String(nodeSpacing * 0.5),
35
+ "elk.layered.cycleBreaking.strategy": "MODEL_ORDER",
36
+ "elk.layered.considerModelOrder": "NODES_AND_EDGES"
37
+ },
38
+ _meta: { path, isCompound }
39
+ };
40
+ if (!isCompound) {
41
+ node.width = Math.max(textWidth(label, 8, 32), 92);
42
+ node.height = 44;
43
+ } else {
44
+ node.layoutOptions["elk.hierarchyHandling"] = "INCLUDE_CHILDREN";
45
+ node.layoutOptions["elk.padding"] = `[top=36,left=${nodeSpacing * 0.6},bottom=${nodeSpacing * 0.6},right=${nodeSpacing * 0.6}]`;
46
+ const children = [];
47
+ for (const [childKey, parentKey] of shape.hierarchy) {
48
+ if (parentKey === fullKey) children.push(childKey);
49
+ }
50
+ node.children = children.map(
51
+ (childKey) => buildElkNode(childKey, shape, nodeSpacing, layerSpacing, direction)
52
+ );
53
+ }
54
+ return node;
55
+ }
56
+ function indexNodes(node, byId) {
57
+ byId.set(node.id, node);
58
+ for (const child of node.children ?? []) indexNodes(child, byId);
59
+ }
60
+ export async function runElkLayout(shape, opts = {}) {
61
+ const nodeSpacing = opts.nodeSpacing ?? 40;
62
+ const layerSpacing = opts.layerSpacing ?? nodeSpacing + 20;
63
+ const direction = opts.direction ?? "DOWN";
64
+ const edgeRouting = opts.edgeRouting ?? "ORTHOGONAL";
65
+ const rootKeys = [];
66
+ for (const [fullKey, parentKey] of shape.hierarchy) {
67
+ if (parentKey === void 0 && fullKey !== "") rootKeys.push(fullKey);
68
+ }
69
+ const rootChildren = rootKeys.map(
70
+ (key) => buildElkNode(key, shape, nodeSpacing, layerSpacing, direction)
71
+ );
72
+ const byId = /* @__PURE__ */ new Map();
73
+ for (const child of rootChildren) indexNodes(child, byId);
74
+ const rootEdges = [];
75
+ for (const [sourceFullKey, eventMap] of shape.transitions) {
76
+ for (const [event, targetFullKey] of eventMap) {
77
+ if (!shape.states.has(targetFullKey)) continue;
78
+ if (!byId.has(toElkId(sourceFullKey)) || !byId.has(toElkId(targetFullKey))) continue;
79
+ const sourcePath = sourceFullKey.split(".");
80
+ const targetPath = targetFullKey.split(".");
81
+ const edge = {
82
+ id: `e:${toElkId(sourceFullKey)}->${toElkId(targetFullKey)}:${event}`,
83
+ sources: [toElkId(sourceFullKey)],
84
+ targets: [toElkId(targetFullKey)],
85
+ labels: [{ text: event, width: textWidth(event, 6.6, 14), height: 16 }],
86
+ _meta: { event, sourcePath, targetPath }
87
+ };
88
+ const lca = lcaPath(sourcePath, targetPath);
89
+ const lcaKey = lca.join(".");
90
+ const owner = lcaKey ? byId.get(toElkId(lcaKey)) : null;
91
+ if (owner?.children) {
92
+ owner.edges = owner.edges ?? [];
93
+ owner.edges.push(edge);
94
+ } else {
95
+ rootEdges.push(edge);
96
+ }
97
+ }
98
+ }
99
+ const elkInput = {
100
+ id: "root",
101
+ layoutOptions: {
102
+ "elk.algorithm": "layered",
103
+ "elk.direction": direction,
104
+ "elk.hierarchyHandling": "INCLUDE_CHILDREN",
105
+ "elk.edgeRouting": edgeRouting,
106
+ "elk.layered.spacing.nodeNodeBetweenLayers": String(layerSpacing),
107
+ "elk.spacing.nodeNode": String(nodeSpacing),
108
+ // Keep parallel edges visually separate — don't merge or collapse them.
109
+ "elk.layered.mergeEdges": "false",
110
+ "elk.layered.mergeHierarchyEdges": "false",
111
+ "elk.layered.cycleBreaking.strategy": "MODEL_ORDER",
112
+ "elk.layered.considerModelOrder": "NODES_AND_EDGES",
113
+ "elk.padding": `[top=${nodeSpacing * 0.6},left=${nodeSpacing * 0.6},bottom=${nodeSpacing * 0.6},right=${nodeSpacing * 0.6}]`
114
+ },
115
+ children: rootChildren,
116
+ edges: rootEdges
117
+ };
118
+ const result = await elk.layout(elkInput);
119
+ const nodes = [];
120
+ const edges = [];
121
+ const absById = /* @__PURE__ */ new Map();
122
+ absById.set("root", { x: 0, y: 0 });
123
+ function walkNodes(node, ox, oy) {
124
+ const ax = (node.x ?? 0) + ox;
125
+ const ay = (node.y ?? 0) + oy;
126
+ absById.set(node.id, { x: ax, y: ay });
127
+ if (node.id !== "root" && node._meta) {
128
+ nodes.push({
129
+ id: fromElkId(node.id),
130
+ x: ax,
131
+ y: ay,
132
+ width: node.width ?? 92,
133
+ height: node.height ?? 44,
134
+ label: node.labels?.[0]?.text ?? node.id,
135
+ isCompound: node._meta.isCompound,
136
+ path: node._meta.path
137
+ });
138
+ }
139
+ for (const child of node.children ?? []) walkNodes(child, ax, ay);
140
+ }
141
+ walkNodes(result, 0, 0);
142
+ function walkEdges(node) {
143
+ for (const edge of node.edges ?? []) {
144
+ const containerId = edge.container ?? node.id ?? "root";
145
+ const off = absById.get(containerId) ?? { x: 0, y: 0 };
146
+ const label = edge.labels?.[0];
147
+ edges.push({
148
+ id: edge.id,
149
+ event: edge._meta?.event ?? "",
150
+ sourcePath: edge._meta?.sourcePath ?? [],
151
+ targetPath: edge._meta?.targetPath ?? [],
152
+ sections: (edge.sections ?? []).map((s) => ({
153
+ startPoint: { x: s.startPoint.x + off.x, y: s.startPoint.y + off.y },
154
+ endPoint: { x: s.endPoint.x + off.x, y: s.endPoint.y + off.y },
155
+ bendPoints: (s.bendPoints ?? []).map((b) => ({ x: b.x + off.x, y: b.y + off.y }))
156
+ })),
157
+ label: label ? {
158
+ text: label.text,
159
+ x: (label.x ?? 0) + off.x,
160
+ y: (label.y ?? 0) + off.y,
161
+ width: label.width ?? 60,
162
+ height: label.height ?? 16
163
+ } : null
164
+ });
165
+ }
166
+ for (const child of node.children ?? []) walkEdges(child);
167
+ }
168
+ walkEdges(result);
169
+ return {
170
+ nodes,
171
+ edges,
172
+ width: result.width ?? 800,
173
+ height: result.height ?? 600
174
+ };
175
+ }
@@ -0,0 +1,6 @@
1
+ export { SvgInspector } from './SvgInspector.js';
2
+ export type { SvgInspectorProps } from './SvgInspector.js';
3
+ export { runElkLayout } from './elk-layout.js';
4
+ export type { ElkLayoutOptions, SvgLayout, SvgNode, SvgEdge } from './elk-layout.js';
5
+ export { layoutToSvg } from './layout-to-svg.js';
6
+ export type { LayoutToSvgOptions } from './layout-to-svg.js';
package/dist/index.js ADDED
@@ -0,0 +1,26 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ Object.defineProperty(exports, "SvgInspector", {
7
+ enumerable: true,
8
+ get: function () {
9
+ return _SvgInspector.SvgInspector;
10
+ }
11
+ });
12
+ Object.defineProperty(exports, "layoutToSvg", {
13
+ enumerable: true,
14
+ get: function () {
15
+ return _layoutToSvg.layoutToSvg;
16
+ }
17
+ });
18
+ Object.defineProperty(exports, "runElkLayout", {
19
+ enumerable: true,
20
+ get: function () {
21
+ return _elkLayout.runElkLayout;
22
+ }
23
+ });
24
+ var _SvgInspector = require("./SvgInspector.js");
25
+ var _elkLayout = require("./elk-layout.js");
26
+ var _layoutToSvg = require("./layout-to-svg.js");
package/dist/index.mjs ADDED
@@ -0,0 +1,3 @@
1
+ export { SvgInspector } from "./SvgInspector.js";
2
+ export { runElkLayout } from "./elk-layout.js";
3
+ export { layoutToSvg } from "./layout-to-svg.js";
@@ -0,0 +1,13 @@
1
+ import type { SvgLayout } from './elk-layout.js';
2
+ export interface LayoutToSvgOptions {
3
+ /** Current active state value (dot-separated full key). */
4
+ value?: string;
5
+ /** @deprecated No longer used; the SVG is sized to exact content dimensions. */
6
+ padding?: number;
7
+ }
8
+ /**
9
+ * Produce a complete SVG markup string from a precomputed ELK layout.
10
+ * Output is sized to the layout's intrinsic dimensions plus padding,
11
+ * with a viewBox so it scales to its container via `width="100%" height="100%"`.
12
+ */
13
+ export declare function layoutToSvg(layout: SvgLayout, opts?: LayoutToSvgOptions): string;
@@ -0,0 +1,148 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.layoutToSvg = layoutToSvg;
7
+ var _svgPath = require("./svg-path.js");
8
+ const V = {
9
+ accent: "var(--matchina-viz-accent, #2dd4bf)",
10
+ bg: "var(--matchina-viz-bg, #0a0f17)",
11
+ node: "var(--matchina-viz-node, rgba(28,38,54,0.95))",
12
+ nodeActive: "var(--matchina-viz-node-active, rgba(20,90,82,0.85))",
13
+ nodeCompound: "var(--matchina-viz-node-compound, rgba(20,28,40,0.7))",
14
+ border: "var(--matchina-viz-border, rgba(148,163,184,0.25))",
15
+ text: "var(--matchina-viz-text, rgba(226,232,240,0.92))",
16
+ textActive: "var(--matchina-viz-text-active, #e6fffb)",
17
+ edge: "var(--matchina-viz-edge, rgba(100,116,139,0.55))",
18
+ labelBg: "var(--matchina-viz-label-bg, rgba(15,23,33,0.95))",
19
+ labelBgActive: "var(--matchina-viz-label-bg-active, rgba(8,47,51,0.95))",
20
+ labelText: "var(--matchina-viz-label-text, rgba(203,213,225,0.82))"
21
+ };
22
+ const FONT = "var(--matchina-viz-font, 'JetBrains Mono', monospace)";
23
+ function esc(s) {
24
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
25
+ }
26
+ function nodeSvg(node, isActive, isAncestor) {
27
+ const stroke = isActive || isAncestor ? V.accent : V.border;
28
+ const strokeWidth = isActive ? 2 : isAncestor ? 1.5 : 1;
29
+ const fill = node.isCompound ? V.nodeCompound : isActive ? V.nodeActive : V.node;
30
+ const textFill = isActive ? V.textActive : isActive || isAncestor ? V.accent : V.text;
31
+ const rect = `<rect x="${node.x}" y="${node.y}" width="${node.width}" height="${node.height}" rx="10" ry="10" style="fill:${fill};stroke:${stroke};stroke-width:${strokeWidth}" />`;
32
+ const label = node.isCompound ? `<text x="${node.x + 14}" y="${node.y + 22}" style="fill:${isActive || isAncestor ? V.accent : V.text};font-family:${FONT};font-size:12px;font-weight:600;letter-spacing:0.06em">${esc(node.label)}</text>` : `<text x="${node.x + node.width / 2}" y="${node.y + node.height / 2 + 5}" text-anchor="middle" style="fill:${textFill};font-family:${FONT};font-size:14px;font-weight:${isActive ? 600 : 500}">${esc(node.label)}</text>`;
33
+ const activeDot = isActive && !node.isCompound ? `<circle cx="${node.x + node.width - 10}" cy="${node.y + 10}" r="4" style="fill:${V.accent}"><animate attributeName="opacity" values="1;0.35;1" dur="1.6s" repeatCount="indefinite" /></circle>` : "";
34
+ return `<g data-node-id="${esc(node.id)}" data-active="${isActive}" data-ancestor="${isAncestor}">${rect}${label}${activeDot}</g>`;
35
+ }
36
+ function selfLoopSvg(edge, node, isOutgoing, loopIndex) {
37
+ const stroke = isOutgoing ? V.accent : V.edge;
38
+ const strokeWidth = isOutgoing ? 2 : 1.25;
39
+ const opacity = isOutgoing ? 1 : 0.65;
40
+ const markerId = isOutgoing ? "matchina-svg-arrow-active" : "matchina-svg-arrow";
41
+ const {
42
+ label
43
+ } = edge;
44
+ const hw = node.width / 2;
45
+ const hh = node.height / 2;
46
+ const sx = node.x + hw;
47
+ const sy = node.y + hh;
48
+ const loopRadius = 28 + loopIndex * 16;
49
+ const startX = sx + hw - 8 - loopIndex * 2;
50
+ const startY = sy - hh;
51
+ const endX = sx + hw;
52
+ const endY = sy - hh + 8 + loopIndex * 2;
53
+ const d = `M ${startX} ${startY} C ${startX} ${startY - loopRadius}, ${endX + loopRadius} ${endY}, ${endX} ${endY}`;
54
+ const labelX = sx + hw + loopRadius + 4;
55
+ const labelY = sy - hh - 10 + loopIndex * (label ? label.height + 8 : 24);
56
+ const path = `<path d="${d}" fill="none" style="stroke:${stroke};stroke-width:${strokeWidth};opacity:${opacity}" marker-end="url(#${markerId})" />`;
57
+ const labelMarkup = label ? `<g transform="translate(${labelX}, ${labelY - label.height / 2})" style="opacity:${opacity}">
58
+ <rect x="-6" y="-2" width="${label.width + 12}" height="${label.height + 4}" rx="6" ry="6" style="fill:${isOutgoing ? V.labelBgActive : V.labelBg};stroke:${isOutgoing ? V.accent : "rgba(100,116,139,0.45)"};stroke-width:${isOutgoing ? 1 : 0.75}" />
59
+ <text x="${label.width / 2}" y="${(label.height + 4) / 2 + 4}" text-anchor="middle" style="fill:${isOutgoing ? V.accent : V.labelText};font-family:${FONT};font-size:11px;font-weight:${isOutgoing ? 600 : 500};letter-spacing:0.04em">${esc(label.text)}</text>
60
+ </g>` : "";
61
+ return `<g data-edge-id="${esc(edge.id)}" data-event="${esc(edge.event)}">${path}${labelMarkup}</g>`;
62
+ }
63
+ function edgeSvg(edge, isOutgoing, labelT) {
64
+ const section = edge.sections?.[0];
65
+ if (!section?.startPoint || !section?.endPoint) return "";
66
+ const d = (0, _svgPath.buildCurvedPath)(section);
67
+ const stroke = isOutgoing ? V.accent : V.edge;
68
+ const strokeWidth = isOutgoing ? 2 : 1.25;
69
+ const opacity = isOutgoing ? 1 : 0.65;
70
+ const {
71
+ label
72
+ } = edge;
73
+ const markerId = isOutgoing ? "matchina-svg-arrow-active" : "matchina-svg-arrow";
74
+ const mid = label ? (0, _svgPath.pathAtT)(section, labelT) : null;
75
+ const path = `<path d="${d}" fill="none" style="stroke:${stroke};stroke-width:${strokeWidth};opacity:${opacity}" marker-end="url(#${markerId})" />`;
76
+ const labelMarkup = label && mid ? `<g transform="translate(${mid.x - label.width / 2}, ${mid.y - label.height / 2})" style="opacity:${opacity}">
77
+ <rect x="-6" y="-2" width="${label.width + 12}" height="${label.height + 4}" rx="6" ry="6" style="fill:${isOutgoing ? V.labelBgActive : V.labelBg};stroke:${isOutgoing ? V.accent : "rgba(100,116,139,0.45)"};stroke-width:${isOutgoing ? 1 : 0.75}" />
78
+ <text x="${label.width / 2}" y="${(label.height + 4) / 2 + 4}" text-anchor="middle" style="fill:${isOutgoing ? V.accent : V.labelText};font-family:${FONT};font-size:11px;font-weight:${isOutgoing ? 600 : 500};letter-spacing:0.04em">${esc(label.text)}</text>
79
+ </g>` : "";
80
+ return `<g data-edge-id="${esc(edge.id)}" data-event="${esc(edge.event)}">${path}${labelMarkup}</g>`;
81
+ }
82
+ function layoutToSvg(layout, opts = {}) {
83
+ const {
84
+ value = ""
85
+ } = opts;
86
+ const padding = 0;
87
+ const activePath = value ? value.split(".") : [];
88
+ const activeLeafId = value;
89
+ const activeAncestorIds = /* @__PURE__ */new Set();
90
+ for (let i = 0; i < activePath.length - 1; i++) {
91
+ activeAncestorIds.add(activePath.slice(0, i + 1).join("."));
92
+ }
93
+ const activeSourceIds = /* @__PURE__ */new Set();
94
+ for (let i = 1; i <= activePath.length; i++) {
95
+ activeSourceIds.add(activePath.slice(0, i).join("."));
96
+ }
97
+ const pairTotal = /* @__PURE__ */new Map();
98
+ for (const edge of layout.edges) {
99
+ const key = `${edge.sourcePath.join(".")}\u2192${edge.targetPath.join(".")}`;
100
+ pairTotal.set(key, (pairTotal.get(key) ?? 0) + 1);
101
+ }
102
+ const pairNextIdx = /* @__PURE__ */new Map();
103
+ const edgeLabelT = /* @__PURE__ */new Map();
104
+ for (const edge of layout.edges) {
105
+ const key = `${edge.sourcePath.join(".")}\u2192${edge.targetPath.join(".")}`;
106
+ const count = pairTotal.get(key) ?? 1;
107
+ const idx = pairNextIdx.get(key) ?? 0;
108
+ pairNextIdx.set(key, idx + 1);
109
+ const t = count === 1 ? 0.5 : 0.3 + idx / (count - 1) * 0.4;
110
+ edgeLabelT.set(edge.id, t);
111
+ }
112
+ const compounds = layout.nodes.filter(n => n.isCompound);
113
+ const leaves = layout.nodes.filter(n => !n.isCompound);
114
+ const nodeById = new Map(layout.nodes.map(n => [n.id, n]));
115
+ const selfLoopIndexByNode = /* @__PURE__ */new Map();
116
+ const edgeMarkup = layout.edges.map(edge => {
117
+ const isSelf = edge.sourcePath.join(".") === edge.targetPath.join(".");
118
+ const isOutgoing = activeSourceIds.has(edge.sourcePath.join("."));
119
+ if (isSelf) {
120
+ const nodeId = edge.sourcePath.join(".");
121
+ const node = nodeById.get(nodeId);
122
+ if (!node) return "";
123
+ const loopIndex = selfLoopIndexByNode.get(nodeId) ?? 0;
124
+ selfLoopIndexByNode.set(nodeId, loopIndex + 1);
125
+ return selfLoopSvg(edge, node, isOutgoing, loopIndex);
126
+ }
127
+ return edgeSvg(edge, isOutgoing, edgeLabelT.get(edge.id) ?? 0.5);
128
+ }).join("");
129
+ const compoundsMarkup = compounds.map(n => nodeSvg(n, n.id === activeLeafId, activeAncestorIds.has(n.id))).join("");
130
+ const leavesMarkup = leaves.map(n => nodeSvg(n, n.id === activeLeafId, activeAncestorIds.has(n.id))).join("");
131
+ const vw = layout.width + padding * 2;
132
+ const vh = layout.height + padding * 2;
133
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%" viewBox="0 0 ${vw} ${vh}" preserveAspectRatio="xMidYMid meet" style="display:block;background:${V.bg}">
134
+ <defs>
135
+ <marker id="matchina-svg-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
136
+ <path d="M 0 0 L 10 5 L 0 10 z" style="fill:rgba(100,116,139,0.7)" />
137
+ </marker>
138
+ <marker id="matchina-svg-arrow-active" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
139
+ <path d="M 0 0 L 10 5 L 0 10 z" style="fill:${V.accent}" />
140
+ </marker>
141
+ </defs>
142
+ <g transform="translate(${padding}, ${padding})">
143
+ ${compoundsMarkup}
144
+ ${edgeMarkup}
145
+ ${leavesMarkup}
146
+ </g>
147
+ </svg>`;
148
+ }