@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,136 @@
1
+ import { buildCurvedPath, pathAtT } from "./svg-path.js";
2
+ const V = {
3
+ accent: "var(--matchina-viz-accent, #2dd4bf)",
4
+ bg: "var(--matchina-viz-bg, #0a0f17)",
5
+ node: "var(--matchina-viz-node, rgba(28,38,54,0.95))",
6
+ nodeActive: "var(--matchina-viz-node-active, rgba(20,90,82,0.85))",
7
+ nodeCompound: "var(--matchina-viz-node-compound, rgba(20,28,40,0.7))",
8
+ border: "var(--matchina-viz-border, rgba(148,163,184,0.25))",
9
+ text: "var(--matchina-viz-text, rgba(226,232,240,0.92))",
10
+ textActive: "var(--matchina-viz-text-active, #e6fffb)",
11
+ edge: "var(--matchina-viz-edge, rgba(100,116,139,0.55))",
12
+ labelBg: "var(--matchina-viz-label-bg, rgba(15,23,33,0.95))",
13
+ labelBgActive: "var(--matchina-viz-label-bg-active, rgba(8,47,51,0.95))",
14
+ labelText: "var(--matchina-viz-label-text, rgba(203,213,225,0.82))"
15
+ };
16
+ const FONT = "var(--matchina-viz-font, 'JetBrains Mono', monospace)";
17
+ function esc(s) {
18
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
19
+ }
20
+ function nodeSvg(node, isActive, isAncestor) {
21
+ const stroke = isActive || isAncestor ? V.accent : V.border;
22
+ const strokeWidth = isActive ? 2 : isAncestor ? 1.5 : 1;
23
+ const fill = node.isCompound ? V.nodeCompound : isActive ? V.nodeActive : V.node;
24
+ const textFill = isActive ? V.textActive : isActive || isAncestor ? V.accent : V.text;
25
+ 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}" />`;
26
+ 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>`;
27
+ 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>` : "";
28
+ return `<g data-node-id="${esc(node.id)}" data-active="${isActive}" data-ancestor="${isAncestor}">${rect}${label}${activeDot}</g>`;
29
+ }
30
+ function selfLoopSvg(edge, node, isOutgoing, loopIndex) {
31
+ const stroke = isOutgoing ? V.accent : V.edge;
32
+ const strokeWidth = isOutgoing ? 2 : 1.25;
33
+ const opacity = isOutgoing ? 1 : 0.65;
34
+ const markerId = isOutgoing ? "matchina-svg-arrow-active" : "matchina-svg-arrow";
35
+ const { label } = edge;
36
+ const hw = node.width / 2;
37
+ const hh = node.height / 2;
38
+ const sx = node.x + hw;
39
+ const sy = node.y + hh;
40
+ const loopRadius = 28 + loopIndex * 16;
41
+ const startX = sx + hw - 8 - loopIndex * 2;
42
+ const startY = sy - hh;
43
+ const endX = sx + hw;
44
+ const endY = sy - hh + 8 + loopIndex * 2;
45
+ const d = `M ${startX} ${startY} C ${startX} ${startY - loopRadius}, ${endX + loopRadius} ${endY}, ${endX} ${endY}`;
46
+ const labelX = sx + hw + loopRadius + 4;
47
+ const labelY = sy - hh - 10 + loopIndex * (label ? label.height + 8 : 24);
48
+ const path = `<path d="${d}" fill="none" style="stroke:${stroke};stroke-width:${strokeWidth};opacity:${opacity}" marker-end="url(#${markerId})" />`;
49
+ const labelMarkup = label ? `<g transform="translate(${labelX}, ${labelY - label.height / 2})" style="opacity:${opacity}">
50
+ <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}" />
51
+ <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>
52
+ </g>` : "";
53
+ return `<g data-edge-id="${esc(edge.id)}" data-event="${esc(edge.event)}">${path}${labelMarkup}</g>`;
54
+ }
55
+ function edgeSvg(edge, isOutgoing, labelT) {
56
+ const section = edge.sections?.[0];
57
+ if (!section?.startPoint || !section?.endPoint) return "";
58
+ const d = buildCurvedPath(section);
59
+ const stroke = isOutgoing ? V.accent : V.edge;
60
+ const strokeWidth = isOutgoing ? 2 : 1.25;
61
+ const opacity = isOutgoing ? 1 : 0.65;
62
+ const { label } = edge;
63
+ const markerId = isOutgoing ? "matchina-svg-arrow-active" : "matchina-svg-arrow";
64
+ const mid = label ? pathAtT(section, labelT) : null;
65
+ const path = `<path d="${d}" fill="none" style="stroke:${stroke};stroke-width:${strokeWidth};opacity:${opacity}" marker-end="url(#${markerId})" />`;
66
+ const labelMarkup = label && mid ? `<g transform="translate(${mid.x - label.width / 2}, ${mid.y - label.height / 2})" style="opacity:${opacity}">
67
+ <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}" />
68
+ <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>
69
+ </g>` : "";
70
+ return `<g data-edge-id="${esc(edge.id)}" data-event="${esc(edge.event)}">${path}${labelMarkup}</g>`;
71
+ }
72
+ export function layoutToSvg(layout, opts = {}) {
73
+ const { value = "" } = opts;
74
+ const padding = 0;
75
+ const activePath = value ? value.split(".") : [];
76
+ const activeLeafId = value;
77
+ const activeAncestorIds = /* @__PURE__ */ new Set();
78
+ for (let i = 0; i < activePath.length - 1; i++) {
79
+ activeAncestorIds.add(activePath.slice(0, i + 1).join("."));
80
+ }
81
+ const activeSourceIds = /* @__PURE__ */ new Set();
82
+ for (let i = 1; i <= activePath.length; i++) {
83
+ activeSourceIds.add(activePath.slice(0, i).join("."));
84
+ }
85
+ const pairTotal = /* @__PURE__ */ new Map();
86
+ for (const edge of layout.edges) {
87
+ const key = `${edge.sourcePath.join(".")}\u2192${edge.targetPath.join(".")}`;
88
+ pairTotal.set(key, (pairTotal.get(key) ?? 0) + 1);
89
+ }
90
+ const pairNextIdx = /* @__PURE__ */ new Map();
91
+ const edgeLabelT = /* @__PURE__ */ new Map();
92
+ for (const edge of layout.edges) {
93
+ const key = `${edge.sourcePath.join(".")}\u2192${edge.targetPath.join(".")}`;
94
+ const count = pairTotal.get(key) ?? 1;
95
+ const idx = pairNextIdx.get(key) ?? 0;
96
+ pairNextIdx.set(key, idx + 1);
97
+ const t = count === 1 ? 0.5 : 0.3 + idx / (count - 1) * 0.4;
98
+ edgeLabelT.set(edge.id, t);
99
+ }
100
+ const compounds = layout.nodes.filter((n) => n.isCompound);
101
+ const leaves = layout.nodes.filter((n) => !n.isCompound);
102
+ const nodeById = new Map(layout.nodes.map((n) => [n.id, n]));
103
+ const selfLoopIndexByNode = /* @__PURE__ */ new Map();
104
+ const edgeMarkup = layout.edges.map((edge) => {
105
+ const isSelf = edge.sourcePath.join(".") === edge.targetPath.join(".");
106
+ const isOutgoing = activeSourceIds.has(edge.sourcePath.join("."));
107
+ if (isSelf) {
108
+ const nodeId = edge.sourcePath.join(".");
109
+ const node = nodeById.get(nodeId);
110
+ if (!node) return "";
111
+ const loopIndex = selfLoopIndexByNode.get(nodeId) ?? 0;
112
+ selfLoopIndexByNode.set(nodeId, loopIndex + 1);
113
+ return selfLoopSvg(edge, node, isOutgoing, loopIndex);
114
+ }
115
+ return edgeSvg(edge, isOutgoing, edgeLabelT.get(edge.id) ?? 0.5);
116
+ }).join("");
117
+ const compoundsMarkup = compounds.map((n) => nodeSvg(n, n.id === activeLeafId, activeAncestorIds.has(n.id))).join("");
118
+ const leavesMarkup = leaves.map((n) => nodeSvg(n, n.id === activeLeafId, activeAncestorIds.has(n.id))).join("");
119
+ const vw = layout.width + padding * 2;
120
+ const vh = layout.height + padding * 2;
121
+ 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}">
122
+ <defs>
123
+ <marker id="matchina-svg-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
124
+ <path d="M 0 0 L 10 5 L 0 10 z" style="fill:rgba(100,116,139,0.7)" />
125
+ </marker>
126
+ <marker id="matchina-svg-arrow-active" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
127
+ <path d="M 0 0 L 10 5 L 0 10 z" style="fill:${V.accent}" />
128
+ </marker>
129
+ </defs>
130
+ <g transform="translate(${padding}, ${padding})">
131
+ ${compoundsMarkup}
132
+ ${edgeMarkup}
133
+ ${leavesMarkup}
134
+ </g>
135
+ </svg>`;
136
+ }
@@ -0,0 +1,20 @@
1
+ interface Point {
2
+ x: number;
3
+ y: number;
4
+ }
5
+ export declare function pathAtT(section: {
6
+ startPoint: Point;
7
+ endPoint: Point;
8
+ bendPoints?: Point[];
9
+ }, t: number): Point;
10
+ export declare function pathMidpoint(section: {
11
+ startPoint: Point;
12
+ endPoint: Point;
13
+ bendPoints?: Point[];
14
+ }): Point;
15
+ export declare function buildCurvedPath(section: {
16
+ startPoint: Point;
17
+ endPoint: Point;
18
+ bendPoints?: Point[];
19
+ }, radius?: number): string;
20
+ export {};
@@ -0,0 +1,64 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.buildCurvedPath = buildCurvedPath;
7
+ exports.pathAtT = pathAtT;
8
+ exports.pathMidpoint = pathMidpoint;
9
+ function pathAtT(section, t) {
10
+ const pts = [section.startPoint, ...(section.bendPoints ?? []), section.endPoint];
11
+ const lengths = [];
12
+ let total = 0;
13
+ for (let i = 1; i < pts.length; i++) {
14
+ const l = Math.hypot(pts[i].x - pts[i - 1].x, pts[i].y - pts[i - 1].y);
15
+ lengths.push(l);
16
+ total += l;
17
+ }
18
+ let remaining = total * Math.max(0, Math.min(1, t));
19
+ for (let i = 0; i < lengths.length; i++) {
20
+ if (remaining <= lengths[i]) {
21
+ const s = remaining / lengths[i];
22
+ return {
23
+ x: pts[i].x + s * (pts[i + 1].x - pts[i].x),
24
+ y: pts[i].y + s * (pts[i + 1].y - pts[i].y)
25
+ };
26
+ }
27
+ remaining -= lengths[i];
28
+ }
29
+ return pts[pts.length - 1];
30
+ }
31
+ function pathMidpoint(section) {
32
+ return pathAtT(section, 0.5);
33
+ }
34
+ function buildCurvedPath(section, radius = 14) {
35
+ const pts = [section.startPoint, ...(section.bendPoints ?? []), section.endPoint];
36
+ if (pts.length <= 2) {
37
+ return `M ${pts[0].x} ${pts[0].y} L ${pts[pts.length - 1].x} ${pts[pts.length - 1].y}`;
38
+ }
39
+ let d = `M ${pts[0].x} ${pts[0].y}`;
40
+ for (let i = 1; i < pts.length - 1; i++) {
41
+ const prev = pts[i - 1];
42
+ const cur = pts[i];
43
+ const next = pts[i + 1];
44
+ const v1x = cur.x - prev.x;
45
+ const v1y = cur.y - prev.y;
46
+ const v2x = next.x - cur.x;
47
+ const v2y = next.y - cur.y;
48
+ const len1 = Math.hypot(v1x, v1y) || 1;
49
+ const len2 = Math.hypot(v2x, v2y) || 1;
50
+ const r = Math.min(radius, len1 / 2, len2 / 2);
51
+ const p1 = {
52
+ x: cur.x - v1x / len1 * r,
53
+ y: cur.y - v1y / len1 * r
54
+ };
55
+ const p2 = {
56
+ x: cur.x + v2x / len2 * r,
57
+ y: cur.y + v2y / len2 * r
58
+ };
59
+ d += ` L ${p1.x} ${p1.y} Q ${cur.x} ${cur.y} ${p2.x} ${p2.y}`;
60
+ }
61
+ const last = pts[pts.length - 1];
62
+ d += ` L ${last.x} ${last.y}`;
63
+ return d;
64
+ }
@@ -0,0 +1,50 @@
1
+ export function pathAtT(section, t) {
2
+ const pts = [section.startPoint, ...section.bendPoints ?? [], section.endPoint];
3
+ const lengths = [];
4
+ let total = 0;
5
+ for (let i = 1; i < pts.length; i++) {
6
+ const l = Math.hypot(pts[i].x - pts[i - 1].x, pts[i].y - pts[i - 1].y);
7
+ lengths.push(l);
8
+ total += l;
9
+ }
10
+ let remaining = total * Math.max(0, Math.min(1, t));
11
+ for (let i = 0; i < lengths.length; i++) {
12
+ if (remaining <= lengths[i]) {
13
+ const s = remaining / lengths[i];
14
+ return {
15
+ x: pts[i].x + s * (pts[i + 1].x - pts[i].x),
16
+ y: pts[i].y + s * (pts[i + 1].y - pts[i].y)
17
+ };
18
+ }
19
+ remaining -= lengths[i];
20
+ }
21
+ return pts[pts.length - 1];
22
+ }
23
+ export function pathMidpoint(section) {
24
+ return pathAtT(section, 0.5);
25
+ }
26
+ export function buildCurvedPath(section, radius = 14) {
27
+ const pts = [section.startPoint, ...section.bendPoints ?? [], section.endPoint];
28
+ if (pts.length <= 2) {
29
+ return `M ${pts[0].x} ${pts[0].y} L ${pts[pts.length - 1].x} ${pts[pts.length - 1].y}`;
30
+ }
31
+ let d = `M ${pts[0].x} ${pts[0].y}`;
32
+ for (let i = 1; i < pts.length - 1; i++) {
33
+ const prev = pts[i - 1];
34
+ const cur = pts[i];
35
+ const next = pts[i + 1];
36
+ const v1x = cur.x - prev.x;
37
+ const v1y = cur.y - prev.y;
38
+ const v2x = next.x - cur.x;
39
+ const v2y = next.y - cur.y;
40
+ const len1 = Math.hypot(v1x, v1y) || 1;
41
+ const len2 = Math.hypot(v2x, v2y) || 1;
42
+ const r = Math.min(radius, len1 / 2, len2 / 2);
43
+ const p1 = { x: cur.x - v1x / len1 * r, y: cur.y - v1y / len1 * r };
44
+ const p2 = { x: cur.x + v2x / len2 * r, y: cur.y + v2y / len2 * r };
45
+ d += ` L ${p1.x} ${p1.y} Q ${cur.x} ${cur.y} ${p2.x} ${p2.y}`;
46
+ }
47
+ const last = pts[pts.length - 1];
48
+ d += ` L ${last.x} ${last.y}`;
49
+ return d;
50
+ }
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@matchina/viz-svg",
3
+ "version": "0.1.0-alpha.1",
4
+ "description": "SVG-based state machine visualizer for Matchina using ELK layout",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "module": "./dist/index.mjs",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "node": {
12
+ "types": "./src/index.ts",
13
+ "import": "./src/index.ts"
14
+ },
15
+ "types": "./dist/index.d.ts",
16
+ "import": "./dist/index.mjs",
17
+ "require": "./dist/index.js"
18
+ }
19
+ },
20
+ "files": [
21
+ "dist",
22
+ "src"
23
+ ],
24
+ "scripts": {
25
+ "build": "unbuild",
26
+ "dev": "unbuild --watch"
27
+ },
28
+ "dependencies": {
29
+ "elkjs": "^0.9.3"
30
+ },
31
+ "peerDependencies": {
32
+ "matchina": "^0.3.0",
33
+ "react": "^18.0.0 || ^19.0.0",
34
+ "react-dom": "^18.0.0 || ^19.0.0"
35
+ },
36
+ "devDependencies": {
37
+ "unbuild": "^3.6.1"
38
+ }
39
+ }