@graph-render/react 1.0.1
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/.eslintrc.json +6 -0
- package/CHANGELOG.md +58 -0
- package/README.md +0 -0
- package/dist/index.js +2509 -0
- package/dist/src/components/EdgePath.d.ts +25 -0
- package/dist/src/components/EdgePath.d.ts.map +1 -0
- package/dist/src/components/Graph.d.ts +6 -0
- package/dist/src/components/Graph.d.ts.map +1 -0
- package/dist/src/components/GraphLabels.d.ts +30 -0
- package/dist/src/components/GraphLabels.d.ts.map +1 -0
- package/dist/src/components/GraphNode.d.ts +35 -0
- package/dist/src/components/GraphNode.d.ts.map +1 -0
- package/dist/src/constants/defaults.d.ts +3 -0
- package/dist/src/constants/defaults.d.ts.map +1 -0
- package/dist/src/hooks/useGraphCollapse.d.ts +15 -0
- package/dist/src/hooks/useGraphCollapse.d.ts.map +1 -0
- package/dist/src/hooks/useGraphHover.d.ts +24 -0
- package/dist/src/hooks/useGraphHover.d.ts.map +1 -0
- package/dist/src/hooks/useGraphModel.d.ts +51 -0
- package/dist/src/hooks/useGraphModel.d.ts.map +1 -0
- package/dist/src/hooks/useGraphSearchState.d.ts +29 -0
- package/dist/src/hooks/useGraphSearchState.d.ts.map +1 -0
- package/dist/src/hooks/useStableConfig.d.ts +25 -0
- package/dist/src/hooks/useStableConfig.d.ts.map +1 -0
- package/dist/src/index.d.ts +7 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/utils/columns.d.ts +7 -0
- package/dist/src/utils/columns.d.ts.map +1 -0
- package/dist/src/utils/pathHighlight.d.ts +29 -0
- package/dist/src/utils/pathHighlight.d.ts.map +1 -0
- package/dist/src/utils/viewport.d.ts +14 -0
- package/dist/src/utils/viewport.d.ts.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/package.json +52 -0
- package/project.json +60 -0
- package/src/components/EdgePath.tsx +103 -0
- package/src/components/Graph.tsx +1545 -0
- package/src/components/GraphLabels.tsx +141 -0
- package/src/components/GraphNode.tsx +214 -0
- package/src/constants/defaults.ts +62 -0
- package/src/hooks/useGraphCollapse.ts +53 -0
- package/src/hooks/useGraphHover.ts +206 -0
- package/src/hooks/useGraphModel.ts +445 -0
- package/src/hooks/useGraphSearchState.ts +215 -0
- package/src/hooks/useStableConfig.ts +108 -0
- package/src/index.ts +57 -0
- package/src/utils/columns.ts +51 -0
- package/src/utils/pathHighlight.ts +160 -0
- package/src/utils/viewport.ts +100 -0
- package/tsconfig.json +12 -0
- package/tsconfig.node.json +11 -0
- package/vite.config.ts +17 -0
|
@@ -0,0 +1,1545 @@
|
|
|
1
|
+
import React, {
|
|
2
|
+
useCallback,
|
|
3
|
+
useEffect,
|
|
4
|
+
useId,
|
|
5
|
+
useImperativeHandle,
|
|
6
|
+
useMemo,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
} from 'react';
|
|
10
|
+
import { DEFAULT_THEME, normalizeGraphConfig } from '@graph-render/core';
|
|
11
|
+
import {
|
|
12
|
+
DragState,
|
|
13
|
+
EdgeData,
|
|
14
|
+
GraphControlsPosition,
|
|
15
|
+
GraphHandle,
|
|
16
|
+
GraphProps,
|
|
17
|
+
GraphRenderContext,
|
|
18
|
+
GraphSelection,
|
|
19
|
+
GraphViewport,
|
|
20
|
+
LayoutDirection,
|
|
21
|
+
LayoutType,
|
|
22
|
+
NodeData,
|
|
23
|
+
NxGraphInput,
|
|
24
|
+
PositionedEdge,
|
|
25
|
+
PositionedNode,
|
|
26
|
+
} from '@graph-render/types';
|
|
27
|
+
import { useGraphHover } from '../hooks/useGraphHover';
|
|
28
|
+
import { useGraphCollapse } from '../hooks/useGraphCollapse';
|
|
29
|
+
import { useGraphModel } from '../hooks/useGraphModel';
|
|
30
|
+
import { useStableConfig } from '../hooks/useStableConfig';
|
|
31
|
+
import { groupPositionedNodesByColumn } from '../utils/columns';
|
|
32
|
+
import {
|
|
33
|
+
GraphBounds,
|
|
34
|
+
centerViewportOnNode,
|
|
35
|
+
clampZoom,
|
|
36
|
+
getFitViewport,
|
|
37
|
+
getGraphBounds,
|
|
38
|
+
} from '../utils/viewport';
|
|
39
|
+
import { EdgePath } from './EdgePath';
|
|
40
|
+
import {
|
|
41
|
+
getEffectiveGraphLabels,
|
|
42
|
+
getLabelPillWidth,
|
|
43
|
+
GraphLabels,
|
|
44
|
+
LABEL_PILL_HEIGHT,
|
|
45
|
+
} from './GraphLabels';
|
|
46
|
+
import { GraphNode } from './GraphNode';
|
|
47
|
+
|
|
48
|
+
const DEFAULT_VIEWPORT: GraphViewport = { x: 0, y: 0, zoom: 1 };
|
|
49
|
+
const DEFAULT_SELECTION: GraphSelection = { nodeIds: [], edgeIds: [] };
|
|
50
|
+
const DEFAULT_MIN_ZOOM = 0.25;
|
|
51
|
+
const DEFAULT_MAX_ZOOM = 2.5;
|
|
52
|
+
const DEFAULT_ZOOM_STEP = 0.12;
|
|
53
|
+
const DEFAULT_SELECTION_COLOR = '#f59e0b';
|
|
54
|
+
const DEFAULT_CONTROLS_POSITION: GraphControlsPosition = 'top-left';
|
|
55
|
+
const CONTROL_BUTTON_SIZE = 26;
|
|
56
|
+
// Width used for the two wider label buttons ('Fit', '1:1').
|
|
57
|
+
const CONTROL_LABEL_BUTTON_WIDTH = 34;
|
|
58
|
+
const CONTROL_BUTTON_GAP = 8;
|
|
59
|
+
const EDGE_LABEL_WIDTH = 48;
|
|
60
|
+
const EDGE_LABEL_HEIGHT = 20;
|
|
61
|
+
const FIT_BOUNDS_MARGIN = 8;
|
|
62
|
+
|
|
63
|
+
type PointerState = { x: number; y: number };
|
|
64
|
+
type PinchState = {
|
|
65
|
+
active: boolean;
|
|
66
|
+
startDistance: number;
|
|
67
|
+
startZoom: number;
|
|
68
|
+
worldX: number;
|
|
69
|
+
worldY: number;
|
|
70
|
+
};
|
|
71
|
+
type SelectionBox = {
|
|
72
|
+
startX: number;
|
|
73
|
+
startY: number;
|
|
74
|
+
endX: number;
|
|
75
|
+
endY: number;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const normalizeViewport = (
|
|
79
|
+
viewport: GraphViewport,
|
|
80
|
+
minZoom: number,
|
|
81
|
+
maxZoom: number
|
|
82
|
+
): GraphViewport => ({
|
|
83
|
+
x: Number.isFinite(viewport.x) ? viewport.x : 0,
|
|
84
|
+
y: Number.isFinite(viewport.y) ? viewport.y : 0,
|
|
85
|
+
zoom: clampZoom(Number.isFinite(viewport.zoom) ? viewport.zoom : 1, minZoom, maxZoom),
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const normalizeZoomRange = (
|
|
89
|
+
minZoom: number,
|
|
90
|
+
maxZoom: number
|
|
91
|
+
): { minZoom: number; maxZoom: number } => {
|
|
92
|
+
const safeMinZoom = Number.isFinite(minZoom) && minZoom > 0 ? minZoom : DEFAULT_MIN_ZOOM;
|
|
93
|
+
const safeMaxZoom = Number.isFinite(maxZoom) && maxZoom > 0 ? maxZoom : DEFAULT_MAX_ZOOM;
|
|
94
|
+
|
|
95
|
+
return safeMinZoom <= safeMaxZoom
|
|
96
|
+
? { minZoom: safeMinZoom, maxZoom: safeMaxZoom }
|
|
97
|
+
: { minZoom: safeMaxZoom, maxZoom: safeMinZoom };
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const toError = (error: unknown): Error => {
|
|
101
|
+
return error instanceof Error ? error : new Error(String(error));
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const toggleId = (values: string[], id: string, selectionMode: 'single' | 'multiple'): string[] => {
|
|
105
|
+
if (selectionMode === 'single') {
|
|
106
|
+
return values.length === 1 && values[0] === id ? [] : [id];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return values.includes(id) ? values.filter((value) => value !== id) : [...values, id];
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const getControlPosition = (
|
|
113
|
+
width: number,
|
|
114
|
+
height: number,
|
|
115
|
+
position: GraphControlsPosition
|
|
116
|
+
): { x: number; y: number } => {
|
|
117
|
+
// Total width of the 4 control buttons (2 × icon buttons + 2 × label buttons)
|
|
118
|
+
// plus the 3 gaps between them. Keep in sync with the button widths in
|
|
119
|
+
// viewportControls; if a button is added/removed this must be updated too.
|
|
120
|
+
const controlsWidth =
|
|
121
|
+
2 * CONTROL_BUTTON_SIZE + 2 * CONTROL_LABEL_BUTTON_WIDTH + CONTROL_BUTTON_GAP * 3;
|
|
122
|
+
const inset = 12;
|
|
123
|
+
|
|
124
|
+
switch (position) {
|
|
125
|
+
case 'top-right':
|
|
126
|
+
return { x: width - controlsWidth - inset, y: inset };
|
|
127
|
+
case 'bottom-left':
|
|
128
|
+
return { x: inset, y: height - CONTROL_BUTTON_SIZE - inset };
|
|
129
|
+
case 'bottom-right':
|
|
130
|
+
return {
|
|
131
|
+
x: width - controlsWidth - inset,
|
|
132
|
+
y: height - CONTROL_BUTTON_SIZE - inset,
|
|
133
|
+
};
|
|
134
|
+
case 'top-left':
|
|
135
|
+
default:
|
|
136
|
+
return { x: inset, y: inset };
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const getPointerDistance = (a: PointerState, b: PointerState): number => {
|
|
141
|
+
return Math.hypot(a.x - b.x, a.y - b.y);
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const getPointerMidpoint = (a: PointerState, b: PointerState): PointerState => ({
|
|
145
|
+
x: (a.x + b.x) / 2,
|
|
146
|
+
y: (a.y + b.y) / 2,
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const normalizeRect = (box: SelectionBox) => ({
|
|
150
|
+
x: Math.min(box.startX, box.endX),
|
|
151
|
+
y: Math.min(box.startY, box.endY),
|
|
152
|
+
width: Math.abs(box.endX - box.startX),
|
|
153
|
+
height: Math.abs(box.endY - box.startY),
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const isPointInsideRect = (
|
|
157
|
+
x: number,
|
|
158
|
+
y: number,
|
|
159
|
+
rect: { x: number; y: number; width: number; height: number }
|
|
160
|
+
): boolean => {
|
|
161
|
+
return x >= rect.x && x <= rect.x + rect.width && y >= rect.y && y <= rect.y + rect.height;
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const expandBounds = (bounds: GraphBounds, margin: number): GraphBounds => ({
|
|
165
|
+
minX: bounds.minX - margin,
|
|
166
|
+
minY: bounds.minY - margin,
|
|
167
|
+
maxX: bounds.maxX + margin,
|
|
168
|
+
maxY: bounds.maxY + margin,
|
|
169
|
+
width: bounds.width + margin * 2,
|
|
170
|
+
height: bounds.height + margin * 2,
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const mergeBounds = (base: GraphBounds | null, next: GraphBounds | null): GraphBounds | null => {
|
|
174
|
+
if (!base) {
|
|
175
|
+
return next;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (!next) {
|
|
179
|
+
return base;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const minX = Math.min(base.minX, next.minX);
|
|
183
|
+
const minY = Math.min(base.minY, next.minY);
|
|
184
|
+
const maxX = Math.max(base.maxX, next.maxX);
|
|
185
|
+
const maxY = Math.max(base.maxY, next.maxY);
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
minX,
|
|
189
|
+
minY,
|
|
190
|
+
maxX,
|
|
191
|
+
maxY,
|
|
192
|
+
width: maxX - minX,
|
|
193
|
+
height: maxY - minY,
|
|
194
|
+
};
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const getLabelBounds = (
|
|
198
|
+
nodes: PositionedNode[],
|
|
199
|
+
layout: LayoutType,
|
|
200
|
+
layoutDirection: LayoutDirection,
|
|
201
|
+
labels: string[] | undefined,
|
|
202
|
+
autoLabels: boolean,
|
|
203
|
+
labelOffset: number
|
|
204
|
+
): GraphBounds | null => {
|
|
205
|
+
if (!nodes.length || (!autoLabels && !(labels && labels.length))) {
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const columns = groupPositionedNodesByColumn(nodes);
|
|
210
|
+
if (!columns.length) {
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const orderedColumns =
|
|
215
|
+
layout === LayoutType.Tree && layoutDirection === LayoutDirection.RTL
|
|
216
|
+
? [...columns].reverse()
|
|
217
|
+
: columns;
|
|
218
|
+
const { orderedLabels } = getEffectiveGraphLabels(
|
|
219
|
+
nodes,
|
|
220
|
+
layout,
|
|
221
|
+
layoutDirection,
|
|
222
|
+
labels,
|
|
223
|
+
autoLabels
|
|
224
|
+
);
|
|
225
|
+
// FIX: avoid spreading a potentially large node array into Math.min, which
|
|
226
|
+
// can throw a RangeError when the argument count exceeds the JS engine limit.
|
|
227
|
+
const minY = nodes.reduce(
|
|
228
|
+
(min, node) => Math.min(min, node.position.y),
|
|
229
|
+
Number.POSITIVE_INFINITY
|
|
230
|
+
);
|
|
231
|
+
const topY = minY - labelOffset - LABEL_PILL_HEIGHT + 6;
|
|
232
|
+
|
|
233
|
+
return orderedColumns.reduce<GraphBounds | null>((bounds, column, index) => {
|
|
234
|
+
const labelWidth = getLabelPillWidth(orderedLabels[index] ?? '');
|
|
235
|
+
const labelBounds: GraphBounds = {
|
|
236
|
+
minX: column.centerX - labelWidth / 2,
|
|
237
|
+
minY: topY,
|
|
238
|
+
maxX: column.centerX + labelWidth / 2,
|
|
239
|
+
maxY: topY + LABEL_PILL_HEIGHT,
|
|
240
|
+
width: labelWidth,
|
|
241
|
+
height: LABEL_PILL_HEIGHT,
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
return mergeBounds(bounds, labelBounds);
|
|
245
|
+
}, null);
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
const getEdgeLabelBounds = (edges: PositionedEdge[]): GraphBounds | null => {
|
|
249
|
+
return edges.reduce<GraphBounds | null>((bounds, edge) => {
|
|
250
|
+
if (!edge.labelPosition) {
|
|
251
|
+
return bounds;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const labelBounds: GraphBounds = {
|
|
255
|
+
minX: edge.labelPosition.x - EDGE_LABEL_WIDTH / 2,
|
|
256
|
+
minY: edge.labelPosition.y - EDGE_LABEL_HEIGHT / 2,
|
|
257
|
+
maxX: edge.labelPosition.x + EDGE_LABEL_WIDTH / 2,
|
|
258
|
+
maxY: edge.labelPosition.y + EDGE_LABEL_HEIGHT / 2,
|
|
259
|
+
width: EDGE_LABEL_WIDTH,
|
|
260
|
+
height: EDGE_LABEL_HEIGHT,
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
return mergeBounds(bounds, labelBounds);
|
|
264
|
+
}, null);
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
const getRelativeSvgPoint = (
|
|
268
|
+
svg: SVGSVGElement | null,
|
|
269
|
+
clientX: number,
|
|
270
|
+
clientY: number
|
|
271
|
+
): PointerState => {
|
|
272
|
+
if (!svg) {
|
|
273
|
+
return { x: clientX, y: clientY };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const rect = svg.getBoundingClientRect();
|
|
277
|
+
return {
|
|
278
|
+
x: clientX - rect.left,
|
|
279
|
+
y: clientY - rect.top,
|
|
280
|
+
};
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
const getNearestNodeInDirection = (
|
|
284
|
+
currentNode: PositionedNode,
|
|
285
|
+
nodes: PositionedNode[],
|
|
286
|
+
direction: 'left' | 'right' | 'up' | 'down'
|
|
287
|
+
): PositionedNode | null => {
|
|
288
|
+
const currentCenter = {
|
|
289
|
+
x: currentNode.position.x + (currentNode.size?.width ?? 0) / 2,
|
|
290
|
+
y: currentNode.position.y + (currentNode.size?.height ?? 0) / 2,
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
const candidates = nodes.filter((node) => {
|
|
294
|
+
if (node.id === currentNode.id) {
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const center = {
|
|
299
|
+
x: node.position.x + (node.size?.width ?? 0) / 2,
|
|
300
|
+
y: node.position.y + (node.size?.height ?? 0) / 2,
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
if (direction === 'left') {
|
|
304
|
+
return center.x < currentCenter.x;
|
|
305
|
+
}
|
|
306
|
+
if (direction === 'right') {
|
|
307
|
+
return center.x > currentCenter.x;
|
|
308
|
+
}
|
|
309
|
+
if (direction === 'up') {
|
|
310
|
+
return center.y < currentCenter.y;
|
|
311
|
+
}
|
|
312
|
+
return center.y > currentCenter.y;
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
if (!candidates.length) {
|
|
316
|
+
return null;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Linear scan (O(n)) to find the closest candidate.
|
|
320
|
+
// Sorting the full array is O(n log n) and wasteful since we only need the minimum.
|
|
321
|
+
let nearest = candidates[0];
|
|
322
|
+
let nearestDistance = Math.hypot(
|
|
323
|
+
nearest.position.x + (nearest.size?.width ?? 0) / 2 - currentCenter.x,
|
|
324
|
+
nearest.position.y + (nearest.size?.height ?? 0) / 2 - currentCenter.y
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
for (let i = 1; i < candidates.length; i++) {
|
|
328
|
+
const node = candidates[i];
|
|
329
|
+
const d = Math.hypot(
|
|
330
|
+
node.position.x + (node.size?.width ?? 0) / 2 - currentCenter.x,
|
|
331
|
+
node.position.y + (node.size?.height ?? 0) / 2 - currentCenter.y
|
|
332
|
+
);
|
|
333
|
+
if (d < nearestDistance) {
|
|
334
|
+
nearest = node;
|
|
335
|
+
nearestDistance = d;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return nearest;
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
const GraphInner = (
|
|
343
|
+
{
|
|
344
|
+
graph,
|
|
345
|
+
vertexComponent: Vertex,
|
|
346
|
+
edgeComponent: EdgeComponent = EdgePath,
|
|
347
|
+
config,
|
|
348
|
+
viewport: controlledViewport,
|
|
349
|
+
defaultViewport,
|
|
350
|
+
onViewportChange,
|
|
351
|
+
fitViewOnMount = false,
|
|
352
|
+
fitViewPadding = 32,
|
|
353
|
+
minZoom = DEFAULT_MIN_ZOOM,
|
|
354
|
+
maxZoom = DEFAULT_MAX_ZOOM,
|
|
355
|
+
zoomStep = DEFAULT_ZOOM_STEP,
|
|
356
|
+
panEnabled = true,
|
|
357
|
+
zoomEnabled = true,
|
|
358
|
+
pinchZoomEnabled = true,
|
|
359
|
+
keyboardNavigation = true,
|
|
360
|
+
showControls = false,
|
|
361
|
+
controlsPosition = DEFAULT_CONTROLS_POSITION,
|
|
362
|
+
marqueeSelectionEnabled = true,
|
|
363
|
+
focusedNodeId: controlledFocusedNodeId,
|
|
364
|
+
defaultFocusedNodeId = null,
|
|
365
|
+
onFocusedNodeChange,
|
|
366
|
+
collapsedNodeIds,
|
|
367
|
+
defaultCollapsedNodeIds,
|
|
368
|
+
onCollapsedNodeIdsChange,
|
|
369
|
+
toggleCollapseOnNodeDoubleClick = true,
|
|
370
|
+
hiddenNodeIds,
|
|
371
|
+
onNodeExpand,
|
|
372
|
+
onNodeCollapse,
|
|
373
|
+
searchQuery,
|
|
374
|
+
hideUnmatchedSearch = false,
|
|
375
|
+
searchPredicate,
|
|
376
|
+
highlightedNodeIds,
|
|
377
|
+
highlightedEdgeIds,
|
|
378
|
+
highlightColor = '#f59e0b',
|
|
379
|
+
highlightStrategy,
|
|
380
|
+
onSearchResultsChange,
|
|
381
|
+
selectedNodeIds,
|
|
382
|
+
selectedEdgeIds,
|
|
383
|
+
defaultSelectedNodeIds,
|
|
384
|
+
defaultSelectedEdgeIds,
|
|
385
|
+
onSelectionChange,
|
|
386
|
+
selectionMode = 'single',
|
|
387
|
+
nodeSelectionEnabled = true,
|
|
388
|
+
edgeSelectionEnabled = true,
|
|
389
|
+
selectionColor = DEFAULT_SELECTION_COLOR,
|
|
390
|
+
edgeSelectionColor,
|
|
391
|
+
layoutNodesOverride,
|
|
392
|
+
routeEdgesOverride,
|
|
393
|
+
renderBackground,
|
|
394
|
+
renderOverlay,
|
|
395
|
+
onError,
|
|
396
|
+
onNodeHoverChange,
|
|
397
|
+
onEdgeHoverChange,
|
|
398
|
+
onNodeClick,
|
|
399
|
+
onEdgeClick,
|
|
400
|
+
}: GraphProps<NxGraphInput, PositionedNode, PositionedEdge, NodeData, EdgeData>,
|
|
401
|
+
ref: React.ForwardedRef<GraphHandle>
|
|
402
|
+
) => {
|
|
403
|
+
const zoomRange = useMemo(() => normalizeZoomRange(minZoom, maxZoom), [minZoom, maxZoom]);
|
|
404
|
+
const safeMinZoom = zoomRange.minZoom;
|
|
405
|
+
const safeMaxZoom = zoomRange.maxZoom;
|
|
406
|
+
// Stabilize the config reference so that inline object literals passed by
|
|
407
|
+
// consumers do not cascade a full model recompute on every parent render.
|
|
408
|
+
const stableConfig = useStableConfig(config);
|
|
409
|
+
const markerPrefix = useId().replace(/:/g, '-');
|
|
410
|
+
const [internalViewport, setInternalViewport] = useState<GraphViewport>(() =>
|
|
411
|
+
normalizeViewport({ ...DEFAULT_VIEWPORT, ...(defaultViewport ?? {}) }, safeMinZoom, safeMaxZoom)
|
|
412
|
+
);
|
|
413
|
+
const [internalSelection, setInternalSelection] = useState<GraphSelection>({
|
|
414
|
+
nodeIds: defaultSelectedNodeIds ?? [],
|
|
415
|
+
edgeIds: defaultSelectedEdgeIds ?? [],
|
|
416
|
+
});
|
|
417
|
+
const [internalFocusedNodeId, setInternalFocusedNodeId] = useState<string | null>(
|
|
418
|
+
defaultFocusedNodeId
|
|
419
|
+
);
|
|
420
|
+
const [selectionBox, setSelectionBox] = useState<SelectionBox | null>(null);
|
|
421
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
422
|
+
const dragRef = useRef<DragState>({
|
|
423
|
+
active: false,
|
|
424
|
+
startX: 0,
|
|
425
|
+
startY: 0,
|
|
426
|
+
originX: 0,
|
|
427
|
+
originY: 0,
|
|
428
|
+
});
|
|
429
|
+
const activePointersRef = useRef<Map<number, PointerState>>(new Map());
|
|
430
|
+
const pinchRef = useRef<PinchState>({
|
|
431
|
+
active: false,
|
|
432
|
+
startDistance: 0,
|
|
433
|
+
startZoom: 1,
|
|
434
|
+
worldX: 0,
|
|
435
|
+
worldY: 0,
|
|
436
|
+
});
|
|
437
|
+
const svgRef = useRef<SVGSVGElement>(null);
|
|
438
|
+
const contentRef = useRef<SVGGElement>(null);
|
|
439
|
+
const hasAppliedInitialFitViewRef = useRef(false);
|
|
440
|
+
|
|
441
|
+
const cfg = useMemo(() => normalizeGraphConfig(stableConfig), [stableConfig]);
|
|
442
|
+
const mergedTheme = cfg.theme;
|
|
443
|
+
const edgeColor = mergedTheme.edgeColor ?? DEFAULT_THEME.edgeColor;
|
|
444
|
+
const edgeWidth = mergedTheme.edgeWidth ?? DEFAULT_THEME.edgeWidth;
|
|
445
|
+
const selectionEdgeColor = edgeSelectionColor ?? selectionColor;
|
|
446
|
+
// These are simple nullish-coalescing expressions on string primitives.
|
|
447
|
+
// Wrapping them in useMemo adds hook overhead that costs more than the
|
|
448
|
+
// expression itself, and the parent cfg is already memoized so updates
|
|
449
|
+
// are gated there.
|
|
450
|
+
const hoverNodeBorderColor = cfg.hoverNodeBorderColor ?? cfg.hoverEdgeColor;
|
|
451
|
+
const hoverNodeBothColor = cfg.hoverNodeBothColor ?? cfg.hoverEdgeColor;
|
|
452
|
+
const nodeBorderColor = mergedTheme.nodeBorderColor;
|
|
453
|
+
const nodeBorderWidth = mergedTheme.nodeBorderWidth ?? 0;
|
|
454
|
+
const showArrows = cfg.showArrows;
|
|
455
|
+
const arrowMarkerId = `${markerPrefix}-arrow`;
|
|
456
|
+
const hoverArrowMarkerId = `${markerPrefix}-arrow-hover`;
|
|
457
|
+
const hoverIncomingArrowMarkerId = `${markerPrefix}-arrow-hover-in`;
|
|
458
|
+
const selectionArrowMarkerId = `${markerPrefix}-arrow-selected`;
|
|
459
|
+
|
|
460
|
+
const viewport = useMemo(
|
|
461
|
+
() => normalizeViewport(controlledViewport ?? internalViewport, safeMinZoom, safeMaxZoom),
|
|
462
|
+
[controlledViewport, internalViewport, safeMaxZoom, safeMinZoom]
|
|
463
|
+
);
|
|
464
|
+
// Always keep a ref in sync so gesture handlers can read the latest value
|
|
465
|
+
// without closing over the rendered snapshot (avoids dropped events on rapid input).
|
|
466
|
+
const viewportRef = useRef(viewport);
|
|
467
|
+
viewportRef.current = viewport;
|
|
468
|
+
const onViewportChangeRef = useRef(onViewportChange);
|
|
469
|
+
onViewportChangeRef.current = onViewportChange;
|
|
470
|
+
const selection = useMemo<GraphSelection>(
|
|
471
|
+
() => ({
|
|
472
|
+
nodeIds: selectedNodeIds ?? internalSelection.nodeIds,
|
|
473
|
+
edgeIds: selectedEdgeIds ?? internalSelection.edgeIds,
|
|
474
|
+
}),
|
|
475
|
+
[selectedNodeIds, selectedEdgeIds, internalSelection]
|
|
476
|
+
);
|
|
477
|
+
const selectionRef = useRef(selection);
|
|
478
|
+
selectionRef.current = selection;
|
|
479
|
+
const focusedNodeId =
|
|
480
|
+
controlledFocusedNodeId !== undefined ? controlledFocusedNodeId : internalFocusedNodeId;
|
|
481
|
+
const {
|
|
482
|
+
collapsedIds,
|
|
483
|
+
collapsedNodeSet,
|
|
484
|
+
pendingExpansionNodeSet,
|
|
485
|
+
updateCollapsedNodeIds,
|
|
486
|
+
setPendingExpansionNodeIds,
|
|
487
|
+
} = useGraphCollapse({
|
|
488
|
+
collapsedNodeIds,
|
|
489
|
+
defaultCollapsedNodeIds,
|
|
490
|
+
onCollapsedNodeIdsChange,
|
|
491
|
+
});
|
|
492
|
+
const selectedNodeSet = useMemo(() => new Set(selection.nodeIds), [selection.nodeIds]);
|
|
493
|
+
const selectedEdgeSet = useMemo(() => new Set(selection.edgeIds), [selection.edgeIds]);
|
|
494
|
+
|
|
495
|
+
const updateViewport = useCallback(
|
|
496
|
+
(
|
|
497
|
+
next:
|
|
498
|
+
| Partial<GraphViewport>
|
|
499
|
+
| ((current: GraphViewport) => Partial<GraphViewport> | GraphViewport)
|
|
500
|
+
) => {
|
|
501
|
+
// Read the latest viewport from the ref so that rapid-fire events
|
|
502
|
+
// (wheel, pinch) always compute their delta from the current state
|
|
503
|
+
// rather than a stale closure snapshot.
|
|
504
|
+
const current = viewportRef.current;
|
|
505
|
+
const resolved = typeof next === 'function' ? next(current) : next;
|
|
506
|
+
const normalized = normalizeViewport({ ...current, ...resolved }, safeMinZoom, safeMaxZoom);
|
|
507
|
+
|
|
508
|
+
if (!controlledViewport) {
|
|
509
|
+
setInternalViewport(normalized);
|
|
510
|
+
}
|
|
511
|
+
onViewportChangeRef.current?.(normalized);
|
|
512
|
+
return normalized;
|
|
513
|
+
},
|
|
514
|
+
// viewport and onViewportChange are intentionally excluded: both are read
|
|
515
|
+
// via refs so the callback stays stable across renders.
|
|
516
|
+
[controlledViewport, safeMaxZoom, safeMinZoom]
|
|
517
|
+
);
|
|
518
|
+
|
|
519
|
+
const updateSelection = useCallback(
|
|
520
|
+
(next: GraphSelection | ((current: GraphSelection) => GraphSelection)) => {
|
|
521
|
+
const current = selectionRef.current;
|
|
522
|
+
const resolved = typeof next === 'function' ? next(current) : next;
|
|
523
|
+
if (selectedNodeIds == null || selectedEdgeIds == null) {
|
|
524
|
+
setInternalSelection((previous) => ({
|
|
525
|
+
nodeIds: selectedNodeIds == null ? resolved.nodeIds : previous.nodeIds,
|
|
526
|
+
edgeIds: selectedEdgeIds == null ? resolved.edgeIds : previous.edgeIds,
|
|
527
|
+
}));
|
|
528
|
+
}
|
|
529
|
+
onSelectionChange?.(resolved);
|
|
530
|
+
},
|
|
531
|
+
[onSelectionChange, selectedEdgeIds, selectedNodeIds]
|
|
532
|
+
);
|
|
533
|
+
|
|
534
|
+
const updateFocusedNode = useCallback(
|
|
535
|
+
(nodeId: string | null) => {
|
|
536
|
+
if (controlledFocusedNodeId === undefined) {
|
|
537
|
+
setInternalFocusedNodeId(nodeId);
|
|
538
|
+
}
|
|
539
|
+
onFocusedNodeChange?.(nodeId);
|
|
540
|
+
},
|
|
541
|
+
[controlledFocusedNodeId, onFocusedNodeChange]
|
|
542
|
+
);
|
|
543
|
+
|
|
544
|
+
const {
|
|
545
|
+
childNodeIdsByParent,
|
|
546
|
+
effectiveHighlightedEdgeSet,
|
|
547
|
+
effectiveHighlightedNodeSet,
|
|
548
|
+
handleNodeMeasure,
|
|
549
|
+
positionedEdges,
|
|
550
|
+
positionedNodes,
|
|
551
|
+
visibleEdges,
|
|
552
|
+
visibleNodesWithMeasuredSize,
|
|
553
|
+
} = useGraphModel({
|
|
554
|
+
graph,
|
|
555
|
+
config: cfg,
|
|
556
|
+
mergedTheme,
|
|
557
|
+
collapsedIds,
|
|
558
|
+
hiddenNodeIds,
|
|
559
|
+
searchQuery,
|
|
560
|
+
hideUnmatchedSearch,
|
|
561
|
+
searchPredicate,
|
|
562
|
+
highlightedNodeIds,
|
|
563
|
+
highlightedEdgeIds,
|
|
564
|
+
highlightStrategy,
|
|
565
|
+
onSearchResultsChange,
|
|
566
|
+
layoutNodesOverride,
|
|
567
|
+
routeEdgesOverride,
|
|
568
|
+
onError,
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
const handleNodeDoubleClick = useCallback(
|
|
572
|
+
(node: PositionedNode) => {
|
|
573
|
+
if (!toggleCollapseOnNodeDoubleClick) {
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
const hasChildren = (childNodeIdsByParent.get(node.id) ?? []).length > 0;
|
|
578
|
+
if (!hasChildren || pendingExpansionNodeSet.has(node.id)) {
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
if (collapsedNodeSet.has(node.id)) {
|
|
583
|
+
try {
|
|
584
|
+
const expandResult = onNodeExpand?.(node.id);
|
|
585
|
+
if (expandResult && typeof expandResult === 'object' && 'then' in expandResult) {
|
|
586
|
+
setPendingExpansionNodeIds((current) =>
|
|
587
|
+
current.includes(node.id) ? current : [...current, node.id]
|
|
588
|
+
);
|
|
589
|
+
void Promise.resolve(expandResult)
|
|
590
|
+
.then(() => {
|
|
591
|
+
updateCollapsedNodeIds((current) => current.filter((id) => id !== node.id));
|
|
592
|
+
})
|
|
593
|
+
.catch((error) => {
|
|
594
|
+
onError?.(toError(error), { graph, phase: 'interaction' });
|
|
595
|
+
})
|
|
596
|
+
.finally(() => {
|
|
597
|
+
setPendingExpansionNodeIds((current) =>
|
|
598
|
+
current.filter((pendingNodeId) => pendingNodeId !== node.id)
|
|
599
|
+
);
|
|
600
|
+
});
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
updateCollapsedNodeIds((current) => current.filter((id) => id !== node.id));
|
|
605
|
+
} catch (error) {
|
|
606
|
+
onError?.(toError(error), { graph, phase: 'interaction' });
|
|
607
|
+
}
|
|
608
|
+
} else {
|
|
609
|
+
updateCollapsedNodeIds((current) => [...current, node.id]);
|
|
610
|
+
try {
|
|
611
|
+
onNodeCollapse?.(node.id);
|
|
612
|
+
} catch (error) {
|
|
613
|
+
onError?.(toError(error), { graph, phase: 'interaction' });
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
},
|
|
617
|
+
[
|
|
618
|
+
childNodeIdsByParent,
|
|
619
|
+
collapsedNodeSet,
|
|
620
|
+
graph,
|
|
621
|
+
onError,
|
|
622
|
+
onNodeCollapse,
|
|
623
|
+
onNodeExpand,
|
|
624
|
+
pendingExpansionNodeSet,
|
|
625
|
+
setPendingExpansionNodeIds,
|
|
626
|
+
toggleCollapseOnNodeDoubleClick,
|
|
627
|
+
updateCollapsedNodeIds,
|
|
628
|
+
]
|
|
629
|
+
);
|
|
630
|
+
|
|
631
|
+
useEffect(() => {
|
|
632
|
+
const visibleNodeIds = new Set(visibleNodesWithMeasuredSize.map((node) => node.id));
|
|
633
|
+
const visibleEdgeIds = new Set(visibleEdges.map((edge) => edge.id));
|
|
634
|
+
|
|
635
|
+
if (
|
|
636
|
+
selection.nodeIds.some((nodeId) => !visibleNodeIds.has(nodeId)) ||
|
|
637
|
+
selection.edgeIds.some((edgeId) => !visibleEdgeIds.has(edgeId))
|
|
638
|
+
) {
|
|
639
|
+
updateSelection({
|
|
640
|
+
nodeIds: selection.nodeIds.filter((nodeId) => visibleNodeIds.has(nodeId)),
|
|
641
|
+
edgeIds: selection.edgeIds.filter((edgeId) => visibleEdgeIds.has(edgeId)),
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
if (focusedNodeId && !visibleNodeIds.has(focusedNodeId)) {
|
|
646
|
+
updateFocusedNode(null);
|
|
647
|
+
}
|
|
648
|
+
}, [
|
|
649
|
+
focusedNodeId,
|
|
650
|
+
selection,
|
|
651
|
+
updateFocusedNode,
|
|
652
|
+
updateSelection,
|
|
653
|
+
visibleEdges,
|
|
654
|
+
visibleNodesWithMeasuredSize,
|
|
655
|
+
]);
|
|
656
|
+
|
|
657
|
+
// FIX: pre-build O(1) id→node and id→edge lookup Maps so that hover callbacks
|
|
658
|
+
// can use Map.get() instead of Array.find(), avoiding O(n) scans on every
|
|
659
|
+
// pointer event (which was the main cause of per-hover full-tree re-renders).
|
|
660
|
+
const positionedNodeMap = useMemo(
|
|
661
|
+
() => new Map(positionedNodes.map((n) => [n.id, n])),
|
|
662
|
+
[positionedNodes]
|
|
663
|
+
);
|
|
664
|
+
const positionedEdgeMap = useMemo(
|
|
665
|
+
() => new Map(positionedEdges.map((e) => [e.id, e])),
|
|
666
|
+
[positionedEdges]
|
|
667
|
+
);
|
|
668
|
+
|
|
669
|
+
const graphBounds = useMemo(() => getGraphBounds(positionedNodes), [positionedNodes]);
|
|
670
|
+
const contentBounds = useMemo(() => {
|
|
671
|
+
const bounds = mergeBounds(
|
|
672
|
+
mergeBounds(
|
|
673
|
+
graphBounds,
|
|
674
|
+
getLabelBounds(
|
|
675
|
+
positionedNodes,
|
|
676
|
+
cfg.layout,
|
|
677
|
+
cfg.layoutDirection,
|
|
678
|
+
cfg.labels,
|
|
679
|
+
cfg.autoLabels,
|
|
680
|
+
cfg.labelOffset
|
|
681
|
+
)
|
|
682
|
+
),
|
|
683
|
+
getEdgeLabelBounds(positionedEdges)
|
|
684
|
+
);
|
|
685
|
+
|
|
686
|
+
return bounds ? expandBounds(bounds, FIT_BOUNDS_MARGIN) : bounds;
|
|
687
|
+
}, [
|
|
688
|
+
cfg.autoLabels,
|
|
689
|
+
cfg.labelOffset,
|
|
690
|
+
cfg.labels,
|
|
691
|
+
cfg.layout,
|
|
692
|
+
cfg.layoutDirection,
|
|
693
|
+
graphBounds,
|
|
694
|
+
positionedEdges,
|
|
695
|
+
positionedNodes,
|
|
696
|
+
]);
|
|
697
|
+
|
|
698
|
+
const getViewportDimensions = useCallback(() => {
|
|
699
|
+
const svgElement = svgRef.current;
|
|
700
|
+
if (!svgElement) {
|
|
701
|
+
return { width: cfg.width, height: cfg.height };
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
const containerRect = svgElement.parentElement?.getBoundingClientRect();
|
|
705
|
+
const rect = containerRect ?? svgElement.getBoundingClientRect();
|
|
706
|
+
return {
|
|
707
|
+
width: rect.width || cfg.width,
|
|
708
|
+
height: rect.height || cfg.height,
|
|
709
|
+
};
|
|
710
|
+
}, [cfg.height, cfg.width]);
|
|
711
|
+
|
|
712
|
+
const fitView = useCallback(
|
|
713
|
+
(padding: number = fitViewPadding) => {
|
|
714
|
+
const { width, height } = getViewportDimensions();
|
|
715
|
+
updateViewport(
|
|
716
|
+
getFitViewport(contentBounds, width, height, padding, safeMinZoom, safeMaxZoom)
|
|
717
|
+
);
|
|
718
|
+
},
|
|
719
|
+
[contentBounds, fitViewPadding, getViewportDimensions, safeMaxZoom, safeMinZoom, updateViewport]
|
|
720
|
+
);
|
|
721
|
+
|
|
722
|
+
const centerOnNode = useCallback(
|
|
723
|
+
(nodeId: string) => {
|
|
724
|
+
// FIX: use the pre-built O(1) Map instead of an O(n) Array.find.
|
|
725
|
+
const node = positionedNodeMap.get(nodeId);
|
|
726
|
+
if (!node) {
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
const { width, height } = getViewportDimensions();
|
|
731
|
+
updateViewport(centerViewportOnNode(node, width, height, viewport.zoom));
|
|
732
|
+
},
|
|
733
|
+
[getViewportDimensions, positionedNodeMap, updateViewport, viewport.zoom]
|
|
734
|
+
);
|
|
735
|
+
|
|
736
|
+
useImperativeHandle(
|
|
737
|
+
ref,
|
|
738
|
+
() => ({
|
|
739
|
+
fitView,
|
|
740
|
+
centerOnNode,
|
|
741
|
+
zoomIn: () => updateViewport((current) => ({ zoom: current.zoom + zoomStep })),
|
|
742
|
+
zoomOut: () => updateViewport((current) => ({ zoom: current.zoom - zoomStep })),
|
|
743
|
+
zoomTo: (zoom: number) => updateViewport({ zoom }),
|
|
744
|
+
resetViewport: () => updateViewport(DEFAULT_VIEWPORT),
|
|
745
|
+
getViewport: () => viewport,
|
|
746
|
+
setViewport: updateViewport,
|
|
747
|
+
clearSelection: () => updateSelection(DEFAULT_SELECTION),
|
|
748
|
+
}),
|
|
749
|
+
[centerOnNode, fitView, updateSelection, updateViewport, viewport, zoomStep]
|
|
750
|
+
);
|
|
751
|
+
|
|
752
|
+
useEffect(() => {
|
|
753
|
+
hasAppliedInitialFitViewRef.current = false;
|
|
754
|
+
}, [graph]);
|
|
755
|
+
|
|
756
|
+
useEffect(() => {
|
|
757
|
+
if (!fitViewOnMount || hasAppliedInitialFitViewRef.current || positionedNodes.length === 0) {
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
hasAppliedInitialFitViewRef.current = true;
|
|
762
|
+
fitView();
|
|
763
|
+
}, [fitView, fitViewOnMount, positionedNodes.length]);
|
|
764
|
+
|
|
765
|
+
const {
|
|
766
|
+
hoveredEdgeId,
|
|
767
|
+
setHoveredEdgeId,
|
|
768
|
+
hoveredNodeId,
|
|
769
|
+
setHoveredNodeId,
|
|
770
|
+
focusedPath,
|
|
771
|
+
setFocusedPath,
|
|
772
|
+
pathHighlight,
|
|
773
|
+
hoveredNodeStates,
|
|
774
|
+
edgesForRender,
|
|
775
|
+
} = useGraphHover(positionedNodes, positionedEdges, cfg.hoverHighlight);
|
|
776
|
+
|
|
777
|
+
// FIX: mirror hoveredNodeId in a ref so that handleNodeMouseLeave can always
|
|
778
|
+
// read the current value without depending on the state snapshot that changes
|
|
779
|
+
// every hover event. Without this, handleNodeMouseLeave was recreated on
|
|
780
|
+
// every hover, invalidating React.memo on every GraphNode (full re-render).
|
|
781
|
+
const hoveredNodeIdRef = useRef(hoveredNodeId);
|
|
782
|
+
hoveredNodeIdRef.current = hoveredNodeId;
|
|
783
|
+
|
|
784
|
+
const renderContext = useMemo<GraphRenderContext>(
|
|
785
|
+
() => ({
|
|
786
|
+
graph,
|
|
787
|
+
nodes: positionedNodes,
|
|
788
|
+
edges: positionedEdges,
|
|
789
|
+
config: cfg,
|
|
790
|
+
viewport,
|
|
791
|
+
selection,
|
|
792
|
+
}),
|
|
793
|
+
[cfg, graph, positionedEdges, positionedNodes, selection, viewport]
|
|
794
|
+
);
|
|
795
|
+
|
|
796
|
+
const emitNodeHover = useCallback(
|
|
797
|
+
(node: PositionedNode, hovered: boolean, trigger: 'pointer' | 'path' = 'pointer') => {
|
|
798
|
+
onNodeHoverChange?.(node, hovered, {
|
|
799
|
+
viewport,
|
|
800
|
+
selection,
|
|
801
|
+
trigger,
|
|
802
|
+
});
|
|
803
|
+
},
|
|
804
|
+
[onNodeHoverChange, selection, viewport]
|
|
805
|
+
);
|
|
806
|
+
|
|
807
|
+
const emitEdgeHover = useCallback(
|
|
808
|
+
(edge: PositionedEdge, hovered: boolean, trigger: 'pointer' | 'path' = 'pointer') => {
|
|
809
|
+
onEdgeHoverChange?.(edge, hovered, {
|
|
810
|
+
viewport,
|
|
811
|
+
selection,
|
|
812
|
+
trigger,
|
|
813
|
+
});
|
|
814
|
+
},
|
|
815
|
+
[onEdgeHoverChange, selection, viewport]
|
|
816
|
+
);
|
|
817
|
+
|
|
818
|
+
const handleNodeSelection = useCallback(
|
|
819
|
+
(node: PositionedNode) => {
|
|
820
|
+
if (!nodeSelectionEnabled) {
|
|
821
|
+
updateFocusedNode(node.id);
|
|
822
|
+
onNodeClick?.(node);
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
updateSelection((current) => ({
|
|
827
|
+
nodeIds: toggleId(current.nodeIds, node.id, selectionMode),
|
|
828
|
+
edgeIds: selectionMode === 'single' ? [] : current.edgeIds,
|
|
829
|
+
}));
|
|
830
|
+
updateFocusedNode(node.id);
|
|
831
|
+
onNodeClick?.(node);
|
|
832
|
+
},
|
|
833
|
+
[nodeSelectionEnabled, onNodeClick, selectionMode, updateFocusedNode, updateSelection]
|
|
834
|
+
);
|
|
835
|
+
|
|
836
|
+
const handleEdgeSelection = useCallback(
|
|
837
|
+
(edge: PositionedEdge) => {
|
|
838
|
+
if (!edgeSelectionEnabled) {
|
|
839
|
+
onEdgeClick?.(edge);
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
updateSelection((current) => ({
|
|
844
|
+
nodeIds: selectionMode === 'single' ? [] : current.nodeIds,
|
|
845
|
+
edgeIds: toggleId(current.edgeIds, edge.id, selectionMode),
|
|
846
|
+
}));
|
|
847
|
+
onEdgeClick?.(edge);
|
|
848
|
+
},
|
|
849
|
+
[edgeSelectionEnabled, onEdgeClick, selectionMode, updateSelection]
|
|
850
|
+
);
|
|
851
|
+
|
|
852
|
+
const handlePointerDown = useCallback(
|
|
853
|
+
(event: React.PointerEvent<SVGSVGElement>) => {
|
|
854
|
+
const target = event.target as Element;
|
|
855
|
+
const isInteractiveTarget = Boolean(
|
|
856
|
+
target.closest('[data-graph-node-interactive="true"], [data-graph-edge-interactive="true"]')
|
|
857
|
+
);
|
|
858
|
+
const relativePoint = getRelativeSvgPoint(svgRef.current, event.clientX, event.clientY);
|
|
859
|
+
|
|
860
|
+
if (
|
|
861
|
+
!isInteractiveTarget &&
|
|
862
|
+
selectionMode === 'multiple' &&
|
|
863
|
+
marqueeSelectionEnabled &&
|
|
864
|
+
event.shiftKey
|
|
865
|
+
) {
|
|
866
|
+
setSelectionBox({
|
|
867
|
+
startX: relativePoint.x,
|
|
868
|
+
startY: relativePoint.y,
|
|
869
|
+
endX: relativePoint.x,
|
|
870
|
+
endY: relativePoint.y,
|
|
871
|
+
});
|
|
872
|
+
dragRef.current.active = false;
|
|
873
|
+
return;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
if (event.pointerType === 'touch') {
|
|
877
|
+
activePointersRef.current.set(event.pointerId, relativePoint);
|
|
878
|
+
if (pinchZoomEnabled && zoomEnabled && activePointersRef.current.size === 2) {
|
|
879
|
+
const [first, second] = Array.from(activePointersRef.current.values());
|
|
880
|
+
const midpoint = getPointerMidpoint(first, second);
|
|
881
|
+
pinchRef.current = {
|
|
882
|
+
active: true,
|
|
883
|
+
startDistance: getPointerDistance(first, second),
|
|
884
|
+
startZoom: viewport.zoom,
|
|
885
|
+
worldX: (midpoint.x - viewport.x) / viewport.zoom,
|
|
886
|
+
worldY: (midpoint.y - viewport.y) / viewport.zoom,
|
|
887
|
+
};
|
|
888
|
+
dragRef.current.active = false;
|
|
889
|
+
target.setPointerCapture?.(event.pointerId);
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
if (!panEnabled || event.button !== 0 || isInteractiveTarget) {
|
|
895
|
+
return;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
dragRef.current = {
|
|
899
|
+
active: true,
|
|
900
|
+
startX: relativePoint.x,
|
|
901
|
+
startY: relativePoint.y,
|
|
902
|
+
originX: viewport.x,
|
|
903
|
+
originY: viewport.y,
|
|
904
|
+
};
|
|
905
|
+
setIsDragging(true);
|
|
906
|
+
target.setPointerCapture?.(event.pointerId);
|
|
907
|
+
},
|
|
908
|
+
[marqueeSelectionEnabled, panEnabled, pinchZoomEnabled, selectionMode, viewport, zoomEnabled]
|
|
909
|
+
);
|
|
910
|
+
|
|
911
|
+
const handlePointerMove = useCallback(
|
|
912
|
+
(event: React.PointerEvent<SVGSVGElement>) => {
|
|
913
|
+
const relativePoint = getRelativeSvgPoint(svgRef.current, event.clientX, event.clientY);
|
|
914
|
+
|
|
915
|
+
if (event.pointerType === 'touch' && activePointersRef.current.has(event.pointerId)) {
|
|
916
|
+
activePointersRef.current.set(event.pointerId, relativePoint);
|
|
917
|
+
|
|
918
|
+
if (pinchRef.current.active && activePointersRef.current.size >= 2) {
|
|
919
|
+
const [first, second] = Array.from(activePointersRef.current.values());
|
|
920
|
+
const midpoint = getPointerMidpoint(first, second);
|
|
921
|
+
const distance = getPointerDistance(first, second);
|
|
922
|
+
const nextZoom = clampZoom(
|
|
923
|
+
pinchRef.current.startZoom * (distance / Math.max(1, pinchRef.current.startDistance)),
|
|
924
|
+
safeMinZoom,
|
|
925
|
+
safeMaxZoom
|
|
926
|
+
);
|
|
927
|
+
|
|
928
|
+
updateViewport({
|
|
929
|
+
zoom: nextZoom,
|
|
930
|
+
x: midpoint.x - pinchRef.current.worldX * nextZoom,
|
|
931
|
+
y: midpoint.y - pinchRef.current.worldY * nextZoom,
|
|
932
|
+
});
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
if (selectionBox) {
|
|
938
|
+
setSelectionBox((current) =>
|
|
939
|
+
current
|
|
940
|
+
? {
|
|
941
|
+
...current,
|
|
942
|
+
endX: relativePoint.x,
|
|
943
|
+
endY: relativePoint.y,
|
|
944
|
+
}
|
|
945
|
+
: current
|
|
946
|
+
);
|
|
947
|
+
return;
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
if (!panEnabled || !dragRef.current.active) {
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
updateViewport({
|
|
955
|
+
x: dragRef.current.originX + (relativePoint.x - dragRef.current.startX),
|
|
956
|
+
y: dragRef.current.originY + (relativePoint.y - dragRef.current.startY),
|
|
957
|
+
});
|
|
958
|
+
},
|
|
959
|
+
[panEnabled, safeMaxZoom, safeMinZoom, selectionBox, updateViewport]
|
|
960
|
+
);
|
|
961
|
+
|
|
962
|
+
const handlePointerUp = useCallback(
|
|
963
|
+
(event: React.PointerEvent<SVGSVGElement>) => {
|
|
964
|
+
activePointersRef.current.delete(event.pointerId);
|
|
965
|
+
if (activePointersRef.current.size < 2) {
|
|
966
|
+
pinchRef.current.active = false;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
if (selectionBox) {
|
|
970
|
+
const box = normalizeRect(selectionBox);
|
|
971
|
+
const worldRect = {
|
|
972
|
+
x: (box.x - viewport.x) / viewport.zoom,
|
|
973
|
+
y: (box.y - viewport.y) / viewport.zoom,
|
|
974
|
+
width: box.width / viewport.zoom,
|
|
975
|
+
height: box.height / viewport.zoom,
|
|
976
|
+
};
|
|
977
|
+
|
|
978
|
+
const nodeIds = positionedNodes
|
|
979
|
+
.filter((node) => {
|
|
980
|
+
const width = node.size?.width ?? 0;
|
|
981
|
+
const height = node.size?.height ?? 0;
|
|
982
|
+
return !(
|
|
983
|
+
node.position.x + width < worldRect.x ||
|
|
984
|
+
node.position.x > worldRect.x + worldRect.width ||
|
|
985
|
+
node.position.y + height < worldRect.y ||
|
|
986
|
+
node.position.y > worldRect.y + worldRect.height
|
|
987
|
+
);
|
|
988
|
+
})
|
|
989
|
+
.map((node) => node.id);
|
|
990
|
+
|
|
991
|
+
const edgeIds = positionedEdges
|
|
992
|
+
.filter((edge) => {
|
|
993
|
+
// An edge is inside the marquee if ANY of its path points OR its
|
|
994
|
+
// label centre falls within the world rect. The previous code
|
|
995
|
+
// tested only the label when labelPosition was present, which
|
|
996
|
+
// meant dragging across an edge path would miss it if the label
|
|
997
|
+
// happened to be outside the selection box.
|
|
998
|
+
const inPoints = edge.points.some((point) =>
|
|
999
|
+
isPointInsideRect(point.x, point.y, worldRect)
|
|
1000
|
+
);
|
|
1001
|
+
const inLabel =
|
|
1002
|
+
!!edge.labelPosition &&
|
|
1003
|
+
isPointInsideRect(edge.labelPosition.x, edge.labelPosition.y, worldRect);
|
|
1004
|
+
return inPoints || inLabel;
|
|
1005
|
+
})
|
|
1006
|
+
.map((edge) => edge.id);
|
|
1007
|
+
|
|
1008
|
+
updateSelection((current) => ({
|
|
1009
|
+
nodeIds: Array.from(new Set([...current.nodeIds, ...nodeIds])),
|
|
1010
|
+
edgeIds: Array.from(new Set([...current.edgeIds, ...edgeIds])),
|
|
1011
|
+
}));
|
|
1012
|
+
setSelectionBox(null);
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
dragRef.current.active = false;
|
|
1016
|
+
setIsDragging(false);
|
|
1017
|
+
(event.target as Element).releasePointerCapture?.(event.pointerId);
|
|
1018
|
+
},
|
|
1019
|
+
[positionedEdges, positionedNodes, selectionBox, updateSelection, viewport]
|
|
1020
|
+
);
|
|
1021
|
+
|
|
1022
|
+
const handleWheel = useCallback(
|
|
1023
|
+
(event: React.WheelEvent<SVGSVGElement>) => {
|
|
1024
|
+
if (!zoomEnabled || !svgRef.current) {
|
|
1025
|
+
return;
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
event.preventDefault();
|
|
1029
|
+
const pointer = getRelativeSvgPoint(svgRef.current, event.clientX, event.clientY);
|
|
1030
|
+
// FIX: read the current viewport from the ref instead of the render-closure
|
|
1031
|
+
// snapshot. Rapid wheel/trackpad events fire before React commits the next
|
|
1032
|
+
// render, so using a stale `viewport` closure variable to compute the zoom
|
|
1033
|
+
// anchor causes the focal point to drift with every fast scroll tick.
|
|
1034
|
+
const current = viewportRef.current;
|
|
1035
|
+
const worldX = (pointer.x - current.x) / current.zoom;
|
|
1036
|
+
const worldY = (pointer.y - current.y) / current.zoom;
|
|
1037
|
+
const nextZoom = clampZoom(
|
|
1038
|
+
current.zoom + (event.deltaY < 0 ? zoomStep : -zoomStep),
|
|
1039
|
+
safeMinZoom,
|
|
1040
|
+
safeMaxZoom
|
|
1041
|
+
);
|
|
1042
|
+
|
|
1043
|
+
updateViewport({
|
|
1044
|
+
zoom: nextZoom,
|
|
1045
|
+
x: pointer.x - worldX * nextZoom,
|
|
1046
|
+
y: pointer.y - worldY * nextZoom,
|
|
1047
|
+
});
|
|
1048
|
+
},
|
|
1049
|
+
[safeMaxZoom, safeMinZoom, updateViewport, zoomEnabled, zoomStep]
|
|
1050
|
+
);
|
|
1051
|
+
|
|
1052
|
+
const handleKeyDown = useCallback(
|
|
1053
|
+
(event: React.KeyboardEvent<SVGSVGElement>) => {
|
|
1054
|
+
if (!keyboardNavigation) {
|
|
1055
|
+
return;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
switch (event.key) {
|
|
1059
|
+
case '+':
|
|
1060
|
+
case '=':
|
|
1061
|
+
event.preventDefault();
|
|
1062
|
+
updateViewport((current) => ({ zoom: current.zoom + zoomStep }));
|
|
1063
|
+
break;
|
|
1064
|
+
case '-':
|
|
1065
|
+
case '_':
|
|
1066
|
+
event.preventDefault();
|
|
1067
|
+
updateViewport((current) => ({ zoom: current.zoom - zoomStep }));
|
|
1068
|
+
break;
|
|
1069
|
+
case '0':
|
|
1070
|
+
event.preventDefault();
|
|
1071
|
+
fitView();
|
|
1072
|
+
break;
|
|
1073
|
+
case 'ArrowLeft':
|
|
1074
|
+
case 'ArrowRight':
|
|
1075
|
+
case 'ArrowUp':
|
|
1076
|
+
case 'ArrowDown': {
|
|
1077
|
+
event.preventDefault();
|
|
1078
|
+
if (focusedNodeId) {
|
|
1079
|
+
// FIX: use the pre-built O(1) Map instead of an O(n) Array.find.
|
|
1080
|
+
const currentNode = positionedNodeMap.get(focusedNodeId);
|
|
1081
|
+
if (currentNode) {
|
|
1082
|
+
const direction =
|
|
1083
|
+
event.key === 'ArrowLeft'
|
|
1084
|
+
? 'left'
|
|
1085
|
+
: event.key === 'ArrowRight'
|
|
1086
|
+
? 'right'
|
|
1087
|
+
: event.key === 'ArrowUp'
|
|
1088
|
+
? 'up'
|
|
1089
|
+
: 'down';
|
|
1090
|
+
const nextNode = getNearestNodeInDirection(currentNode, positionedNodes, direction);
|
|
1091
|
+
if (nextNode) {
|
|
1092
|
+
updateFocusedNode(nextNode.id);
|
|
1093
|
+
// Pan the viewport so the newly focused node is visible.
|
|
1094
|
+
// Without this the user can arrow-navigate off-screen.
|
|
1095
|
+
centerOnNode(nextNode.id);
|
|
1096
|
+
}
|
|
1097
|
+
break;
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
updateViewport((current) => {
|
|
1102
|
+
if (event.key === 'ArrowLeft') {
|
|
1103
|
+
return { x: current.x + 32 };
|
|
1104
|
+
}
|
|
1105
|
+
if (event.key === 'ArrowRight') {
|
|
1106
|
+
return { x: current.x - 32 };
|
|
1107
|
+
}
|
|
1108
|
+
if (event.key === 'ArrowUp') {
|
|
1109
|
+
return { y: current.y + 32 };
|
|
1110
|
+
}
|
|
1111
|
+
return { y: current.y - 32 };
|
|
1112
|
+
});
|
|
1113
|
+
break;
|
|
1114
|
+
}
|
|
1115
|
+
case 'Enter':
|
|
1116
|
+
case ' ': {
|
|
1117
|
+
if (!focusedNodeId) {
|
|
1118
|
+
break;
|
|
1119
|
+
}
|
|
1120
|
+
event.preventDefault();
|
|
1121
|
+
// FIX: use the pre-built O(1) Map instead of an O(n) Array.find.
|
|
1122
|
+
const focusedNode = positionedNodeMap.get(focusedNodeId);
|
|
1123
|
+
if (focusedNode) {
|
|
1124
|
+
handleNodeSelection(focusedNode);
|
|
1125
|
+
}
|
|
1126
|
+
break;
|
|
1127
|
+
}
|
|
1128
|
+
case 'Escape':
|
|
1129
|
+
event.preventDefault();
|
|
1130
|
+
setFocusedPath(null);
|
|
1131
|
+
updateSelection(DEFAULT_SELECTION);
|
|
1132
|
+
updateFocusedNode(null);
|
|
1133
|
+
break;
|
|
1134
|
+
default:
|
|
1135
|
+
break;
|
|
1136
|
+
}
|
|
1137
|
+
},
|
|
1138
|
+
[
|
|
1139
|
+
centerOnNode,
|
|
1140
|
+
fitView,
|
|
1141
|
+
focusedNodeId,
|
|
1142
|
+
handleNodeSelection,
|
|
1143
|
+
keyboardNavigation,
|
|
1144
|
+
positionedNodeMap,
|
|
1145
|
+
positionedNodes,
|
|
1146
|
+
setFocusedPath,
|
|
1147
|
+
updateFocusedNode,
|
|
1148
|
+
updateSelection,
|
|
1149
|
+
updateViewport,
|
|
1150
|
+
zoomStep,
|
|
1151
|
+
]
|
|
1152
|
+
);
|
|
1153
|
+
|
|
1154
|
+
const handleNodeMouseEnter = useCallback(
|
|
1155
|
+
(nodeId: string) => {
|
|
1156
|
+
setHoveredNodeId(nodeId);
|
|
1157
|
+
// FIX: use the pre-built Map for O(1) lookup instead of an O(n) Array.find.
|
|
1158
|
+
const node = positionedNodeMap.get(nodeId);
|
|
1159
|
+
if (node) {
|
|
1160
|
+
emitNodeHover(node, true);
|
|
1161
|
+
}
|
|
1162
|
+
},
|
|
1163
|
+
[emitNodeHover, positionedNodeMap, setHoveredNodeId]
|
|
1164
|
+
);
|
|
1165
|
+
|
|
1166
|
+
const handleNodeMouseLeave = useCallback(() => {
|
|
1167
|
+
// FIX: read from hoveredNodeIdRef instead of the hoveredNodeId state
|
|
1168
|
+
// snapshot. The old dep on hoveredNodeId caused a new callback reference
|
|
1169
|
+
// on every hover change, which broke React.memo on every GraphNode child
|
|
1170
|
+
// and triggered a full-node re-render on each single hover event.
|
|
1171
|
+
const currentHoveredId = hoveredNodeIdRef.current;
|
|
1172
|
+
if (currentHoveredId) {
|
|
1173
|
+
const node = positionedNodeMap.get(currentHoveredId);
|
|
1174
|
+
if (node) {
|
|
1175
|
+
emitNodeHover(node, false);
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
setHoveredNodeId(null);
|
|
1179
|
+
setFocusedPath(null);
|
|
1180
|
+
}, [emitNodeHover, positionedNodeMap, setFocusedPath, setHoveredNodeId]);
|
|
1181
|
+
|
|
1182
|
+
const handlePathHover = useCallback(
|
|
1183
|
+
(nodeId: string, sourceIndex: number, pathKey?: string) => {
|
|
1184
|
+
setFocusedPath({ nodeId, sourceIndex, pathKey });
|
|
1185
|
+
// FIX: use the pre-built O(1) Map instead of an O(n) Array.find.
|
|
1186
|
+
const node = positionedNodeMap.get(nodeId);
|
|
1187
|
+
if (node) {
|
|
1188
|
+
emitNodeHover(node, true, 'path');
|
|
1189
|
+
}
|
|
1190
|
+
},
|
|
1191
|
+
[emitNodeHover, positionedNodeMap, setFocusedPath]
|
|
1192
|
+
);
|
|
1193
|
+
|
|
1194
|
+
const handlePathLeave = useCallback(() => {
|
|
1195
|
+
setFocusedPath(null);
|
|
1196
|
+
}, [setFocusedPath]);
|
|
1197
|
+
|
|
1198
|
+
const handleEdgeHoverChange = useCallback(
|
|
1199
|
+
(edgeId: string, isHovered: boolean) => {
|
|
1200
|
+
// FIX: use the pre-built Map for O(1) lookup instead of an O(n) Array.find.
|
|
1201
|
+
const edge = positionedEdgeMap.get(edgeId);
|
|
1202
|
+
if (edge) {
|
|
1203
|
+
emitEdgeHover(edge, isHovered);
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
if (!cfg.hoverHighlight) {
|
|
1207
|
+
return;
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
setHoveredEdgeId(isHovered ? edgeId : null);
|
|
1211
|
+
if (isHovered) {
|
|
1212
|
+
setHoveredNodeId(null);
|
|
1213
|
+
}
|
|
1214
|
+
},
|
|
1215
|
+
[cfg.hoverHighlight, emitEdgeHover, positionedEdgeMap, setHoveredEdgeId, setHoveredNodeId]
|
|
1216
|
+
);
|
|
1217
|
+
|
|
1218
|
+
const svgStyle = useMemo(
|
|
1219
|
+
() => ({
|
|
1220
|
+
background: mergedTheme.background,
|
|
1221
|
+
fontFamily: mergedTheme.fontFamily,
|
|
1222
|
+
cursor: isDragging ? 'grabbing' : panEnabled ? 'grab' : 'default',
|
|
1223
|
+
outline: 'none',
|
|
1224
|
+
touchAction: panEnabled || zoomEnabled ? 'none' : 'auto',
|
|
1225
|
+
overflow: 'hidden',
|
|
1226
|
+
userSelect: 'none' as const,
|
|
1227
|
+
}),
|
|
1228
|
+
[isDragging, mergedTheme.background, mergedTheme.fontFamily, panEnabled, zoomEnabled]
|
|
1229
|
+
);
|
|
1230
|
+
|
|
1231
|
+
const controlsOrigin = useMemo(
|
|
1232
|
+
() => getControlPosition(cfg.width, cfg.height, controlsPosition),
|
|
1233
|
+
[cfg.height, cfg.width, controlsPosition]
|
|
1234
|
+
);
|
|
1235
|
+
|
|
1236
|
+
const selectionRect = selectionBox ? normalizeRect(selectionBox) : null;
|
|
1237
|
+
|
|
1238
|
+
const viewportControls = showControls ? (
|
|
1239
|
+
<g
|
|
1240
|
+
aria-label="viewport-controls"
|
|
1241
|
+
transform={`translate(${controlsOrigin.x}, ${controlsOrigin.y})`}
|
|
1242
|
+
>
|
|
1243
|
+
{(
|
|
1244
|
+
[
|
|
1245
|
+
{
|
|
1246
|
+
key: 'zoom-in',
|
|
1247
|
+
label: '+',
|
|
1248
|
+
width: CONTROL_BUTTON_SIZE,
|
|
1249
|
+
onClick: () => updateViewport((current) => ({ zoom: current.zoom + zoomStep })),
|
|
1250
|
+
},
|
|
1251
|
+
{
|
|
1252
|
+
key: 'zoom-out',
|
|
1253
|
+
label: '−',
|
|
1254
|
+
width: CONTROL_BUTTON_SIZE,
|
|
1255
|
+
onClick: () => updateViewport((current) => ({ zoom: current.zoom - zoomStep })),
|
|
1256
|
+
},
|
|
1257
|
+
{
|
|
1258
|
+
key: 'fit-view',
|
|
1259
|
+
label: 'Fit',
|
|
1260
|
+
width: CONTROL_LABEL_BUTTON_WIDTH,
|
|
1261
|
+
onClick: () => fitView(),
|
|
1262
|
+
},
|
|
1263
|
+
{
|
|
1264
|
+
key: 'reset-view',
|
|
1265
|
+
label: '1:1',
|
|
1266
|
+
width: CONTROL_LABEL_BUTTON_WIDTH,
|
|
1267
|
+
onClick: () => updateViewport(DEFAULT_VIEWPORT),
|
|
1268
|
+
},
|
|
1269
|
+
] as const
|
|
1270
|
+
)
|
|
1271
|
+
.reduce<{
|
|
1272
|
+
x: number;
|
|
1273
|
+
controls: Array<{
|
|
1274
|
+
key: string;
|
|
1275
|
+
label: string;
|
|
1276
|
+
width: number;
|
|
1277
|
+
x: number;
|
|
1278
|
+
onClick: () => void;
|
|
1279
|
+
}>;
|
|
1280
|
+
}>(
|
|
1281
|
+
(acc, control) => ({
|
|
1282
|
+
x: acc.x + control.width + CONTROL_BUTTON_GAP,
|
|
1283
|
+
controls: [...acc.controls, { ...control, x: acc.x }],
|
|
1284
|
+
}),
|
|
1285
|
+
{ x: 0, controls: [] }
|
|
1286
|
+
)
|
|
1287
|
+
.controls.map((control) => (
|
|
1288
|
+
<g
|
|
1289
|
+
key={control.key}
|
|
1290
|
+
transform={`translate(${control.x}, 0)`}
|
|
1291
|
+
role="button"
|
|
1292
|
+
tabIndex={0}
|
|
1293
|
+
onClick={(event) => {
|
|
1294
|
+
event.stopPropagation();
|
|
1295
|
+
control.onClick();
|
|
1296
|
+
}}
|
|
1297
|
+
onKeyDown={(event) => {
|
|
1298
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
1299
|
+
event.preventDefault();
|
|
1300
|
+
event.stopPropagation();
|
|
1301
|
+
control.onClick();
|
|
1302
|
+
}
|
|
1303
|
+
}}
|
|
1304
|
+
>
|
|
1305
|
+
<rect
|
|
1306
|
+
width={control.width}
|
|
1307
|
+
height={CONTROL_BUTTON_SIZE}
|
|
1308
|
+
rx={7}
|
|
1309
|
+
ry={7}
|
|
1310
|
+
fill="rgba(255,255,255,0.92)"
|
|
1311
|
+
stroke="rgba(15,23,42,0.18)"
|
|
1312
|
+
/>
|
|
1313
|
+
<text
|
|
1314
|
+
x={control.width / 2}
|
|
1315
|
+
y={CONTROL_BUTTON_SIZE / 2 + 4}
|
|
1316
|
+
textAnchor="middle"
|
|
1317
|
+
fontSize={control.label.length > 1 ? 10 : 16}
|
|
1318
|
+
fontWeight={700}
|
|
1319
|
+
fill="#0f172a"
|
|
1320
|
+
>
|
|
1321
|
+
{control.label}
|
|
1322
|
+
</text>
|
|
1323
|
+
</g>
|
|
1324
|
+
))}
|
|
1325
|
+
</g>
|
|
1326
|
+
) : null;
|
|
1327
|
+
|
|
1328
|
+
return (
|
|
1329
|
+
<svg
|
|
1330
|
+
ref={svgRef}
|
|
1331
|
+
width={cfg.width}
|
|
1332
|
+
height={cfg.height}
|
|
1333
|
+
role="figure"
|
|
1334
|
+
aria-label="Graph"
|
|
1335
|
+
tabIndex={0}
|
|
1336
|
+
style={svgStyle}
|
|
1337
|
+
onClick={(event) => {
|
|
1338
|
+
if (event.target === event.currentTarget) {
|
|
1339
|
+
updateSelection(DEFAULT_SELECTION);
|
|
1340
|
+
setFocusedPath(null);
|
|
1341
|
+
updateFocusedNode(null);
|
|
1342
|
+
}
|
|
1343
|
+
}}
|
|
1344
|
+
onPointerDown={handlePointerDown}
|
|
1345
|
+
onPointerMove={handlePointerMove}
|
|
1346
|
+
onPointerUp={handlePointerUp}
|
|
1347
|
+
onPointerLeave={handlePointerUp}
|
|
1348
|
+
onWheel={handleWheel}
|
|
1349
|
+
onKeyDown={handleKeyDown}
|
|
1350
|
+
>
|
|
1351
|
+
{showArrows ? (
|
|
1352
|
+
<defs>
|
|
1353
|
+
<marker
|
|
1354
|
+
id={arrowMarkerId}
|
|
1355
|
+
viewBox="0 0 10 10"
|
|
1356
|
+
refX="6"
|
|
1357
|
+
refY="5"
|
|
1358
|
+
markerWidth="6"
|
|
1359
|
+
markerHeight="6"
|
|
1360
|
+
orient="auto-start-reverse"
|
|
1361
|
+
>
|
|
1362
|
+
<path d="M 0 0 L 10 5 L 0 10 z" fill={edgeColor} />
|
|
1363
|
+
</marker>
|
|
1364
|
+
<marker
|
|
1365
|
+
id={hoverArrowMarkerId}
|
|
1366
|
+
viewBox="0 0 10 10"
|
|
1367
|
+
refX="6"
|
|
1368
|
+
refY="5"
|
|
1369
|
+
markerWidth="6"
|
|
1370
|
+
markerHeight="6"
|
|
1371
|
+
orient="auto-start-reverse"
|
|
1372
|
+
>
|
|
1373
|
+
<path d="M 0 0 L 10 5 L 0 10 z" fill={cfg.hoverEdgeColor} />
|
|
1374
|
+
</marker>
|
|
1375
|
+
<marker
|
|
1376
|
+
id={hoverIncomingArrowMarkerId}
|
|
1377
|
+
viewBox="0 0 10 10"
|
|
1378
|
+
refX="6"
|
|
1379
|
+
refY="5"
|
|
1380
|
+
markerWidth="6"
|
|
1381
|
+
markerHeight="6"
|
|
1382
|
+
orient="auto-start-reverse"
|
|
1383
|
+
>
|
|
1384
|
+
<path d="M 0 0 L 10 5 L 0 10 z" fill={cfg.hoverNodeOutColor} />
|
|
1385
|
+
</marker>
|
|
1386
|
+
<marker
|
|
1387
|
+
id={selectionArrowMarkerId}
|
|
1388
|
+
viewBox="0 0 10 10"
|
|
1389
|
+
refX="6"
|
|
1390
|
+
refY="5"
|
|
1391
|
+
markerWidth="6"
|
|
1392
|
+
markerHeight="6"
|
|
1393
|
+
orient="auto-start-reverse"
|
|
1394
|
+
>
|
|
1395
|
+
<path d="M 0 0 L 10 5 L 0 10 z" fill={selectionEdgeColor} />
|
|
1396
|
+
</marker>
|
|
1397
|
+
</defs>
|
|
1398
|
+
) : null}
|
|
1399
|
+
|
|
1400
|
+
<g transform={`translate(${viewport.x}, ${viewport.y}) scale(${viewport.zoom})`}>
|
|
1401
|
+
<g ref={contentRef}>
|
|
1402
|
+
{renderBackground?.(renderContext)}
|
|
1403
|
+
|
|
1404
|
+
<GraphLabels
|
|
1405
|
+
positionedNodes={positionedNodes}
|
|
1406
|
+
layout={cfg.layout}
|
|
1407
|
+
layoutDirection={cfg.layoutDirection}
|
|
1408
|
+
labels={cfg.labels}
|
|
1409
|
+
autoLabels={cfg.autoLabels}
|
|
1410
|
+
labelOffset={cfg.labelOffset}
|
|
1411
|
+
pillBackground={cfg.labelPillBackground}
|
|
1412
|
+
pillBorderColor={cfg.labelPillBorderColor}
|
|
1413
|
+
pillTextColor={cfg.labelPillTextColor}
|
|
1414
|
+
/>
|
|
1415
|
+
|
|
1416
|
+
<g aria-label="edges">
|
|
1417
|
+
{edgesForRender.map((edge) => {
|
|
1418
|
+
const edgeHovered =
|
|
1419
|
+
(cfg.hoverHighlight &&
|
|
1420
|
+
(hoveredEdgeId === edge.id ||
|
|
1421
|
+
(hoveredNodeId &&
|
|
1422
|
+
(edge.source === hoveredNodeId || edge.target === hoveredNodeId)))) ||
|
|
1423
|
+
pathHighlight?.edges.has(edge.id);
|
|
1424
|
+
|
|
1425
|
+
const isIncomingToHovered =
|
|
1426
|
+
hoveredNodeId &&
|
|
1427
|
+
!hoveredEdgeId &&
|
|
1428
|
+
edge.type !== 'undirected' &&
|
|
1429
|
+
edge.target === hoveredNodeId;
|
|
1430
|
+
|
|
1431
|
+
return (
|
|
1432
|
+
<EdgeComponent
|
|
1433
|
+
key={edge.id}
|
|
1434
|
+
edge={edge}
|
|
1435
|
+
color={edgeColor}
|
|
1436
|
+
width={edgeWidth}
|
|
1437
|
+
curveEdges={cfg.curveEdges && cfg.routingStyle !== 'orthogonal'}
|
|
1438
|
+
curveStrength={cfg.curveStrength}
|
|
1439
|
+
markerEnd={showArrows ? `url(#${arrowMarkerId})` : undefined}
|
|
1440
|
+
isHovered={edgeHovered}
|
|
1441
|
+
isSelected={
|
|
1442
|
+
selectedEdgeSet.has(edge.id) || effectiveHighlightedEdgeSet.has(edge.id)
|
|
1443
|
+
}
|
|
1444
|
+
hoverColor={isIncomingToHovered ? cfg.hoverNodeOutColor : cfg.hoverEdgeColor}
|
|
1445
|
+
selectionColor={
|
|
1446
|
+
selectedEdgeSet.has(edge.id) ? selectionEdgeColor : highlightColor
|
|
1447
|
+
}
|
|
1448
|
+
labelColor={cfg.edgeLabelColor}
|
|
1449
|
+
selectionMarker={showArrows ? `url(#${selectionArrowMarkerId})` : undefined}
|
|
1450
|
+
hoverMarker={
|
|
1451
|
+
showArrows
|
|
1452
|
+
? isIncomingToHovered
|
|
1453
|
+
? `url(#${hoverIncomingArrowMarkerId})`
|
|
1454
|
+
: `url(#${hoverArrowMarkerId})`
|
|
1455
|
+
: undefined
|
|
1456
|
+
}
|
|
1457
|
+
hoverEnabled={cfg.hoverHighlight}
|
|
1458
|
+
hitStrokeWidth={edgeWidth + 8}
|
|
1459
|
+
hoverStrokeWidth={edgeWidth + 1.5}
|
|
1460
|
+
selectedStrokeWidth={edgeWidth + 1.5}
|
|
1461
|
+
onHoverChange={(value) => handleEdgeHoverChange(edge.id, value)}
|
|
1462
|
+
onClick={() => handleEdgeSelection(edge)}
|
|
1463
|
+
/>
|
|
1464
|
+
);
|
|
1465
|
+
})}
|
|
1466
|
+
</g>
|
|
1467
|
+
|
|
1468
|
+
<g aria-label="nodes">
|
|
1469
|
+
{positionedNodes.map((node) => (
|
|
1470
|
+
<GraphNode
|
|
1471
|
+
key={node.id}
|
|
1472
|
+
node={node}
|
|
1473
|
+
Vertex={Vertex}
|
|
1474
|
+
isSelected={selectedNodeSet.has(node.id)}
|
|
1475
|
+
isFocused={focusedNodeId === node.id}
|
|
1476
|
+
isHighlighted={effectiveHighlightedNodeSet.has(node.id)}
|
|
1477
|
+
activePathKey={focusedPath?.pathKey}
|
|
1478
|
+
activePathNodeIds={pathHighlight?.nodes}
|
|
1479
|
+
highlightColor={highlightColor}
|
|
1480
|
+
selectionColor={selectionColor}
|
|
1481
|
+
nodeBorderColor={nodeBorderColor}
|
|
1482
|
+
nodeBorderWidth={nodeBorderWidth}
|
|
1483
|
+
hoverNodeBorderColor={hoverNodeBorderColor}
|
|
1484
|
+
hoverNodeBothColor={hoverNodeBothColor}
|
|
1485
|
+
hoverNodeInColor={cfg.hoverNodeInColor}
|
|
1486
|
+
hoverNodeOutColor={cfg.hoverNodeOutColor}
|
|
1487
|
+
hoverNodeHighlight={cfg.hoverNodeHighlight}
|
|
1488
|
+
hoveredNodeStates={hoveredNodeStates ?? undefined}
|
|
1489
|
+
onNodeMeasure={handleNodeMeasure}
|
|
1490
|
+
onNodeFocus={updateFocusedNode}
|
|
1491
|
+
onNodeClick={handleNodeSelection}
|
|
1492
|
+
onNodeDoubleClick={handleNodeDoubleClick}
|
|
1493
|
+
onNodeMouseEnter={handleNodeMouseEnter}
|
|
1494
|
+
onNodeMouseLeave={handleNodeMouseLeave}
|
|
1495
|
+
onPathHover={handlePathHover}
|
|
1496
|
+
onPathLeave={handlePathLeave}
|
|
1497
|
+
/>
|
|
1498
|
+
))}
|
|
1499
|
+
</g>
|
|
1500
|
+
|
|
1501
|
+
{renderOverlay?.(renderContext)}
|
|
1502
|
+
</g>
|
|
1503
|
+
</g>
|
|
1504
|
+
|
|
1505
|
+
{selectionRect ? (
|
|
1506
|
+
<rect
|
|
1507
|
+
x={selectionRect.x}
|
|
1508
|
+
y={selectionRect.y}
|
|
1509
|
+
width={selectionRect.width}
|
|
1510
|
+
height={selectionRect.height}
|
|
1511
|
+
fill="rgba(59, 130, 246, 0.12)"
|
|
1512
|
+
stroke="rgba(59, 130, 246, 0.8)"
|
|
1513
|
+
strokeDasharray="6 4"
|
|
1514
|
+
pointerEvents="none"
|
|
1515
|
+
/>
|
|
1516
|
+
) : null}
|
|
1517
|
+
|
|
1518
|
+
{viewportControls}
|
|
1519
|
+
</svg>
|
|
1520
|
+
);
|
|
1521
|
+
};
|
|
1522
|
+
|
|
1523
|
+
type GraphComponent = <
|
|
1524
|
+
TGraph extends NxGraphInput = NxGraphInput,
|
|
1525
|
+
TNode extends PositionedNode<any, any, any> = PositionedNode,
|
|
1526
|
+
TEdge extends PositionedEdge<any, any> = PositionedEdge,
|
|
1527
|
+
TNodeRecord extends NodeData<any, any, any> = NodeData,
|
|
1528
|
+
TEdgeRecord extends EdgeData<any, any> = EdgeData,
|
|
1529
|
+
>(
|
|
1530
|
+
props: GraphProps<TGraph, TNode, TEdge, TNodeRecord, TEdgeRecord> &
|
|
1531
|
+
React.RefAttributes<GraphHandle>
|
|
1532
|
+
) => React.ReactElement | null;
|
|
1533
|
+
|
|
1534
|
+
const GraphBase = React.memo(
|
|
1535
|
+
React.forwardRef<
|
|
1536
|
+
GraphHandle,
|
|
1537
|
+
GraphProps<NxGraphInput, PositionedNode, PositionedEdge, NodeData, EdgeData>
|
|
1538
|
+
>(GraphInner)
|
|
1539
|
+
);
|
|
1540
|
+
|
|
1541
|
+
GraphBase.displayName = 'Graph';
|
|
1542
|
+
|
|
1543
|
+
export const Graph = GraphBase as GraphComponent;
|
|
1544
|
+
|
|
1545
|
+
export default Graph;
|