@cyvest/cyvest-vis 4.0.0 → 4.1.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.
@@ -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, 18),
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
- emoji: getObservableEmoji(graphNode.type),
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 : 150;
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
- 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>
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
- <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
- />
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={{ 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
- />
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.3 }}
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
- <Controls />
325
- <MiniMap nodeColor={miniMapNodeColor} zoomable pannable />
326
- </ReactFlow>
327
-
328
- {showControls && (
329
- <ForceControls
330
- config={forceConfig}
331
- onChange={handleConfigChange}
332
- onRestart={restartSimulation}
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
  };