@cyvest/cyvest-vis 3.2.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.
@@ -14,11 +14,12 @@ import {
14
14
  type Node,
15
15
  type Edge,
16
16
  type NodeTypes,
17
+ BackgroundVariant,
18
+ MarkerType,
17
19
  } from "@xyflow/react";
18
20
  import "@xyflow/react/dist/style.css";
19
21
 
20
22
  import type { CyvestInvestigation, Check, Container } from "@cyvest/cyvest-js";
21
- import { findRootObservables } from "@cyvest/cyvest-js";
22
23
 
23
24
  import type {
24
25
  InvestigationGraphProps,
@@ -26,11 +27,7 @@ import type {
26
27
  InvestigationNodeType,
27
28
  } from "../types";
28
29
  import { InvestigationNode } from "./InvestigationNode";
29
- import {
30
- getInvestigationNodeEmoji,
31
- getLevelColor,
32
- truncateLabel,
33
- } from "../utils/observables";
30
+ import { getLevelColor, truncateLabel } from "../utils/observables";
34
31
  import { computeDagreLayout } from "../hooks/useDagreLayout";
35
32
 
36
33
  /**
@@ -40,6 +37,23 @@ const nodeTypes: NodeTypes = {
40
37
  investigation: InvestigationNode,
41
38
  };
42
39
 
40
+ /**
41
+ * Default edge style
42
+ */
43
+ const defaultEdgeOptions = {
44
+ type: "smoothstep",
45
+ style: {
46
+ stroke: "#94a3b8",
47
+ strokeWidth: 1.5,
48
+ },
49
+ markerEnd: {
50
+ type: MarkerType.ArrowClosed,
51
+ width: 16,
52
+ height: 16,
53
+ color: "#94a3b8",
54
+ },
55
+ };
56
+
43
57
  /**
44
58
  * Flatten containers recursively to get all container keys.
45
59
  */
@@ -67,13 +81,21 @@ function createInvestigationGraph(
67
81
  const nodes: Node<InvestigationNodeData>[] = [];
68
82
  const edges: Edge[] = [];
69
83
 
70
- // Find root observable(s)
71
- const roots = findRootObservables(investigation);
72
- const primaryRoot = roots.length > 0 ? roots[0] : null;
84
+ const rootType = investigation.data_extraction.root_type;
85
+ const normalizedRootType = rootType?.toLowerCase().trim();
86
+ const rootsByType = normalizedRootType
87
+ ? Object.values(investigation.observables).filter(
88
+ (obs) => obs.type.toLowerCase() === normalizedRootType
89
+ )
90
+ : [];
91
+ const primaryRoot = rootsByType[0] ?? null;
73
92
 
74
93
  // If no root found, use the first observable or create a placeholder
75
- const rootKey = primaryRoot?.key ?? "investigation-root";
76
- const rootValue = primaryRoot?.value ?? "Investigation";
94
+ const rootKey = primaryRoot?.key ?? investigation.investigation_id;
95
+ const rootValue =
96
+ primaryRoot?.value ??
97
+ investigation.investigation_name ??
98
+ investigation.investigation_id;
77
99
  const rootLevel = primaryRoot?.level ?? investigation.level;
78
100
 
79
101
  // Create root node
@@ -82,7 +104,6 @@ function createInvestigationGraph(
82
104
  nodeType: "root",
83
105
  level: rootLevel,
84
106
  score: primaryRoot?.score ?? investigation.score,
85
- emoji: getInvestigationNodeEmoji("root"),
86
107
  };
87
108
 
88
109
  nodes.push({
@@ -90,8 +111,20 @@ function createInvestigationGraph(
90
111
  type: "investigation",
91
112
  position: { x: 0, y: 0 },
92
113
  data: rootNodeData,
114
+ selectable: true,
115
+ draggable: true,
93
116
  });
94
117
 
118
+ // Collect all check keys that belong to containers
119
+ // These checks should NOT have a direct link to the root node
120
+ const allContainers = flattenContainers(investigation.containers);
121
+ const checksInContainers = new Set<string>();
122
+ for (const container of allContainers) {
123
+ for (const checkKey of container.checks) {
124
+ checksInContainers.add(checkKey);
125
+ }
126
+ }
127
+
95
128
  // Add check nodes
96
129
  // Group checks by scope for better organization
97
130
  const allChecks: Check[] = [];
@@ -111,7 +144,6 @@ function createInvestigationGraph(
111
144
  level: check.level,
112
145
  score: check.score,
113
146
  description: truncateLabel(check.description, 30),
114
- emoji: getInvestigationNodeEmoji("check"),
115
147
  };
116
148
 
117
149
  nodes.push({
@@ -119,28 +151,34 @@ function createInvestigationGraph(
119
151
  type: "investigation",
120
152
  position: { x: 0, y: 0 },
121
153
  data: checkNodeData,
154
+ selectable: true,
155
+ draggable: true,
122
156
  });
123
157
 
124
- // Edge from root to check
125
- edges.push({
126
- id: `edge-root-${check.key}`,
127
- source: rootKey,
128
- target: `check-${check.key}`,
129
- type: "default",
130
- });
158
+ // Only create edge from root to check if check is NOT in a container
159
+ // Checks in containers will be linked through their container instead
160
+ if (!checksInContainers.has(check.key)) {
161
+ edges.push({
162
+ id: `edge-root-${check.key}`,
163
+ source: rootKey,
164
+ target: `check-${check.key}`,
165
+ type: "smoothstep",
166
+ animated: false,
167
+ });
168
+ }
131
169
  }
132
170
 
133
171
  // Add container nodes
134
- const allContainers = flattenContainers(investigation.containers);
135
-
136
172
  for (const container of allContainers) {
137
173
  const containerNodeData: InvestigationNodeData = {
138
- label: truncateLabel(container.path.split("/").pop() ?? container.path, 20),
174
+ label: truncateLabel(
175
+ container.path.split("/").pop() ?? container.path,
176
+ 20
177
+ ),
139
178
  nodeType: "container",
140
179
  level: container.aggregated_level,
141
180
  score: container.aggregated_score,
142
181
  path: container.path,
143
- emoji: getInvestigationNodeEmoji("container"),
144
182
  };
145
183
 
146
184
  nodes.push({
@@ -148,6 +186,8 @@ function createInvestigationGraph(
148
186
  type: "investigation",
149
187
  position: { x: 0, y: 0 },
150
188
  data: containerNodeData,
189
+ selectable: true,
190
+ draggable: true,
151
191
  });
152
192
 
153
193
  // Edge from root to container
@@ -155,7 +195,8 @@ function createInvestigationGraph(
155
195
  id: `edge-root-container-${container.key}`,
156
196
  source: rootKey,
157
197
  target: `container-${container.key}`,
158
- type: "default",
198
+ type: "smoothstep",
199
+ animated: false,
159
200
  });
160
201
 
161
202
  // Edges from container to its checks
@@ -165,8 +206,8 @@ function createInvestigationGraph(
165
206
  id: `edge-container-check-${container.key}-${checkKey}`,
166
207
  source: `container-${container.key}`,
167
208
  target: `check-${checkKey}`,
168
- type: "default",
169
- style: { strokeDasharray: "5,5" },
209
+ type: "smoothstep",
210
+ animated: false,
170
211
  });
171
212
  }
172
213
  }
@@ -196,8 +237,8 @@ export const InvestigationGraph: React.FC<InvestigationGraphProps> = ({
196
237
  const { nodes: layoutNodes, edges: layoutEdges } = useMemo(() => {
197
238
  return computeDagreLayout(initialNodes, initialEdges, {
198
239
  direction: "LR",
199
- nodeSpacing: 30,
200
- rankSpacing: 120,
240
+ nodeSpacing: 40,
241
+ rankSpacing: 140,
201
242
  });
202
243
  }, [initialNodes, initialEdges]);
203
244
 
@@ -226,15 +267,19 @@ export const InvestigationGraph: React.FC<InvestigationGraphProps> = ({
226
267
  return getLevelColor(data.level);
227
268
  }, []);
228
269
 
270
+ // Container styles
271
+ const containerStyle = useMemo(
272
+ () => ({
273
+ width,
274
+ height,
275
+ position: "relative" as const,
276
+ background: "linear-gradient(180deg, #fafbfc 0%, #f0f4f8 100%)",
277
+ }),
278
+ [width, height]
279
+ );
280
+
229
281
  return (
230
- <div
231
- className={className}
232
- style={{
233
- width,
234
- height,
235
- position: "relative",
236
- }}
237
- >
282
+ <div className={className} style={containerStyle}>
238
283
  <ReactFlow
239
284
  nodes={nodes}
240
285
  edges={edges}
@@ -242,15 +287,48 @@ export const InvestigationGraph: React.FC<InvestigationGraphProps> = ({
242
287
  onEdgesChange={onEdgesChange}
243
288
  onNodeClick={handleNodeClick}
244
289
  nodeTypes={nodeTypes}
290
+ defaultEdgeOptions={defaultEdgeOptions}
245
291
  fitView
246
- fitViewOptions={{ padding: 0.2 }}
292
+ fitViewOptions={{ padding: 0.3, maxZoom: 1.5 }}
247
293
  minZoom={0.1}
248
- maxZoom={2}
294
+ maxZoom={2.5}
249
295
  proOptions={{ hideAttribution: true }}
296
+ // UX settings
297
+ nodesDraggable={true}
298
+ nodesConnectable={false}
299
+ elementsSelectable={true}
300
+ selectNodesOnDrag={false}
301
+ panOnDrag={true}
302
+ zoomOnScroll={true}
303
+ zoomOnPinch={true}
304
+ panOnScroll={false}
250
305
  >
251
- <Background />
252
- <Controls />
253
- <MiniMap nodeColor={miniMapNodeColor} zoomable pannable />
306
+ <Background
307
+ variant={BackgroundVariant.Dots}
308
+ gap={24}
309
+ size={1}
310
+ color="#d1d5db"
311
+ />
312
+ <Controls
313
+ showInteractive={false}
314
+ style={{
315
+ borderRadius: 10,
316
+ boxShadow: "0 2px 12px rgba(0,0,0,0.1)",
317
+ border: "1px solid rgba(0,0,0,0.06)",
318
+ }}
319
+ />
320
+ <MiniMap
321
+ nodeColor={miniMapNodeColor}
322
+ zoomable
323
+ pannable
324
+ style={{
325
+ borderRadius: 10,
326
+ boxShadow: "0 2px 12px rgba(0,0,0,0.1)",
327
+ border: "1px solid rgba(0,0,0,0.06)",
328
+ background: "rgba(255,255,255,0.9)",
329
+ }}
330
+ maskColor="rgba(0,0,0,0.08)"
331
+ />
254
332
  </ReactFlow>
255
333
  </div>
256
334
  );
@@ -1,143 +1,160 @@
1
1
  /**
2
2
  * Custom node component for the Investigation Graph (Dagre layout).
3
- * Renders root, check, and container nodes.
3
+ * Professional design with SVG icons for root, check, and container nodes.
4
4
  */
5
5
 
6
- import React, { memo } from "react";
6
+ import React, { memo, useMemo } from "react";
7
7
  import { Handle, Position, type NodeProps } from "@xyflow/react";
8
8
  import type { InvestigationNodeData } from "../types";
9
9
  import { getLevelColor, getLevelBackgroundColor } from "../utils/observables";
10
+ import { getInvestigationIcon } from "./Icons";
10
11
 
11
12
  /**
12
- * Investigation node component.
13
+ * Node style configuration by type
13
14
  */
14
- function InvestigationNodeComponent({
15
- data,
16
- selected,
17
- }: NodeProps) {
15
+ const NODE_CONFIG = {
16
+ root: {
17
+ minWidth: 140,
18
+ padding: "10px 18px",
19
+ borderRadius: 20,
20
+ fontWeight: 600 as const,
21
+ fontSize: 13,
22
+ iconSize: 18,
23
+ showIcon: true,
24
+ alignCenter: true,
25
+ },
26
+ check: {
27
+ minWidth: 140,
28
+ padding: "8px 14px",
29
+ borderRadius: 8,
30
+ fontWeight: 500 as const,
31
+ fontSize: 12,
32
+ iconSize: 14,
33
+ showIcon: false, // No icon for checks
34
+ alignCenter: false, // Left-aligned
35
+ },
36
+ container: {
37
+ minWidth: 120,
38
+ padding: "8px 14px",
39
+ borderRadius: 16,
40
+ fontWeight: 500 as const,
41
+ fontSize: 12,
42
+ iconSize: 16,
43
+ showIcon: true,
44
+ alignCenter: true,
45
+ },
46
+ } as const;
47
+
48
+ /**
49
+ * Investigation node component with professional design.
50
+ */
51
+ function InvestigationNodeComponent({ data, selected }: NodeProps) {
18
52
  const nodeData = data as unknown as InvestigationNodeData;
19
- const {
20
- label,
21
- emoji,
22
- nodeType,
23
- level,
24
- description,
25
- } = nodeData;
53
+ const { label, nodeType, level, description } = nodeData;
26
54
 
27
55
  const borderColor = getLevelColor(level);
28
56
  const backgroundColor = getLevelBackgroundColor(level);
57
+ const config = NODE_CONFIG[nodeType] || NODE_CONFIG.check;
29
58
 
30
- // Different styles based on node type
31
- const getNodeStyle = () => {
32
- switch (nodeType) {
33
- case "root":
34
- return {
35
- minWidth: 120,
36
- padding: "8px 16px",
37
- borderRadius: 8,
38
- fontWeight: 600 as const,
39
- };
40
- case "check":
41
- return {
42
- minWidth: 100,
43
- padding: "6px 12px",
44
- borderRadius: 4,
45
- fontWeight: 400 as const,
46
- };
47
- case "container":
48
- return {
49
- minWidth: 100,
50
- padding: "6px 12px",
51
- borderRadius: 12,
52
- fontWeight: 400 as const,
53
- };
54
- default:
55
- return {
56
- minWidth: 80,
57
- padding: "6px 12px",
58
- borderRadius: 4,
59
- fontWeight: 400 as const,
60
- };
61
- }
62
- };
59
+ // Get the appropriate icon component
60
+ const IconComponent = useMemo(
61
+ () => getInvestigationIcon(nodeType),
62
+ [nodeType]
63
+ );
64
+
65
+ // Memoize node style
66
+ const nodeStyle = useMemo(
67
+ () => ({
68
+ minWidth: config.minWidth,
69
+ padding: config.padding,
70
+ borderRadius: config.borderRadius,
71
+ display: "flex",
72
+ flexDirection: "column" as const,
73
+ alignItems: config.alignCenter ? "center" : "flex-start",
74
+ backgroundColor,
75
+ border: `2px solid ${borderColor}`,
76
+ boxShadow: selected
77
+ ? `0 0 0 3px ${borderColor}40, 0 4px 12px rgba(0,0,0,0.15)`
78
+ : "0 2px 8px rgba(0,0,0,0.08)",
79
+ cursor: "pointer",
80
+ fontFamily:
81
+ "'SF Pro Text', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
82
+ transition: "box-shadow 0.15s ease-out, transform 0.1s ease-out",
83
+ }),
84
+ [config, backgroundColor, borderColor, selected]
85
+ );
63
86
 
64
- const style = getNodeStyle();
87
+ const headerStyle = useMemo(
88
+ () => ({
89
+ display: "flex",
90
+ alignItems: "center",
91
+ gap: 8,
92
+ width: config.alignCenter ? "auto" : "100%",
93
+ }),
94
+ [config.alignCenter]
95
+ );
96
+
97
+ const labelStyle = useMemo(
98
+ () => ({
99
+ fontSize: config.fontSize,
100
+ fontWeight: config.fontWeight,
101
+ maxWidth: 180,
102
+ overflow: "hidden",
103
+ textOverflow: "ellipsis",
104
+ whiteSpace: "nowrap" as const,
105
+ color: "#1f2937",
106
+ letterSpacing: "-0.01em",
107
+ }),
108
+ [config]
109
+ );
110
+
111
+ const descriptionStyle = useMemo(
112
+ () => ({
113
+ marginTop: 4,
114
+ fontSize: 10,
115
+ color: "#6b7280",
116
+ maxWidth: 170,
117
+ overflow: "hidden",
118
+ textOverflow: "ellipsis",
119
+ whiteSpace: "nowrap" as const,
120
+ lineHeight: 1.3,
121
+ width: "100%",
122
+ textAlign: config.alignCenter ? ("center" as const) : ("left" as const),
123
+ }),
124
+ [config.alignCenter]
125
+ );
126
+
127
+ // Hidden handle style - edges connect but no visible dots
128
+ const handleStyle: React.CSSProperties = {
129
+ width: 1,
130
+ height: 1,
131
+ background: "transparent",
132
+ border: "none",
133
+ opacity: 0,
134
+ };
65
135
 
66
136
  return (
67
- <div
68
- className="investigation-node"
69
- style={{
70
- ...style,
71
- display: "flex",
72
- flexDirection: "column",
73
- alignItems: "center",
74
- backgroundColor,
75
- border: `${selected ? 3 : 2}px solid ${borderColor}`,
76
- cursor: "pointer",
77
- fontFamily: "system-ui, sans-serif",
78
- }}
79
- >
80
- {/* Header with emoji and label */}
81
- <div
82
- style={{
83
- display: "flex",
84
- alignItems: "center",
85
- gap: 6,
86
- }}
87
- >
88
- <span style={{ fontSize: 14 }}>{emoji}</span>
89
- <span
90
- style={{
91
- fontSize: 12,
92
- fontWeight: style.fontWeight,
93
- maxWidth: 150,
94
- overflow: "hidden",
95
- textOverflow: "ellipsis",
96
- whiteSpace: "nowrap",
97
- }}
98
- title={label}
99
- >
137
+ <div className="investigation-node" style={nodeStyle}>
138
+ {/* Header with optional icon and label */}
139
+ <div style={headerStyle}>
140
+ {config.showIcon && (
141
+ <IconComponent size={config.iconSize} color={borderColor} />
142
+ )}
143
+ <span style={labelStyle} title={label}>
100
144
  {label}
101
145
  </span>
102
146
  </div>
103
147
 
104
148
  {/* Description for checks */}
105
149
  {description && (
106
- <div
107
- style={{
108
- marginTop: 4,
109
- fontSize: 10,
110
- color: "#6b7280",
111
- maxWidth: 140,
112
- overflow: "hidden",
113
- textOverflow: "ellipsis",
114
- whiteSpace: "nowrap",
115
- }}
116
- title={description}
117
- >
150
+ <div style={descriptionStyle} title={description}>
118
151
  {description}
119
152
  </div>
120
153
  )}
121
154
 
122
- {/* Handles for edges */}
123
- <Handle
124
- type="target"
125
- position={Position.Left}
126
- style={{
127
- width: 8,
128
- height: 8,
129
- background: borderColor,
130
- }}
131
- />
132
- <Handle
133
- type="source"
134
- position={Position.Right}
135
- style={{
136
- width: 8,
137
- height: 8,
138
- background: borderColor,
139
- }}
140
- />
155
+ {/* Hidden handles for edges - edges still connect but no visible dots */}
156
+ <Handle type="target" position={Position.Left} style={handleStyle} />
157
+ <Handle type="source" position={Position.Right} style={handleStyle} />
141
158
  </div>
142
159
  );
143
160
  }