@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.
@@ -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
+ }