@cyvest/cyvest-vis 4.0.0 → 4.2.0
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 +122 -10
- package/dist/index.d.mts +156 -6
- package/dist/index.d.ts +156 -6
- package/dist/index.js +1445 -650
- package/dist/index.mjs +1457 -665
- package/package.json +2 -2
- package/src/components/CyvestGraph.tsx +115 -46
- package/src/components/FloatingEdge.tsx +41 -11
- package/src/components/Icons.tsx +729 -0
- package/src/components/InvestigationGraph.tsx +107 -36
- package/src/components/InvestigationNode.tsx +129 -112
- package/src/components/ObservableNode.tsx +116 -135
- package/src/components/ObservablesGraph.tsx +241 -111
- package/src/hooks/useForceLayout.ts +136 -62
- package/src/index.ts +25 -2
- package/src/types.ts +9 -11
- package/src/utils/observables.ts +9 -97
- package/tests/observables.test.ts +10 -17
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* Uses iterative d3-force simulation for smooth, interactive layout.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import React, { useMemo, useCallback, useState } from "react";
|
|
6
|
+
import React, { useMemo, useCallback, useState, useRef } from "react";
|
|
7
7
|
import {
|
|
8
8
|
ReactFlow,
|
|
9
9
|
ReactFlowProvider,
|
|
@@ -17,6 +17,8 @@ import {
|
|
|
17
17
|
type NodeTypes,
|
|
18
18
|
type EdgeTypes,
|
|
19
19
|
ConnectionMode,
|
|
20
|
+
BackgroundVariant,
|
|
21
|
+
Panel,
|
|
20
22
|
} from "@xyflow/react";
|
|
21
23
|
import "@xyflow/react/dist/style.css";
|
|
22
24
|
|
|
@@ -32,12 +34,7 @@ import type {
|
|
|
32
34
|
import { DEFAULT_FORCE_CONFIG } from "../types";
|
|
33
35
|
import { ObservableNode } from "./ObservableNode";
|
|
34
36
|
import { FloatingEdge } from "./FloatingEdge";
|
|
35
|
-
import {
|
|
36
|
-
getObservableEmoji,
|
|
37
|
-
getObservableShape,
|
|
38
|
-
truncateLabel,
|
|
39
|
-
getLevelColor,
|
|
40
|
-
} from "../utils/observables";
|
|
37
|
+
import { truncateLabel, getLevelColor } from "../utils/observables";
|
|
41
38
|
import { useForceLayout } from "../hooks/useForceLayout";
|
|
42
39
|
|
|
43
40
|
/**
|
|
@@ -54,6 +51,14 @@ const edgeTypes: EdgeTypes = {
|
|
|
54
51
|
floating: FloatingEdge,
|
|
55
52
|
};
|
|
56
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Default edge style options
|
|
56
|
+
*/
|
|
57
|
+
const defaultEdgeOptions = {
|
|
58
|
+
type: "floating",
|
|
59
|
+
style: { stroke: "#94a3b8", strokeWidth: 1.5 },
|
|
60
|
+
};
|
|
61
|
+
|
|
57
62
|
/**
|
|
58
63
|
* Convert investigation observables to React Flow nodes.
|
|
59
64
|
*/
|
|
@@ -65,16 +70,14 @@ function createObservableNodes(
|
|
|
65
70
|
|
|
66
71
|
return graph.nodes.map((graphNode, index) => {
|
|
67
72
|
const isRoot = rootObservableIds.has(graphNode.id);
|
|
68
|
-
const shape = getObservableShape(graphNode.type, isRoot);
|
|
69
73
|
|
|
70
74
|
const nodeData: ObservableNodeData = {
|
|
71
|
-
label: truncateLabel(graphNode.value,
|
|
75
|
+
label: truncateLabel(graphNode.value, 16),
|
|
72
76
|
fullValue: graphNode.value,
|
|
73
77
|
observableType: graphNode.type,
|
|
74
78
|
level: graphNode.level,
|
|
75
79
|
score: graphNode.score,
|
|
76
|
-
|
|
77
|
-
shape,
|
|
80
|
+
shape: "circle",
|
|
78
81
|
isRoot,
|
|
79
82
|
whitelisted: graphNode.whitelisted,
|
|
80
83
|
internal: graphNode.internal,
|
|
@@ -82,7 +85,7 @@ function createObservableNodes(
|
|
|
82
85
|
|
|
83
86
|
// Spread initial positions in a circle for better starting layout
|
|
84
87
|
const angle = (index / graph.nodes.length) * 2 * Math.PI;
|
|
85
|
-
const radius = isRoot ? 0 :
|
|
88
|
+
const radius = isRoot ? 0 : 180;
|
|
86
89
|
|
|
87
90
|
return {
|
|
88
91
|
id: graphNode.id,
|
|
@@ -92,6 +95,9 @@ function createObservableNodes(
|
|
|
92
95
|
y: Math.sin(angle) * radius,
|
|
93
96
|
},
|
|
94
97
|
data: nodeData,
|
|
98
|
+
// Enable selection for better UX
|
|
99
|
+
selectable: true,
|
|
100
|
+
draggable: true,
|
|
95
101
|
};
|
|
96
102
|
});
|
|
97
103
|
}
|
|
@@ -116,100 +122,181 @@ function createObservableEdges(
|
|
|
116
122
|
target: graphEdge.target,
|
|
117
123
|
type: "floating",
|
|
118
124
|
data: edgeData,
|
|
125
|
+
// Animated edges for a modern feel
|
|
126
|
+
animated: false,
|
|
119
127
|
style: { stroke: "#94a3b8", strokeWidth: 1.5 },
|
|
120
128
|
};
|
|
121
129
|
});
|
|
122
130
|
}
|
|
123
131
|
|
|
124
132
|
/**
|
|
125
|
-
* Force controls panel component.
|
|
133
|
+
* Force controls panel component with modern styling.
|
|
126
134
|
*/
|
|
127
135
|
const ForceControls: React.FC<{
|
|
128
136
|
config: ForceLayoutConfig;
|
|
129
137
|
onChange: (updates: Partial<ForceLayoutConfig>) => void;
|
|
130
138
|
onRestart: () => void;
|
|
131
139
|
}> = ({ config, onChange, onRestart }) => {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
140
|
+
const [isExpanded, setIsExpanded] = useState(false);
|
|
141
|
+
|
|
142
|
+
const panelStyle: React.CSSProperties = {
|
|
143
|
+
background: "rgba(255, 255, 255, 0.95)",
|
|
144
|
+
backdropFilter: "blur(8px)",
|
|
145
|
+
padding: isExpanded ? 14 : 10,
|
|
146
|
+
borderRadius: 12,
|
|
147
|
+
boxShadow: "0 4px 16px rgba(0,0,0,0.12)",
|
|
148
|
+
fontSize: 12,
|
|
149
|
+
fontFamily:
|
|
150
|
+
"'SF Pro Text', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
|
|
151
|
+
minWidth: isExpanded ? 180 : "auto",
|
|
152
|
+
transition: "all 0.2s ease",
|
|
153
|
+
border: "1px solid rgba(0,0,0,0.06)",
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const headerStyle: React.CSSProperties = {
|
|
157
|
+
display: "flex",
|
|
158
|
+
alignItems: "center",
|
|
159
|
+
justifyContent: "space-between",
|
|
160
|
+
gap: 8,
|
|
161
|
+
cursor: "pointer",
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const titleStyle: React.CSSProperties = {
|
|
165
|
+
fontWeight: 600,
|
|
166
|
+
color: "#1f2937",
|
|
167
|
+
fontSize: 12,
|
|
168
|
+
letterSpacing: "-0.01em",
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const toggleStyle: React.CSSProperties = {
|
|
172
|
+
background: "none",
|
|
173
|
+
border: "none",
|
|
174
|
+
cursor: "pointer",
|
|
175
|
+
padding: 4,
|
|
176
|
+
borderRadius: 4,
|
|
177
|
+
color: "#6b7280",
|
|
178
|
+
display: "flex",
|
|
179
|
+
alignItems: "center",
|
|
180
|
+
transition: "transform 0.2s ease",
|
|
181
|
+
transform: isExpanded ? "rotate(180deg)" : "rotate(0deg)",
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const sliderContainerStyle: React.CSSProperties = {
|
|
185
|
+
marginTop: 12,
|
|
186
|
+
display: isExpanded ? "block" : "none",
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const sliderLabelStyle: React.CSSProperties = {
|
|
190
|
+
display: "flex",
|
|
191
|
+
justifyContent: "space-between",
|
|
192
|
+
marginBottom: 4,
|
|
193
|
+
color: "#4b5563",
|
|
194
|
+
fontSize: 11,
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const sliderStyle: React.CSSProperties = {
|
|
198
|
+
width: "100%",
|
|
199
|
+
height: 4,
|
|
200
|
+
appearance: "none",
|
|
201
|
+
background: "#e5e7eb",
|
|
202
|
+
borderRadius: 2,
|
|
203
|
+
outline: "none",
|
|
204
|
+
cursor: "pointer",
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const buttonStyle: React.CSSProperties = {
|
|
208
|
+
width: "100%",
|
|
209
|
+
padding: "8px 12px",
|
|
210
|
+
border: "none",
|
|
211
|
+
borderRadius: 8,
|
|
212
|
+
background: "linear-gradient(135deg, #3b82f6 0%, #2563eb 100%)",
|
|
213
|
+
color: "white",
|
|
214
|
+
cursor: "pointer",
|
|
215
|
+
fontSize: 12,
|
|
216
|
+
fontWeight: 500,
|
|
217
|
+
marginTop: 12,
|
|
218
|
+
transition: "transform 0.1s ease, box-shadow 0.1s ease",
|
|
219
|
+
boxShadow: "0 2px 4px rgba(59, 130, 246, 0.3)",
|
|
220
|
+
};
|
|
165
221
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
</
|
|
170
|
-
<
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
onChange={(e) =>
|
|
176
|
-
onChange({ linkDistance: Number(e.target.value) })
|
|
177
|
-
}
|
|
178
|
-
style={{ width: "100%" }}
|
|
179
|
-
/>
|
|
222
|
+
return (
|
|
223
|
+
<div style={panelStyle}>
|
|
224
|
+
<div style={headerStyle} onClick={() => setIsExpanded(!isExpanded)}>
|
|
225
|
+
<span style={titleStyle}>⚡ Force Layout</span>
|
|
226
|
+
<button style={toggleStyle}>
|
|
227
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
|
228
|
+
<polyline points="6 9 12 15 18 9" />
|
|
229
|
+
</svg>
|
|
230
|
+
</button>
|
|
180
231
|
</div>
|
|
181
232
|
|
|
182
|
-
<div style={
|
|
183
|
-
<
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
233
|
+
<div style={sliderContainerStyle}>
|
|
234
|
+
<div style={{ marginBottom: 10 }}>
|
|
235
|
+
<div style={sliderLabelStyle}>
|
|
236
|
+
<span>Repulsion</span>
|
|
237
|
+
<span>{config.chargeStrength}</span>
|
|
238
|
+
</div>
|
|
239
|
+
<input
|
|
240
|
+
type="range"
|
|
241
|
+
min="-500"
|
|
242
|
+
max="-50"
|
|
243
|
+
value={config.chargeStrength}
|
|
244
|
+
onChange={(e) =>
|
|
245
|
+
onChange({ chargeStrength: Number(e.target.value) })
|
|
246
|
+
}
|
|
247
|
+
style={sliderStyle}
|
|
248
|
+
/>
|
|
249
|
+
</div>
|
|
250
|
+
|
|
251
|
+
<div style={{ marginBottom: 10 }}>
|
|
252
|
+
<div style={sliderLabelStyle}>
|
|
253
|
+
<span>Link Distance</span>
|
|
254
|
+
<span>{config.linkDistance}</span>
|
|
255
|
+
</div>
|
|
256
|
+
<input
|
|
257
|
+
type="range"
|
|
258
|
+
min="30"
|
|
259
|
+
max="200"
|
|
260
|
+
value={config.linkDistance}
|
|
261
|
+
onChange={(e) =>
|
|
262
|
+
onChange({ linkDistance: Number(e.target.value) })
|
|
263
|
+
}
|
|
264
|
+
style={sliderStyle}
|
|
265
|
+
/>
|
|
266
|
+
</div>
|
|
267
|
+
|
|
268
|
+
<div style={{ marginBottom: 6 }}>
|
|
269
|
+
<div style={sliderLabelStyle}>
|
|
270
|
+
<span>Collision</span>
|
|
271
|
+
<span>{config.collisionRadius}</span>
|
|
272
|
+
</div>
|
|
273
|
+
<input
|
|
274
|
+
type="range"
|
|
275
|
+
min="10"
|
|
276
|
+
max="80"
|
|
277
|
+
value={config.collisionRadius}
|
|
278
|
+
onChange={(e) =>
|
|
279
|
+
onChange({ collisionRadius: Number(e.target.value) })
|
|
280
|
+
}
|
|
281
|
+
style={sliderStyle}
|
|
282
|
+
/>
|
|
283
|
+
</div>
|
|
284
|
+
|
|
285
|
+
<button
|
|
286
|
+
onClick={onRestart}
|
|
287
|
+
style={buttonStyle}
|
|
288
|
+
onMouseEnter={(e) => {
|
|
289
|
+
e.currentTarget.style.transform = "translateY(-1px)";
|
|
290
|
+
e.currentTarget.style.boxShadow = "0 4px 8px rgba(59, 130, 246, 0.4)";
|
|
291
|
+
}}
|
|
292
|
+
onMouseLeave={(e) => {
|
|
293
|
+
e.currentTarget.style.transform = "translateY(0)";
|
|
294
|
+
e.currentTarget.style.boxShadow = "0 2px 4px rgba(59, 130, 246, 0.3)";
|
|
295
|
+
}}
|
|
296
|
+
>
|
|
297
|
+
Restart Simulation
|
|
298
|
+
</button>
|
|
196
299
|
</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
300
|
</div>
|
|
214
301
|
);
|
|
215
302
|
};
|
|
@@ -242,6 +329,9 @@ const ObservablesGraphInner: React.FC<
|
|
|
242
329
|
...initialForceConfig,
|
|
243
330
|
});
|
|
244
331
|
|
|
332
|
+
// Track if this is the first render for fitView
|
|
333
|
+
const initialFitDone = useRef(false);
|
|
334
|
+
|
|
245
335
|
// React Flow state - initialized with initial nodes/edges
|
|
246
336
|
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
|
|
247
337
|
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
|
|
@@ -250,6 +340,7 @@ const ObservablesGraphInner: React.FC<
|
|
|
250
340
|
React.useEffect(() => {
|
|
251
341
|
setNodes(initialNodes);
|
|
252
342
|
setEdges(initialEdges);
|
|
343
|
+
initialFitDone.current = false;
|
|
253
344
|
}, [initialNodes, initialEdges, setNodes, setEdges]);
|
|
254
345
|
|
|
255
346
|
// Use the iterative force layout hook
|
|
@@ -292,15 +383,19 @@ const ObservablesGraphInner: React.FC<
|
|
|
292
383
|
return getLevelColor(data.level);
|
|
293
384
|
}, []);
|
|
294
385
|
|
|
386
|
+
// Container styles
|
|
387
|
+
const containerStyle = useMemo(
|
|
388
|
+
() => ({
|
|
389
|
+
width,
|
|
390
|
+
height,
|
|
391
|
+
position: "relative" as const,
|
|
392
|
+
background: "linear-gradient(180deg, #fafbfc 0%, #f0f4f8 100%)",
|
|
393
|
+
}),
|
|
394
|
+
[width, height]
|
|
395
|
+
);
|
|
396
|
+
|
|
295
397
|
return (
|
|
296
|
-
<div
|
|
297
|
-
className={className}
|
|
298
|
-
style={{
|
|
299
|
-
width,
|
|
300
|
-
height,
|
|
301
|
-
position: "relative",
|
|
302
|
-
}}
|
|
303
|
-
>
|
|
398
|
+
<div className={className} style={containerStyle}>
|
|
304
399
|
<ReactFlow
|
|
305
400
|
nodes={nodes}
|
|
306
401
|
edges={edges}
|
|
@@ -313,25 +408,60 @@ const ObservablesGraphInner: React.FC<
|
|
|
313
408
|
onNodeDragStop={onNodeDragStop}
|
|
314
409
|
nodeTypes={nodeTypes}
|
|
315
410
|
edgeTypes={edgeTypes}
|
|
411
|
+
defaultEdgeOptions={defaultEdgeOptions}
|
|
316
412
|
connectionMode={ConnectionMode.Loose}
|
|
317
413
|
fitView
|
|
318
|
-
fitViewOptions={{ padding: 0.
|
|
414
|
+
fitViewOptions={{ padding: 0.4, maxZoom: 1.5 }}
|
|
319
415
|
minZoom={0.1}
|
|
320
|
-
maxZoom={2}
|
|
416
|
+
maxZoom={2.5}
|
|
321
417
|
proOptions={{ hideAttribution: true }}
|
|
418
|
+
// Better defaults for UX
|
|
419
|
+
nodesDraggable={true}
|
|
420
|
+
nodesConnectable={false}
|
|
421
|
+
elementsSelectable={true}
|
|
422
|
+
selectNodesOnDrag={false}
|
|
423
|
+
panOnDrag={true}
|
|
424
|
+
zoomOnScroll={true}
|
|
425
|
+
zoomOnPinch={true}
|
|
426
|
+
panOnScroll={false}
|
|
322
427
|
>
|
|
323
|
-
<Background
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
<
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
428
|
+
<Background
|
|
429
|
+
variant={BackgroundVariant.Dots}
|
|
430
|
+
gap={24}
|
|
431
|
+
size={1}
|
|
432
|
+
color="#d1d5db"
|
|
433
|
+
/>
|
|
434
|
+
<Controls
|
|
435
|
+
showInteractive={false}
|
|
436
|
+
style={{
|
|
437
|
+
borderRadius: 10,
|
|
438
|
+
boxShadow: "0 2px 12px rgba(0,0,0,0.1)",
|
|
439
|
+
border: "1px solid rgba(0,0,0,0.06)",
|
|
440
|
+
}}
|
|
441
|
+
/>
|
|
442
|
+
<MiniMap
|
|
443
|
+
nodeColor={miniMapNodeColor}
|
|
444
|
+
zoomable
|
|
445
|
+
pannable
|
|
446
|
+
style={{
|
|
447
|
+
borderRadius: 10,
|
|
448
|
+
boxShadow: "0 2px 12px rgba(0,0,0,0.1)",
|
|
449
|
+
border: "1px solid rgba(0,0,0,0.06)",
|
|
450
|
+
background: "rgba(255,255,255,0.9)",
|
|
451
|
+
}}
|
|
452
|
+
maskColor="rgba(0,0,0,0.08)"
|
|
333
453
|
/>
|
|
334
|
-
|
|
454
|
+
|
|
455
|
+
{showControls && (
|
|
456
|
+
<Panel position="top-right">
|
|
457
|
+
<ForceControls
|
|
458
|
+
config={forceConfig}
|
|
459
|
+
onChange={handleConfigChange}
|
|
460
|
+
onRestart={restartSimulation}
|
|
461
|
+
/>
|
|
462
|
+
</Panel>
|
|
463
|
+
)}
|
|
464
|
+
</ReactFlow>
|
|
335
465
|
</div>
|
|
336
466
|
);
|
|
337
467
|
};
|