@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,445 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import { EdgeType, LayoutType, Point } from '@graph-render/types';
3
+ import { fromTypedNxGraph, layoutNodes, routeEdges } from '@graph-render/core';
4
+ import {
5
+ EdgeData,
6
+ GraphConfig,
7
+ GraphSearchResults,
8
+ LayoutOptions,
9
+ NodeData,
10
+ NxGraphInput,
11
+ PositionedEdge,
12
+ PositionedNode,
13
+ RouteEdgesOptions,
14
+ } from '@graph-render/types';
15
+ import type { NormalizedGraphConfig } from '@graph-render/core';
16
+ import { useGraphSearchState } from './useGraphSearchState';
17
+
18
+ const isFinitePoint = (point: Point | undefined): point is Point => {
19
+ return Boolean(point && Number.isFinite(point.x) && Number.isFinite(point.y));
20
+ };
21
+
22
+ const validatePositionedNodes = (
23
+ nodes: PositionedNode[],
24
+ expectedNodes: NodeData[],
25
+ source: 'layout' | 'layout override'
26
+ ): void => {
27
+ const expectedIds = new Set(expectedNodes.map((node) => node.id));
28
+
29
+ if (nodes.length !== expectedNodes.length) {
30
+ throw new Error(
31
+ `${source} must return ${expectedNodes.length} nodes, received ${nodes.length}.`
32
+ );
33
+ }
34
+
35
+ const seenIds = new Set<string>();
36
+ nodes.forEach((node) => {
37
+ if (!expectedIds.has(node.id)) {
38
+ throw new Error(`${source} returned unknown node id "${node.id}".`);
39
+ }
40
+ if (seenIds.has(node.id)) {
41
+ throw new Error(`${source} returned duplicate node id "${node.id}".`);
42
+ }
43
+ if (!isFinitePoint(node.position)) {
44
+ throw new Error(`${source} returned a non-finite position for node "${node.id}".`);
45
+ }
46
+ seenIds.add(node.id);
47
+ });
48
+ };
49
+
50
+ const validatePositionedEdges = (
51
+ edges: PositionedEdge[],
52
+ nodeIds: Set<string>,
53
+ source: 'routing' | 'routing override'
54
+ ): void => {
55
+ const seenIds = new Set<string>();
56
+
57
+ edges.forEach((edge) => {
58
+ if (seenIds.has(edge.id)) {
59
+ throw new Error(`${source} returned duplicate edge id "${edge.id}".`);
60
+ }
61
+ if (!nodeIds.has(edge.source) || !nodeIds.has(edge.target)) {
62
+ throw new Error(
63
+ `${source} returned edge "${edge.id}" with unknown endpoint(s): ${edge.source} -> ${edge.target}.`
64
+ );
65
+ }
66
+ if (!Array.isArray(edge.points) || edge.points.length < 2) {
67
+ throw new Error(`${source} returned edge "${edge.id}" without a valid point path.`);
68
+ }
69
+ edge.points.forEach((point, index) => {
70
+ if (!isFinitePoint(point)) {
71
+ throw new Error(
72
+ `${source} returned a non-finite point at index ${index} for edge "${edge.id}".`
73
+ );
74
+ }
75
+ });
76
+ seenIds.add(edge.id);
77
+ });
78
+ };
79
+
80
+ const toError = (error: unknown): Error => {
81
+ return error instanceof Error ? error : new Error(String(error));
82
+ };
83
+
84
+ const buildFallbackLayout = (layoutOptions: LayoutOptions): PositionedNode[] => {
85
+ return layoutNodes({
86
+ ...layoutOptions,
87
+ layout: LayoutType.Centered,
88
+ });
89
+ };
90
+
91
+ const buildFallbackEdges = (
92
+ positionedNodes: PositionedNode[],
93
+ edges: EdgeData[]
94
+ ): PositionedEdge[] => {
95
+ const nodeMap = new Map(positionedNodes.map((node) => [node.id, node]));
96
+
97
+ return edges.flatMap((edge) => {
98
+ const source = nodeMap.get(edge.source);
99
+ const target = nodeMap.get(edge.target);
100
+
101
+ if (!source || !target) {
102
+ return [];
103
+ }
104
+
105
+ const sourceWidth = source.size?.width ?? 0;
106
+ const sourceHeight = source.size?.height ?? 0;
107
+ const targetWidth = target.size?.width ?? 0;
108
+ const targetHeight = target.size?.height ?? 0;
109
+
110
+ if (source.id === target.id) {
111
+ const right = source.position.x + sourceWidth;
112
+ const top = source.position.y;
113
+ return [
114
+ {
115
+ ...edge,
116
+ type: edge.type ?? EdgeType.Directed,
117
+ points: [
118
+ { x: right - Math.min(sourceWidth * 0.25, 18), y: top + Math.min(sourceHeight * 0.35, 18) },
119
+ { x: right + 28, y: top - 20 },
120
+ { x: right + 36, y: top + sourceHeight / 2 },
121
+ { x: right - Math.min(sourceWidth * 0.25, 18), y: top + sourceHeight * 0.8 },
122
+ ],
123
+ },
124
+ ];
125
+ }
126
+
127
+ return [
128
+ {
129
+ ...edge,
130
+ type: edge.type ?? EdgeType.Directed,
131
+ points: [
132
+ {
133
+ x: source.position.x + sourceWidth / 2,
134
+ y: source.position.y + sourceHeight / 2,
135
+ },
136
+ {
137
+ x: target.position.x + targetWidth / 2,
138
+ y: target.position.y + targetHeight / 2,
139
+ },
140
+ ],
141
+ },
142
+ ];
143
+ });
144
+ };
145
+
146
+ interface UseGraphModelOptions {
147
+ graph: NxGraphInput;
148
+ config: NormalizedGraphConfig;
149
+ mergedTheme: NonNullable<GraphConfig['theme']>;
150
+ collapsedIds: string[];
151
+ hiddenNodeIds?: string[];
152
+ searchQuery?: string;
153
+ hideUnmatchedSearch?: boolean;
154
+ searchPredicate?: (node: NodeData, query: string) => boolean;
155
+ highlightedNodeIds?: string[];
156
+ highlightedEdgeIds?: string[];
157
+ highlightStrategy?: (context: {
158
+ nodes: NodeData[];
159
+ edges: EdgeData[];
160
+ query: string;
161
+ matchedNodeIds: string[];
162
+ matchedEdgeIds: string[];
163
+ }) => Partial<GraphSearchResults>;
164
+ onSearchResultsChange?: (results: GraphSearchResults) => void;
165
+ layoutNodesOverride?: (options: LayoutOptions) => PositionedNode[];
166
+ routeEdgesOverride?: (
167
+ nodes: PositionedNode[],
168
+ edges: EdgeData[],
169
+ options?: RouteEdgesOptions
170
+ ) => PositionedEdge[];
171
+ /**
172
+ * Called whenever an internal layout or routing step throws.
173
+ * - `'layout'` — the default `layoutNodes` threw
174
+ * - `'layout-override'` — a `layoutNodesOverride` threw (default was used as fallback)
175
+ * - `'routing'` — the default `routeEdges` threw
176
+ * - `'routing-override'`— a `routeEdgesOverride` threw (default was used as fallback)
177
+ */
178
+ onError?: (error: Error, context: { graph: NxGraphInput; phase: 'layout' | 'layout-override' | 'routing' | 'routing-override' }) => void;
179
+ }
180
+
181
+ export interface GraphModelResult {
182
+ childNodeIdsByParent: Map<string, string[]>;
183
+ effectiveHighlightedEdgeSet: Set<string>;
184
+ effectiveHighlightedNodeSet: Set<string>;
185
+ handleNodeMeasure: (nodeId: string, size: { width: number; height: number }) => void;
186
+ positionedEdges: PositionedEdge[];
187
+ positionedNodes: PositionedNode[];
188
+ visibleEdges: EdgeData[];
189
+ visibleNodesWithMeasuredSize: NodeData[];
190
+ }
191
+
192
+ export const useGraphModel = ({
193
+ graph,
194
+ config,
195
+ mergedTheme,
196
+ collapsedIds,
197
+ hiddenNodeIds,
198
+ searchQuery,
199
+ hideUnmatchedSearch = false,
200
+ searchPredicate,
201
+ highlightedNodeIds,
202
+ highlightedEdgeIds,
203
+ highlightStrategy,
204
+ onSearchResultsChange,
205
+ layoutNodesOverride,
206
+ routeEdgesOverride,
207
+ onError,
208
+ }: UseGraphModelOptions): GraphModelResult => {
209
+ const [measuredNodeSizes, setMeasuredNodeSizes] = useState<
210
+ Record<string, { width: number; height: number }>
211
+ >({});
212
+
213
+ const { nodes: sourceNodes, edges: sourceEdges } = useMemo(
214
+ () => fromTypedNxGraph(graph, config.defaultEdgeType),
215
+ [config.defaultEdgeType, graph]
216
+ );
217
+
218
+ const nodesWithMeasuredSize = useMemo(
219
+ () =>
220
+ sourceNodes.map((node) => ({
221
+ ...node,
222
+ measuredSize: measuredNodeSizes[node.id] ?? node.measuredSize,
223
+ })),
224
+ [measuredNodeSizes, sourceNodes]
225
+ );
226
+
227
+ useEffect(() => {
228
+ const validNodeIds = new Set(sourceNodes.map((node) => node.id));
229
+
230
+ setMeasuredNodeSizes((current) => {
231
+ const nextEntries = Object.entries(current).filter(([nodeId]) => validNodeIds.has(nodeId));
232
+ if (nextEntries.length === Object.keys(current).length) {
233
+ return current;
234
+ }
235
+
236
+ return Object.fromEntries(nextEntries);
237
+ });
238
+ }, [sourceNodes]);
239
+
240
+ const normalizedEdges = useMemo(
241
+ () =>
242
+ sourceEdges.map((edge) => ({
243
+ ...edge,
244
+ type: edge.type ?? config.defaultEdgeType,
245
+ })),
246
+ [config.defaultEdgeType, sourceEdges]
247
+ );
248
+
249
+ const {
250
+ effectiveHighlightedNodeSet,
251
+ effectiveHighlightedEdgeSet,
252
+ visibleNodes,
253
+ visibleEdges,
254
+ childNodeIdsByParent,
255
+ } = useGraphSearchState({
256
+ nodes: nodesWithMeasuredSize,
257
+ edges: normalizedEdges,
258
+ collapsedIds,
259
+ hiddenNodeIds,
260
+ searchQuery,
261
+ hideUnmatchedSearch,
262
+ searchPredicate,
263
+ highlightedNodeIds,
264
+ highlightedEdgeIds,
265
+ highlightStrategy,
266
+ onSearchResultsChange,
267
+ });
268
+
269
+ const layoutOptions = useMemo(
270
+ () => ({
271
+ nodes: visibleNodes,
272
+ edges: visibleEdges,
273
+ theme: mergedTheme,
274
+ padding: config.padding,
275
+ layout: config.layout,
276
+ width: config.width,
277
+ height: config.height,
278
+ layoutDirection: config.layoutDirection,
279
+ nodeSizing: config.nodeSizing,
280
+ fixedNodeSize: config.fixedNodeSize,
281
+ labelMeasurementPaddingX: config.labelMeasurementPaddingX,
282
+ labelMeasurementPaddingY: config.labelMeasurementPaddingY,
283
+ labelMeasurementCharWidth: config.labelMeasurementCharWidth,
284
+ labelMeasurementLineHeight: config.labelMeasurementLineHeight,
285
+ }),
286
+ [config, mergedTheme, visibleEdges, visibleNodes]
287
+ );
288
+
289
+ const handleNodeMeasure = useCallback(
290
+ (nodeId: string, size: { width: number; height: number }) => {
291
+ if (config.nodeSizing !== 'measured') {
292
+ return;
293
+ }
294
+
295
+ setMeasuredNodeSizes((current) => {
296
+ const previous = current[nodeId];
297
+ if (previous && previous.width === size.width && previous.height === size.height) {
298
+ return current;
299
+ }
300
+
301
+ return {
302
+ ...current,
303
+ [nodeId]: size,
304
+ };
305
+ });
306
+ },
307
+ [config.nodeSizing]
308
+ );
309
+
310
+ const positionedNodes: PositionedNode[] = useMemo(
311
+ () => {
312
+ if (!layoutNodesOverride) {
313
+ try {
314
+ const laidOutNodes = layoutNodes(layoutOptions);
315
+ validatePositionedNodes(laidOutNodes, visibleNodes, 'layout');
316
+ return laidOutNodes;
317
+ } catch (error) {
318
+ onError?.(toError(error), {
319
+ graph,
320
+ phase: 'layout',
321
+ });
322
+
323
+ try {
324
+ const fallbackNodes = buildFallbackLayout(layoutOptions);
325
+ validatePositionedNodes(fallbackNodes, visibleNodes, 'layout');
326
+ return fallbackNodes;
327
+ } catch (fallbackError) {
328
+ const normalizedFallbackError = toError(fallbackError);
329
+ onError?.(normalizedFallbackError, {
330
+ graph,
331
+ phase: 'layout',
332
+ });
333
+ throw normalizedFallbackError;
334
+ }
335
+ }
336
+ }
337
+
338
+ try {
339
+ const overrideNodes = layoutNodesOverride(layoutOptions);
340
+ validatePositionedNodes(overrideNodes, visibleNodes, 'layout override');
341
+ return overrideNodes;
342
+ } catch (error) {
343
+ onError?.(toError(error), {
344
+ graph,
345
+ phase: 'layout-override',
346
+ });
347
+
348
+ try {
349
+ const fallbackNodes = layoutNodes(layoutOptions);
350
+ validatePositionedNodes(fallbackNodes, visibleNodes, 'layout');
351
+ return fallbackNodes;
352
+ } catch (fallbackError) {
353
+ onError?.(toError(fallbackError), { graph, phase: 'layout' });
354
+
355
+ try {
356
+ const finalFallbackNodes = buildFallbackLayout(layoutOptions);
357
+ validatePositionedNodes(finalFallbackNodes, visibleNodes, 'layout');
358
+ return finalFallbackNodes;
359
+ } catch (finalFallbackError) {
360
+ const normalizedFinalFallbackError = toError(finalFallbackError);
361
+ onError?.(normalizedFinalFallbackError, { graph, phase: 'layout' });
362
+ throw normalizedFinalFallbackError;
363
+ }
364
+ }
365
+ }
366
+ },
367
+ [graph, layoutNodesOverride, layoutOptions, onError, visibleNodes]
368
+ );
369
+
370
+ const edgeRoutingOptions = useMemo(
371
+ () => ({
372
+ arrowPadding: config.arrowPadding,
373
+ straight: !config.curveEdges || config.routingStyle === 'orthogonal',
374
+ layoutDirection: config.layoutDirection,
375
+ forceRightToLeft: config.forceRightToLeft,
376
+ routingStyle: config.routingStyle,
377
+ edgeSeparation: config.edgeSeparation,
378
+ selfLoopRadius: config.selfLoopRadius,
379
+ }),
380
+ [config]
381
+ );
382
+
383
+ const positionedEdges: PositionedEdge[] = useMemo(
384
+ () => {
385
+ const nodeIds = new Set(positionedNodes.map((node) => node.id));
386
+
387
+ if (!routeEdgesOverride) {
388
+ try {
389
+ const routedEdges = routeEdges(positionedNodes, visibleEdges, edgeRoutingOptions);
390
+ validatePositionedEdges(routedEdges, nodeIds, 'routing');
391
+ return routedEdges;
392
+ } catch (error) {
393
+ onError?.(toError(error), {
394
+ graph,
395
+ phase: 'routing',
396
+ });
397
+ const fallbackEdges = buildFallbackEdges(positionedNodes, visibleEdges);
398
+ validatePositionedEdges(fallbackEdges, nodeIds, 'routing');
399
+ return fallbackEdges;
400
+ }
401
+ }
402
+
403
+ try {
404
+ const overrideEdges = routeEdgesOverride(positionedNodes, visibleEdges, edgeRoutingOptions);
405
+ validatePositionedEdges(overrideEdges, nodeIds, 'routing override');
406
+ return overrideEdges;
407
+ } catch (error) {
408
+ onError?.(toError(error), {
409
+ graph,
410
+ phase: 'routing-override',
411
+ });
412
+
413
+ try {
414
+ const fallbackEdges = routeEdges(positionedNodes, visibleEdges, edgeRoutingOptions);
415
+ validatePositionedEdges(fallbackEdges, nodeIds, 'routing');
416
+ return fallbackEdges;
417
+ } catch (fallbackError) {
418
+ onError?.(toError(fallbackError), { graph, phase: 'routing' });
419
+ const finalFallbackEdges = buildFallbackEdges(positionedNodes, visibleEdges);
420
+ validatePositionedEdges(finalFallbackEdges, nodeIds, 'routing');
421
+ return finalFallbackEdges;
422
+ }
423
+ }
424
+ },
425
+ [
426
+ edgeRoutingOptions,
427
+ graph,
428
+ onError,
429
+ positionedNodes,
430
+ routeEdgesOverride,
431
+ visibleEdges,
432
+ ]
433
+ );
434
+
435
+ return {
436
+ childNodeIdsByParent,
437
+ effectiveHighlightedEdgeSet,
438
+ effectiveHighlightedNodeSet,
439
+ handleNodeMeasure,
440
+ positionedEdges,
441
+ positionedNodes,
442
+ visibleEdges,
443
+ visibleNodesWithMeasuredSize: visibleNodes,
444
+ };
445
+ };
@@ -0,0 +1,215 @@
1
+ import { useEffect, useMemo, useRef } from 'react';
2
+ import { EdgeData, GraphSearchResults, NodeData } from '@graph-render/types';
3
+
4
+ interface UseGraphSearchStateOptions<
5
+ TNode extends NodeData<any, any, any> = NodeData,
6
+ TEdge extends EdgeData<any, any> = EdgeData,
7
+ > {
8
+ nodes: TNode[];
9
+ edges: TEdge[];
10
+ collapsedIds: string[];
11
+ hiddenNodeIds?: string[];
12
+ searchQuery?: string;
13
+ hideUnmatchedSearch?: boolean;
14
+ searchPredicate?: (node: TNode, query: string) => boolean;
15
+ highlightedNodeIds?: string[];
16
+ highlightedEdgeIds?: string[];
17
+ highlightStrategy?: (context: {
18
+ nodes: TNode[];
19
+ edges: TEdge[];
20
+ query: string;
21
+ matchedNodeIds: string[];
22
+ matchedEdgeIds: string[];
23
+ }) => Partial<GraphSearchResults>;
24
+ onSearchResultsChange?: (results: GraphSearchResults) => void;
25
+ }
26
+
27
+ export const useGraphSearchState = <
28
+ TNode extends NodeData<any, any, any> = NodeData,
29
+ TEdge extends EdgeData<any, any> = EdgeData,
30
+ >({
31
+ nodes,
32
+ edges,
33
+ collapsedIds,
34
+ hiddenNodeIds,
35
+ searchQuery,
36
+ hideUnmatchedSearch = false,
37
+ searchPredicate,
38
+ highlightedNodeIds,
39
+ highlightedEdgeIds,
40
+ highlightStrategy,
41
+ onSearchResultsChange,
42
+ }: UseGraphSearchStateOptions<TNode, TEdge>) => {
43
+ // Store the callback in a ref so that inline closures (whose reference
44
+ // changes on every parent render) do not destabilise the effect below and
45
+ // create an infinite render → effect → setState → render loop.
46
+ const onSearchResultsChangeRef = useRef(onSearchResultsChange);
47
+ onSearchResultsChangeRef.current = onSearchResultsChange;
48
+
49
+ const searchMatchedNodeIds = useMemo(() => {
50
+ const query = searchQuery?.trim().toLowerCase();
51
+ if (!query) {
52
+ return [];
53
+ }
54
+
55
+ return nodes
56
+ .filter((node) => {
57
+ if (searchPredicate) {
58
+ try {
59
+ return searchPredicate(node, query);
60
+ } catch {
61
+ return false;
62
+ }
63
+ }
64
+
65
+ const label =
66
+ typeof node.label === 'string' || typeof node.label === 'number'
67
+ ? String(node.label)
68
+ : node.id;
69
+
70
+ return node.id.toLowerCase().includes(query) || label.toLowerCase().includes(query);
71
+ })
72
+ .map((node) => node.id);
73
+ }, [nodes, searchPredicate, searchQuery]);
74
+
75
+ const searchMatchedNodeIdSet = useMemo(
76
+ () => new Set(searchMatchedNodeIds),
77
+ [searchMatchedNodeIds]
78
+ );
79
+
80
+ const searchMatchedEdgeIds = useMemo(() => {
81
+ const query = searchQuery?.trim().toLowerCase();
82
+ if (!query) {
83
+ return [];
84
+ }
85
+
86
+ return edges
87
+ .filter((edge) => {
88
+ const label = edge.label != null ? String(edge.label).toLowerCase() : '';
89
+ return (
90
+ searchMatchedNodeIdSet.has(edge.source) ||
91
+ searchMatchedNodeIdSet.has(edge.target) ||
92
+ label.includes(query)
93
+ );
94
+ })
95
+ .map((edge) => edge.id);
96
+ }, [edges, searchMatchedNodeIdSet, searchQuery]);
97
+
98
+ const derivedHighlightResults = useMemo(() => {
99
+ if (!searchQuery?.trim()) {
100
+ return { nodeIds: [], edgeIds: [] };
101
+ }
102
+
103
+ return (
104
+ (() => {
105
+ try {
106
+ return (
107
+ highlightStrategy?.({
108
+ nodes,
109
+ edges,
110
+ query: searchQuery,
111
+ matchedNodeIds: searchMatchedNodeIds,
112
+ matchedEdgeIds: searchMatchedEdgeIds,
113
+ }) ?? { nodeIds: [], edgeIds: [] }
114
+ );
115
+ } catch {
116
+ return { nodeIds: [], edgeIds: [] };
117
+ }
118
+ })()
119
+ );
120
+ }, [edges, highlightStrategy, nodes, searchMatchedEdgeIds, searchMatchedNodeIds, searchQuery]);
121
+
122
+ const effectiveHighlightedNodeSet = useMemo(
123
+ () =>
124
+ new Set([
125
+ ...searchMatchedNodeIds,
126
+ ...(derivedHighlightResults.nodeIds ?? []),
127
+ ...(highlightedNodeIds ?? []),
128
+ ]),
129
+ [derivedHighlightResults.nodeIds, highlightedNodeIds, searchMatchedNodeIds]
130
+ );
131
+
132
+ const effectiveHighlightedEdgeSet = useMemo(
133
+ () =>
134
+ new Set([
135
+ ...searchMatchedEdgeIds,
136
+ ...(derivedHighlightResults.edgeIds ?? []),
137
+ ...(highlightedEdgeIds ?? []),
138
+ ]),
139
+ [derivedHighlightResults.edgeIds, highlightedEdgeIds, searchMatchedEdgeIds]
140
+ );
141
+
142
+ useEffect(() => {
143
+ onSearchResultsChangeRef.current?.({
144
+ nodeIds: searchMatchedNodeIds,
145
+ edgeIds: searchMatchedEdgeIds,
146
+ });
147
+ // onSearchResultsChange is intentionally read via ref — omitting it from
148
+ // the dep array prevents inline callbacks from creating an infinite loop.
149
+ }, [searchMatchedEdgeIds, searchMatchedNodeIds]);
150
+
151
+ const hiddenNodeSet = useMemo(() => {
152
+ const hidden = new Set(hiddenNodeIds ?? []);
153
+ if (hideUnmatchedSearch && searchQuery?.trim()) {
154
+ nodes.forEach((node) => {
155
+ if (!effectiveHighlightedNodeSet.has(node.id)) {
156
+ hidden.add(node.id);
157
+ }
158
+ });
159
+ }
160
+ return hidden;
161
+ }, [effectiveHighlightedNodeSet, hiddenNodeIds, hideUnmatchedSearch, nodes, searchQuery]);
162
+
163
+ // Build the outgoing adjacency map once, keyed only on `edges`.
164
+ // Both `descendantHiddenNodeSet` (collapse traversal) and
165
+ // `childNodeIdsByParent` (returned to callers) previously built this map
166
+ // independently, causing duplicate O(n) work on every collapse toggle.
167
+ const outgoingBySource = useMemo(() => {
168
+ const map = new Map<string, string[]>();
169
+ edges.forEach((edge) => {
170
+ map.set(edge.source, [...(map.get(edge.source) ?? []), edge.target]);
171
+ });
172
+ return map;
173
+ }, [edges]);
174
+
175
+ const descendantHiddenNodeSet = useMemo(() => {
176
+ const hidden = new Set(hiddenNodeSet);
177
+ collapsedIds.forEach((nodeId) => {
178
+ const stack = [...(outgoingBySource.get(nodeId) ?? [])];
179
+ while (stack.length) {
180
+ const current = stack.pop();
181
+ if (!current || hidden.has(current)) {
182
+ continue;
183
+ }
184
+ hidden.add(current);
185
+ stack.push(...(outgoingBySource.get(current) ?? []));
186
+ }
187
+ });
188
+
189
+ return hidden;
190
+ }, [collapsedIds, hiddenNodeSet, outgoingBySource]);
191
+
192
+ const visibleNodes = useMemo(
193
+ () => nodes.filter((node) => !descendantHiddenNodeSet.has(node.id)),
194
+ [descendantHiddenNodeSet, nodes]
195
+ );
196
+
197
+ const visibleEdges = useMemo(
198
+ () =>
199
+ edges.filter(
200
+ (edge) =>
201
+ !descendantHiddenNodeSet.has(edge.source) && !descendantHiddenNodeSet.has(edge.target)
202
+ ),
203
+ [descendantHiddenNodeSet, edges]
204
+ );
205
+
206
+ const childNodeIdsByParent = outgoingBySource;
207
+
208
+ return {
209
+ effectiveHighlightedNodeSet,
210
+ effectiveHighlightedEdgeSet,
211
+ visibleNodes,
212
+ visibleEdges,
213
+ childNodeIdsByParent,
214
+ };
215
+ };