@cyvest/cyvest-vis 2.0.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/README.md +42 -0
- package/dist/index.d.mts +48 -0
- package/dist/index.d.ts +48 -0
- package/dist/index.js +1293 -0
- package/dist/index.mjs +1284 -0
- package/package.json +34 -0
- package/src/components/CyvestGraph.tsx +117 -0
- package/src/components/FloatingEdge.tsx +43 -0
- package/src/components/InvestigationGraph.tsx +257 -0
- package/src/components/InvestigationNode.tsx +145 -0
- package/src/components/ObservableNode.tsx +173 -0
- package/src/components/ObservablesGraph.tsx +376 -0
- package/src/hooks/useDagreLayout.ts +102 -0
- package/src/hooks/useForceLayout.ts +383 -0
- package/src/index.ts +13 -0
- package/src/types.ts +193 -0
- package/src/utils/observables.ts +164 -0
- package/tests/observables.test.ts +43 -0
- package/tsconfig.json +4 -0
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Custom node component for the Observables Graph.
|
|
3
|
+
* Renders nodes with different shapes based on observable type.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import React, { memo } from "react";
|
|
7
|
+
import { Handle, Position, type NodeProps } from "@xyflow/react";
|
|
8
|
+
import type { ObservableNodeData, ObservableShape } from "../types";
|
|
9
|
+
import { getLevelColor, getLevelBackgroundColor } from "../utils/observables";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Node size constants - keep small for nice force layout
|
|
13
|
+
*/
|
|
14
|
+
const NODE_SIZE = 28;
|
|
15
|
+
const ROOT_NODE_SIZE = 36;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Observable node component with centered connection points.
|
|
19
|
+
*/
|
|
20
|
+
function ObservableNodeComponent({
|
|
21
|
+
data,
|
|
22
|
+
selected,
|
|
23
|
+
}: NodeProps) {
|
|
24
|
+
const nodeData = data as unknown as ObservableNodeData;
|
|
25
|
+
const {
|
|
26
|
+
label,
|
|
27
|
+
emoji,
|
|
28
|
+
shape,
|
|
29
|
+
level,
|
|
30
|
+
isRoot,
|
|
31
|
+
whitelisted,
|
|
32
|
+
fullValue,
|
|
33
|
+
} = nodeData;
|
|
34
|
+
|
|
35
|
+
const size = isRoot ? ROOT_NODE_SIZE : NODE_SIZE;
|
|
36
|
+
const borderColor = getLevelColor(level);
|
|
37
|
+
const backgroundColor = getLevelBackgroundColor(level);
|
|
38
|
+
|
|
39
|
+
// Get shape styles
|
|
40
|
+
const getShapeStyle = (): React.CSSProperties => {
|
|
41
|
+
const baseStyle: React.CSSProperties = {
|
|
42
|
+
width: size,
|
|
43
|
+
height: size,
|
|
44
|
+
display: "flex",
|
|
45
|
+
alignItems: "center",
|
|
46
|
+
justifyContent: "center",
|
|
47
|
+
backgroundColor,
|
|
48
|
+
border: `${selected ? 3 : 2}px solid ${borderColor}`,
|
|
49
|
+
opacity: whitelisted ? 0.5 : 1,
|
|
50
|
+
fontSize: isRoot ? 14 : 12,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
switch (shape) {
|
|
54
|
+
case "square":
|
|
55
|
+
return { ...baseStyle, borderRadius: 4 };
|
|
56
|
+
case "circle":
|
|
57
|
+
return { ...baseStyle, borderRadius: "50%" };
|
|
58
|
+
case "triangle":
|
|
59
|
+
// Use clip-path for triangle
|
|
60
|
+
return {
|
|
61
|
+
...baseStyle,
|
|
62
|
+
borderRadius: 0,
|
|
63
|
+
border: "none",
|
|
64
|
+
background: `linear-gradient(to bottom right, ${backgroundColor} 50%, transparent 50%)`,
|
|
65
|
+
clipPath: "polygon(50% 0%, 100% 100%, 0% 100%)",
|
|
66
|
+
position: "relative",
|
|
67
|
+
};
|
|
68
|
+
case "rectangle":
|
|
69
|
+
default:
|
|
70
|
+
return { ...baseStyle, width: size * 1.4, borderRadius: 6 };
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// For triangle, we need a different approach
|
|
75
|
+
const isTriangle = shape === "triangle";
|
|
76
|
+
|
|
77
|
+
return (
|
|
78
|
+
<div
|
|
79
|
+
className="observable-node"
|
|
80
|
+
style={{
|
|
81
|
+
display: "flex",
|
|
82
|
+
flexDirection: "column",
|
|
83
|
+
alignItems: "center",
|
|
84
|
+
cursor: "pointer",
|
|
85
|
+
}}
|
|
86
|
+
>
|
|
87
|
+
{/* Shape container */}
|
|
88
|
+
<div style={{ position: "relative" }}>
|
|
89
|
+
{isTriangle ? (
|
|
90
|
+
// Triangle using SVG
|
|
91
|
+
<svg width={size} height={size} viewBox="0 0 100 100">
|
|
92
|
+
<polygon
|
|
93
|
+
points="50,10 90,90 10,90"
|
|
94
|
+
fill={backgroundColor}
|
|
95
|
+
stroke={borderColor}
|
|
96
|
+
strokeWidth={selected ? 6 : 4}
|
|
97
|
+
opacity={whitelisted ? 0.5 : 1}
|
|
98
|
+
/>
|
|
99
|
+
<text
|
|
100
|
+
x="50"
|
|
101
|
+
y="65"
|
|
102
|
+
textAnchor="middle"
|
|
103
|
+
fontSize="32"
|
|
104
|
+
dominantBaseline="middle"
|
|
105
|
+
>
|
|
106
|
+
{emoji}
|
|
107
|
+
</text>
|
|
108
|
+
</svg>
|
|
109
|
+
) : (
|
|
110
|
+
// Other shapes using CSS
|
|
111
|
+
<div style={getShapeStyle()}>
|
|
112
|
+
<span style={{ userSelect: "none" }}>{emoji}</span>
|
|
113
|
+
</div>
|
|
114
|
+
)}
|
|
115
|
+
|
|
116
|
+
{/* Center handle for source connections */}
|
|
117
|
+
<Handle
|
|
118
|
+
type="source"
|
|
119
|
+
position={Position.Right}
|
|
120
|
+
id="source"
|
|
121
|
+
style={{
|
|
122
|
+
position: "absolute",
|
|
123
|
+
top: "50%",
|
|
124
|
+
left: "50%",
|
|
125
|
+
transform: "translate(-50%, -50%)",
|
|
126
|
+
width: 1,
|
|
127
|
+
height: 1,
|
|
128
|
+
background: "transparent",
|
|
129
|
+
border: "none",
|
|
130
|
+
opacity: 0,
|
|
131
|
+
}}
|
|
132
|
+
/>
|
|
133
|
+
{/* Center handle for target connections */}
|
|
134
|
+
<Handle
|
|
135
|
+
type="target"
|
|
136
|
+
position={Position.Left}
|
|
137
|
+
id="target"
|
|
138
|
+
style={{
|
|
139
|
+
position: "absolute",
|
|
140
|
+
top: "50%",
|
|
141
|
+
left: "50%",
|
|
142
|
+
transform: "translate(-50%, -50%)",
|
|
143
|
+
width: 1,
|
|
144
|
+
height: 1,
|
|
145
|
+
background: "transparent",
|
|
146
|
+
border: "none",
|
|
147
|
+
opacity: 0,
|
|
148
|
+
}}
|
|
149
|
+
/>
|
|
150
|
+
</div>
|
|
151
|
+
|
|
152
|
+
{/* Label below the shape */}
|
|
153
|
+
<div
|
|
154
|
+
style={{
|
|
155
|
+
marginTop: 2,
|
|
156
|
+
fontSize: 9,
|
|
157
|
+
maxWidth: 70,
|
|
158
|
+
textAlign: "center",
|
|
159
|
+
overflow: "hidden",
|
|
160
|
+
textOverflow: "ellipsis",
|
|
161
|
+
whiteSpace: "nowrap",
|
|
162
|
+
color: "#374151",
|
|
163
|
+
fontFamily: "system-ui, sans-serif",
|
|
164
|
+
}}
|
|
165
|
+
title={fullValue}
|
|
166
|
+
>
|
|
167
|
+
{label}
|
|
168
|
+
</div>
|
|
169
|
+
</div>
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export const ObservableNode = memo(ObservableNodeComponent);
|
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ObservablesGraph component - displays observables as a force-directed graph.
|
|
3
|
+
* Uses iterative d3-force simulation for smooth, interactive layout.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import React, { useMemo, useCallback, useState } from "react";
|
|
7
|
+
import {
|
|
8
|
+
ReactFlow,
|
|
9
|
+
ReactFlowProvider,
|
|
10
|
+
Background,
|
|
11
|
+
Controls,
|
|
12
|
+
MiniMap,
|
|
13
|
+
useNodesState,
|
|
14
|
+
useEdgesState,
|
|
15
|
+
type Node,
|
|
16
|
+
type Edge,
|
|
17
|
+
type NodeTypes,
|
|
18
|
+
type EdgeTypes,
|
|
19
|
+
ConnectionMode,
|
|
20
|
+
} from "@xyflow/react";
|
|
21
|
+
import "@xyflow/react/dist/style.css";
|
|
22
|
+
|
|
23
|
+
import type { CyvestInvestigation } from "@cyvest/cyvest-js";
|
|
24
|
+
import { getObservableGraph, findRootObservables } from "@cyvest/cyvest-js";
|
|
25
|
+
|
|
26
|
+
import type {
|
|
27
|
+
ObservablesGraphProps,
|
|
28
|
+
ObservableNodeData,
|
|
29
|
+
ObservableEdgeData,
|
|
30
|
+
ForceLayoutConfig,
|
|
31
|
+
} from "../types";
|
|
32
|
+
import { DEFAULT_FORCE_CONFIG } from "../types";
|
|
33
|
+
import { ObservableNode } from "./ObservableNode";
|
|
34
|
+
import { FloatingEdge } from "./FloatingEdge";
|
|
35
|
+
import {
|
|
36
|
+
getObservableEmoji,
|
|
37
|
+
getObservableShape,
|
|
38
|
+
truncateLabel,
|
|
39
|
+
getLevelColor,
|
|
40
|
+
} from "../utils/observables";
|
|
41
|
+
import { useForceLayout } from "../hooks/useForceLayout";
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Custom node types for React Flow.
|
|
45
|
+
*/
|
|
46
|
+
const nodeTypes: NodeTypes = {
|
|
47
|
+
observable: ObservableNode,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Custom edge types for React Flow.
|
|
52
|
+
*/
|
|
53
|
+
const edgeTypes: EdgeTypes = {
|
|
54
|
+
floating: FloatingEdge,
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Convert investigation observables to React Flow nodes.
|
|
59
|
+
*/
|
|
60
|
+
function createObservableNodes(
|
|
61
|
+
investigation: CyvestInvestigation,
|
|
62
|
+
rootObservableIds: Set<string>
|
|
63
|
+
): Node<ObservableNodeData>[] {
|
|
64
|
+
const graph = getObservableGraph(investigation);
|
|
65
|
+
|
|
66
|
+
return graph.nodes.map((graphNode, index) => {
|
|
67
|
+
const isRoot = rootObservableIds.has(graphNode.id);
|
|
68
|
+
const shape = getObservableShape(graphNode.type, isRoot);
|
|
69
|
+
|
|
70
|
+
const nodeData: ObservableNodeData = {
|
|
71
|
+
label: truncateLabel(graphNode.value, 18),
|
|
72
|
+
fullValue: graphNode.value,
|
|
73
|
+
observableType: graphNode.type,
|
|
74
|
+
level: graphNode.level,
|
|
75
|
+
score: graphNode.score,
|
|
76
|
+
emoji: getObservableEmoji(graphNode.type),
|
|
77
|
+
shape,
|
|
78
|
+
isRoot,
|
|
79
|
+
whitelisted: graphNode.whitelisted,
|
|
80
|
+
internal: graphNode.internal,
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// Spread initial positions in a circle for better starting layout
|
|
84
|
+
const angle = (index / graph.nodes.length) * 2 * Math.PI;
|
|
85
|
+
const radius = isRoot ? 0 : 150;
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
id: graphNode.id,
|
|
89
|
+
type: "observable",
|
|
90
|
+
position: {
|
|
91
|
+
x: Math.cos(angle) * radius,
|
|
92
|
+
y: Math.sin(angle) * radius,
|
|
93
|
+
},
|
|
94
|
+
data: nodeData,
|
|
95
|
+
};
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Convert investigation relationships to React Flow edges.
|
|
101
|
+
*/
|
|
102
|
+
function createObservableEdges(
|
|
103
|
+
investigation: CyvestInvestigation
|
|
104
|
+
): Edge<ObservableEdgeData>[] {
|
|
105
|
+
const graph = getObservableGraph(investigation);
|
|
106
|
+
|
|
107
|
+
return graph.edges.map((graphEdge, index) => {
|
|
108
|
+
const edgeData: ObservableEdgeData = {
|
|
109
|
+
relationshipType: graphEdge.type,
|
|
110
|
+
bidirectional: graphEdge.direction === "bidirectional",
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
id: `edge-${graphEdge.source}-${graphEdge.target}-${index}`,
|
|
115
|
+
source: graphEdge.source,
|
|
116
|
+
target: graphEdge.target,
|
|
117
|
+
type: "floating",
|
|
118
|
+
data: edgeData,
|
|
119
|
+
style: { stroke: "#94a3b8", strokeWidth: 1.5 },
|
|
120
|
+
};
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Force controls panel component.
|
|
126
|
+
*/
|
|
127
|
+
const ForceControls: React.FC<{
|
|
128
|
+
config: ForceLayoutConfig;
|
|
129
|
+
onChange: (updates: Partial<ForceLayoutConfig>) => void;
|
|
130
|
+
onRestart: () => void;
|
|
131
|
+
}> = ({ config, onChange, onRestart }) => {
|
|
132
|
+
return (
|
|
133
|
+
<div
|
|
134
|
+
style={{
|
|
135
|
+
position: "absolute",
|
|
136
|
+
top: 10,
|
|
137
|
+
right: 10,
|
|
138
|
+
background: "white",
|
|
139
|
+
padding: 12,
|
|
140
|
+
borderRadius: 8,
|
|
141
|
+
boxShadow: "0 2px 8px rgba(0,0,0,0.15)",
|
|
142
|
+
fontSize: 12,
|
|
143
|
+
fontFamily: "system-ui, sans-serif",
|
|
144
|
+
zIndex: 10,
|
|
145
|
+
minWidth: 160,
|
|
146
|
+
}}
|
|
147
|
+
>
|
|
148
|
+
<div style={{ fontWeight: 600, marginBottom: 8 }}>Force Layout</div>
|
|
149
|
+
|
|
150
|
+
<div style={{ marginBottom: 8 }}>
|
|
151
|
+
<label style={{ display: "block", marginBottom: 2 }}>
|
|
152
|
+
Repulsion: {config.chargeStrength}
|
|
153
|
+
</label>
|
|
154
|
+
<input
|
|
155
|
+
type="range"
|
|
156
|
+
min="-500"
|
|
157
|
+
max="-50"
|
|
158
|
+
value={config.chargeStrength}
|
|
159
|
+
onChange={(e) =>
|
|
160
|
+
onChange({ chargeStrength: Number(e.target.value) })
|
|
161
|
+
}
|
|
162
|
+
style={{ width: "100%" }}
|
|
163
|
+
/>
|
|
164
|
+
</div>
|
|
165
|
+
|
|
166
|
+
<div style={{ marginBottom: 8 }}>
|
|
167
|
+
<label style={{ display: "block", marginBottom: 2 }}>
|
|
168
|
+
Link Distance: {config.linkDistance}
|
|
169
|
+
</label>
|
|
170
|
+
<input
|
|
171
|
+
type="range"
|
|
172
|
+
min="30"
|
|
173
|
+
max="200"
|
|
174
|
+
value={config.linkDistance}
|
|
175
|
+
onChange={(e) =>
|
|
176
|
+
onChange({ linkDistance: Number(e.target.value) })
|
|
177
|
+
}
|
|
178
|
+
style={{ width: "100%" }}
|
|
179
|
+
/>
|
|
180
|
+
</div>
|
|
181
|
+
|
|
182
|
+
<div style={{ marginBottom: 8 }}>
|
|
183
|
+
<label style={{ display: "block", marginBottom: 2 }}>
|
|
184
|
+
Collision: {config.collisionRadius}
|
|
185
|
+
</label>
|
|
186
|
+
<input
|
|
187
|
+
type="range"
|
|
188
|
+
min="10"
|
|
189
|
+
max="80"
|
|
190
|
+
value={config.collisionRadius}
|
|
191
|
+
onChange={(e) =>
|
|
192
|
+
onChange({ collisionRadius: Number(e.target.value) })
|
|
193
|
+
}
|
|
194
|
+
style={{ width: "100%" }}
|
|
195
|
+
/>
|
|
196
|
+
</div>
|
|
197
|
+
|
|
198
|
+
<button
|
|
199
|
+
onClick={onRestart}
|
|
200
|
+
style={{
|
|
201
|
+
width: "100%",
|
|
202
|
+
padding: "6px 12px",
|
|
203
|
+
border: "none",
|
|
204
|
+
borderRadius: 4,
|
|
205
|
+
background: "#3b82f6",
|
|
206
|
+
color: "white",
|
|
207
|
+
cursor: "pointer",
|
|
208
|
+
fontSize: 12,
|
|
209
|
+
}}
|
|
210
|
+
>
|
|
211
|
+
Restart Simulation
|
|
212
|
+
</button>
|
|
213
|
+
</div>
|
|
214
|
+
);
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Inner component that uses the force layout hook.
|
|
219
|
+
* Must be wrapped in ReactFlowProvider.
|
|
220
|
+
*/
|
|
221
|
+
const ObservablesGraphInner: React.FC<
|
|
222
|
+
ObservablesGraphProps & {
|
|
223
|
+
initialNodes: Node[];
|
|
224
|
+
initialEdges: Edge[];
|
|
225
|
+
primaryRootId?: string;
|
|
226
|
+
}
|
|
227
|
+
> = ({
|
|
228
|
+
initialNodes,
|
|
229
|
+
initialEdges,
|
|
230
|
+
primaryRootId,
|
|
231
|
+
height,
|
|
232
|
+
width,
|
|
233
|
+
forceConfig: initialForceConfig = {},
|
|
234
|
+
onNodeClick,
|
|
235
|
+
onNodeDoubleClick,
|
|
236
|
+
className,
|
|
237
|
+
showControls = true,
|
|
238
|
+
}) => {
|
|
239
|
+
// Force config state
|
|
240
|
+
const [forceConfig, setForceConfig] = useState<ForceLayoutConfig>({
|
|
241
|
+
...DEFAULT_FORCE_CONFIG,
|
|
242
|
+
...initialForceConfig,
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
// React Flow state - initialized with initial nodes/edges
|
|
246
|
+
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
|
|
247
|
+
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
|
|
248
|
+
|
|
249
|
+
// Set initial nodes/edges when they change
|
|
250
|
+
React.useEffect(() => {
|
|
251
|
+
setNodes(initialNodes);
|
|
252
|
+
setEdges(initialEdges);
|
|
253
|
+
}, [initialNodes, initialEdges, setNodes, setEdges]);
|
|
254
|
+
|
|
255
|
+
// Use the iterative force layout hook
|
|
256
|
+
const {
|
|
257
|
+
onNodeDragStart,
|
|
258
|
+
onNodeDrag,
|
|
259
|
+
onNodeDragStop,
|
|
260
|
+
updateForceConfig,
|
|
261
|
+
restartSimulation,
|
|
262
|
+
} = useForceLayout(forceConfig, primaryRootId);
|
|
263
|
+
|
|
264
|
+
// Handle node click
|
|
265
|
+
const handleNodeClick = useCallback(
|
|
266
|
+
(_: React.MouseEvent, node: Node) => {
|
|
267
|
+
onNodeClick?.(node.id);
|
|
268
|
+
},
|
|
269
|
+
[onNodeClick]
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
// Handle node double click
|
|
273
|
+
const handleNodeDoubleClick = useCallback(
|
|
274
|
+
(_: React.MouseEvent, node: Node) => {
|
|
275
|
+
onNodeDoubleClick?.(node.id);
|
|
276
|
+
},
|
|
277
|
+
[onNodeDoubleClick]
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
// Handle force config update
|
|
281
|
+
const handleConfigChange = useCallback(
|
|
282
|
+
(updates: Partial<ForceLayoutConfig>) => {
|
|
283
|
+
setForceConfig((prev) => ({ ...prev, ...updates }));
|
|
284
|
+
updateForceConfig(updates);
|
|
285
|
+
},
|
|
286
|
+
[updateForceConfig]
|
|
287
|
+
);
|
|
288
|
+
|
|
289
|
+
// MiniMap node color based on level
|
|
290
|
+
const miniMapNodeColor = useCallback((node: Node) => {
|
|
291
|
+
const data = node.data as unknown as ObservableNodeData;
|
|
292
|
+
return getLevelColor(data.level);
|
|
293
|
+
}, []);
|
|
294
|
+
|
|
295
|
+
return (
|
|
296
|
+
<div
|
|
297
|
+
className={className}
|
|
298
|
+
style={{
|
|
299
|
+
width,
|
|
300
|
+
height,
|
|
301
|
+
position: "relative",
|
|
302
|
+
}}
|
|
303
|
+
>
|
|
304
|
+
<ReactFlow
|
|
305
|
+
nodes={nodes}
|
|
306
|
+
edges={edges}
|
|
307
|
+
onNodesChange={onNodesChange}
|
|
308
|
+
onEdgesChange={onEdgesChange}
|
|
309
|
+
onNodeClick={handleNodeClick}
|
|
310
|
+
onNodeDoubleClick={handleNodeDoubleClick}
|
|
311
|
+
onNodeDragStart={onNodeDragStart}
|
|
312
|
+
onNodeDrag={onNodeDrag}
|
|
313
|
+
onNodeDragStop={onNodeDragStop}
|
|
314
|
+
nodeTypes={nodeTypes}
|
|
315
|
+
edgeTypes={edgeTypes}
|
|
316
|
+
connectionMode={ConnectionMode.Loose}
|
|
317
|
+
fitView
|
|
318
|
+
fitViewOptions={{ padding: 0.3 }}
|
|
319
|
+
minZoom={0.1}
|
|
320
|
+
maxZoom={2}
|
|
321
|
+
proOptions={{ hideAttribution: true }}
|
|
322
|
+
>
|
|
323
|
+
<Background />
|
|
324
|
+
<Controls />
|
|
325
|
+
<MiniMap nodeColor={miniMapNodeColor} zoomable pannable />
|
|
326
|
+
</ReactFlow>
|
|
327
|
+
|
|
328
|
+
{showControls && (
|
|
329
|
+
<ForceControls
|
|
330
|
+
config={forceConfig}
|
|
331
|
+
onChange={handleConfigChange}
|
|
332
|
+
onRestart={restartSimulation}
|
|
333
|
+
/>
|
|
334
|
+
)}
|
|
335
|
+
</div>
|
|
336
|
+
);
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* ObservablesGraph component.
|
|
341
|
+
* Displays all observables from an investigation as a force-directed graph.
|
|
342
|
+
* Wraps the inner component with ReactFlowProvider for hook access.
|
|
343
|
+
*/
|
|
344
|
+
export const ObservablesGraph: React.FC<ObservablesGraphProps> = (props) => {
|
|
345
|
+
const { investigation } = props;
|
|
346
|
+
|
|
347
|
+
// Find root observables
|
|
348
|
+
const rootObservables = useMemo(() => {
|
|
349
|
+
const roots = findRootObservables(investigation);
|
|
350
|
+
return new Set(roots.map((r) => r.key));
|
|
351
|
+
}, [investigation]);
|
|
352
|
+
|
|
353
|
+
// Get first root for centering
|
|
354
|
+
const primaryRootId = useMemo(() => {
|
|
355
|
+
const roots = findRootObservables(investigation);
|
|
356
|
+
return roots.length > 0 ? roots[0].key : undefined;
|
|
357
|
+
}, [investigation]);
|
|
358
|
+
|
|
359
|
+
// Create initial nodes and edges
|
|
360
|
+
const { initialNodes, initialEdges } = useMemo(() => {
|
|
361
|
+
const nodes = createObservableNodes(investigation, rootObservables);
|
|
362
|
+
const edges = createObservableEdges(investigation);
|
|
363
|
+
return { initialNodes: nodes, initialEdges: edges };
|
|
364
|
+
}, [investigation, rootObservables]);
|
|
365
|
+
|
|
366
|
+
return (
|
|
367
|
+
<ReactFlowProvider>
|
|
368
|
+
<ObservablesGraphInner
|
|
369
|
+
{...props}
|
|
370
|
+
initialNodes={initialNodes}
|
|
371
|
+
initialEdges={initialEdges}
|
|
372
|
+
primaryRootId={primaryRootId}
|
|
373
|
+
/>
|
|
374
|
+
</ReactFlowProvider>
|
|
375
|
+
);
|
|
376
|
+
};
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hook for computing Dagre layout (hierarchical).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { useMemo } from "react";
|
|
6
|
+
import Dagre from "@dagrejs/dagre";
|
|
7
|
+
import type { Node, Edge } from "@xyflow/react";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Dagre layout options.
|
|
11
|
+
*/
|
|
12
|
+
export interface DagreLayoutOptions {
|
|
13
|
+
/** Direction of the layout: TB (top-bottom), BT, LR (left-right), RL */
|
|
14
|
+
direction?: "TB" | "BT" | "LR" | "RL";
|
|
15
|
+
/** Horizontal spacing between nodes */
|
|
16
|
+
nodeSpacing?: number;
|
|
17
|
+
/** Vertical spacing between ranks */
|
|
18
|
+
rankSpacing?: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const DEFAULT_OPTIONS: Required<DagreLayoutOptions> = {
|
|
22
|
+
direction: "LR", // Horizontal layout by default
|
|
23
|
+
nodeSpacing: 50,
|
|
24
|
+
rankSpacing: 100,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Apply Dagre layout to nodes and edges.
|
|
29
|
+
*/
|
|
30
|
+
export function computeDagreLayout(
|
|
31
|
+
nodes: Node[],
|
|
32
|
+
edges: Edge[],
|
|
33
|
+
options: DagreLayoutOptions = {}
|
|
34
|
+
): { nodes: Node[]; edges: Edge[] } {
|
|
35
|
+
if (nodes.length === 0) {
|
|
36
|
+
return { nodes, edges };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const opts = { ...DEFAULT_OPTIONS, ...options };
|
|
40
|
+
|
|
41
|
+
// Create dagre graph
|
|
42
|
+
const g = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({}));
|
|
43
|
+
|
|
44
|
+
g.setGraph({
|
|
45
|
+
rankdir: opts.direction,
|
|
46
|
+
nodesep: opts.nodeSpacing,
|
|
47
|
+
ranksep: opts.rankSpacing,
|
|
48
|
+
marginx: 20,
|
|
49
|
+
marginy: 20,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Add nodes to graph
|
|
53
|
+
for (const node of nodes) {
|
|
54
|
+
// Estimate node dimensions
|
|
55
|
+
const width = node.measured?.width ?? 150;
|
|
56
|
+
const height = node.measured?.height ?? 50;
|
|
57
|
+
g.setNode(node.id, { width, height });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Add edges to graph
|
|
61
|
+
for (const edge of edges) {
|
|
62
|
+
g.setEdge(edge.source, edge.target);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Compute layout
|
|
66
|
+
Dagre.layout(g);
|
|
67
|
+
|
|
68
|
+
// Update node positions
|
|
69
|
+
const positionedNodes = nodes.map((node) => {
|
|
70
|
+
const dagNode = g.node(node.id);
|
|
71
|
+
// Dagre returns center positions, adjust for React Flow (top-left)
|
|
72
|
+
const width = node.measured?.width ?? 150;
|
|
73
|
+
const height = node.measured?.height ?? 50;
|
|
74
|
+
return {
|
|
75
|
+
...node,
|
|
76
|
+
position: {
|
|
77
|
+
x: dagNode.x - width / 2,
|
|
78
|
+
y: dagNode.y - height / 2,
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
return { nodes: positionedNodes, edges };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Hook for Dagre layout computation.
|
|
88
|
+
*/
|
|
89
|
+
export function useDagreLayout(
|
|
90
|
+
initialNodes: Node[],
|
|
91
|
+
initialEdges: Edge[],
|
|
92
|
+
options: DagreLayoutOptions = {}
|
|
93
|
+
): {
|
|
94
|
+
nodes: Node[];
|
|
95
|
+
edges: Edge[];
|
|
96
|
+
} {
|
|
97
|
+
const { nodes, edges } = useMemo(() => {
|
|
98
|
+
return computeDagreLayout(initialNodes, initialEdges, options);
|
|
99
|
+
}, [initialNodes, initialEdges, options]);
|
|
100
|
+
|
|
101
|
+
return { nodes, edges };
|
|
102
|
+
}
|