@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,383 @@
1
+ /**
2
+ * Hook for computing force-directed layout using d3-force.
3
+ * Uses an iterative simulation that updates on each tick.
4
+ */
5
+
6
+ import { useEffect, useRef, useCallback, useMemo } from "react";
7
+ import {
8
+ forceSimulation,
9
+ forceLink,
10
+ forceManyBody,
11
+ forceCenter,
12
+ forceCollide,
13
+ forceX,
14
+ forceY,
15
+ type Simulation,
16
+ type SimulationNodeDatum,
17
+ type SimulationLinkDatum,
18
+ } from "d3-force";
19
+ import {
20
+ useReactFlow,
21
+ useNodesInitialized,
22
+ useStore,
23
+ type Node,
24
+ type Edge,
25
+ } from "@xyflow/react";
26
+ import type { ForceLayoutConfig } from "../types";
27
+ import { DEFAULT_FORCE_CONFIG } from "../types";
28
+
29
+ /**
30
+ * D3 simulation node with position.
31
+ */
32
+ interface SimNode extends SimulationNodeDatum {
33
+ id: string;
34
+ x: number;
35
+ y: number;
36
+ fx?: number | null;
37
+ fy?: number | null;
38
+ }
39
+
40
+ /**
41
+ * D3 simulation link.
42
+ */
43
+ interface SimLink extends SimulationLinkDatum<SimNode> {
44
+ source: string | SimNode;
45
+ target: string | SimNode;
46
+ }
47
+
48
+ /**
49
+ * Selector to check if nodes are initialized
50
+ */
51
+ const nodeCountSelector = (state: { nodeLookup: Map<string, Node> }) =>
52
+ state.nodeLookup.size;
53
+
54
+ /**
55
+ * Hook that applies iterative force-directed layout to React Flow nodes.
56
+ * The simulation runs continuously and updates node positions on each tick.
57
+ */
58
+ export function useForceLayout(
59
+ config: Partial<ForceLayoutConfig> = {},
60
+ rootNodeId?: string
61
+ ) {
62
+ const { getNodes, getEdges, setNodes } = useReactFlow();
63
+ const nodesInitialized = useNodesInitialized();
64
+ const nodeCount = useStore(nodeCountSelector);
65
+
66
+ // Merge config with defaults
67
+ const forceConfig = useMemo(
68
+ () => ({ ...DEFAULT_FORCE_CONFIG, ...config }),
69
+ [config]
70
+ );
71
+
72
+ // Store the simulation reference
73
+ const simulationRef = useRef<Simulation<SimNode, SimLink> | null>(null);
74
+
75
+ // Store dragging state
76
+ const draggingNodeRef = useRef<string | null>(null);
77
+
78
+ // Initialize and run the simulation
79
+ useEffect(() => {
80
+ if (!nodesInitialized || nodeCount === 0) {
81
+ return;
82
+ }
83
+
84
+ const nodes = getNodes();
85
+ const edges = getEdges();
86
+
87
+ // Create simulation nodes from React Flow nodes
88
+ const simNodes: SimNode[] = nodes.map((node) => {
89
+ // Check if this node already exists in the simulation
90
+ const existingNode = simulationRef.current?.nodes().find((n) => n.id === node.id);
91
+
92
+ return {
93
+ id: node.id,
94
+ // Use existing simulation position or node position
95
+ x: existingNode?.x ?? node.position.x ?? Math.random() * 500 - 250,
96
+ y: existingNode?.y ?? node.position.y ?? Math.random() * 500 - 250,
97
+ // Preserve fixed positions for dragged nodes
98
+ fx: existingNode?.fx ?? null,
99
+ fy: existingNode?.fy ?? null,
100
+ };
101
+ });
102
+
103
+ // Fix root node at center
104
+ if (rootNodeId) {
105
+ const rootNode = simNodes.find((n) => n.id === rootNodeId);
106
+ if (rootNode) {
107
+ rootNode.x = 0;
108
+ rootNode.y = 0;
109
+ rootNode.fx = 0;
110
+ rootNode.fy = 0;
111
+ }
112
+ }
113
+
114
+ // Create simulation links from React Flow edges
115
+ const simLinks: SimLink[] = edges.map((edge) => ({
116
+ source: edge.source,
117
+ target: edge.target,
118
+ }));
119
+
120
+ // Stop existing simulation
121
+ if (simulationRef.current) {
122
+ simulationRef.current.stop();
123
+ }
124
+
125
+ // Create the force simulation
126
+ const simulation = forceSimulation<SimNode>(simNodes)
127
+ .force(
128
+ "link",
129
+ forceLink<SimNode, SimLink>(simLinks)
130
+ .id((d) => d.id)
131
+ .distance(forceConfig.linkDistance)
132
+ .strength(0.5)
133
+ )
134
+ .force(
135
+ "charge",
136
+ forceManyBody<SimNode>().strength(forceConfig.chargeStrength)
137
+ )
138
+ .force(
139
+ "center",
140
+ forceCenter(0, 0).strength(forceConfig.centerStrength)
141
+ )
142
+ .force(
143
+ "collision",
144
+ forceCollide<SimNode>(forceConfig.collisionRadius)
145
+ )
146
+ .force(
147
+ "x",
148
+ forceX<SimNode>(0).strength(0.01)
149
+ )
150
+ .force(
151
+ "y",
152
+ forceY<SimNode>(0).strength(0.01)
153
+ )
154
+ .alphaDecay(0.02)
155
+ .velocityDecay(0.4);
156
+
157
+ // Update React Flow nodes on each tick
158
+ simulation.on("tick", () => {
159
+ setNodes((currentNodes) =>
160
+ currentNodes.map((node) => {
161
+ const simNode = simulation.nodes().find((n) => n.id === node.id);
162
+ if (!simNode) return node;
163
+
164
+ return {
165
+ ...node,
166
+ position: {
167
+ x: simNode.x,
168
+ y: simNode.y,
169
+ },
170
+ };
171
+ })
172
+ );
173
+ });
174
+
175
+ simulationRef.current = simulation;
176
+
177
+ // Cleanup: stop simulation when unmounting or dependencies change
178
+ return () => {
179
+ simulation.stop();
180
+ };
181
+ }, [
182
+ nodesInitialized,
183
+ nodeCount,
184
+ getNodes,
185
+ getEdges,
186
+ setNodes,
187
+ forceConfig,
188
+ rootNodeId,
189
+ ]);
190
+
191
+ /**
192
+ * Handle drag start - fix the node position and reheat simulation
193
+ */
194
+ const onNodeDragStart = useCallback(
195
+ (_: React.MouseEvent, node: Node) => {
196
+ const simulation = simulationRef.current;
197
+ if (!simulation) return;
198
+
199
+ draggingNodeRef.current = node.id;
200
+
201
+ // Reheat the simulation
202
+ simulation.alphaTarget(0.3).restart();
203
+
204
+ // Fix the node position
205
+ const simNode = simulation.nodes().find((n) => n.id === node.id);
206
+ if (simNode) {
207
+ simNode.fx = simNode.x;
208
+ simNode.fy = simNode.y;
209
+ }
210
+ },
211
+ []
212
+ );
213
+
214
+ /**
215
+ * Handle drag - update the fixed position
216
+ */
217
+ const onNodeDrag = useCallback(
218
+ (_: React.MouseEvent, node: Node) => {
219
+ const simulation = simulationRef.current;
220
+ if (!simulation) return;
221
+
222
+ const simNode = simulation.nodes().find((n) => n.id === node.id);
223
+ if (simNode) {
224
+ simNode.fx = node.position.x;
225
+ simNode.fy = node.position.y;
226
+ }
227
+ },
228
+ []
229
+ );
230
+
231
+ /**
232
+ * Handle drag end - unfix the node and let simulation cool down
233
+ */
234
+ const onNodeDragStop = useCallback(
235
+ (_: React.MouseEvent, node: Node) => {
236
+ const simulation = simulationRef.current;
237
+ if (!simulation) return;
238
+
239
+ draggingNodeRef.current = null;
240
+
241
+ // Let simulation cool down
242
+ simulation.alphaTarget(0);
243
+
244
+ // Unfix the node (unless it's the root)
245
+ if (node.id !== rootNodeId) {
246
+ const simNode = simulation.nodes().find((n) => n.id === node.id);
247
+ if (simNode) {
248
+ simNode.fx = null;
249
+ simNode.fy = null;
250
+ }
251
+ }
252
+ },
253
+ [rootNodeId]
254
+ );
255
+
256
+ /**
257
+ * Update force configuration dynamically
258
+ */
259
+ const updateForceConfig = useCallback(
260
+ (updates: Partial<ForceLayoutConfig>) => {
261
+ const simulation = simulationRef.current;
262
+ if (!simulation) return;
263
+
264
+ if (updates.chargeStrength !== undefined) {
265
+ simulation.force(
266
+ "charge",
267
+ forceManyBody<SimNode>().strength(updates.chargeStrength)
268
+ );
269
+ }
270
+
271
+ if (updates.linkDistance !== undefined) {
272
+ const linkForce = simulation.force("link") as ReturnType<typeof forceLink<SimNode, SimLink>> | undefined;
273
+ if (linkForce) {
274
+ linkForce.distance(updates.linkDistance);
275
+ }
276
+ }
277
+
278
+ if (updates.collisionRadius !== undefined) {
279
+ simulation.force(
280
+ "collision",
281
+ forceCollide<SimNode>(updates.collisionRadius)
282
+ );
283
+ }
284
+
285
+ // Reheat simulation to apply changes
286
+ simulation.alpha(0.5).restart();
287
+ },
288
+ []
289
+ );
290
+
291
+ /**
292
+ * Manually restart the simulation
293
+ */
294
+ const restartSimulation = useCallback(() => {
295
+ const simulation = simulationRef.current;
296
+ if (!simulation) return;
297
+
298
+ simulation.alpha(1).restart();
299
+ }, []);
300
+
301
+ return {
302
+ onNodeDragStart,
303
+ onNodeDrag,
304
+ onNodeDragStop,
305
+ updateForceConfig,
306
+ restartSimulation,
307
+ };
308
+ }
309
+
310
+ /**
311
+ * One-time force layout computation (for static layouts).
312
+ * Use this when you don't need continuous simulation.
313
+ */
314
+ export function computeForceLayout(
315
+ nodes: Node[],
316
+ edges: Edge[],
317
+ config: ForceLayoutConfig,
318
+ centerX: number = 0,
319
+ centerY: number = 0,
320
+ rootNodeId?: string
321
+ ): { nodes: Node[]; edges: Edge[] } {
322
+ if (nodes.length === 0) {
323
+ return { nodes, edges };
324
+ }
325
+
326
+ // Create simulation nodes
327
+ const simNodes: SimNode[] = nodes.map((node) => ({
328
+ id: node.id,
329
+ x: node.position.x || Math.random() * 400 - 200,
330
+ y: node.position.y || Math.random() * 400 - 200,
331
+ fx: null,
332
+ fy: null,
333
+ }));
334
+
335
+ // Find root node and fix it at center
336
+ if (rootNodeId) {
337
+ const rootNode = simNodes.find((n) => n.id === rootNodeId);
338
+ if (rootNode) {
339
+ rootNode.x = centerX;
340
+ rootNode.y = centerY;
341
+ rootNode.fx = centerX;
342
+ rootNode.fy = centerY;
343
+ }
344
+ }
345
+
346
+ // Create simulation links
347
+ const simLinks: SimLink[] = edges.map((edge) => ({
348
+ source: edge.source,
349
+ target: edge.target,
350
+ }));
351
+
352
+ // Create and run simulation
353
+ const simulation = forceSimulation<SimNode>(simNodes)
354
+ .force(
355
+ "link",
356
+ forceLink<SimNode, SimLink>(simLinks)
357
+ .id((d) => d.id)
358
+ .distance(config.linkDistance)
359
+ )
360
+ .force("charge", forceManyBody().strength(config.chargeStrength))
361
+ .force("center", forceCenter(centerX, centerY).strength(config.centerStrength))
362
+ .force("collision", forceCollide(config.collisionRadius))
363
+ .stop();
364
+
365
+ // Run simulation ticks
366
+ for (let i = 0; i < config.iterations; i++) {
367
+ simulation.tick();
368
+ }
369
+
370
+ // Create positioned nodes
371
+ const positionedNodes = nodes.map((node) => {
372
+ const simNode = simNodes.find((n) => n.id === node.id);
373
+ return {
374
+ ...node,
375
+ position: {
376
+ x: simNode?.x ?? node.position.x,
377
+ y: simNode?.y ?? node.position.y,
378
+ },
379
+ };
380
+ });
381
+
382
+ return { nodes: positionedNodes, edges };
383
+ }
package/src/index.ts ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Cyvest Visualization Library
3
+ *
4
+ * React components for visualizing Cyvest investigations using React Flow.
5
+ *
6
+ * @packageDocumentation
7
+ */
8
+
9
+ // Main component export
10
+ export { CyvestGraph } from "./components/CyvestGraph";
11
+
12
+ // Re-export types for consumers
13
+ export type { CyvestGraphProps, ForceLayoutConfig } from "./types";
package/src/types.ts ADDED
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Type definitions for cyvest-vis visualization library.
3
+ */
4
+
5
+ import type { Node, Edge } from "@xyflow/react";
6
+ import type { Level } from "@cyvest/cyvest-js";
7
+
8
+ // ============================================================================
9
+ // Observable Graph Types
10
+ // ============================================================================
11
+
12
+ /**
13
+ * Shape types for observable nodes.
14
+ */
15
+ export type ObservableShape = "square" | "circle" | "rectangle" | "triangle";
16
+
17
+ /**
18
+ * Data attached to observable graph nodes.
19
+ */
20
+ export interface ObservableNodeData extends Record<string, unknown> {
21
+ /** Display label (may be truncated) */
22
+ label: string;
23
+ /** Full observable value */
24
+ fullValue: string;
25
+ /** Observable type (e.g., "domain-name", "ipv4-addr") */
26
+ observableType: string;
27
+ /** Security level */
28
+ level: Level;
29
+ /** Numeric score */
30
+ score: number;
31
+ /** Emoji representing the observable type */
32
+ emoji: string;
33
+ /** Shape for this node type */
34
+ shape: ObservableShape;
35
+ /** Whether this is the root observable */
36
+ isRoot: boolean;
37
+ /** Whether the observable is whitelisted */
38
+ whitelisted: boolean;
39
+ /** Whether the observable is internal */
40
+ internal: boolean;
41
+ }
42
+
43
+ /**
44
+ * Observable graph node type.
45
+ */
46
+ export type ObservableNode = Node<ObservableNodeData, "observable">;
47
+
48
+ /**
49
+ * Data attached to observable graph edges.
50
+ */
51
+ export interface ObservableEdgeData extends Record<string, unknown> {
52
+ /** Relationship type (e.g., "related-to") */
53
+ relationshipType: string;
54
+ /** Whether this is a bidirectional relationship */
55
+ bidirectional: boolean;
56
+ }
57
+
58
+ /**
59
+ * Observable graph edge type.
60
+ */
61
+ export type ObservableEdge = Edge<ObservableEdgeData>;
62
+
63
+ // ============================================================================
64
+ // Investigation Graph Types (Dagre Layout)
65
+ // ============================================================================
66
+
67
+ /**
68
+ * Node types for the investigation graph view.
69
+ */
70
+ export type InvestigationNodeType = "root" | "check" | "container";
71
+
72
+ /**
73
+ * Data attached to investigation graph nodes.
74
+ */
75
+ export interface InvestigationNodeData extends Record<string, unknown> {
76
+ /** Display label */
77
+ label: string;
78
+ /** Node type (root, check, or container) */
79
+ nodeType: InvestigationNodeType;
80
+ /** Security level */
81
+ level: Level;
82
+ /** Numeric score */
83
+ score: number;
84
+ /** Description (for checks) */
85
+ description?: string;
86
+ /** Path (for containers) */
87
+ path?: string;
88
+ /** Emoji for the node */
89
+ emoji: string;
90
+ }
91
+
92
+ /**
93
+ * Investigation graph node type.
94
+ */
95
+ export type InvestigationNode = Node<InvestigationNodeData, "investigation">;
96
+
97
+ /**
98
+ * Investigation graph edge type.
99
+ */
100
+ export type InvestigationEdge = Edge;
101
+
102
+ // ============================================================================
103
+ // Force Layout Configuration
104
+ // ============================================================================
105
+
106
+ /**
107
+ * Configuration options for d3-force layout.
108
+ */
109
+ export interface ForceLayoutConfig {
110
+ /** Strength of the charge force (repulsion). Default: -300 */
111
+ chargeStrength: number;
112
+ /** Target distance between linked nodes. Default: 100 */
113
+ linkDistance: number;
114
+ /** Strength of the centering force. Default: 0.1 */
115
+ centerStrength: number;
116
+ /** Strength of the collision force. Default: 30 */
117
+ collisionRadius: number;
118
+ /** Number of simulation iterations. Default: 300 */
119
+ iterations: number;
120
+ }
121
+
122
+ /**
123
+ * Default force layout configuration.
124
+ */
125
+ export const DEFAULT_FORCE_CONFIG: ForceLayoutConfig = {
126
+ chargeStrength: -200,
127
+ linkDistance: 80,
128
+ centerStrength: 0.05,
129
+ collisionRadius: 40,
130
+ iterations: 300,
131
+ };
132
+
133
+ // ============================================================================
134
+ // Component Props
135
+ // ============================================================================
136
+
137
+ /**
138
+ * Props for the ObservablesGraph component.
139
+ */
140
+ export interface ObservablesGraphProps {
141
+ /** The Cyvest investigation to visualize */
142
+ investigation: import("@cyvest/cyvest-js").CyvestInvestigation;
143
+ /** Height of the graph container */
144
+ height?: number | string;
145
+ /** Width of the graph container */
146
+ width?: number | string;
147
+ /** Force layout configuration */
148
+ forceConfig?: Partial<ForceLayoutConfig>;
149
+ /** Callback when a node is clicked */
150
+ onNodeClick?: (nodeId: string) => void;
151
+ /** Callback when a node is double-clicked */
152
+ onNodeDoubleClick?: (nodeId: string) => void;
153
+ /** Custom class name for the container */
154
+ className?: string;
155
+ /** Whether to show the force controls panel */
156
+ showControls?: boolean;
157
+ }
158
+
159
+ /**
160
+ * Props for the InvestigationGraph component.
161
+ */
162
+ export interface InvestigationGraphProps {
163
+ /** The Cyvest investigation to visualize */
164
+ investigation: import("@cyvest/cyvest-js").CyvestInvestigation;
165
+ /** Height of the graph container */
166
+ height?: number | string;
167
+ /** Width of the graph container */
168
+ width?: number | string;
169
+ /** Callback when a node is clicked */
170
+ onNodeClick?: (nodeId: string, nodeType: InvestigationNodeType) => void;
171
+ /** Custom class name for the container */
172
+ className?: string;
173
+ }
174
+
175
+ /**
176
+ * Props for the CyvestGraph component (combined view).
177
+ */
178
+ export interface CyvestGraphProps {
179
+ /** The Cyvest investigation to visualize */
180
+ investigation: import("@cyvest/cyvest-js").CyvestInvestigation;
181
+ /** Height of the graph container */
182
+ height?: number | string;
183
+ /** Width of the graph container */
184
+ width?: number | string;
185
+ /** Initial view to display */
186
+ initialView?: "observables" | "investigation";
187
+ /** Callback when a node is clicked */
188
+ onNodeClick?: (nodeId: string) => void;
189
+ /** Custom class name for the container */
190
+ className?: string;
191
+ /** Whether to show view toggle */
192
+ showViewToggle?: boolean;
193
+ }