@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,613 @@
|
|
|
1
|
+
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import type { MachineShape } from 'matchina';
|
|
3
|
+
import { runElkLayout } from './elk-layout.js';
|
|
4
|
+
import type { ElkLayoutOptions, SvgEdge, SvgLayout, SvgNode } from './elk-layout.js';
|
|
5
|
+
import { buildCurvedPath, pathAtT } from './svg-path.js';
|
|
6
|
+
|
|
7
|
+
// CSS variable names with their default values (dark teal theme).
|
|
8
|
+
// Consumers can override any of these on a parent element.
|
|
9
|
+
//
|
|
10
|
+
// --matchina-viz-accent active highlight color
|
|
11
|
+
// --matchina-viz-bg canvas background
|
|
12
|
+
// --matchina-viz-node leaf node fill
|
|
13
|
+
// --matchina-viz-node-active active leaf fill
|
|
14
|
+
// --matchina-viz-node-compound compound node fill
|
|
15
|
+
// --matchina-viz-border inactive node border
|
|
16
|
+
// --matchina-viz-text label text
|
|
17
|
+
// --matchina-viz-text-active active label text
|
|
18
|
+
// --matchina-viz-edge inactive edge stroke
|
|
19
|
+
|
|
20
|
+
const V = {
|
|
21
|
+
accent: 'var(--matchina-viz-accent, #2dd4bf)',
|
|
22
|
+
bg: 'var(--matchina-viz-bg, #0a0f17)',
|
|
23
|
+
node: 'var(--matchina-viz-node, rgba(28,38,54,0.95))',
|
|
24
|
+
nodeActive: 'var(--matchina-viz-node-active, rgba(20,90,82,0.85))',
|
|
25
|
+
nodeCompound: 'var(--matchina-viz-node-compound, rgba(20,28,40,0.7))',
|
|
26
|
+
border: 'var(--matchina-viz-border, rgba(148,163,184,0.25))',
|
|
27
|
+
text: 'var(--matchina-viz-text, rgba(226,232,240,0.92))',
|
|
28
|
+
textActive: 'var(--matchina-viz-text-active, #e6fffb)',
|
|
29
|
+
edge: 'var(--matchina-viz-edge, rgba(100,116,139,0.55))',
|
|
30
|
+
labelBg: 'var(--matchina-viz-label-bg, rgba(15,23,33,0.95))',
|
|
31
|
+
labelBgActive: 'var(--matchina-viz-label-bg-active, rgba(8,47,51,0.95))',
|
|
32
|
+
labelText: 'var(--matchina-viz-label-text, rgba(203,213,225,0.82))',
|
|
33
|
+
ctrlBg: 'var(--matchina-viz-ctrl-bg, rgba(20,28,40,0.85))',
|
|
34
|
+
ctrlBorder: 'var(--matchina-viz-ctrl-border, rgba(148,163,184,0.24))',
|
|
35
|
+
ctrlText: 'var(--matchina-viz-ctrl-text, rgba(226,232,240,0.65))',
|
|
36
|
+
} as const;
|
|
37
|
+
|
|
38
|
+
function NodeShape({ node, isActive, isAncestor }: {
|
|
39
|
+
node: SvgNode;
|
|
40
|
+
isActive: boolean;
|
|
41
|
+
isAncestor: boolean;
|
|
42
|
+
}) {
|
|
43
|
+
const stroke = isActive || isAncestor ? V.accent : V.border;
|
|
44
|
+
const strokeWidth = isActive ? 2 : isAncestor ? 1.5 : 1;
|
|
45
|
+
const fill = node.isCompound
|
|
46
|
+
? V.nodeCompound
|
|
47
|
+
: isActive
|
|
48
|
+
? V.nodeActive
|
|
49
|
+
: V.node;
|
|
50
|
+
const textFill = isActive ? V.textActive : isActive || isAncestor ? V.accent : V.text;
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<g>
|
|
54
|
+
<rect
|
|
55
|
+
x={node.x} y={node.y}
|
|
56
|
+
width={node.width} height={node.height}
|
|
57
|
+
rx={10} ry={10}
|
|
58
|
+
style={{ fill, stroke, strokeWidth, transition: 'stroke 280ms ease, fill 280ms ease' }}
|
|
59
|
+
/>
|
|
60
|
+
{node.isCompound ? (
|
|
61
|
+
<text
|
|
62
|
+
x={node.x + 14} y={node.y + 22}
|
|
63
|
+
style={{
|
|
64
|
+
fill: isActive || isAncestor ? V.accent : V.text,
|
|
65
|
+
fontFamily: "var(--matchina-viz-font, 'JetBrains Mono', monospace)",
|
|
66
|
+
fontSize: 12,
|
|
67
|
+
fontWeight: 600,
|
|
68
|
+
letterSpacing: '0.06em',
|
|
69
|
+
transition: 'fill 280ms ease',
|
|
70
|
+
}}
|
|
71
|
+
>
|
|
72
|
+
{node.label}
|
|
73
|
+
</text>
|
|
74
|
+
) : (
|
|
75
|
+
<text
|
|
76
|
+
x={node.x + node.width / 2} y={node.y + node.height / 2 + 5}
|
|
77
|
+
textAnchor="middle"
|
|
78
|
+
style={{
|
|
79
|
+
fill: textFill,
|
|
80
|
+
fontFamily: "var(--matchina-viz-font, 'JetBrains Mono', monospace)",
|
|
81
|
+
fontSize: 14,
|
|
82
|
+
fontWeight: isActive ? 600 : 500,
|
|
83
|
+
transition: 'fill 280ms ease',
|
|
84
|
+
}}
|
|
85
|
+
>
|
|
86
|
+
{node.label}
|
|
87
|
+
</text>
|
|
88
|
+
)}
|
|
89
|
+
{isActive && !node.isCompound && (
|
|
90
|
+
<circle cx={node.x + node.width - 10} cy={node.y + 10} r={4} style={{ fill: V.accent }}>
|
|
91
|
+
<animate attributeName="opacity" values="1;0.35;1" dur="1.6s" repeatCount="indefinite" />
|
|
92
|
+
</circle>
|
|
93
|
+
)}
|
|
94
|
+
</g>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Self-loop: cubic bezier looping out from the top-right corner of the node.
|
|
99
|
+
// Ported from FloatingEdge.tsx in viz-reactflow — stacks multiple loops by index.
|
|
100
|
+
function SelfLoopShape({ edge, node, isOutgoing, onFire, loopIndex }: {
|
|
101
|
+
edge: SvgEdge;
|
|
102
|
+
node: SvgNode;
|
|
103
|
+
isOutgoing: boolean;
|
|
104
|
+
onFire: (event: string) => void;
|
|
105
|
+
loopIndex: number;
|
|
106
|
+
}) {
|
|
107
|
+
const [hovered, setHovered] = useState(false);
|
|
108
|
+
const stroke = isOutgoing ? V.accent : V.edge;
|
|
109
|
+
const strokeWidth = isOutgoing ? (hovered ? 2.5 : 2) : 1.25;
|
|
110
|
+
const opacity = isOutgoing ? 1 : 0.65;
|
|
111
|
+
const markerId = isOutgoing ? 'matchina-svg-arrow-active' : 'matchina-svg-arrow';
|
|
112
|
+
const { label } = edge;
|
|
113
|
+
|
|
114
|
+
const hw = node.width / 2;
|
|
115
|
+
const hh = node.height / 2;
|
|
116
|
+
const sx = node.x + hw; // node center x
|
|
117
|
+
const sy = node.y + hh; // node center y
|
|
118
|
+
const loopRadius = 28 + loopIndex * 16;
|
|
119
|
+
|
|
120
|
+
const startX = sx + hw - 8 - loopIndex * 2;
|
|
121
|
+
const startY = sy - hh;
|
|
122
|
+
const endX = sx + hw;
|
|
123
|
+
const endY = sy - hh + 8 + loopIndex * 2;
|
|
124
|
+
|
|
125
|
+
const d = `M ${startX} ${startY} C ${startX} ${startY - loopRadius}, ${endX + loopRadius} ${endY}, ${endX} ${endY}`;
|
|
126
|
+
const labelX = sx + hw + loopRadius + 4;
|
|
127
|
+
const labelY = sy - hh - 10 + loopIndex * (label ? label.height + 8 : 24);
|
|
128
|
+
|
|
129
|
+
return (
|
|
130
|
+
<g
|
|
131
|
+
style={{ cursor: isOutgoing ? 'pointer' : 'default' }}
|
|
132
|
+
onClick={isOutgoing ? () => onFire(edge.event) : undefined}
|
|
133
|
+
onMouseEnter={isOutgoing ? () => setHovered(true) : undefined}
|
|
134
|
+
onMouseLeave={isOutgoing ? () => setHovered(false) : undefined}
|
|
135
|
+
>
|
|
136
|
+
{isOutgoing && <path d={d} fill="none" stroke="transparent" strokeWidth={14} />}
|
|
137
|
+
<path
|
|
138
|
+
d={d} fill="none"
|
|
139
|
+
style={{ stroke, strokeWidth, opacity, transition: 'stroke 220ms ease, opacity 220ms ease' }}
|
|
140
|
+
markerEnd={`url(#${markerId})`}
|
|
141
|
+
/>
|
|
142
|
+
{label && (
|
|
143
|
+
<g
|
|
144
|
+
transform={`translate(${labelX}, ${labelY - label.height / 2})`}
|
|
145
|
+
style={{ opacity, transition: 'opacity 220ms ease', cursor: isOutgoing ? 'pointer' : 'default' }}
|
|
146
|
+
onClick={isOutgoing ? () => onFire(edge.event) : undefined}
|
|
147
|
+
>
|
|
148
|
+
<rect
|
|
149
|
+
x={-6} y={-2}
|
|
150
|
+
width={label.width + 12} height={label.height + 4}
|
|
151
|
+
rx={6} ry={6}
|
|
152
|
+
style={{
|
|
153
|
+
fill: isOutgoing ? (hovered ? V.accent : V.labelBgActive) : V.labelBg,
|
|
154
|
+
stroke: isOutgoing ? V.accent : 'rgba(100,116,139,0.45)',
|
|
155
|
+
strokeWidth: isOutgoing ? 1 : 0.75,
|
|
156
|
+
transition: 'fill 150ms ease, stroke 150ms ease',
|
|
157
|
+
}}
|
|
158
|
+
/>
|
|
159
|
+
<text
|
|
160
|
+
x={label.width / 2} y={(label.height + 4) / 2 + 4}
|
|
161
|
+
textAnchor="middle"
|
|
162
|
+
style={{
|
|
163
|
+
fill: isOutgoing ? (hovered ? V.labelBg : V.accent) : V.labelText,
|
|
164
|
+
fontFamily: "var(--matchina-viz-font, 'JetBrains Mono', monospace)",
|
|
165
|
+
fontSize: 11,
|
|
166
|
+
fontWeight: isOutgoing ? 600 : 500,
|
|
167
|
+
letterSpacing: '0.04em',
|
|
168
|
+
userSelect: 'none',
|
|
169
|
+
transition: 'fill 150ms ease',
|
|
170
|
+
}}
|
|
171
|
+
>
|
|
172
|
+
{label.text}
|
|
173
|
+
</text>
|
|
174
|
+
</g>
|
|
175
|
+
)}
|
|
176
|
+
</g>
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function EdgeShape({ edge, isOutgoing, onFire, labelT = 0.5 }: {
|
|
181
|
+
edge: SvgEdge;
|
|
182
|
+
isOutgoing: boolean;
|
|
183
|
+
onFire: (event: string) => void;
|
|
184
|
+
labelT?: number;
|
|
185
|
+
}) {
|
|
186
|
+
const [hovered, setHovered] = useState(false);
|
|
187
|
+
const section = edge.sections?.[0];
|
|
188
|
+
if (!section?.startPoint || !section?.endPoint) return null;
|
|
189
|
+
|
|
190
|
+
const d = buildCurvedPath(section);
|
|
191
|
+
const stroke = isOutgoing ? V.accent : V.edge;
|
|
192
|
+
const strokeWidth = isOutgoing ? (hovered ? 2.5 : 2) : 1.25;
|
|
193
|
+
const opacity = isOutgoing ? 1 : 0.65;
|
|
194
|
+
const { label } = edge;
|
|
195
|
+
const markerId = isOutgoing ? 'matchina-svg-arrow-active' : 'matchina-svg-arrow';
|
|
196
|
+
|
|
197
|
+
// Use labelT to spread parallel edge labels along the path instead of all at midpoint.
|
|
198
|
+
const mid = label ? pathAtT(section, labelT) : null;
|
|
199
|
+
|
|
200
|
+
return (
|
|
201
|
+
<g
|
|
202
|
+
style={{ cursor: isOutgoing ? 'pointer' : 'default' }}
|
|
203
|
+
onClick={isOutgoing ? () => onFire(edge.event) : undefined}
|
|
204
|
+
onMouseEnter={isOutgoing ? () => setHovered(true) : undefined}
|
|
205
|
+
onMouseLeave={isOutgoing ? () => setHovered(false) : undefined}
|
|
206
|
+
>
|
|
207
|
+
{isOutgoing && (
|
|
208
|
+
<path d={d} fill="none" stroke="transparent" strokeWidth={18} />
|
|
209
|
+
)}
|
|
210
|
+
<path
|
|
211
|
+
d={d} fill="none"
|
|
212
|
+
style={{
|
|
213
|
+
stroke, strokeWidth, opacity,
|
|
214
|
+
transition: 'stroke 220ms ease, opacity 220ms ease',
|
|
215
|
+
}}
|
|
216
|
+
markerEnd={`url(#${markerId})`}
|
|
217
|
+
/>
|
|
218
|
+
{label && mid && (
|
|
219
|
+
<g
|
|
220
|
+
transform={`translate(${mid.x - label.width / 2}, ${mid.y - label.height / 2})`}
|
|
221
|
+
style={{ opacity, transition: 'opacity 220ms ease', cursor: isOutgoing ? 'pointer' : 'default' }}
|
|
222
|
+
onClick={isOutgoing ? () => onFire(edge.event) : undefined}
|
|
223
|
+
>
|
|
224
|
+
<rect
|
|
225
|
+
x={-6} y={-2}
|
|
226
|
+
width={label.width + 12} height={label.height + 4}
|
|
227
|
+
rx={6} ry={6}
|
|
228
|
+
style={{
|
|
229
|
+
fill: isOutgoing
|
|
230
|
+
? hovered ? V.accent : V.labelBgActive
|
|
231
|
+
: V.labelBg,
|
|
232
|
+
stroke: isOutgoing ? V.accent : 'rgba(100,116,139,0.45)',
|
|
233
|
+
strokeWidth: isOutgoing ? 1 : 0.75,
|
|
234
|
+
transition: 'fill 150ms ease, stroke 150ms ease',
|
|
235
|
+
}}
|
|
236
|
+
/>
|
|
237
|
+
<text
|
|
238
|
+
x={label.width / 2} y={(label.height + 4) / 2 + 4}
|
|
239
|
+
textAnchor="middle"
|
|
240
|
+
style={{
|
|
241
|
+
fill: isOutgoing
|
|
242
|
+
? hovered ? V.labelBg : V.accent
|
|
243
|
+
: V.labelText,
|
|
244
|
+
fontFamily: "var(--matchina-viz-font, 'JetBrains Mono', monospace)",
|
|
245
|
+
fontSize: 11,
|
|
246
|
+
fontWeight: isOutgoing ? 600 : 500,
|
|
247
|
+
letterSpacing: '0.04em',
|
|
248
|
+
userSelect: 'none',
|
|
249
|
+
transition: 'fill 150ms ease',
|
|
250
|
+
}}
|
|
251
|
+
>
|
|
252
|
+
{label.text}
|
|
253
|
+
</text>
|
|
254
|
+
</g>
|
|
255
|
+
)}
|
|
256
|
+
</g>
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const ctrlBtn: React.CSSProperties = {
|
|
261
|
+
background: 'transparent',
|
|
262
|
+
border: 'none',
|
|
263
|
+
color: V.ctrlText,
|
|
264
|
+
// 44×44 meets a11y touch-target minimum on coarse pointers; on fine pointers
|
|
265
|
+
// (mouse) the visual hit area is still adequate. Keeps the corner control
|
|
266
|
+
// stack compact while remaining tappable on mobile.
|
|
267
|
+
width: 44,
|
|
268
|
+
height: 44,
|
|
269
|
+
display: 'flex',
|
|
270
|
+
alignItems: 'center',
|
|
271
|
+
justifyContent: 'center',
|
|
272
|
+
fontFamily: "var(--matchina-viz-font, 'JetBrains Mono', monospace)",
|
|
273
|
+
fontSize: 14,
|
|
274
|
+
lineHeight: 1,
|
|
275
|
+
cursor: 'pointer',
|
|
276
|
+
padding: 0,
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
export interface SvgInspectorProps {
|
|
280
|
+
shape: MachineShape;
|
|
281
|
+
value: string;
|
|
282
|
+
onFire?: (event: string) => void;
|
|
283
|
+
options?: ElkLayoutOptions;
|
|
284
|
+
interactive?: boolean;
|
|
285
|
+
/**
|
|
286
|
+
* Pre-computed layout (e.g. from runElkLayout in an Astro frontmatter, serialized to JSON).
|
|
287
|
+
* When supplied, the inspector renders synchronously from this layout — no "computing layout…"
|
|
288
|
+
* placeholder on first paint. ELK still re-runs in the background when `options` changes after mount.
|
|
289
|
+
*/
|
|
290
|
+
precomputedLayout?: SvgLayout;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const MAX_FIT_ZOOM = 1.0;
|
|
294
|
+
|
|
295
|
+
function computeFit(
|
|
296
|
+
contentW: number,
|
|
297
|
+
contentH: number,
|
|
298
|
+
containerW: number,
|
|
299
|
+
containerH: number,
|
|
300
|
+
): { zoom: number; pan: { x: number; y: number } } {
|
|
301
|
+
const scaleX = containerW / contentW;
|
|
302
|
+
const scaleY = containerH / contentH;
|
|
303
|
+
const zoom = Math.min(scaleX, scaleY, MAX_FIT_ZOOM);
|
|
304
|
+
const pan = {
|
|
305
|
+
x: (containerW - contentW * zoom) / 2,
|
|
306
|
+
y: (containerH - contentH * zoom) / 2,
|
|
307
|
+
};
|
|
308
|
+
return { zoom, pan };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export const SvgInspector = React.memo(function SvgInspector({
|
|
312
|
+
shape,
|
|
313
|
+
value,
|
|
314
|
+
onFire,
|
|
315
|
+
options,
|
|
316
|
+
interactive = true,
|
|
317
|
+
precomputedLayout,
|
|
318
|
+
}: SvgInspectorProps) {
|
|
319
|
+
const [layout, setLayout] = useState<SvgLayout | null>(precomputedLayout ?? null);
|
|
320
|
+
const [pan, setPan] = useState({ x: 20, y: 20 });
|
|
321
|
+
const [zoom, setZoom] = useState(1);
|
|
322
|
+
// `interacted` flips true on the first wheel/drag/fit. Until then we render via
|
|
323
|
+
// SVG's native viewBox so the diagram is centered and scaled to fit without any
|
|
324
|
+
// JS-driven post-mount fit — making the SSR'd initial frame correct on first paint.
|
|
325
|
+
const [interacted, setInteracted] = useState(false);
|
|
326
|
+
const dragRef = useRef({ active: false, sx: 0, sy: 0, px: 0, py: 0 });
|
|
327
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
328
|
+
|
|
329
|
+
// Stable options key — re-layout only when options actually change
|
|
330
|
+
const optionsKey = JSON.stringify(options ?? {});
|
|
331
|
+
const initialOptionsKey = useRef(optionsKey);
|
|
332
|
+
const initialShapeRef = useRef(shape);
|
|
333
|
+
|
|
334
|
+
useEffect(() => {
|
|
335
|
+
// Skip the initial ELK run when we already have a precomputed layout for the same inputs.
|
|
336
|
+
if (
|
|
337
|
+
precomputedLayout &&
|
|
338
|
+
shape === initialShapeRef.current &&
|
|
339
|
+
optionsKey === initialOptionsKey.current
|
|
340
|
+
) {
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
runElkLayout(shape, options ?? {}).then((l) => {
|
|
344
|
+
setLayout(l);
|
|
345
|
+
// When layout changes after the user has already started interacting,
|
|
346
|
+
// re-fit so the new graph isn't off-screen.
|
|
347
|
+
if (interacted) fitToContainer(l);
|
|
348
|
+
}).catch(console.error);
|
|
349
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
350
|
+
}, [shape, optionsKey]);
|
|
351
|
+
|
|
352
|
+
function fitToContainer(l: SvgLayout) {
|
|
353
|
+
const el = containerRef.current;
|
|
354
|
+
if (!el) return;
|
|
355
|
+
const { zoom: z, pan: p } = computeFit(l.width, l.height, el.clientWidth, el.clientHeight);
|
|
356
|
+
setZoom(z);
|
|
357
|
+
setPan(p);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Compute the effective pan+zoom that the pre-interaction viewBox is currently producing,
|
|
362
|
+
* then seed state with those values and flip into imperative-transform mode. This
|
|
363
|
+
* keeps the diagram visually identical across the viewBox→transform handoff: same
|
|
364
|
+
* scale, same center. The MAX_FIT_ZOOM cap matches the viewBox path below, which
|
|
365
|
+
* expands the viewBox to container dimensions when content is smaller than the
|
|
366
|
+
* container so small diagrams render at 1x rather than ballooning to fill.
|
|
367
|
+
*/
|
|
368
|
+
function leaveViewBoxMode(l: SvgLayout) {
|
|
369
|
+
const el = containerRef.current;
|
|
370
|
+
if (!el) { setInteracted(true); return; }
|
|
371
|
+
const scaleX = el.clientWidth / l.width;
|
|
372
|
+
const scaleY = el.clientHeight / l.height;
|
|
373
|
+
const z = Math.min(scaleX, scaleY, MAX_FIT_ZOOM);
|
|
374
|
+
const p = {
|
|
375
|
+
x: (el.clientWidth - l.width * z) / 2,
|
|
376
|
+
y: (el.clientHeight - l.height * z) / 2,
|
|
377
|
+
};
|
|
378
|
+
setZoom(z);
|
|
379
|
+
setPan(p);
|
|
380
|
+
setInteracted(true);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Derive active paths from the current state value (dot-separated fullKey)
|
|
384
|
+
const activePath = useMemo(() => (value ? value.split('.') : []), [value]);
|
|
385
|
+
|
|
386
|
+
const activeLeafId = value;
|
|
387
|
+
const activeAncestorIds = useMemo(() => {
|
|
388
|
+
const set = new Set<string>();
|
|
389
|
+
for (let i = 0; i < activePath.length - 1; i++) {
|
|
390
|
+
set.add(activePath.slice(0, i + 1).join('.'));
|
|
391
|
+
}
|
|
392
|
+
return set;
|
|
393
|
+
}, [activePath]);
|
|
394
|
+
|
|
395
|
+
// Outgoing edges: source is on the active path (leaf or any ancestor)
|
|
396
|
+
const activeSourceIds = useMemo(() => {
|
|
397
|
+
const set = new Set<string>();
|
|
398
|
+
for (let i = 1; i <= activePath.length; i++) {
|
|
399
|
+
set.add(activePath.slice(0, i).join('.'));
|
|
400
|
+
}
|
|
401
|
+
return set;
|
|
402
|
+
}, [activePath]);
|
|
403
|
+
|
|
404
|
+
function handleFire(event: string) {
|
|
405
|
+
if (interactive) onFire?.(event);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function onWheel(e: React.WheelEvent) {
|
|
409
|
+
e.preventDefault();
|
|
410
|
+
if (!interacted && layout) leaveViewBoxMode(layout);
|
|
411
|
+
setZoom(z => Math.min(2.5, Math.max(0.3, z * (e.deltaY > 0 ? 0.92 : 1.08))));
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function onMouseDown(e: React.MouseEvent) {
|
|
415
|
+
if (e.button !== 0) return;
|
|
416
|
+
if (!interacted && layout) leaveViewBoxMode(layout);
|
|
417
|
+
dragRef.current = { active: true, sx: e.clientX, sy: e.clientY, px: pan.x, py: pan.y };
|
|
418
|
+
}
|
|
419
|
+
function onMouseMove(e: React.MouseEvent) {
|
|
420
|
+
if (!dragRef.current.active) return;
|
|
421
|
+
setPan({
|
|
422
|
+
x: dragRef.current.px + e.clientX - dragRef.current.sx,
|
|
423
|
+
y: dragRef.current.py + e.clientY - dragRef.current.sy,
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
function onMouseUp() { dragRef.current.active = false; }
|
|
427
|
+
|
|
428
|
+
// Spread label positions for parallel edges (same source→target pair).
|
|
429
|
+
// Uses evenly spaced t values across [0.3..0.7] so labels don't stack.
|
|
430
|
+
// Must be before the early return to satisfy Rules of Hooks.
|
|
431
|
+
const edgeLabelT = useMemo(() => {
|
|
432
|
+
const allEdges = layout?.edges ?? [];
|
|
433
|
+
const pairNextIdx = new Map<string, number>();
|
|
434
|
+
const pairTotal = new Map<string, number>();
|
|
435
|
+
for (const edge of allEdges) {
|
|
436
|
+
const key = `${edge.sourcePath.join('.')}→${edge.targetPath.join('.')}`;
|
|
437
|
+
pairTotal.set(key, (pairTotal.get(key) ?? 0) + 1);
|
|
438
|
+
}
|
|
439
|
+
const result = new Map<string, number>();
|
|
440
|
+
for (const edge of allEdges) {
|
|
441
|
+
const key = `${edge.sourcePath.join('.')}→${edge.targetPath.join('.')}`;
|
|
442
|
+
const count = pairTotal.get(key) ?? 1;
|
|
443
|
+
const idx = pairNextIdx.get(key) ?? 0;
|
|
444
|
+
pairNextIdx.set(key, idx + 1);
|
|
445
|
+
// Spread evenly: 1 edge → 0.5, 2 → [0.35, 0.65], 3 → [0.3, 0.5, 0.7], etc.
|
|
446
|
+
const t = count === 1 ? 0.5 : 0.3 + (idx / (count - 1)) * 0.4;
|
|
447
|
+
result.set(edge.id, t);
|
|
448
|
+
}
|
|
449
|
+
return result;
|
|
450
|
+
}, [layout]);
|
|
451
|
+
|
|
452
|
+
if (!layout) {
|
|
453
|
+
return (
|
|
454
|
+
<div style={{ width: '100%', height: '100%', background: V.bg, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
|
455
|
+
<span style={{ color: V.edge, fontFamily: "var(--matchina-viz-font, 'JetBrains Mono', monospace)", fontSize: 12 }}>computing layout…</span>
|
|
456
|
+
</div>
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const { nodes, edges, width, height } = layout;
|
|
461
|
+
const compounds = nodes.filter(n => n.isCompound);
|
|
462
|
+
const leaves = nodes.filter(n => !n.isCompound);
|
|
463
|
+
|
|
464
|
+
return (
|
|
465
|
+
<div
|
|
466
|
+
ref={containerRef}
|
|
467
|
+
style={{
|
|
468
|
+
position: 'relative',
|
|
469
|
+
width: '100%',
|
|
470
|
+
height: '100%',
|
|
471
|
+
overflow: 'hidden',
|
|
472
|
+
cursor: 'grab',
|
|
473
|
+
background: V.bg,
|
|
474
|
+
backgroundImage: `radial-gradient(ellipse 80% 60% at 70% 0%, color-mix(in srgb, ${V.accent} 5%, transparent), transparent 60%)`,
|
|
475
|
+
// Pre-interaction we let the SVG act as a flex item so its maxWidth/maxHeight
|
|
476
|
+
// (set to content dimensions) caps it at 1x and centers it. Post-interaction
|
|
477
|
+
// the inner <g transform> controls placement, so we revert to block layout.
|
|
478
|
+
...(!interacted && {
|
|
479
|
+
display: 'flex',
|
|
480
|
+
alignItems: 'center',
|
|
481
|
+
justifyContent: 'center',
|
|
482
|
+
}),
|
|
483
|
+
}}
|
|
484
|
+
onWheel={onWheel}
|
|
485
|
+
onMouseDown={onMouseDown}
|
|
486
|
+
onMouseMove={onMouseMove}
|
|
487
|
+
onMouseUp={onMouseUp}
|
|
488
|
+
onMouseLeave={onMouseUp}
|
|
489
|
+
>
|
|
490
|
+
<svg
|
|
491
|
+
{...(interacted && { width: '100%', height: '100%' })}
|
|
492
|
+
style={{
|
|
493
|
+
display: 'block',
|
|
494
|
+
// Pre-interaction: cap intrinsic size at the content's natural dimensions so
|
|
495
|
+
// small diagrams sit at 1x (centered by the parent flex container) instead of
|
|
496
|
+
// ballooning to fill via viewBox. Larger diagrams still shrink to fit via the
|
|
497
|
+
// viewBox + `meet` because we still allow width/height to expand to 100%.
|
|
498
|
+
...(!interacted && {
|
|
499
|
+
width: '100%',
|
|
500
|
+
height: '100%',
|
|
501
|
+
maxWidth: width,
|
|
502
|
+
maxHeight: height,
|
|
503
|
+
}),
|
|
504
|
+
}}
|
|
505
|
+
{...(!interacted && {
|
|
506
|
+
viewBox: `0 0 ${width} ${height}`,
|
|
507
|
+
preserveAspectRatio: 'xMidYMid meet',
|
|
508
|
+
})}
|
|
509
|
+
>
|
|
510
|
+
<defs>
|
|
511
|
+
<marker id="matchina-svg-arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
|
512
|
+
<path d="M 0 0 L 10 5 L 0 10 z" style={{ fill: 'rgba(100,116,139,0.7)' }} />
|
|
513
|
+
</marker>
|
|
514
|
+
<marker id="matchina-svg-arrow-active" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
|
515
|
+
<path d="M 0 0 L 10 5 L 0 10 z" style={{ fill: V.accent }} />
|
|
516
|
+
</marker>
|
|
517
|
+
</defs>
|
|
518
|
+
|
|
519
|
+
<g transform={interacted ? `translate(${pan.x}, ${pan.y}) scale(${zoom})` : undefined}>
|
|
520
|
+
{/* Compound containers rendered first (lowest z) */}
|
|
521
|
+
{compounds.map(node => (
|
|
522
|
+
<NodeShape
|
|
523
|
+
key={node.id}
|
|
524
|
+
node={node}
|
|
525
|
+
isActive={node.id === activeLeafId}
|
|
526
|
+
isAncestor={activeAncestorIds.has(node.id)}
|
|
527
|
+
/>
|
|
528
|
+
))}
|
|
529
|
+
{/* Edges — self-loops rendered separately with custom arc geometry */}
|
|
530
|
+
{(() => {
|
|
531
|
+
const nodeById = new Map(nodes.map(n => [n.id, n]));
|
|
532
|
+
const selfLoopIndexByNode = new Map<string, number>();
|
|
533
|
+
return edges.map(edge => {
|
|
534
|
+
const isSelf = edge.sourcePath.join('.') === edge.targetPath.join('.');
|
|
535
|
+
const isOutgoing = activeSourceIds.has(edge.sourcePath.join('.'));
|
|
536
|
+
if (isSelf) {
|
|
537
|
+
const nodeId = edge.sourcePath.join('.');
|
|
538
|
+
const node = nodeById.get(nodeId);
|
|
539
|
+
if (!node) return null;
|
|
540
|
+
const loopIndex = selfLoopIndexByNode.get(nodeId) ?? 0;
|
|
541
|
+
selfLoopIndexByNode.set(nodeId, loopIndex + 1);
|
|
542
|
+
return (
|
|
543
|
+
<SelfLoopShape
|
|
544
|
+
key={edge.id}
|
|
545
|
+
edge={edge}
|
|
546
|
+
node={node}
|
|
547
|
+
isOutgoing={isOutgoing}
|
|
548
|
+
onFire={handleFire}
|
|
549
|
+
loopIndex={loopIndex}
|
|
550
|
+
/>
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
return (
|
|
554
|
+
<EdgeShape
|
|
555
|
+
key={edge.id}
|
|
556
|
+
edge={edge}
|
|
557
|
+
isOutgoing={isOutgoing}
|
|
558
|
+
onFire={handleFire}
|
|
559
|
+
labelT={edgeLabelT.get(edge.id) ?? 0.5}
|
|
560
|
+
/>
|
|
561
|
+
);
|
|
562
|
+
});
|
|
563
|
+
})()}
|
|
564
|
+
{/* Leaves on top */}
|
|
565
|
+
{leaves.map(node => (
|
|
566
|
+
<NodeShape
|
|
567
|
+
key={node.id}
|
|
568
|
+
node={node}
|
|
569
|
+
isActive={node.id === activeLeafId}
|
|
570
|
+
isAncestor={activeAncestorIds.has(node.id)}
|
|
571
|
+
/>
|
|
572
|
+
))}
|
|
573
|
+
</g>
|
|
574
|
+
</svg>
|
|
575
|
+
|
|
576
|
+
<div style={{
|
|
577
|
+
position: 'absolute', bottom: 14, right: 14,
|
|
578
|
+
display: 'flex', flexDirection: 'column',
|
|
579
|
+
background: V.ctrlBg,
|
|
580
|
+
border: `1px solid ${V.ctrlBorder}`,
|
|
581
|
+
borderRadius: 'var(--matchina-viz-radius, 2px)',
|
|
582
|
+
overflow: 'hidden',
|
|
583
|
+
}}>
|
|
584
|
+
<button
|
|
585
|
+
aria-label="Zoom in"
|
|
586
|
+
title="Zoom in"
|
|
587
|
+
onClick={() => {
|
|
588
|
+
if (!interacted && layout) leaveViewBoxMode(layout);
|
|
589
|
+
setZoom(z => Math.min(2.5, z * 1.15));
|
|
590
|
+
}}
|
|
591
|
+
style={ctrlBtn}
|
|
592
|
+
>+</button>
|
|
593
|
+
<button
|
|
594
|
+
aria-label="Zoom out"
|
|
595
|
+
title="Zoom out"
|
|
596
|
+
onClick={() => {
|
|
597
|
+
if (!interacted && layout) leaveViewBoxMode(layout);
|
|
598
|
+
setZoom(z => Math.max(0.3, z * 0.87));
|
|
599
|
+
}}
|
|
600
|
+
style={{ ...ctrlBtn, borderTop: `1px solid ${V.ctrlBorder}` }}
|
|
601
|
+
>−</button>
|
|
602
|
+
<button
|
|
603
|
+
aria-label="Fit view"
|
|
604
|
+
title="Fit view"
|
|
605
|
+
onClick={() => layout && leaveViewBoxMode(layout)}
|
|
606
|
+
style={{ ...ctrlBtn, borderTop: `1px solid ${V.ctrlBorder}` }}
|
|
607
|
+
>⛶</button>
|
|
608
|
+
</div>
|
|
609
|
+
</div>
|
|
610
|
+
);
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
export default SvgInspector;
|