@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.
- 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 +730 -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
|
@@ -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
|
-
//
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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(
|
|
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: "
|
|
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: "
|
|
176
|
-
|
|
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:
|
|
207
|
-
rankSpacing:
|
|
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.
|
|
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
|
-
|
|
260
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
13
|
+
* Node style configuration by type
|
|
13
14
|
*/
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
//
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
|
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
|
-
|
|
69
|
-
style={
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
{/*
|
|
123
|
-
<Handle
|
|
124
|
-
|
|
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
|
}
|