@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.
Files changed (52) hide show
  1. package/.eslintrc.json +6 -0
  2. package/CHANGELOG.md +58 -0
  3. package/README.md +0 -0
  4. package/dist/index.js +2509 -0
  5. package/dist/src/components/EdgePath.d.ts +25 -0
  6. package/dist/src/components/EdgePath.d.ts.map +1 -0
  7. package/dist/src/components/Graph.d.ts +6 -0
  8. package/dist/src/components/Graph.d.ts.map +1 -0
  9. package/dist/src/components/GraphLabels.d.ts +30 -0
  10. package/dist/src/components/GraphLabels.d.ts.map +1 -0
  11. package/dist/src/components/GraphNode.d.ts +35 -0
  12. package/dist/src/components/GraphNode.d.ts.map +1 -0
  13. package/dist/src/constants/defaults.d.ts +3 -0
  14. package/dist/src/constants/defaults.d.ts.map +1 -0
  15. package/dist/src/hooks/useGraphCollapse.d.ts +15 -0
  16. package/dist/src/hooks/useGraphCollapse.d.ts.map +1 -0
  17. package/dist/src/hooks/useGraphHover.d.ts +24 -0
  18. package/dist/src/hooks/useGraphHover.d.ts.map +1 -0
  19. package/dist/src/hooks/useGraphModel.d.ts +51 -0
  20. package/dist/src/hooks/useGraphModel.d.ts.map +1 -0
  21. package/dist/src/hooks/useGraphSearchState.d.ts +29 -0
  22. package/dist/src/hooks/useGraphSearchState.d.ts.map +1 -0
  23. package/dist/src/hooks/useStableConfig.d.ts +25 -0
  24. package/dist/src/hooks/useStableConfig.d.ts.map +1 -0
  25. package/dist/src/index.d.ts +7 -0
  26. package/dist/src/index.d.ts.map +1 -0
  27. package/dist/src/utils/columns.d.ts +7 -0
  28. package/dist/src/utils/columns.d.ts.map +1 -0
  29. package/dist/src/utils/pathHighlight.d.ts +29 -0
  30. package/dist/src/utils/pathHighlight.d.ts.map +1 -0
  31. package/dist/src/utils/viewport.d.ts +14 -0
  32. package/dist/src/utils/viewport.d.ts.map +1 -0
  33. package/dist/tsconfig.tsbuildinfo +1 -0
  34. package/package.json +52 -0
  35. package/project.json +60 -0
  36. package/src/components/EdgePath.tsx +103 -0
  37. package/src/components/Graph.tsx +1545 -0
  38. package/src/components/GraphLabels.tsx +141 -0
  39. package/src/components/GraphNode.tsx +214 -0
  40. package/src/constants/defaults.ts +62 -0
  41. package/src/hooks/useGraphCollapse.ts +53 -0
  42. package/src/hooks/useGraphHover.ts +206 -0
  43. package/src/hooks/useGraphModel.ts +445 -0
  44. package/src/hooks/useGraphSearchState.ts +215 -0
  45. package/src/hooks/useStableConfig.ts +108 -0
  46. package/src/index.ts +57 -0
  47. package/src/utils/columns.ts +51 -0
  48. package/src/utils/pathHighlight.ts +160 -0
  49. package/src/utils/viewport.ts +100 -0
  50. package/tsconfig.json +12 -0
  51. package/tsconfig.node.json +11 -0
  52. 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;