@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.
- package/dist/SvgInspector.d.ts +17 -0
- package/dist/SvgInspector.js +586 -0
- package/dist/SvgInspector.mjs +522 -0
- package/dist/elk-layout.d.ts +51 -0
- package/dist/elk-layout.js +210 -0
- package/dist/elk-layout.mjs +175 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +26 -0
- package/dist/index.mjs +3 -0
- package/dist/layout-to-svg.d.ts +13 -0
- package/dist/layout-to-svg.js +148 -0
- package/dist/layout-to-svg.mjs +136 -0
- package/dist/svg-path.d.ts +20 -0
- package/dist/svg-path.js +64 -0
- package/dist/svg-path.mjs +50 -0
- package/package.json +39 -0
- package/src/SvgInspector.tsx +613 -0
- package/src/elk-layout.ts +279 -0
- package/src/index.ts +6 -0
- package/src/layout-to-svg.ts +203 -0
- package/src/svg-path.ts +75 -0
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
// ELK layout adapter for MachineShape.
|
|
2
|
+
// Uses LCA-based edge placement (edges live in their lowest common ancestor container).
|
|
3
|
+
// Returns flat arrays with absolute positions — no ReactFlow dependency.
|
|
4
|
+
|
|
5
|
+
import ELKConstructor from 'elkjs/lib/elk.bundled.js';
|
|
6
|
+
import type { MachineShape } from 'matchina';
|
|
7
|
+
|
|
8
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
9
|
+
const elk = new (ELKConstructor as any)();
|
|
10
|
+
|
|
11
|
+
export interface SvgNode {
|
|
12
|
+
id: string;
|
|
13
|
+
x: number;
|
|
14
|
+
y: number;
|
|
15
|
+
width: number;
|
|
16
|
+
height: number;
|
|
17
|
+
label: string;
|
|
18
|
+
isCompound: boolean;
|
|
19
|
+
path: string[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface SvgEdge {
|
|
23
|
+
id: string;
|
|
24
|
+
event: string;
|
|
25
|
+
sourcePath: string[];
|
|
26
|
+
targetPath: string[];
|
|
27
|
+
sections: {
|
|
28
|
+
startPoint: { x: number; y: number };
|
|
29
|
+
endPoint: { x: number; y: number };
|
|
30
|
+
bendPoints?: { x: number; y: number }[];
|
|
31
|
+
}[];
|
|
32
|
+
label: { text: string; x: number; y: number; width: number; height: number } | null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface SvgLayout {
|
|
36
|
+
nodes: SvgNode[];
|
|
37
|
+
edges: SvgEdge[];
|
|
38
|
+
width: number;
|
|
39
|
+
height: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ElkLayoutOptions {
|
|
43
|
+
direction?: 'RIGHT' | 'DOWN';
|
|
44
|
+
edgeRouting?: 'ORTHOGONAL' | 'POLYLINE';
|
|
45
|
+
nodeSpacing?: number;
|
|
46
|
+
layerSpacing?: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ELK parses dots in node IDs as "nodeId.portId", so sanitize them.
|
|
50
|
+
function toElkId(fullKey: string): string { return fullKey.replace(/\./g, '|'); }
|
|
51
|
+
function fromElkId(id: string): string { return id.replace(/\|/g, '.'); }
|
|
52
|
+
|
|
53
|
+
function textWidth(text: string, charW = 7.2, pad = 24): number {
|
|
54
|
+
return Math.max(60, Math.ceil(text.length * charW) + pad);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function lcaPath(a: string[], b: string[]): string[] {
|
|
58
|
+
const out: string[] = [];
|
|
59
|
+
const n = Math.min(a.length, b.length);
|
|
60
|
+
for (let i = 0; i < n; i++) {
|
|
61
|
+
if (a[i] === b[i]) out.push(a[i]);
|
|
62
|
+
else break;
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
type ElkNodeDraft = {
|
|
68
|
+
id: string;
|
|
69
|
+
labels: { text: string; width: number; height: number }[];
|
|
70
|
+
layoutOptions: Record<string, string | undefined>;
|
|
71
|
+
width?: number;
|
|
72
|
+
height?: number;
|
|
73
|
+
children?: ElkNodeDraft[];
|
|
74
|
+
edges?: ElkEdgeDraft[];
|
|
75
|
+
_meta: { path: string[]; isCompound: boolean };
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
type ElkEdgeDraft = {
|
|
79
|
+
id: string;
|
|
80
|
+
sources: string[];
|
|
81
|
+
targets: string[];
|
|
82
|
+
labels: { text: string; width: number; height: number }[];
|
|
83
|
+
_meta: { event: string; sourcePath: string[]; targetPath: string[] };
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
function buildElkNode(
|
|
87
|
+
fullKey: string,
|
|
88
|
+
shape: MachineShape,
|
|
89
|
+
nodeSpacing: number,
|
|
90
|
+
layerSpacing: number,
|
|
91
|
+
direction: string,
|
|
92
|
+
): ElkNodeDraft {
|
|
93
|
+
const stateNode = shape.states.get(fullKey)!;
|
|
94
|
+
const label = stateNode.key;
|
|
95
|
+
const isCompound = stateNode.isCompound;
|
|
96
|
+
const path = fullKey.split('.');
|
|
97
|
+
|
|
98
|
+
const node: ElkNodeDraft = {
|
|
99
|
+
id: toElkId(fullKey),
|
|
100
|
+
labels: [{ text: label, width: textWidth(label, 7.5, 16), height: 18 }],
|
|
101
|
+
layoutOptions: {
|
|
102
|
+
'elk.algorithm': 'layered',
|
|
103
|
+
'elk.direction': direction,
|
|
104
|
+
'elk.layered.spacing.nodeNodeBetweenLayers': String(layerSpacing),
|
|
105
|
+
'elk.spacing.nodeNode': String(nodeSpacing),
|
|
106
|
+
'elk.spacing.edgeNode': String(nodeSpacing * 0.5),
|
|
107
|
+
'elk.layered.cycleBreaking.strategy': 'MODEL_ORDER',
|
|
108
|
+
'elk.layered.considerModelOrder': 'NODES_AND_EDGES',
|
|
109
|
+
},
|
|
110
|
+
_meta: { path, isCompound },
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
if (!isCompound) {
|
|
114
|
+
node.width = Math.max(textWidth(label, 8, 32), 92);
|
|
115
|
+
node.height = 44;
|
|
116
|
+
} else {
|
|
117
|
+
node.layoutOptions['elk.hierarchyHandling'] = 'INCLUDE_CHILDREN';
|
|
118
|
+
node.layoutOptions['elk.padding'] =
|
|
119
|
+
`[top=36,left=${nodeSpacing * 0.6},bottom=${nodeSpacing * 0.6},right=${nodeSpacing * 0.6}]`;
|
|
120
|
+
|
|
121
|
+
// Find children via hierarchy map
|
|
122
|
+
const children: string[] = [];
|
|
123
|
+
for (const [childKey, parentKey] of shape.hierarchy) {
|
|
124
|
+
if (parentKey === fullKey) children.push(childKey);
|
|
125
|
+
}
|
|
126
|
+
node.children = children.map(childKey =>
|
|
127
|
+
buildElkNode(childKey, shape, nodeSpacing, layerSpacing, direction),
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return node;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function indexNodes(node: ElkNodeDraft, byId: Map<string, ElkNodeDraft>): void {
|
|
135
|
+
byId.set(node.id, node);
|
|
136
|
+
for (const child of node.children ?? []) indexNodes(child, byId);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export async function runElkLayout(
|
|
140
|
+
shape: MachineShape,
|
|
141
|
+
opts: ElkLayoutOptions = {},
|
|
142
|
+
): Promise<SvgLayout> {
|
|
143
|
+
const nodeSpacing = opts.nodeSpacing ?? 40;
|
|
144
|
+
const layerSpacing = opts.layerSpacing ?? nodeSpacing + 20;
|
|
145
|
+
const direction = opts.direction ?? 'DOWN';
|
|
146
|
+
const edgeRouting = opts.edgeRouting ?? 'ORTHOGONAL';
|
|
147
|
+
|
|
148
|
+
// Root states: those without a parent
|
|
149
|
+
const rootKeys: string[] = [];
|
|
150
|
+
for (const [fullKey, parentKey] of shape.hierarchy) {
|
|
151
|
+
if (parentKey === undefined && fullKey !== '') rootKeys.push(fullKey);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const rootChildren = rootKeys.map(key =>
|
|
155
|
+
buildElkNode(key, shape, nodeSpacing, layerSpacing, direction),
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
// Build id → node index for LCA edge assignment
|
|
159
|
+
const byId = new Map<string, ElkNodeDraft>();
|
|
160
|
+
for (const child of rootChildren) indexNodes(child, byId);
|
|
161
|
+
|
|
162
|
+
// Assign edges to their LCA container
|
|
163
|
+
const rootEdges: ElkEdgeDraft[] = [];
|
|
164
|
+
for (const [sourceFullKey, eventMap] of shape.transitions) {
|
|
165
|
+
for (const [event, targetFullKey] of eventMap) {
|
|
166
|
+
if (!shape.states.has(targetFullKey)) continue;
|
|
167
|
+
if (!byId.has(toElkId(sourceFullKey)) || !byId.has(toElkId(targetFullKey))) continue;
|
|
168
|
+
|
|
169
|
+
const sourcePath = sourceFullKey.split('.');
|
|
170
|
+
const targetPath = targetFullKey.split('.');
|
|
171
|
+
const edge: ElkEdgeDraft = {
|
|
172
|
+
id: `e:${toElkId(sourceFullKey)}->${toElkId(targetFullKey)}:${event}`,
|
|
173
|
+
sources: [toElkId(sourceFullKey)],
|
|
174
|
+
targets: [toElkId(targetFullKey)],
|
|
175
|
+
labels: [{ text: event, width: textWidth(event, 6.6, 14), height: 16 }],
|
|
176
|
+
_meta: { event, sourcePath, targetPath },
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const lca = lcaPath(sourcePath, targetPath);
|
|
180
|
+
const lcaKey = lca.join('.');
|
|
181
|
+
const owner = lcaKey ? byId.get(toElkId(lcaKey)) : null;
|
|
182
|
+
if (owner?.children) {
|
|
183
|
+
owner.edges = owner.edges ?? [];
|
|
184
|
+
owner.edges.push(edge);
|
|
185
|
+
} else {
|
|
186
|
+
rootEdges.push(edge);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const elkInput = {
|
|
192
|
+
id: 'root',
|
|
193
|
+
layoutOptions: {
|
|
194
|
+
'elk.algorithm': 'layered',
|
|
195
|
+
'elk.direction': direction,
|
|
196
|
+
'elk.hierarchyHandling': 'INCLUDE_CHILDREN',
|
|
197
|
+
'elk.edgeRouting': edgeRouting,
|
|
198
|
+
'elk.layered.spacing.nodeNodeBetweenLayers': String(layerSpacing),
|
|
199
|
+
'elk.spacing.nodeNode': String(nodeSpacing),
|
|
200
|
+
// Keep parallel edges visually separate — don't merge or collapse them.
|
|
201
|
+
'elk.layered.mergeEdges': 'false',
|
|
202
|
+
'elk.layered.mergeHierarchyEdges': 'false',
|
|
203
|
+
'elk.layered.cycleBreaking.strategy': 'MODEL_ORDER',
|
|
204
|
+
'elk.layered.considerModelOrder': 'NODES_AND_EDGES',
|
|
205
|
+
'elk.padding': `[top=${nodeSpacing * 0.6},left=${nodeSpacing * 0.6},bottom=${nodeSpacing * 0.6},right=${nodeSpacing * 0.6}]`,
|
|
206
|
+
},
|
|
207
|
+
children: rootChildren,
|
|
208
|
+
edges: rootEdges,
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
212
|
+
const result = await elk.layout(elkInput as any) as any;
|
|
213
|
+
|
|
214
|
+
// Flatten tree → absolute-positioned arrays
|
|
215
|
+
const nodes: SvgNode[] = [];
|
|
216
|
+
const edges: SvgEdge[] = [];
|
|
217
|
+
const absById = new Map<string, { x: number; y: number }>();
|
|
218
|
+
absById.set('root', { x: 0, y: 0 });
|
|
219
|
+
|
|
220
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
221
|
+
function walkNodes(node: any, ox: number, oy: number): void {
|
|
222
|
+
const ax = (node.x ?? 0) + ox;
|
|
223
|
+
const ay = (node.y ?? 0) + oy;
|
|
224
|
+
absById.set(node.id, { x: ax, y: ay });
|
|
225
|
+
|
|
226
|
+
if (node.id !== 'root' && node._meta) {
|
|
227
|
+
nodes.push({
|
|
228
|
+
id: fromElkId(node.id),
|
|
229
|
+
x: ax,
|
|
230
|
+
y: ay,
|
|
231
|
+
width: node.width ?? 92,
|
|
232
|
+
height: node.height ?? 44,
|
|
233
|
+
label: node.labels?.[0]?.text ?? node.id,
|
|
234
|
+
isCompound: node._meta.isCompound,
|
|
235
|
+
path: node._meta.path,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
for (const child of (node.children ?? []) as typeof node[]) walkNodes(child, ax, ay);
|
|
239
|
+
}
|
|
240
|
+
walkNodes(result, 0, 0);
|
|
241
|
+
|
|
242
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
243
|
+
function walkEdges(node: any): void {
|
|
244
|
+
for (const edge of node.edges ?? []) {
|
|
245
|
+
const containerId: string = edge.container ?? node.id ?? 'root';
|
|
246
|
+
const off = absById.get(containerId) ?? { x: 0, y: 0 };
|
|
247
|
+
const label = edge.labels?.[0];
|
|
248
|
+
edges.push({
|
|
249
|
+
id: edge.id,
|
|
250
|
+
event: edge._meta?.event ?? '',
|
|
251
|
+
sourcePath: edge._meta?.sourcePath ?? [],
|
|
252
|
+
targetPath: edge._meta?.targetPath ?? [],
|
|
253
|
+
sections: (edge.sections ?? []).map((s: any) => ({
|
|
254
|
+
startPoint: { x: s.startPoint.x + off.x, y: s.startPoint.y + off.y },
|
|
255
|
+
endPoint: { x: s.endPoint.x + off.x, y: s.endPoint.y + off.y },
|
|
256
|
+
bendPoints: (s.bendPoints ?? []).map((b: any) => ({ x: b.x + off.x, y: b.y + off.y })),
|
|
257
|
+
})),
|
|
258
|
+
label: label
|
|
259
|
+
? {
|
|
260
|
+
text: label.text,
|
|
261
|
+
x: (label.x ?? 0) + off.x,
|
|
262
|
+
y: (label.y ?? 0) + off.y,
|
|
263
|
+
width: label.width ?? 60,
|
|
264
|
+
height: label.height ?? 16,
|
|
265
|
+
}
|
|
266
|
+
: null,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
for (const child of node.children ?? []) walkEdges(child);
|
|
270
|
+
}
|
|
271
|
+
walkEdges(result);
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
nodes,
|
|
275
|
+
edges,
|
|
276
|
+
width: result.width ?? 800,
|
|
277
|
+
height: result.height ?? 600,
|
|
278
|
+
};
|
|
279
|
+
}
|
package/src/index.ts
ADDED
|
@@ -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';
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
// Pure SVG string generator from an ELK layout.
|
|
2
|
+
// Mirrors the rendering logic of SvgInspector.tsx, but emits a string instead of React elements.
|
|
3
|
+
// Use this for build-time SSR (e.g. from Astro frontmatter) where no React runtime is needed.
|
|
4
|
+
|
|
5
|
+
import type { SvgLayout, SvgNode, SvgEdge } from './elk-layout.js';
|
|
6
|
+
import { buildCurvedPath, pathAtT } from './svg-path.js';
|
|
7
|
+
|
|
8
|
+
// CSS variable names with their default values (dark teal theme).
|
|
9
|
+
// Match SvgInspector.tsx.
|
|
10
|
+
const V = {
|
|
11
|
+
accent: 'var(--matchina-viz-accent, #2dd4bf)',
|
|
12
|
+
bg: 'var(--matchina-viz-bg, #0a0f17)',
|
|
13
|
+
node: 'var(--matchina-viz-node, rgba(28,38,54,0.95))',
|
|
14
|
+
nodeActive: 'var(--matchina-viz-node-active, rgba(20,90,82,0.85))',
|
|
15
|
+
nodeCompound: 'var(--matchina-viz-node-compound, rgba(20,28,40,0.7))',
|
|
16
|
+
border: 'var(--matchina-viz-border, rgba(148,163,184,0.25))',
|
|
17
|
+
text: 'var(--matchina-viz-text, rgba(226,232,240,0.92))',
|
|
18
|
+
textActive: 'var(--matchina-viz-text-active, #e6fffb)',
|
|
19
|
+
edge: 'var(--matchina-viz-edge, rgba(100,116,139,0.55))',
|
|
20
|
+
labelBg: 'var(--matchina-viz-label-bg, rgba(15,23,33,0.95))',
|
|
21
|
+
labelBgActive: 'var(--matchina-viz-label-bg-active, rgba(8,47,51,0.95))',
|
|
22
|
+
labelText: 'var(--matchina-viz-label-text, rgba(203,213,225,0.82))',
|
|
23
|
+
} as const;
|
|
24
|
+
|
|
25
|
+
const FONT = "var(--matchina-viz-font, 'JetBrains Mono', monospace)";
|
|
26
|
+
|
|
27
|
+
function esc(s: string): string {
|
|
28
|
+
return s
|
|
29
|
+
.replace(/&/g, '&')
|
|
30
|
+
.replace(/</g, '<')
|
|
31
|
+
.replace(/>/g, '>')
|
|
32
|
+
.replace(/"/g, '"');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function nodeSvg(node: SvgNode, isActive: boolean, isAncestor: boolean): string {
|
|
36
|
+
const stroke = isActive || isAncestor ? V.accent : V.border;
|
|
37
|
+
const strokeWidth = isActive ? 2 : isAncestor ? 1.5 : 1;
|
|
38
|
+
const fill = node.isCompound ? V.nodeCompound : isActive ? V.nodeActive : V.node;
|
|
39
|
+
const textFill = isActive ? V.textActive : isActive || isAncestor ? V.accent : V.text;
|
|
40
|
+
|
|
41
|
+
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}" />`;
|
|
42
|
+
|
|
43
|
+
const label = node.isCompound
|
|
44
|
+
? `<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>`
|
|
45
|
+
: `<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>`;
|
|
46
|
+
|
|
47
|
+
const activeDot = isActive && !node.isCompound
|
|
48
|
+
? `<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>`
|
|
49
|
+
: '';
|
|
50
|
+
|
|
51
|
+
return `<g data-node-id="${esc(node.id)}" data-active="${isActive}" data-ancestor="${isAncestor}">${rect}${label}${activeDot}</g>`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function selfLoopSvg(edge: SvgEdge, node: SvgNode, isOutgoing: boolean, loopIndex: number): string {
|
|
55
|
+
const stroke = isOutgoing ? V.accent : V.edge;
|
|
56
|
+
const strokeWidth = isOutgoing ? 2 : 1.25;
|
|
57
|
+
const opacity = isOutgoing ? 1 : 0.65;
|
|
58
|
+
const markerId = isOutgoing ? 'matchina-svg-arrow-active' : 'matchina-svg-arrow';
|
|
59
|
+
const { label } = edge;
|
|
60
|
+
|
|
61
|
+
const hw = node.width / 2;
|
|
62
|
+
const hh = node.height / 2;
|
|
63
|
+
const sx = node.x + hw;
|
|
64
|
+
const sy = node.y + hh;
|
|
65
|
+
const loopRadius = 28 + loopIndex * 16;
|
|
66
|
+
|
|
67
|
+
const startX = sx + hw - 8 - loopIndex * 2;
|
|
68
|
+
const startY = sy - hh;
|
|
69
|
+
const endX = sx + hw;
|
|
70
|
+
const endY = sy - hh + 8 + loopIndex * 2;
|
|
71
|
+
|
|
72
|
+
const d = `M ${startX} ${startY} C ${startX} ${startY - loopRadius}, ${endX + loopRadius} ${endY}, ${endX} ${endY}`;
|
|
73
|
+
const labelX = sx + hw + loopRadius + 4;
|
|
74
|
+
const labelY = sy - hh - 10 + loopIndex * (label ? label.height + 8 : 24);
|
|
75
|
+
|
|
76
|
+
const path = `<path d="${d}" fill="none" style="stroke:${stroke};stroke-width:${strokeWidth};opacity:${opacity}" marker-end="url(#${markerId})" />`;
|
|
77
|
+
|
|
78
|
+
const labelMarkup = label
|
|
79
|
+
? `<g transform="translate(${labelX}, ${labelY - label.height / 2})" style="opacity:${opacity}">
|
|
80
|
+
<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}" />
|
|
81
|
+
<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>
|
|
82
|
+
</g>`
|
|
83
|
+
: '';
|
|
84
|
+
|
|
85
|
+
return `<g data-edge-id="${esc(edge.id)}" data-event="${esc(edge.event)}">${path}${labelMarkup}</g>`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function edgeSvg(edge: SvgEdge, isOutgoing: boolean, labelT: number): string {
|
|
89
|
+
const section = edge.sections?.[0];
|
|
90
|
+
if (!section?.startPoint || !section?.endPoint) return '';
|
|
91
|
+
|
|
92
|
+
const d = buildCurvedPath(section);
|
|
93
|
+
const stroke = isOutgoing ? V.accent : V.edge;
|
|
94
|
+
const strokeWidth = isOutgoing ? 2 : 1.25;
|
|
95
|
+
const opacity = isOutgoing ? 1 : 0.65;
|
|
96
|
+
const { label } = edge;
|
|
97
|
+
const markerId = isOutgoing ? 'matchina-svg-arrow-active' : 'matchina-svg-arrow';
|
|
98
|
+
const mid = label ? pathAtT(section, labelT) : null;
|
|
99
|
+
|
|
100
|
+
const path = `<path d="${d}" fill="none" style="stroke:${stroke};stroke-width:${strokeWidth};opacity:${opacity}" marker-end="url(#${markerId})" />`;
|
|
101
|
+
|
|
102
|
+
const labelMarkup = label && mid
|
|
103
|
+
? `<g transform="translate(${mid.x - label.width / 2}, ${mid.y - label.height / 2})" style="opacity:${opacity}">
|
|
104
|
+
<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}" />
|
|
105
|
+
<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>
|
|
106
|
+
</g>`
|
|
107
|
+
: '';
|
|
108
|
+
|
|
109
|
+
return `<g data-edge-id="${esc(edge.id)}" data-event="${esc(edge.event)}">${path}${labelMarkup}</g>`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface LayoutToSvgOptions {
|
|
113
|
+
/** Current active state value (dot-separated full key). */
|
|
114
|
+
value?: string;
|
|
115
|
+
/** @deprecated No longer used; the SVG is sized to exact content dimensions. */
|
|
116
|
+
padding?: number;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Produce a complete SVG markup string from a precomputed ELK layout.
|
|
121
|
+
* Output is sized to the layout's intrinsic dimensions plus padding,
|
|
122
|
+
* with a viewBox so it scales to its container via `width="100%" height="100%"`.
|
|
123
|
+
*/
|
|
124
|
+
export function layoutToSvg(layout: SvgLayout, opts: LayoutToSvgOptions = {}): string {
|
|
125
|
+
const { value = '' } = opts;
|
|
126
|
+
const padding = 0;
|
|
127
|
+
|
|
128
|
+
const activePath = value ? value.split('.') : [];
|
|
129
|
+
const activeLeafId = value;
|
|
130
|
+
|
|
131
|
+
const activeAncestorIds = new Set<string>();
|
|
132
|
+
for (let i = 0; i < activePath.length - 1; i++) {
|
|
133
|
+
activeAncestorIds.add(activePath.slice(0, i + 1).join('.'));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const activeSourceIds = new Set<string>();
|
|
137
|
+
for (let i = 1; i <= activePath.length; i++) {
|
|
138
|
+
activeSourceIds.add(activePath.slice(0, i).join('.'));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Spread label positions for parallel edges, same heuristic as SvgInspector.
|
|
142
|
+
const pairTotal = new Map<string, number>();
|
|
143
|
+
for (const edge of layout.edges) {
|
|
144
|
+
const key = `${edge.sourcePath.join('.')}→${edge.targetPath.join('.')}`;
|
|
145
|
+
pairTotal.set(key, (pairTotal.get(key) ?? 0) + 1);
|
|
146
|
+
}
|
|
147
|
+
const pairNextIdx = new Map<string, number>();
|
|
148
|
+
const edgeLabelT = new Map<string, number>();
|
|
149
|
+
for (const edge of layout.edges) {
|
|
150
|
+
const key = `${edge.sourcePath.join('.')}→${edge.targetPath.join('.')}`;
|
|
151
|
+
const count = pairTotal.get(key) ?? 1;
|
|
152
|
+
const idx = pairNextIdx.get(key) ?? 0;
|
|
153
|
+
pairNextIdx.set(key, idx + 1);
|
|
154
|
+
const t = count === 1 ? 0.5 : 0.3 + (idx / (count - 1)) * 0.4;
|
|
155
|
+
edgeLabelT.set(edge.id, t);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const compounds = layout.nodes.filter((n) => n.isCompound);
|
|
159
|
+
const leaves = layout.nodes.filter((n) => !n.isCompound);
|
|
160
|
+
|
|
161
|
+
const nodeById = new Map(layout.nodes.map((n) => [n.id, n]));
|
|
162
|
+
const selfLoopIndexByNode = new Map<string, number>();
|
|
163
|
+
|
|
164
|
+
const edgeMarkup = layout.edges.map((edge) => {
|
|
165
|
+
const isSelf = edge.sourcePath.join('.') === edge.targetPath.join('.');
|
|
166
|
+
const isOutgoing = activeSourceIds.has(edge.sourcePath.join('.'));
|
|
167
|
+
if (isSelf) {
|
|
168
|
+
const nodeId = edge.sourcePath.join('.');
|
|
169
|
+
const node = nodeById.get(nodeId);
|
|
170
|
+
if (!node) return '';
|
|
171
|
+
const loopIndex = selfLoopIndexByNode.get(nodeId) ?? 0;
|
|
172
|
+
selfLoopIndexByNode.set(nodeId, loopIndex + 1);
|
|
173
|
+
return selfLoopSvg(edge, node, isOutgoing, loopIndex);
|
|
174
|
+
}
|
|
175
|
+
return edgeSvg(edge, isOutgoing, edgeLabelT.get(edge.id) ?? 0.5);
|
|
176
|
+
}).join('');
|
|
177
|
+
|
|
178
|
+
const compoundsMarkup = compounds
|
|
179
|
+
.map((n) => nodeSvg(n, n.id === activeLeafId, activeAncestorIds.has(n.id)))
|
|
180
|
+
.join('');
|
|
181
|
+
const leavesMarkup = leaves
|
|
182
|
+
.map((n) => nodeSvg(n, n.id === activeLeafId, activeAncestorIds.has(n.id)))
|
|
183
|
+
.join('');
|
|
184
|
+
|
|
185
|
+
const vw = layout.width + padding * 2;
|
|
186
|
+
const vh = layout.height + padding * 2;
|
|
187
|
+
|
|
188
|
+
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}">
|
|
189
|
+
<defs>
|
|
190
|
+
<marker id="matchina-svg-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
|
191
|
+
<path d="M 0 0 L 10 5 L 0 10 z" style="fill:rgba(100,116,139,0.7)" />
|
|
192
|
+
</marker>
|
|
193
|
+
<marker id="matchina-svg-arrow-active" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
|
194
|
+
<path d="M 0 0 L 10 5 L 0 10 z" style="fill:${V.accent}" />
|
|
195
|
+
</marker>
|
|
196
|
+
</defs>
|
|
197
|
+
<g transform="translate(${padding}, ${padding})">
|
|
198
|
+
${compoundsMarkup}
|
|
199
|
+
${edgeMarkup}
|
|
200
|
+
${leavesMarkup}
|
|
201
|
+
</g>
|
|
202
|
+
</svg>`;
|
|
203
|
+
}
|
package/src/svg-path.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// SVG path utilities for curved edge rendering.
|
|
2
|
+
// Produces quadratic bézier rounded corners at each bend point.
|
|
3
|
+
|
|
4
|
+
interface Point {
|
|
5
|
+
x: number;
|
|
6
|
+
y: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function pathAtT(
|
|
10
|
+
section: { startPoint: Point; endPoint: Point; bendPoints?: Point[] },
|
|
11
|
+
t: number,
|
|
12
|
+
): Point {
|
|
13
|
+
const pts = [section.startPoint, ...(section.bendPoints ?? []), section.endPoint];
|
|
14
|
+
const lengths: number[] = [];
|
|
15
|
+
let total = 0;
|
|
16
|
+
for (let i = 1; i < pts.length; i++) {
|
|
17
|
+
const l = Math.hypot(pts[i].x - pts[i - 1].x, pts[i].y - pts[i - 1].y);
|
|
18
|
+
lengths.push(l);
|
|
19
|
+
total += l;
|
|
20
|
+
}
|
|
21
|
+
let remaining = total * Math.max(0, Math.min(1, t));
|
|
22
|
+
for (let i = 0; i < lengths.length; i++) {
|
|
23
|
+
if (remaining <= lengths[i]) {
|
|
24
|
+
const s = remaining / lengths[i];
|
|
25
|
+
return {
|
|
26
|
+
x: pts[i].x + s * (pts[i + 1].x - pts[i].x),
|
|
27
|
+
y: pts[i].y + s * (pts[i + 1].y - pts[i].y),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
remaining -= lengths[i];
|
|
31
|
+
}
|
|
32
|
+
return pts[pts.length - 1];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function pathMidpoint(section: {
|
|
36
|
+
startPoint: Point;
|
|
37
|
+
endPoint: Point;
|
|
38
|
+
bendPoints?: Point[];
|
|
39
|
+
}): Point {
|
|
40
|
+
return pathAtT(section, 0.5);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function buildCurvedPath(
|
|
44
|
+
section: {
|
|
45
|
+
startPoint: Point;
|
|
46
|
+
endPoint: Point;
|
|
47
|
+
bendPoints?: Point[];
|
|
48
|
+
},
|
|
49
|
+
radius = 14,
|
|
50
|
+
): string {
|
|
51
|
+
const pts = [section.startPoint, ...(section.bendPoints ?? []), section.endPoint];
|
|
52
|
+
if (pts.length <= 2) {
|
|
53
|
+
return `M ${pts[0].x} ${pts[0].y} L ${pts[pts.length - 1].x} ${pts[pts.length - 1].y}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let d = `M ${pts[0].x} ${pts[0].y}`;
|
|
57
|
+
for (let i = 1; i < pts.length - 1; i++) {
|
|
58
|
+
const prev = pts[i - 1];
|
|
59
|
+
const cur = pts[i];
|
|
60
|
+
const next = pts[i + 1];
|
|
61
|
+
const v1x = cur.x - prev.x;
|
|
62
|
+
const v1y = cur.y - prev.y;
|
|
63
|
+
const v2x = next.x - cur.x;
|
|
64
|
+
const v2y = next.y - cur.y;
|
|
65
|
+
const len1 = Math.hypot(v1x, v1y) || 1;
|
|
66
|
+
const len2 = Math.hypot(v2x, v2y) || 1;
|
|
67
|
+
const r = Math.min(radius, len1 / 2, len2 / 2);
|
|
68
|
+
const p1 = { x: cur.x - (v1x / len1) * r, y: cur.y - (v1y / len1) * r };
|
|
69
|
+
const p2 = { x: cur.x + (v2x / len2) * r, y: cur.y + (v2y / len2) * r };
|
|
70
|
+
d += ` L ${p1.x} ${p1.y} Q ${cur.x} ${cur.y} ${p2.x} ${p2.y}`;
|
|
71
|
+
}
|
|
72
|
+
const last = pts[pts.length - 1];
|
|
73
|
+
d += ` L ${last.x} ${last.y}`;
|
|
74
|
+
return d;
|
|
75
|
+
}
|