@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.
@@ -14,6 +14,8 @@ 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
 
@@ -25,11 +27,7 @@ import type {
25
27
  InvestigationNodeType,
26
28
  } from "../types";
27
29
  import { InvestigationNode } from "./InvestigationNode";
28
- import {
29
- getInvestigationNodeEmoji,
30
- getLevelColor,
31
- truncateLabel,
32
- } from "../utils/observables";
30
+ import { getLevelColor, truncateLabel } from "../utils/observables";
33
31
  import { computeDagreLayout } from "../hooks/useDagreLayout";
34
32
 
35
33
  /**
@@ -39,6 +37,23 @@ const nodeTypes: NodeTypes = {
39
37
  investigation: InvestigationNode,
40
38
  };
41
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
+
42
57
  /**
43
58
  * Flatten containers recursively to get all container keys.
44
59
  */
@@ -89,7 +104,6 @@ function createInvestigationGraph(
89
104
  nodeType: "root",
90
105
  level: rootLevel,
91
106
  score: primaryRoot?.score ?? investigation.score,
92
- emoji: getInvestigationNodeEmoji("root"),
93
107
  };
94
108
 
95
109
  nodes.push({
@@ -97,8 +111,20 @@ function createInvestigationGraph(
97
111
  type: "investigation",
98
112
  position: { x: 0, y: 0 },
99
113
  data: rootNodeData,
114
+ selectable: true,
115
+ draggable: true,
100
116
  });
101
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
+
102
128
  // Add check nodes
103
129
  // Group checks by scope for better organization
104
130
  const allChecks: Check[] = [];
@@ -118,7 +144,6 @@ function createInvestigationGraph(
118
144
  level: check.level,
119
145
  score: check.score,
120
146
  description: truncateLabel(check.description, 30),
121
- emoji: getInvestigationNodeEmoji("check"),
122
147
  };
123
148
 
124
149
  nodes.push({
@@ -126,28 +151,34 @@ function createInvestigationGraph(
126
151
  type: "investigation",
127
152
  position: { x: 0, y: 0 },
128
153
  data: checkNodeData,
154
+ selectable: true,
155
+ draggable: true,
129
156
  });
130
157
 
131
- // Edge from root to check
132
- edges.push({
133
- id: `edge-root-${check.key}`,
134
- source: rootKey,
135
- target: `check-${check.key}`,
136
- type: "default",
137
- });
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
+ }
138
169
  }
139
170
 
140
171
  // Add container nodes
141
- const allContainers = flattenContainers(investigation.containers);
142
-
143
172
  for (const container of allContainers) {
144
173
  const containerNodeData: InvestigationNodeData = {
145
- label: truncateLabel(container.path.split("/").pop() ?? container.path, 20),
174
+ label: truncateLabel(
175
+ container.path.split("/").pop() ?? container.path,
176
+ 20
177
+ ),
146
178
  nodeType: "container",
147
179
  level: container.aggregated_level,
148
180
  score: container.aggregated_score,
149
181
  path: container.path,
150
- emoji: getInvestigationNodeEmoji("container"),
151
182
  };
152
183
 
153
184
  nodes.push({
@@ -155,6 +186,8 @@ function createInvestigationGraph(
155
186
  type: "investigation",
156
187
  position: { x: 0, y: 0 },
157
188
  data: containerNodeData,
189
+ selectable: true,
190
+ draggable: true,
158
191
  });
159
192
 
160
193
  // Edge from root to container
@@ -162,7 +195,8 @@ function createInvestigationGraph(
162
195
  id: `edge-root-container-${container.key}`,
163
196
  source: rootKey,
164
197
  target: `container-${container.key}`,
165
- type: "default",
198
+ type: "smoothstep",
199
+ animated: false,
166
200
  });
167
201
 
168
202
  // Edges from container to its checks
@@ -172,8 +206,8 @@ function createInvestigationGraph(
172
206
  id: `edge-container-check-${container.key}-${checkKey}`,
173
207
  source: `container-${container.key}`,
174
208
  target: `check-${checkKey}`,
175
- type: "default",
176
- style: { strokeDasharray: "5,5" },
209
+ type: "smoothstep",
210
+ animated: false,
177
211
  });
178
212
  }
179
213
  }
@@ -203,8 +237,8 @@ export const InvestigationGraph: React.FC<InvestigationGraphProps> = ({
203
237
  const { nodes: layoutNodes, edges: layoutEdges } = useMemo(() => {
204
238
  return computeDagreLayout(initialNodes, initialEdges, {
205
239
  direction: "LR",
206
- nodeSpacing: 30,
207
- rankSpacing: 120,
240
+ nodeSpacing: 40,
241
+ rankSpacing: 140,
208
242
  });
209
243
  }, [initialNodes, initialEdges]);
210
244
 
@@ -233,15 +267,19 @@ export const InvestigationGraph: React.FC<InvestigationGraphProps> = ({
233
267
  return getLevelColor(data.level);
234
268
  }, []);
235
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
+
236
281
  return (
237
- <div
238
- className={className}
239
- style={{
240
- width,
241
- height,
242
- position: "relative",
243
- }}
244
- >
282
+ <div className={className} style={containerStyle}>
245
283
  <ReactFlow
246
284
  nodes={nodes}
247
285
  edges={edges}
@@ -249,15 +287,48 @@ export const InvestigationGraph: React.FC<InvestigationGraphProps> = ({
249
287
  onEdgesChange={onEdgesChange}
250
288
  onNodeClick={handleNodeClick}
251
289
  nodeTypes={nodeTypes}
290
+ defaultEdgeOptions={defaultEdgeOptions}
252
291
  fitView
253
- fitViewOptions={{ padding: 0.2 }}
292
+ fitViewOptions={{ padding: 0.3, maxZoom: 1.5 }}
254
293
  minZoom={0.1}
255
- maxZoom={2}
294
+ maxZoom={2.5}
256
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}
257
305
  >
258
- <Background />
259
- <Controls />
260
- <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
+ />
261
332
  </ReactFlow>
262
333
  </div>
263
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
  }