@ltm-blueverse/alpha-semantic-hub 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1674 -0
  3. package/explorer/README.md +282 -0
  4. package/explorer/src/App.css +0 -0
  5. package/explorer/src/App.tsx +2035 -0
  6. package/explorer/src/ErrorBoundary.tsx +112 -0
  7. package/explorer/src/ExploreWorkspaceTabs.tsx +29 -0
  8. package/explorer/src/assets/hero.png +0 -0
  9. package/explorer/src/assets/react.svg +1 -0
  10. package/explorer/src/assets/vite.svg +1 -0
  11. package/explorer/src/explorerCapabilities.ts +24 -0
  12. package/explorer/src/index.css +71 -0
  13. package/explorer/src/main.tsx +7 -0
  14. package/explorer/src/store/edgePairKeys.d.ts +2 -0
  15. package/explorer/src/store/edgePairKeys.js +7 -0
  16. package/explorer/src/store/graphStore.ts +183 -0
  17. package/explorer/src/store/registryStore.ts +78 -0
  18. package/explorer/src/types.d.ts +16 -0
  19. package/explorer/src/ui/primitives.css +414 -0
  20. package/explorer/src/ui/primitives.tsx +170 -0
  21. package/explorer/src/workspaces/DecisionWorkspace/DecisionWorkspace.tsx +279 -0
  22. package/explorer/src/workspaces/DiffMergeWorkspace/DiffMergeWorkspace.tsx +121 -0
  23. package/explorer/src/workspaces/EnrichWorkspace/EntityResolutionTab.tsx +454 -0
  24. package/explorer/src/workspaces/EnrichWorkspace/RegistryTab.tsx +285 -0
  25. package/explorer/src/workspaces/GraphWorkspace/GraphCanvas.tsx +2673 -0
  26. package/explorer/src/workspaces/GraphWorkspace/GraphInspectorPanel.tsx +771 -0
  27. package/explorer/src/workspaces/GraphWorkspace/GraphLoadingOverlay.tsx +410 -0
  28. package/explorer/src/workspaces/GraphWorkspace/GraphWorkspace.tsx +3584 -0
  29. package/explorer/src/workspaces/GraphWorkspace/MarkdownContentViewer.tsx +705 -0
  30. package/explorer/src/workspaces/GraphWorkspace/SigmaSceneAdapter.tsx +55 -0
  31. package/explorer/src/workspaces/GraphWorkspace/TimelinePanel.tsx +193 -0
  32. package/explorer/src/workspaces/GraphWorkspace/behaviors/clickSelectionBehavior.ts +29 -0
  33. package/explorer/src/workspaces/GraphWorkspace/behaviors/fitViewBehavior.ts +15 -0
  34. package/explorer/src/workspaces/GraphWorkspace/behaviors/focusCameraBehavior.ts +25 -0
  35. package/explorer/src/workspaces/GraphWorkspace/behaviors/hoverActivationBehavior.ts +15 -0
  36. package/explorer/src/workspaces/GraphWorkspace/behaviors/pathHighlightBehavior.ts +52 -0
  37. package/explorer/src/workspaces/GraphWorkspace/behaviors/searchFocusBehavior.ts +36 -0
  38. package/explorer/src/workspaces/GraphWorkspace/behaviors/types.ts +41 -0
  39. package/explorer/src/workspaces/GraphWorkspace/behaviors/viewModeSwitchBehavior.ts +29 -0
  40. package/explorer/src/workspaces/GraphWorkspace/graphAnalytics.ts +779 -0
  41. package/explorer/src/workspaces/GraphWorkspace/graphColorLegend.ts +36 -0
  42. package/explorer/src/workspaces/GraphWorkspace/graphConfig.ts +102 -0
  43. package/explorer/src/workspaces/GraphWorkspace/graphEntityShape.ts +34 -0
  44. package/explorer/src/workspaces/GraphWorkspace/graphLoading.ts +84 -0
  45. package/explorer/src/workspaces/GraphWorkspace/graphSceneLayers.ts +775 -0
  46. package/explorer/src/workspaces/GraphWorkspace/graphSceneState.ts +2753 -0
  47. package/explorer/src/workspaces/GraphWorkspace/graphStructureLayer.ts +335 -0
  48. package/explorer/src/workspaces/GraphWorkspace/graphTheme.ts +1078 -0
  49. package/explorer/src/workspaces/GraphWorkspace/graphViewCopy.ts +41 -0
  50. package/explorer/src/workspaces/GraphWorkspace/localGraphTransition.ts +9 -0
  51. package/explorer/src/workspaces/GraphWorkspace/markdownEditorState.ts +114 -0
  52. package/explorer/src/workspaces/GraphWorkspace/markdownResourceClient.ts +103 -0
  53. package/explorer/src/workspaces/GraphWorkspace/markdownUrlSafety.ts +29 -0
  54. package/explorer/src/workspaces/GraphWorkspace/nodeMarkdownSync.ts +81 -0
  55. package/explorer/src/workspaces/GraphWorkspace/pluginRegistryPredicates.ts +24 -0
  56. package/explorer/src/workspaces/GraphWorkspace/plugins/explorationEffectsPlugin.tsx +353 -0
  57. package/explorer/src/workspaces/GraphWorkspace/plugins/explorationEffectsPluginPhaseC.tsx +522 -0
  58. package/explorer/src/workspaces/GraphWorkspace/plugins/index.ts +16 -0
  59. package/explorer/src/workspaces/GraphWorkspace/plugins/legendPlugin.tsx +120 -0
  60. package/explorer/src/workspaces/GraphWorkspace/plugins/neighborhoodPanelPlugin.tsx +231 -0
  61. package/explorer/src/workspaces/GraphWorkspace/plugins/temporalOverlayPlugin.tsx +142 -0
  62. package/explorer/src/workspaces/GraphWorkspace/plugins/types.ts +106 -0
  63. package/explorer/src/workspaces/GraphWorkspace/realtimeGraphAttributes.ts +50 -0
  64. package/explorer/src/workspaces/GraphWorkspace/scene.ts +79 -0
  65. package/explorer/src/workspaces/GraphWorkspace/sigmaNativeRendering.ts +421 -0
  66. package/explorer/src/workspaces/GraphWorkspace/smallGraphLayout.ts +135 -0
  67. package/explorer/src/workspaces/GraphWorkspace/temporalLifecyclePredicates.ts +31 -0
  68. package/explorer/src/workspaces/GraphWorkspace/temporalScrubberBounds.ts +46 -0
  69. package/explorer/src/workspaces/GraphWorkspace/temporalSnapshotGuards.ts +113 -0
  70. package/explorer/src/workspaces/GraphWorkspace/types.ts +395 -0
  71. package/explorer/src/workspaces/GraphWorkspace/useLoadGraph.ts +746 -0
  72. package/explorer/src/workspaces/GraphWorkspace/useMarkdownEditor.ts +177 -0
  73. package/explorer/src/workspaces/ImportExportWorkspace/ImportExportWorkspace.tsx +196 -0
  74. package/explorer/src/workspaces/LineageWorkspace/LineageDiagram.tsx +177 -0
  75. package/explorer/src/workspaces/ManageWorkspace/KGOverviewTab.tsx +291 -0
  76. package/explorer/src/workspaces/ManageWorkspace/OntologySummaryTab.tsx +346 -0
  77. package/explorer/src/workspaces/MemoryWorkspace.tsx +464 -0
  78. package/explorer/src/workspaces/OntologyWorkspace/AlignmentsTab.tsx +437 -0
  79. package/explorer/src/workspaces/OntologyWorkspace/HealthTab.tsx +214 -0
  80. package/explorer/src/workspaces/OntologyWorkspace/OntologyEditor.tsx +770 -0
  81. package/explorer/src/workspaces/OntologyWorkspace/OntologyLoader.tsx +913 -0
  82. package/explorer/src/workspaces/OntologyWorkspace/OntologyManager.tsx +930 -0
  83. package/explorer/src/workspaces/OntologyWorkspace/OntologySearch.tsx +590 -0
  84. package/explorer/src/workspaces/OntologyWorkspace/ProposalReview.tsx +429 -0
  85. package/explorer/src/workspaces/OntologyWorkspace/SKOSVocabularyManager.tsx +696 -0
  86. package/explorer/src/workspaces/OntologyWorkspace/ShaclStudio.tsx +363 -0
  87. package/explorer/src/workspaces/OntologyWorkspace/VersionsTab.tsx +546 -0
  88. package/explorer/src/workspaces/OntologyWorkspace/api.ts +164 -0
  89. package/explorer/src/workspaces/OntologyWorkspace/index.tsx +101 -0
  90. package/explorer/src/workspaces/OntologyWorkspace/ontologyEditorModel.ts +87 -0
  91. package/explorer/src/workspaces/OntologyWorkspace/ontologyUrlState.ts +94 -0
  92. package/explorer/src/workspaces/OntologyWorkspace/types.ts +119 -0
  93. package/explorer/src/workspaces/ReasoningWorkspace.tsx +243 -0
  94. package/explorer/src/workspaces/SparqlWorkspace/SparqlWorkspace.tsx +271 -0
  95. package/explorer/src/workspaces/VocabularyWorkspace/ConceptTree.tsx +97 -0
  96. package/explorer/src/workspaces/VocabularyWorkspace/ImportDropzone.tsx +95 -0
  97. package/explorer/src/workspaces/VocabularyWorkspace/PropertyPanel.tsx +116 -0
  98. package/explorer/src/workspaces/VocabularyWorkspace/Sidebar.tsx +112 -0
  99. package/explorer/src/workspaces/VocabularyWorkspace/VocabularyWorkspace.tsx +70 -0
  100. package/explorer/src/workspaces/VocabularyWorkspace/queries.ts +52 -0
  101. package/explorer/src/workspaces/VocabularyWorkspace/types.ts +24 -0
  102. package/package.json +31 -0
  103. package/plugins/.claude-plugin/README.md +262 -0
  104. package/plugins/.claude-plugin/marketplace.json +20 -0
  105. package/plugins/.claude-plugin/plugin.json +33 -0
  106. package/plugins/.cline-plugin/README.md +32 -0
  107. package/plugins/.cline-plugin/marketplace.json +17 -0
  108. package/plugins/.cline-plugin/plugin.json +35 -0
  109. package/plugins/.codex-plugin/marketplace.json +21 -0
  110. package/plugins/.codex-plugin/plugin.json +35 -0
  111. package/plugins/.continue-plugin/README.md +34 -0
  112. package/plugins/.continue-plugin/marketplace.json +17 -0
  113. package/plugins/.continue-plugin/plugin.json +33 -0
  114. package/plugins/.cursor-plugin/marketplace.json +18 -0
  115. package/plugins/.cursor-plugin/plugin.json +32 -0
  116. package/plugins/.openclaw-plugin/README.md +62 -0
  117. package/plugins/.openclaw-plugin/marketplace.json +18 -0
  118. package/plugins/.openclaw-plugin/plugin.json +46 -0
  119. package/plugins/.pi-plugin/README.md +35 -0
  120. package/plugins/.vscode-plugin/README.md +48 -0
  121. package/plugins/.vscode-plugin/marketplace.json +17 -0
  122. package/plugins/.vscode-plugin/plugin.json +35 -0
  123. package/plugins/.windsurf-plugin/README.md +39 -0
  124. package/plugins/.windsurf-plugin/marketplace.json +17 -0
  125. package/plugins/.windsurf-plugin/plugin.json +35 -0
  126. package/plugins/agents/decision-advisor.md +126 -0
  127. package/plugins/agents/explainability.md +129 -0
  128. package/plugins/agents/kg-assistant.md +73 -0
  129. package/plugins/hooks/hooks.json +12 -0
  130. package/plugins/skills/causal/SKILL.md +74 -0
  131. package/plugins/skills/change/SKILL.md +80 -0
  132. package/plugins/skills/decision/SKILL.md +197 -0
  133. package/plugins/skills/deduplicate/SKILL.md +41 -0
  134. package/plugins/skills/embed/SKILL.md +230 -0
  135. package/plugins/skills/explain/SKILL.md +44 -0
  136. package/plugins/skills/export/SKILL.md +67 -0
  137. package/plugins/skills/extract/SKILL.md +93 -0
  138. package/plugins/skills/ingest/SKILL.md +38 -0
  139. package/plugins/skills/ontology/SKILL.md +89 -0
  140. package/plugins/skills/policy/SKILL.md +66 -0
  141. package/plugins/skills/provenance/SKILL.md +70 -0
  142. package/plugins/skills/query/SKILL.md +84 -0
  143. package/plugins/skills/reason/SKILL.md +201 -0
  144. package/plugins/skills/temporal/SKILL.md +164 -0
  145. package/plugins/skills/validate/SKILL.md +228 -0
  146. package/plugins/skills/visualize/SKILL.md +249 -0
@@ -0,0 +1,2673 @@
1
+ import { useEffect, useMemo, useRef, useCallback, forwardRef, useImperativeHandle, useState, type ReactNode } from "react";
2
+ import Sigma from "sigma";
3
+ import FA2Layout from "graphology-layout-forceatlas2/worker";
4
+ import { graph, type EdgeAttributes, type NodeAttributes } from "../../store/graphStore";
5
+ import type { GraphBehavior, GraphBehaviorContext, GraphBehaviorActionRequest } from "./behaviors/types";
6
+ import { hoverActivationBehavior } from "./behaviors/hoverActivationBehavior";
7
+ import { clickSelectionBehavior } from "./behaviors/clickSelectionBehavior";
8
+ import { focusCameraBehavior } from "./behaviors/focusCameraBehavior";
9
+ import { createSearchFocusBehavior } from "./behaviors/searchFocusBehavior";
10
+ import { createPathHighlightBehavior } from "./behaviors/pathHighlightBehavior";
11
+ import { fitViewBehavior } from "./behaviors/fitViewBehavior";
12
+ import { createViewModeSwitchBehavior } from "./behaviors/viewModeSwitchBehavior";
13
+ import {
14
+ GRAPH_THEME,
15
+ getZoomTier,
16
+ type GraphBadgeKind,
17
+ type GraphZoomTier,
18
+ withAlpha,
19
+ zoomTierAtLeast,
20
+ } from "./graphTheme";
21
+ import {
22
+ buildFocusSetInGraph,
23
+ buildEdgeEndpointSet,
24
+ buildPathEdgeSet,
25
+ collectInteractionRefreshTargets,
26
+ createInteractionState,
27
+ isEdgeInteractable,
28
+ classifyFullGraphEdge,
29
+ mapFullEdgeClassToVisualState,
30
+ resolveEdgeElementStyle,
31
+ resolveEdgeVisualState,
32
+ resolveDistanceEdgeStyle,
33
+ resolveDistanceNodeStyle,
34
+ resolveNodeElementStyle,
35
+ resolveNodeVisualState,
36
+ } from "./graphSceneState";
37
+ import { buildGraphAnalyticsSnapshot, computeGraphAnalyticsBase } from "./graphAnalytics";
38
+ import {
39
+ collectVisibleNodeSamples,
40
+ drawContourLayer,
41
+ drawLensLayer,
42
+ drawPathEffectsLayer,
43
+ drawSemanticRegionsLayer,
44
+ drawTemporalEmphasisLayer,
45
+ type PathSegmentOverlay,
46
+ type ViewportPoint,
47
+ } from "./graphSceneLayers";
48
+ import {
49
+ ASHUB_EDGE_PROGRAM_CLASSES,
50
+ ASHUB_NODE_PROGRAM_CLASSES,
51
+ drawAlphaSemanticHubNodeHover,
52
+ drawAlphaSemanticHubNodeLabel,
53
+ } from "./sigmaNativeRendering";
54
+ import {
55
+ buildGraphStructureCurveCache,
56
+ clearGraphStructureLayer,
57
+ createGraphStructureCacheKey,
58
+ drawGraphStructureLayer,
59
+ evaluateGraphStructureLayerGate,
60
+ getGraphStructureLayerDiagnostics,
61
+ type GraphStructureCurveCache,
62
+ } from "./graphStructureLayer";
63
+ import type {
64
+ GraphAnalyticsSnapshot,
65
+ GraphCameraState,
66
+ GraphDistanceVisualState,
67
+ GraphDisplayMeta,
68
+ GraphDisplayStateSnapshot,
69
+ GraphDiagnosticsSnapshot,
70
+ GraphEffectsState,
71
+ GraphFullEdgeClass,
72
+ GraphFullEdgeClassCounts,
73
+ GraphFullEdgeClassDiagnostics,
74
+ GraphInteractionState,
75
+ GraphLayoutStatus,
76
+ GraphRuntimeDiagnosticsSnapshot,
77
+ GraphStructureLayerDiagnostics,
78
+ GraphTemporalState,
79
+ GraphViewMode,
80
+ } from "./types";
81
+ import type { GraphSceneGraph, GraphSceneRuntime } from "./scene";
82
+
83
+ export type { GraphViewMode } from "./types";
84
+
85
+ export interface GraphCanvasHandle {
86
+ fitView: () => void;
87
+ focusNode: (nodeId: string) => void;
88
+ zoomIn: () => void;
89
+ zoomOut: () => void;
90
+ requestRender: () => void;
91
+ getCameraState: () => GraphCameraState | null;
92
+ }
93
+
94
+ export interface GraphCanvasProps {
95
+ graphVersion: number;
96
+ graphReady: boolean;
97
+ displayGraph: GraphSceneGraph;
98
+ displayMeta: GraphDisplayMeta;
99
+ displayState?: GraphDisplayStateSnapshot;
100
+ onNodeClick: (nodeId: string) => void;
101
+ onEdgeClick?: (edgeId: string) => void;
102
+ selectedNodeId: string;
103
+ focusedNodeId: string;
104
+ selectedEdgeId: string;
105
+ activePath?: string[];
106
+ activePathEdgeIds?: string[];
107
+ distanceVisualState?: GraphDistanceVisualState;
108
+ effectsState: GraphEffectsState;
109
+ temporalState?: GraphTemporalState | null;
110
+ isLayoutRunning: boolean;
111
+ onLayoutRunningChange?: (running: boolean) => void;
112
+ layoutSource?: string;
113
+ onLayoutStatusChange?: (status: GraphLayoutStatus) => void;
114
+ viewMode: GraphViewMode;
115
+ className?: string;
116
+ showFitViewButton?: boolean;
117
+ pluginOverlays?: ReactNode[];
118
+ onSceneRuntimeChange?: (runtime: GraphSceneRuntime | null) => void;
119
+ onInteractionStateChange?: (interactionState: GraphInteractionState) => void;
120
+ onCameraStateChange?: (cameraState: GraphCameraState) => void;
121
+ onDiagnosticsChange?: (diagnostics: GraphRuntimeDiagnosticsSnapshot) => void;
122
+ onAnalyticsChange?: (analytics: GraphAnalyticsSnapshot | null) => void;
123
+ }
124
+
125
+ const FA2_SETTINGS = {
126
+ iterations: 50,
127
+ settings: {
128
+ barnesHutOptimize: true,
129
+ barnesHutTheta: 0.5,
130
+ adjustSizes: false,
131
+ gravity: 0.16,
132
+ scalingRatio: 7.5,
133
+ edgeWeightInfluence: 0.3,
134
+ linLogMode: true,
135
+ strongGravityMode: false,
136
+ slowDown: 18,
137
+ },
138
+ };
139
+
140
+ const FULL_GRAPH_LAYOUT_SETTLE_MS = 4_500;
141
+
142
+ const GROUPED_FA2_SETTINGS = {
143
+ iterations: GRAPH_THEME.grouped.layout.iterations,
144
+ settings: {
145
+ barnesHutOptimize: true,
146
+ barnesHutTheta: 0.5,
147
+ adjustSizes: true,
148
+ gravity: GRAPH_THEME.grouped.layout.gravity,
149
+ scalingRatio: GRAPH_THEME.grouped.layout.scalingRatio,
150
+ edgeWeightInfluence: GRAPH_THEME.grouped.layout.edgeWeightInfluence,
151
+ linLogMode: false,
152
+ strongGravityMode: false,
153
+ slowDown: GRAPH_THEME.grouped.layout.slowDown,
154
+ },
155
+ };
156
+
157
+ const SIGMA_SETTINGS = {
158
+ allowInvalidContainer: true,
159
+ labelRenderedSizeThreshold: 6,
160
+ defaultNodeType: "circle",
161
+ defaultEdgeType: "line",
162
+ hideLabelsOnMove: true,
163
+ hideEdgesOnMove: true,
164
+ enableEdgeEvents: true,
165
+ // #1009: edge labels (the edge `type` — "works_for", "leads", ...) were
166
+ // hardcoded off, so edge text never rendered regardless of data. The
167
+ // labelDensity / labelGridCellSize / labelRenderedSizeThreshold settings
168
+ // below already throttle label density for both nodes and edges.
169
+ renderEdgeLabels: true,
170
+ labelDensity: 0.7,
171
+ labelGridCellSize: 140,
172
+ zIndex: true,
173
+ minCameraRatio: 0.04,
174
+ maxCameraRatio: 8,
175
+ webGLTarget: "webgl2" as const,
176
+ nodeProgramClasses: ASHUB_NODE_PROGRAM_CLASSES,
177
+ edgeProgramClasses: ASHUB_EDGE_PROGRAM_CLASSES,
178
+ defaultDrawNodeLabel: drawAlphaSemanticHubNodeLabel,
179
+ defaultDrawNodeHover: drawAlphaSemanticHubNodeHover,
180
+ };
181
+
182
+ const DEBUG_GRAPH_RUNTIME = import.meta.env.DEV;
183
+
184
+ function debugGraphRuntime(message: string, payload?: Record<string, unknown>) {
185
+ if (!DEBUG_GRAPH_RUNTIME) {
186
+ return;
187
+ }
188
+ console.debug(`[GraphCanvas] ${message}`, payload ?? {});
189
+ }
190
+
191
+ function syncDisplayNodePositionsFromStore(
192
+ targetGraph: GraphSceneGraph,
193
+ storeGraph: typeof graph,
194
+ ): number {
195
+ let changed = 0;
196
+
197
+ targetGraph.forEachNode((nodeId, attrs) => {
198
+ if (!storeGraph.hasNode(nodeId)) {
199
+ return;
200
+ }
201
+
202
+ const sourceAttrs = storeGraph.getNodeAttributes(nodeId) as NodeAttributes;
203
+ const nextX = Number(sourceAttrs.x);
204
+ const nextY = Number(sourceAttrs.y);
205
+ const currentAttrs = attrs as NodeAttributes;
206
+ if (!Number.isFinite(nextX) || !Number.isFinite(nextY)) {
207
+ return;
208
+ }
209
+
210
+ const currentX = Number(currentAttrs.x);
211
+ const currentY = Number(currentAttrs.y);
212
+ if (Math.abs(currentX - nextX) <= 0.001 && Math.abs(currentY - nextY) <= 0.001) {
213
+ return;
214
+ }
215
+
216
+ targetGraph.mergeNodeAttributes(nodeId, {
217
+ x: nextX,
218
+ y: nextY,
219
+ });
220
+ changed += 1;
221
+ });
222
+
223
+ return changed;
224
+ }
225
+
226
+ function resolveGroupedSelectionNodeId(
227
+ displayGraph: GraphSceneGraph,
228
+ nodeId: string,
229
+ ): string | null {
230
+ if (!nodeId) {
231
+ return null;
232
+ }
233
+ if (displayGraph.hasNode(nodeId)) {
234
+ return nodeId;
235
+ }
236
+
237
+ let resolvedNodeId: string | null = null;
238
+ displayGraph.forEachNode((candidateId, attrs) => {
239
+ if (resolvedNodeId) {
240
+ return;
241
+ }
242
+ const communityGroup = (attrs as NodeAttributes).properties?.__communityGroup as
243
+ | {
244
+ anchorNodeId?: string | null;
245
+ memberNodeIds?: string[];
246
+ sampleNodeIds?: string[];
247
+ }
248
+ | undefined;
249
+ if (!communityGroup) {
250
+ return;
251
+ }
252
+
253
+ if (communityGroup.anchorNodeId === nodeId) {
254
+ resolvedNodeId = candidateId;
255
+ return;
256
+ }
257
+
258
+ if (communityGroup.sampleNodeIds?.includes(nodeId) || communityGroup.memberNodeIds?.includes(nodeId)) {
259
+ resolvedNodeId = candidateId;
260
+ }
261
+ });
262
+
263
+ return resolvedNodeId;
264
+ }
265
+
266
+ function collectSelectionContextNodeIds(
267
+ displayGraph: GraphSceneGraph,
268
+ displayState: GraphDisplayStateSnapshot | undefined,
269
+ nodeId: string,
270
+ ): string[] {
271
+ if (!nodeId) {
272
+ return [];
273
+ }
274
+
275
+ const resolvedNodeId = resolveGroupedSelectionNodeId(displayGraph, nodeId);
276
+ if (!resolvedNodeId) {
277
+ return [];
278
+ }
279
+
280
+ const neighborIds = (displayState?.selectedVisibleNeighborIds ?? [])
281
+ .filter((neighborId) => displayGraph.hasNode(neighborId))
282
+ .slice(0, GRAPH_THEME.focus.maxNeighbors);
283
+
284
+ return Array.from(new Set([resolvedNodeId, ...neighborIds]));
285
+ }
286
+
287
+ function computeGraphSpaceBounds(
288
+ displayGraph: GraphSceneGraph,
289
+ nodeIds: string[],
290
+ ): { minX: number; maxX: number; minY: number; maxY: number; count: number } | null {
291
+ let minX = Infinity;
292
+ let maxX = -Infinity;
293
+ let minY = Infinity;
294
+ let maxY = -Infinity;
295
+ let count = 0;
296
+
297
+ nodeIds.forEach((nodeId) => {
298
+ if (!displayGraph.hasNode(nodeId)) {
299
+ return;
300
+ }
301
+
302
+ const attrs = displayGraph.getNodeAttributes(nodeId) as NodeAttributes;
303
+ const x = Number(attrs.x);
304
+ const y = Number(attrs.y);
305
+ if (!Number.isFinite(x) || !Number.isFinite(y)) {
306
+ return;
307
+ }
308
+
309
+ minX = Math.min(minX, x);
310
+ maxX = Math.max(maxX, x);
311
+ minY = Math.min(minY, y);
312
+ maxY = Math.max(maxY, y);
313
+ count += 1;
314
+ });
315
+
316
+ if (count === 0) {
317
+ return null;
318
+ }
319
+
320
+ return { minX, maxX, minY, maxY, count };
321
+ }
322
+
323
+ type GraphSpaceBounds = NonNullable<ReturnType<typeof computeGraphSpaceBounds>>;
324
+ type SigmaCustomBBox = {
325
+ x: [number, number];
326
+ y: [number, number];
327
+ };
328
+
329
+ type DisplayFitSignature = {
330
+ graphVersion: number;
331
+ viewMode: GraphViewMode;
332
+ layoutMode: GraphDisplayMeta["layoutMode"];
333
+ displayGraph: GraphSceneGraph;
334
+ };
335
+
336
+ function isSameDisplayFitSignature(
337
+ left: DisplayFitSignature | null,
338
+ right: DisplayFitSignature,
339
+ ): boolean {
340
+ if (!left) {
341
+ return false;
342
+ }
343
+
344
+ return left.graphVersion === right.graphVersion
345
+ && left.viewMode === right.viewMode
346
+ && left.layoutMode === right.layoutMode
347
+ && left.displayGraph === right.displayGraph;
348
+ }
349
+
350
+ function computeDisplayedNodeBounds(
351
+ sigma: Sigma,
352
+ nodeIds: string[],
353
+ ): GraphSpaceBounds | null {
354
+ let minX = Infinity;
355
+ let maxX = -Infinity;
356
+ let minY = Infinity;
357
+ let maxY = -Infinity;
358
+ let count = 0;
359
+
360
+ nodeIds.forEach((nodeId) => {
361
+ const displayData = sigma.getNodeDisplayData(nodeId);
362
+ if (!displayData) {
363
+ return;
364
+ }
365
+
366
+ const x = Number(displayData.x);
367
+ const y = Number(displayData.y);
368
+ if (!Number.isFinite(x) || !Number.isFinite(y)) {
369
+ return;
370
+ }
371
+
372
+ minX = Math.min(minX, x);
373
+ maxX = Math.max(maxX, x);
374
+ minY = Math.min(minY, y);
375
+ maxY = Math.max(maxY, y);
376
+ count += 1;
377
+ });
378
+
379
+ if (count === 0) {
380
+ return null;
381
+ }
382
+
383
+ return { minX, maxX, minY, maxY, count };
384
+ }
385
+
386
+ function computeDisplayedGraphBounds(
387
+ sigma: Sigma,
388
+ displayGraph: GraphSceneGraph,
389
+ ): GraphSpaceBounds | null {
390
+ const nodeIds: string[] = [];
391
+ displayGraph.forEachNode((nodeId) => {
392
+ nodeIds.push(nodeId);
393
+ });
394
+ return computeDisplayedNodeBounds(sigma, nodeIds);
395
+ }
396
+
397
+ function computeCameraTargetFromBounds(
398
+ sigma: Sigma,
399
+ bounds: SigmaCustomBBox,
400
+ ): { x: number; y: number; ratio: number; angle: number } | null {
401
+ const camera = sigma.getCamera();
402
+ const cameraState = camera.getState();
403
+ const viewRect = sigma.viewRectangle();
404
+
405
+ const currentWidth = Math.abs(viewRect.x2 - viewRect.x1);
406
+ const currentHeight = Math.abs(viewRect.height);
407
+ const targetWidth = Math.abs(bounds.x[1] - bounds.x[0]);
408
+ const targetHeight = Math.abs(bounds.y[1] - bounds.y[0]);
409
+ const centerX = (bounds.x[0] + bounds.x[1]) / 2;
410
+ const centerY = (bounds.y[0] + bounds.y[1]) / 2;
411
+
412
+ if (
413
+ ![currentWidth, currentHeight, targetWidth, targetHeight, centerX, centerY].every(Number.isFinite)
414
+ || currentWidth <= 0
415
+ || currentHeight <= 0
416
+ || targetWidth <= 0
417
+ || targetHeight <= 0
418
+ ) {
419
+ return null;
420
+ }
421
+
422
+ const fitScale = Math.max(targetWidth / currentWidth, targetHeight / currentHeight);
423
+ const nextRatio = camera.getBoundedRatio(cameraState.ratio * fitScale);
424
+ if (!Number.isFinite(nextRatio) || nextRatio <= 0) {
425
+ return null;
426
+ }
427
+
428
+ return {
429
+ x: centerX,
430
+ y: centerY,
431
+ ratio: nextRatio,
432
+ angle: cameraState.angle,
433
+ };
434
+ }
435
+
436
+ function expandGraphSpaceBounds(
437
+ bounds: GraphSpaceBounds,
438
+ referenceBounds: GraphSpaceBounds | null,
439
+ options?: {
440
+ paddingRatio?: number;
441
+ minSpanRatio?: number;
442
+ minSpanFloor?: number;
443
+ },
444
+ ): SigmaCustomBBox | null {
445
+ const paddingRatio = options?.paddingRatio ?? 0.12;
446
+ const minSpanRatio = options?.minSpanRatio ?? 0.03;
447
+ const minSpanFloor = options?.minSpanFloor ?? 1;
448
+
449
+ const referenceSpanX = Math.max(
450
+ referenceBounds ? referenceBounds.maxX - referenceBounds.minX : 0,
451
+ minSpanFloor,
452
+ );
453
+ const referenceSpanY = Math.max(
454
+ referenceBounds ? referenceBounds.maxY - referenceBounds.minY : 0,
455
+ minSpanFloor,
456
+ );
457
+
458
+ const spanX = Math.max(bounds.maxX - bounds.minX, 0);
459
+ const spanY = Math.max(bounds.maxY - bounds.minY, 0);
460
+ const minSpanX = Math.max(referenceSpanX * minSpanRatio, minSpanFloor);
461
+ const minSpanY = Math.max(referenceSpanY * minSpanRatio, minSpanFloor);
462
+ const targetSpanX = Math.max(spanX, minSpanX);
463
+ const targetSpanY = Math.max(spanY, minSpanY);
464
+ const centerX = (bounds.minX + bounds.maxX) / 2;
465
+ const centerY = (bounds.minY + bounds.maxY) / 2;
466
+ if (![targetSpanX, targetSpanY, centerX, centerY].every(Number.isFinite)) {
467
+ return null;
468
+ }
469
+
470
+ const paddedSpanX = targetSpanX * (1 + paddingRatio * 2);
471
+ const paddedSpanY = targetSpanY * (1 + paddingRatio * 2);
472
+ if (paddedSpanX <= 0 || paddedSpanY <= 0) {
473
+ return null;
474
+ }
475
+
476
+ return {
477
+ x: [centerX - paddedSpanX / 2, centerX + paddedSpanX / 2],
478
+ y: [centerY - paddedSpanY / 2, centerY + paddedSpanY / 2],
479
+ };
480
+ }
481
+
482
+ function isPointNearViewport(point: ViewportPoint, width: number, height: number, padding = 96) {
483
+ return point.x >= -padding
484
+ && point.y >= -padding
485
+ && point.x <= width + padding
486
+ && point.y <= height + padding;
487
+ }
488
+
489
+ function collectPathSegments(
490
+ sigma: Sigma,
491
+ path: string[],
492
+ pathEdgeIds: string[],
493
+ zoomTier: GraphZoomTier,
494
+ viewportWidth: number,
495
+ viewportHeight: number,
496
+ ): PathSegmentOverlay[] {
497
+ const segments: PathSegmentOverlay[] = [];
498
+
499
+ for (let index = 0; index < path.length - 1; index += 1) {
500
+ const sourceId = path[index];
501
+ const targetId = path[index + 1];
502
+ const sourceData = sigma.getNodeDisplayData(sourceId);
503
+ const targetData = sigma.getNodeDisplayData(targetId);
504
+ if (!sourceData || !targetData) {
505
+ continue;
506
+ }
507
+
508
+ const source = sigma.graphToViewport({ x: sourceData.x, y: sourceData.y });
509
+ const target = sigma.graphToViewport({ x: targetData.x, y: targetData.y });
510
+ if (!isPointNearViewport(source, viewportWidth, viewportHeight) && !isPointNearViewport(target, viewportWidth, viewportHeight)) {
511
+ continue;
512
+ }
513
+
514
+ const attrs = pathEdgeIds[index] && graph.hasEdge(pathEdgeIds[index])
515
+ ? (graph.getEdgeAttributes(pathEdgeIds[index]) as EdgeAttributes)
516
+ : ({
517
+ baseColor: GRAPH_THEME.palette.accent.path,
518
+ baseSize: 1.2,
519
+ edgeVariant: "pathSignal",
520
+ arrowVisibilityPolicy: "always",
521
+ } as EdgeAttributes);
522
+ const pathStyle = resolveEdgeElementStyle(GRAPH_THEME, zoomTier, "path", attrs, sourceId, targetId);
523
+ if (!pathStyle.color || !pathStyle.size) {
524
+ continue;
525
+ }
526
+
527
+ segments.push({
528
+ sourceId,
529
+ targetId,
530
+ source,
531
+ target,
532
+ color: pathStyle.color,
533
+ size: pathStyle.size,
534
+ });
535
+ }
536
+
537
+ return segments;
538
+ }
539
+
540
+ function buildEffectAvailability(
541
+ interactionState: GraphInteractionState,
542
+ effectsState: GraphEffectsState,
543
+ temporalState: GraphTemporalState | null | undefined,
544
+ analytics: GraphAnalyticsSnapshot | null,
545
+ isLayoutRunning: boolean,
546
+ sigma: Sigma | null,
547
+ viewportWidth: number,
548
+ viewportHeight: number,
549
+ ): GraphDiagnosticsSnapshot["effectAvailability"] {
550
+ const visiblePathSegments = sigma
551
+ ? collectPathSegments(
552
+ sigma,
553
+ interactionState.activePath,
554
+ interactionState.activePathEdgeIds,
555
+ interactionState.zoomTier,
556
+ viewportWidth,
557
+ viewportHeight,
558
+ ).length
559
+ : 0;
560
+ const hasActivePath = interactionState.activePath.length > 1;
561
+ const pulseTierReady = zoomTierAtLeast(interactionState.zoomTier, GRAPH_THEME.effects.pathPulse.minZoomTier);
562
+ const flowTierReady = zoomTierAtLeast(interactionState.zoomTier, GRAPH_THEME.effects.pathFlow.minZoomTier);
563
+ const lensTierReady = zoomTierAtLeast(interactionState.zoomTier, GRAPH_THEME.effects.lens.minZoomTier);
564
+ const temporalTierReady = zoomTierAtLeast(interactionState.zoomTier, GRAPH_THEME.effects.temporalEmphasis.minZoomTier);
565
+ const regionsTierReady = zoomTierAtLeast(interactionState.zoomTier, GRAPH_THEME.effects.semanticRegions.minZoomTier);
566
+ const contoursTierReady = zoomTierAtLeast(interactionState.zoomTier, GRAPH_THEME.effects.contours.minZoomTier);
567
+ const hasPrimaryNode = Boolean(interactionState.hoveredNodeId || interactionState.selectedNodeId);
568
+ const layoutReason = "Layout is still settling";
569
+
570
+ const pathPulse = !effectsState.pathPulseEnabled
571
+ ? { enabled: false, available: false, reason: "Disabled by toggle" }
572
+ : isLayoutRunning
573
+ ? { enabled: true, available: false, reason: layoutReason }
574
+ : !hasActivePath
575
+ ? { enabled: true, available: false, reason: "No active path" }
576
+ : !pulseTierReady
577
+ ? {
578
+ enabled: true,
579
+ available: false,
580
+ reason: "Disabled by zoom tier",
581
+ detail: `Requires ${GRAPH_THEME.effects.pathPulse.minZoomTier}`,
582
+ }
583
+ : visiblePathSegments === 0
584
+ ? { enabled: true, available: false, reason: "Path is off-screen" }
585
+ : visiblePathSegments > GRAPH_THEME.effects.pathPulse.maxSegments
586
+ ? {
587
+ enabled: true,
588
+ available: false,
589
+ reason: "Disabled by path size cap",
590
+ detail: `${visiblePathSegments} visible segments`,
591
+ visibleSegments: visiblePathSegments,
592
+ segmentCap: GRAPH_THEME.effects.pathPulse.maxSegments,
593
+ }
594
+ : {
595
+ enabled: true,
596
+ available: true,
597
+ reason: "Ready",
598
+ visibleSegments: visiblePathSegments,
599
+ segmentCap: GRAPH_THEME.effects.pathPulse.maxSegments,
600
+ };
601
+
602
+ const pathFlow = !effectsState.pathFlowEnabled
603
+ ? { enabled: false, available: false, reason: "Disabled by toggle" }
604
+ : isLayoutRunning
605
+ ? { enabled: true, available: false, reason: layoutReason }
606
+ : !hasActivePath
607
+ ? { enabled: true, available: false, reason: "No active path" }
608
+ : !flowTierReady
609
+ ? {
610
+ enabled: true,
611
+ available: false,
612
+ reason: "Disabled by zoom tier",
613
+ detail: `Requires ${GRAPH_THEME.effects.pathFlow.minZoomTier}`,
614
+ }
615
+ : visiblePathSegments === 0
616
+ ? { enabled: true, available: false, reason: "Path is off-screen" }
617
+ : visiblePathSegments > GRAPH_THEME.effects.pathFlow.maxSegments
618
+ ? {
619
+ enabled: true,
620
+ available: false,
621
+ reason: "Disabled by path size cap",
622
+ detail: `${visiblePathSegments} visible segments`,
623
+ visibleSegments: visiblePathSegments,
624
+ segmentCap: GRAPH_THEME.effects.pathFlow.maxSegments,
625
+ }
626
+ : {
627
+ enabled: true,
628
+ available: true,
629
+ reason: "Ready",
630
+ visibleSegments: visiblePathSegments,
631
+ segmentCap: GRAPH_THEME.effects.pathFlow.maxSegments,
632
+ };
633
+
634
+ const lens = !effectsState.lensEnabled
635
+ ? { enabled: false, available: false, reason: "Disabled by toggle" }
636
+ : isLayoutRunning
637
+ ? { enabled: true, available: false, reason: layoutReason }
638
+ : !hasPrimaryNode
639
+ ? { enabled: true, available: false, reason: "No focal node" }
640
+ : !lensTierReady
641
+ ? {
642
+ enabled: true,
643
+ available: false,
644
+ reason: "Disabled by zoom tier",
645
+ detail: `Requires ${GRAPH_THEME.effects.lens.minZoomTier}`,
646
+ }
647
+ : { enabled: true, available: true, reason: "Ready" };
648
+
649
+ const temporalEmphasis = !effectsState.temporalEmphasisEnabled
650
+ ? { enabled: false, available: false, reason: "Disabled by toggle" }
651
+ : isLayoutRunning
652
+ ? { enabled: true, available: false, reason: layoutReason }
653
+ : !temporalState?.currentTime
654
+ ? { enabled: true, available: false, reason: "No temporal focus time" }
655
+ : !temporalTierReady
656
+ ? {
657
+ enabled: true,
658
+ available: false,
659
+ reason: "Disabled by zoom tier",
660
+ detail: `Requires ${GRAPH_THEME.effects.temporalEmphasis.minZoomTier}`,
661
+ }
662
+ : { enabled: true, available: true, reason: "Ready" };
663
+
664
+ const semanticRegions = !effectsState.semanticRegionsEnabled
665
+ ? { enabled: false, available: false, reason: "Disabled by toggle" }
666
+ : isLayoutRunning
667
+ ? { enabled: true, available: false, reason: layoutReason }
668
+ : !regionsTierReady
669
+ ? {
670
+ enabled: true,
671
+ available: false,
672
+ reason: "Disabled by zoom tier",
673
+ detail: `Requires ${GRAPH_THEME.effects.semanticRegions.minZoomTier}`,
674
+ }
675
+ : !analytics?.semanticRegions.ready
676
+ ? {
677
+ enabled: true,
678
+ available: false,
679
+ reason: analytics?.semanticRegions.reason ?? "Waiting for semantic region summaries",
680
+ }
681
+ : { enabled: true, available: true, reason: analytics.semanticRegions.reason };
682
+
683
+ const contours = !effectsState.contoursEnabled
684
+ ? { enabled: false, available: false, reason: "Disabled by toggle" }
685
+ : isLayoutRunning
686
+ ? { enabled: true, available: false, reason: layoutReason }
687
+ : !contoursTierReady
688
+ ? {
689
+ enabled: true,
690
+ available: false,
691
+ reason: "Disabled by zoom tier",
692
+ detail: `Requires ${GRAPH_THEME.effects.contours.minZoomTier}`,
693
+ }
694
+ : !analytics?.centrality.ready
695
+ ? {
696
+ enabled: true,
697
+ available: false,
698
+ reason: analytics?.centrality.reason ?? "Waiting for centrality ranking",
699
+ }
700
+ : { enabled: true, available: true, reason: analytics.centrality.reason };
701
+
702
+ const pathfinding = !effectsState.pathfindingEnabled
703
+ ? { enabled: false, available: false, reason: "Disabled by toggle" }
704
+ : !analytics?.directedPath.ready
705
+ ? {
706
+ enabled: true,
707
+ available: false,
708
+ reason: analytics?.directedPath.reason ?? "Waiting for a traced path",
709
+ }
710
+ : {
711
+ enabled: true,
712
+ available: true,
713
+ reason: analytics.directedPath.verifiedAgainstActivePath
714
+ ? "Ready · local path matches traced path"
715
+ : "Ready · local directed path differs from traced path",
716
+ };
717
+
718
+ const communities = !effectsState.communitiesEnabled
719
+ ? { enabled: false, available: false, reason: "Disabled by toggle" }
720
+ : !analytics?.communities.ready
721
+ ? {
722
+ enabled: true,
723
+ available: false,
724
+ reason: analytics?.communities.reason ?? "Waiting for community summaries",
725
+ }
726
+ : {
727
+ enabled: true,
728
+ available: true,
729
+ reason: analytics.communities.modularity !== null
730
+ ? `Ready · modularity ${analytics.communities.modularity.toFixed(3)}`
731
+ : analytics.communities.reason,
732
+ };
733
+
734
+ const centrality = !effectsState.centralityEnabled
735
+ ? { enabled: false, available: false, reason: "Disabled by toggle" }
736
+ : !analytics?.centrality.ready
737
+ ? {
738
+ enabled: true,
739
+ available: false,
740
+ reason: analytics?.centrality.reason ?? "Waiting for centrality ranking",
741
+ }
742
+ : { enabled: true, available: true, reason: analytics.centrality.reason };
743
+
744
+ const legend = effectsState.legendEnabled
745
+ ? { enabled: true, available: true, reason: "Panel enabled" }
746
+ : { enabled: false, available: false, reason: "Disabled by toggle" };
747
+
748
+ // #1009: edge labels are immediately available once the graph is loaded —
749
+ // they have no async analytics or zoom-tier dependency.
750
+ const edgeLabels = effectsState.edgeLabelsEnabled
751
+ ? { enabled: true, available: true, reason: "Ready" }
752
+ : { enabled: false, available: false, reason: "Disabled by toggle" };
753
+
754
+ const diagnostics = !GRAPH_THEME.effects.diagnostics.enabledInDev
755
+ ? { enabled: false, available: false, reason: "Disabled in production" }
756
+ : effectsState.diagnosticsEnabled
757
+ ? { enabled: true, available: true, reason: "Ready" }
758
+ : { enabled: false, available: false, reason: "Disabled by toggle" };
759
+
760
+ return {
761
+ pathPulse,
762
+ pathFlow,
763
+ lens,
764
+ temporalEmphasis,
765
+ semanticRegions,
766
+ contours,
767
+ pathfinding,
768
+ communities,
769
+ centrality,
770
+ legend,
771
+ edgeLabels,
772
+ diagnostics,
773
+ };
774
+ }
775
+
776
+ function drawGlowHalo(
777
+ context: CanvasRenderingContext2D,
778
+ x: number,
779
+ y: number,
780
+ radius: number,
781
+ color: string,
782
+ ) {
783
+ const gradient = context.createRadialGradient(x, y, 0, x, y, radius);
784
+ gradient.addColorStop(0, color);
785
+ gradient.addColorStop(1, "rgba(0,0,0,0)");
786
+ context.fillStyle = gradient;
787
+ context.beginPath();
788
+ context.arc(x, y, radius, 0, Math.PI * 2);
789
+ context.fill();
790
+ }
791
+
792
+ function drawNodeBadge(
793
+ context: CanvasRenderingContext2D,
794
+ x: number,
795
+ y: number,
796
+ nodeRadius: number,
797
+ badgeKind: GraphBadgeKind,
798
+ badgeCount: number | undefined,
799
+ ) {
800
+ const badgeTheme = GRAPH_THEME.nodes.badges[badgeKind];
801
+ const radius = GRAPH_THEME.nodes.badge.radius;
802
+ const offset = GRAPH_THEME.nodes.badge.offset;
803
+ const badgeX = x + Math.max(nodeRadius * 0.62, radius + offset);
804
+ const badgeY = y - Math.max(nodeRadius * 0.62, radius + offset);
805
+
806
+ drawGlowHalo(
807
+ context,
808
+ badgeX,
809
+ badgeY,
810
+ GRAPH_THEME.overlays.badgeGlowRadius,
811
+ withAlpha(badgeTheme.color, GRAPH_THEME.nodes.badge.glowAlpha),
812
+ );
813
+
814
+ context.fillStyle = badgeTheme.color;
815
+ context.beginPath();
816
+ context.arc(badgeX, badgeY, radius, 0, Math.PI * 2);
817
+ context.fill();
818
+
819
+ context.strokeStyle = GRAPH_THEME.nodes.badge.stroke;
820
+ context.lineWidth = 1;
821
+ context.beginPath();
822
+ context.arc(badgeX, badgeY, radius, 0, Math.PI * 2);
823
+ context.stroke();
824
+
825
+ context.fillStyle = GRAPH_THEME.nodes.badge.textColor;
826
+ context.font = `700 ${GRAPH_THEME.nodes.badge.fontSize}px Inter, system-ui, sans-serif`;
827
+ context.textAlign = "center";
828
+ context.textBaseline = "middle";
829
+ const label = badgeKind === "provenance" && badgeCount && badgeCount > 1
830
+ ? String(Math.min(9, badgeCount))
831
+ : badgeTheme.label;
832
+ context.fillText(label, badgeX, badgeY + 0.5);
833
+ }
834
+
835
+ type ReducerSceneState = {
836
+ viewMode: GraphViewMode;
837
+ zoomTier: GraphZoomTier;
838
+ hoveredNodeId: string | null;
839
+ selectedNodeId: string;
840
+ selectedEdgeId: string;
841
+ activePath: string[];
842
+ activePathEdgeIds: string[];
843
+ focusIds: Set<string>;
844
+ edgeEndpointIds: Set<string>;
845
+ pathNodeIds: Set<string>;
846
+ pathEdgeIds: Set<string>;
847
+ highlightedIncidentEdgeIds: Set<string>;
848
+ overviewBackboneEdgeIds: Set<string>;
849
+ distanceVisualState?: GraphDistanceVisualState;
850
+ };
851
+
852
+ const FULL_EDGE_CLASSES: GraphFullEdgeClass[] = [
853
+ "hidden",
854
+ "backbone",
855
+ "bridge",
856
+ "local-context",
857
+ "selected",
858
+ "path",
859
+ "muted",
860
+ ];
861
+
862
+ function createFullEdgeClassCounts(): GraphFullEdgeClassCounts {
863
+ return FULL_EDGE_CLASSES.reduce((counts, edgeClass) => {
864
+ counts[edgeClass] = 0;
865
+ return counts;
866
+ }, {} as GraphFullEdgeClassCounts);
867
+ }
868
+
869
+ function getIncidentEdgeRevealCap(viewMode: GraphViewMode, zoomTier: GraphZoomTier): number {
870
+ return GRAPH_THEME.edges.contextCaps[viewMode]?.[zoomTier] ?? 0;
871
+ }
872
+
873
+ function scoreIncidentEdge(
874
+ attrs: EdgeAttributes,
875
+ edgeId: string,
876
+ otherEndpointId: string,
877
+ visibleNeighborIds: Set<string>,
878
+ focusIds: Set<string>,
879
+ pathEdgeIds: Set<string>,
880
+ selectedEdgeId: string,
881
+ ): number {
882
+ if (pathEdgeIds.has(edgeId)) {
883
+ return Number.POSITIVE_INFINITY;
884
+ }
885
+ if (selectedEdgeId && edgeId === selectedEdgeId) {
886
+ return Number.POSITIVE_INFINITY;
887
+ }
888
+
889
+ const weight = Math.max(Number(attrs.weight ?? attrs.representativeWeight ?? 1) || 1, 1);
890
+ const normalizedWeight = Math.min(1, Math.log1p(weight) / Math.log(25));
891
+ const visualPriority = Math.max(0, Math.min(Number(attrs.visualPriority ?? 0), 1));
892
+ const relationshipStrength = Math.max(0, Math.min(Number(attrs.relationshipStrength ?? 0), 1));
893
+
894
+ return (visibleNeighborIds.has(otherEndpointId) ? 6 : 0)
895
+ + (focusIds.has(otherEndpointId) ? 1.25 : 0)
896
+ + visualPriority * 2
897
+ + normalizedWeight * 1.4
898
+ + relationshipStrength;
899
+ }
900
+
901
+ function buildHighlightedIncidentEdgeIds(
902
+ displayGraph: GraphSceneGraph,
903
+ interactionState: GraphInteractionState,
904
+ displayState: GraphDisplayStateSnapshot | undefined,
905
+ focusIds: Set<string>,
906
+ pathEdgeIds: Set<string>,
907
+ ): Set<string> {
908
+ const primaryNodeId = interactionState.hoveredNodeId || interactionState.selectedNodeId;
909
+ if (!primaryNodeId || !displayGraph.hasNode(primaryNodeId)) {
910
+ return new Set();
911
+ }
912
+
913
+ const cap = getIncidentEdgeRevealCap(interactionState.viewMode, interactionState.zoomTier);
914
+ if (cap <= 0 && !interactionState.selectedEdgeId && pathEdgeIds.size === 0) {
915
+ return new Set();
916
+ }
917
+
918
+ const visibleNeighborIds = new Set(
919
+ (displayState?.selectedVisibleNeighborIds ?? [])
920
+ .filter((nodeId) => displayGraph.hasNode(nodeId)),
921
+ );
922
+ const candidates: Array<{ edgeId: string; score: number }> = [];
923
+
924
+ displayGraph.edges(primaryNodeId).forEach((edge) => {
925
+ const edgeId = String(edge);
926
+ if (!displayGraph.hasEdge(edgeId)) {
927
+ return;
928
+ }
929
+ const [source, target] = displayGraph.extremities(edgeId);
930
+ const sourceId = String(source);
931
+ const targetId = String(target);
932
+ const otherEndpointId = sourceId === primaryNodeId ? targetId : sourceId;
933
+ const attrs = displayGraph.getEdgeAttributes(edgeId) as EdgeAttributes;
934
+ candidates.push({
935
+ edgeId,
936
+ score: scoreIncidentEdge(
937
+ attrs,
938
+ edgeId,
939
+ otherEndpointId,
940
+ visibleNeighborIds,
941
+ focusIds,
942
+ pathEdgeIds,
943
+ interactionState.selectedEdgeId,
944
+ ),
945
+ });
946
+ });
947
+
948
+ candidates.sort((left, right) => {
949
+ if (right.score !== left.score) {
950
+ return right.score - left.score;
951
+ }
952
+ return left.edgeId.localeCompare(right.edgeId);
953
+ });
954
+
955
+ const selected = new Set<string>();
956
+ candidates.forEach((candidate) => {
957
+ if (
958
+ candidate.edgeId === interactionState.selectedEdgeId
959
+ || pathEdgeIds.has(candidate.edgeId)
960
+ || selected.size < cap
961
+ ) {
962
+ selected.add(candidate.edgeId);
963
+ }
964
+ });
965
+ return selected;
966
+ }
967
+
968
+ function buildReducerSceneState(
969
+ displayGraph: GraphSceneGraph,
970
+ interactionState: GraphInteractionState,
971
+ displayState?: GraphDisplayStateSnapshot,
972
+ analyticsSnapshot?: GraphAnalyticsSnapshot | null,
973
+ distanceVisualState?: GraphDistanceVisualState,
974
+ ): ReducerSceneState {
975
+ const { viewMode, zoomTier, hoveredNodeId, selectedNodeId, selectedEdgeId, activePath } = interactionState;
976
+ const primaryNodeId = hoveredNodeId || selectedNodeId;
977
+ const focusIds = primaryNodeId
978
+ ? (
979
+ displayGraph.hasNode(primaryNodeId)
980
+ ? buildFocusSetInGraph(displayGraph, primaryNodeId)
981
+ : new Set<string>()
982
+ )
983
+ : new Set<string>();
984
+ const pathEdgeIds = buildPathEdgeSet(displayGraph, activePath, interactionState.activePathEdgeIds);
985
+
986
+ return {
987
+ viewMode,
988
+ zoomTier,
989
+ hoveredNodeId,
990
+ selectedNodeId,
991
+ selectedEdgeId,
992
+ activePath,
993
+ activePathEdgeIds: interactionState.activePathEdgeIds,
994
+ focusIds,
995
+ edgeEndpointIds: buildEdgeEndpointSet(displayGraph, selectedEdgeId),
996
+ pathNodeIds: new Set(activePath),
997
+ pathEdgeIds,
998
+ overviewBackboneEdgeIds: new Set(analyticsSnapshot?.overviewBackbone.edgeIds ?? []),
999
+ distanceVisualState,
1000
+ highlightedIncidentEdgeIds: buildHighlightedIncidentEdgeIds(
1001
+ displayGraph,
1002
+ interactionState,
1003
+ displayState,
1004
+ focusIds,
1005
+ pathEdgeIds,
1006
+ ),
1007
+ };
1008
+ }
1009
+
1010
+ function getFullGraphEdgeClass(
1011
+ displayGraph: GraphSceneGraph,
1012
+ edgeId: string,
1013
+ currentState: ReducerSceneState,
1014
+ ): GraphFullEdgeClass {
1015
+ if (!displayGraph.hasEdge(edgeId)) {
1016
+ return "hidden";
1017
+ }
1018
+
1019
+ const [source, target] = displayGraph.extremities(edgeId);
1020
+ const sourceId = String(source);
1021
+ const targetId = String(target);
1022
+ const sourceAttrs = displayGraph.hasNode(sourceId)
1023
+ ? displayGraph.getNodeAttributes(sourceId) as NodeAttributes
1024
+ : undefined;
1025
+ const targetAttrs = displayGraph.hasNode(targetId)
1026
+ ? displayGraph.getNodeAttributes(targetId) as NodeAttributes
1027
+ : undefined;
1028
+
1029
+ return classifyFullGraphEdge(
1030
+ edgeId,
1031
+ sourceId,
1032
+ targetId,
1033
+ currentState.zoomTier,
1034
+ currentState.hoveredNodeId,
1035
+ currentState.selectedNodeId,
1036
+ currentState.selectedEdgeId,
1037
+ currentState.focusIds,
1038
+ currentState.pathEdgeIds,
1039
+ currentState.highlightedIncidentEdgeIds,
1040
+ currentState.overviewBackboneEdgeIds,
1041
+ sourceAttrs,
1042
+ targetAttrs,
1043
+ );
1044
+ }
1045
+
1046
+ function buildFullGraphEdgeClassDiagnostics(
1047
+ displayGraph: GraphSceneGraph,
1048
+ currentState: ReducerSceneState,
1049
+ ): GraphFullEdgeClassDiagnostics {
1050
+ const counts = createFullEdgeClassCounts();
1051
+
1052
+ displayGraph.forEachEdge((edgeId) => {
1053
+ const edgeClass = currentState.viewMode === "full"
1054
+ ? getFullGraphEdgeClass(displayGraph, String(edgeId), currentState)
1055
+ : "hidden";
1056
+ counts[edgeClass] += 1;
1057
+ });
1058
+
1059
+ return {
1060
+ mode: currentState.viewMode,
1061
+ zoomTier: currentState.zoomTier,
1062
+ totalEdges: displayGraph.size,
1063
+ visibleEdges: displayGraph.size - counts.hidden,
1064
+ counts,
1065
+ updatedAt: Date.now(),
1066
+ };
1067
+ }
1068
+
1069
+ function applySceneState(
1070
+ sigma: Sigma,
1071
+ reducerSceneStateRef: { current: ReducerSceneState },
1072
+ reducerWarningStateRef: { current: { missingEdges: Set<string>; missingNodes: Set<string> } },
1073
+ refreshTargets?: {
1074
+ nodes?: string[];
1075
+ edges?: string[];
1076
+ },
1077
+ ) {
1078
+ sigma.setSetting("nodeReducer", (node, data) => {
1079
+ const currentGraph = sigma.getGraph() as GraphSceneGraph;
1080
+ const currentState = reducerSceneStateRef.current;
1081
+ const cameraRatio = sigma.getCamera().getState().ratio;
1082
+ if (!currentGraph.hasNode(node)) {
1083
+ if (!reducerWarningStateRef.current.missingNodes.has(node)) {
1084
+ reducerWarningStateRef.current.missingNodes.add(node);
1085
+ debugGraphRuntime("node-reducer-node-missing", {
1086
+ nodeId: node,
1087
+ order: currentGraph.order,
1088
+ size: currentGraph.size,
1089
+ });
1090
+ }
1091
+ return {
1092
+ ...data,
1093
+ hidden: true,
1094
+ };
1095
+ }
1096
+ const attrs = data as NodeAttributes;
1097
+ const state = resolveNodeVisualState(
1098
+ node,
1099
+ currentState.zoomTier,
1100
+ currentState.hoveredNodeId,
1101
+ currentState.selectedNodeId,
1102
+ currentState.selectedEdgeId,
1103
+ currentState.focusIds,
1104
+ currentState.edgeEndpointIds,
1105
+ currentState.pathNodeIds,
1106
+ );
1107
+ const style = resolveNodeElementStyle(
1108
+ GRAPH_THEME,
1109
+ currentState.zoomTier,
1110
+ state,
1111
+ attrs,
1112
+ data.label,
1113
+ cameraRatio,
1114
+ );
1115
+ const distanceStyle = currentState.viewMode === "full"
1116
+ ? resolveDistanceNodeStyle(
1117
+ GRAPH_THEME,
1118
+ currentState.zoomTier,
1119
+ style,
1120
+ currentState.distanceVisualState,
1121
+ String(node),
1122
+ )
1123
+ : {};
1124
+ const resolvedStyle = { ...style, ...distanceStyle };
1125
+
1126
+ return {
1127
+ ...data,
1128
+ color: resolvedStyle.color,
1129
+ shellColor: resolvedStyle.shellColor,
1130
+ coreScale: resolvedStyle.coreScale,
1131
+ size: resolvedStyle.size,
1132
+ forceLabel: resolvedStyle.forceLabel,
1133
+ label: resolvedStyle.label,
1134
+ zIndex: resolvedStyle.zIndex,
1135
+ hidden: resolvedStyle.hidden,
1136
+ borderColor: resolvedStyle.borderColor,
1137
+ borderSize: resolvedStyle.borderSize,
1138
+ ringColor: resolvedStyle.showRing ? resolvedStyle.ringColor : resolvedStyle.borderColor,
1139
+ ringSize: resolvedStyle.ringSize,
1140
+ entityShape: resolvedStyle.entityShape,
1141
+ entityShapeKind: resolvedStyle.entityShapeKind,
1142
+ entityAspectRatio: resolvedStyle.entityAspectRatio,
1143
+ };
1144
+ });
1145
+
1146
+ sigma.setSetting("edgeReducer", (edge, data) => {
1147
+ const currentGraph = sigma.getGraph() as GraphSceneGraph;
1148
+ const currentState = reducerSceneStateRef.current;
1149
+ if (!currentGraph.hasEdge(edge)) {
1150
+ if (!reducerWarningStateRef.current.missingEdges.has(edge)) {
1151
+ reducerWarningStateRef.current.missingEdges.add(edge);
1152
+ debugGraphRuntime("edge-reducer-edge-missing", {
1153
+ edgeId: edge,
1154
+ order: currentGraph.order,
1155
+ size: currentGraph.size,
1156
+ });
1157
+ }
1158
+ return {
1159
+ ...data,
1160
+ hidden: true,
1161
+ };
1162
+ }
1163
+ const attrs = data as EdgeAttributes;
1164
+ const [source, target] = currentGraph.extremities(edge);
1165
+ const stableEdgeId = String(edge);
1166
+ const hasActiveInteraction = Boolean(
1167
+ currentState.hoveredNodeId
1168
+ || currentState.selectedNodeId
1169
+ || currentState.selectedEdgeId
1170
+ || currentState.pathEdgeIds.size > 0,
1171
+ );
1172
+ const fullEdgeClass = currentState.viewMode === "full"
1173
+ ? getFullGraphEdgeClass(currentGraph, stableEdgeId, currentState)
1174
+ : undefined;
1175
+ const state = currentState.viewMode === "full"
1176
+ ? mapFullEdgeClassToVisualState(
1177
+ fullEdgeClass ?? "hidden",
1178
+ {
1179
+ hoveredNodeId: currentState.hoveredNodeId,
1180
+ hasActiveInteraction,
1181
+ },
1182
+ )
1183
+ : resolveEdgeVisualState(
1184
+ stableEdgeId,
1185
+ String(source),
1186
+ String(target),
1187
+ currentState.zoomTier,
1188
+ currentState.hoveredNodeId,
1189
+ currentState.selectedNodeId,
1190
+ currentState.selectedEdgeId,
1191
+ currentState.focusIds,
1192
+ currentState.pathEdgeIds,
1193
+ currentState.highlightedIncidentEdgeIds,
1194
+ );
1195
+ const style = resolveEdgeElementStyle(
1196
+ GRAPH_THEME,
1197
+ currentState.zoomTier,
1198
+ state,
1199
+ attrs,
1200
+ source,
1201
+ target,
1202
+ currentState.viewMode,
1203
+ stableEdgeId,
1204
+ fullEdgeClass,
1205
+ );
1206
+ const distanceStyle = currentState.viewMode === "full"
1207
+ ? resolveDistanceEdgeStyle(
1208
+ style,
1209
+ currentState.distanceVisualState,
1210
+ String(source),
1211
+ String(target),
1212
+ fullEdgeClass,
1213
+ )
1214
+ : {};
1215
+ const resolvedStyle = { ...style, ...distanceStyle };
1216
+
1217
+ return {
1218
+ ...data,
1219
+ hidden: resolvedStyle.hidden,
1220
+ type: resolvedStyle.type,
1221
+ color: resolvedStyle.color,
1222
+ size: resolvedStyle.size,
1223
+ zIndex: resolvedStyle.zIndex,
1224
+ curvature: resolvedStyle.curvature,
1225
+ // #1009: Sigma's edge label renderer draws data.label — the graph
1226
+ // stores the relationship type in edgeType, which the renderer never
1227
+ // saw, so enabling renderEdgeLabels alone left edges blank.
1228
+ // Use || rather than ?? so that an empty-string edgeType (possible
1229
+ // when the API returns type: "") does not produce a blank label.
1230
+ label: resolvedStyle.hidden ? undefined : String(attrs.edgeType || data.label || ""),
1231
+ };
1232
+ });
1233
+
1234
+ if ((refreshTargets?.nodes?.length ?? 0) > 0 || (refreshTargets?.edges?.length ?? 0) > 0) {
1235
+ sigma.scheduleRefresh({
1236
+ partialGraph: {
1237
+ nodes: refreshTargets?.nodes,
1238
+ edges: refreshTargets?.edges,
1239
+ },
1240
+ });
1241
+ return;
1242
+ }
1243
+
1244
+ sigma.scheduleRefresh();
1245
+ }
1246
+
1247
+ function dispatchBehaviorAction(
1248
+ behaviors: GraphBehavior[],
1249
+ context: GraphBehaviorContext,
1250
+ action: GraphBehaviorActionRequest,
1251
+ ) {
1252
+ for (const behavior of behaviors) {
1253
+ if (behavior.performAction?.(context, action)) {
1254
+ return;
1255
+ }
1256
+ }
1257
+ }
1258
+
1259
+ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(
1260
+ function GraphCanvas(
1261
+ {
1262
+ graphVersion,
1263
+ graphReady,
1264
+ displayGraph,
1265
+ displayMeta,
1266
+ displayState,
1267
+ onNodeClick,
1268
+ onEdgeClick,
1269
+ selectedNodeId,
1270
+ focusedNodeId,
1271
+ selectedEdgeId,
1272
+ activePath = [],
1273
+ activePathEdgeIds = [],
1274
+ distanceVisualState,
1275
+ effectsState,
1276
+ temporalState,
1277
+ isLayoutRunning,
1278
+ onLayoutRunningChange,
1279
+ viewMode,
1280
+ className,
1281
+ showFitViewButton = true,
1282
+ pluginOverlays = [],
1283
+ onSceneRuntimeChange,
1284
+ onInteractionStateChange,
1285
+ onCameraStateChange,
1286
+ onDiagnosticsChange,
1287
+ onAnalyticsChange,
1288
+ },
1289
+ ref,
1290
+ ) {
1291
+ const containerRef = useRef<HTMLDivElement>(null);
1292
+ const overlayRef = useRef<HTMLCanvasElement>(null);
1293
+ const structureLayerCanvasRef = useRef<HTMLCanvasElement | null>(null);
1294
+ const structureLayerCacheRef = useRef<GraphStructureCurveCache | null>(null);
1295
+ const structureLayerLastDrawAtRef = useRef<number | null>(null);
1296
+ const structureLayerDiagnosticsRef = useRef<GraphStructureLayerDiagnostics | null>(null);
1297
+ const sigmaRef = useRef<Sigma | null>(null);
1298
+ const fa2Ref = useRef<FA2Layout | null>(null);
1299
+ const behaviorContextRef = useRef<GraphBehaviorContext | null>(null);
1300
+ const runtimeRef = useRef<GraphSceneRuntime | null>(null);
1301
+ const sigmaResizeObserverRef = useRef<ResizeObserver | null>(null);
1302
+ const sigmaCameraSyncRef = useRef<(() => void) | null>(null);
1303
+ const displayGraphRef = useRef<GraphSceneGraph>(displayGraph);
1304
+ const displayStateRef = useRef(displayState);
1305
+ const displayMetaRef = useRef(displayMeta);
1306
+ const graphVersionRef = useRef(graphVersion);
1307
+ const selectedNodeIdRef = useRef(selectedNodeId);
1308
+ const focusedNodeIdRef = useRef(focusedNodeId);
1309
+ const distanceVisualStateRef = useRef(distanceVisualState);
1310
+ const viewModeRef = useRef(viewMode);
1311
+ const onNodeClickRef = useRef(onNodeClick);
1312
+ const onEdgeClickRef = useRef(onEdgeClick);
1313
+ const onSceneRuntimeChangeRef = useRef(onSceneRuntimeChange);
1314
+ const onCameraStateChangeRef = useRef(onCameraStateChange);
1315
+ // #1009: tracked as a ref so the Sigma creation effect always reads the
1316
+ // current value without needing effectsState in its dependency array.
1317
+ const effectsStateRef = useRef(effectsState);
1318
+ const [hoveredNodeId, setHoveredNodeId] = useState<string | null>(null);
1319
+ const [zoomTier, setZoomTier] = useState<GraphZoomTier>("overview");
1320
+ const [analyticsSnapshot, setAnalyticsSnapshot] = useState<GraphAnalyticsSnapshot | null>(null);
1321
+ const [layoutSettledEpoch, setLayoutSettledEpoch] = useState(0);
1322
+ const appliedGraphVersionRef = useRef<number | null>(null);
1323
+ const fittedDisplaySignatureRef = useRef<DisplayFitSignature | null>(null);
1324
+ const layoutSyncFrameRef = useRef<number | null>(null);
1325
+ const layoutSyncTickRef = useRef(0);
1326
+ const deferredFocusFrameRef = useRef<number | null>(null);
1327
+ const fullGraphLayoutSettleTimeoutRef = useRef<number | null>(null);
1328
+ const groupedLayoutSettleTimeoutRef = useRef<number | null>(null);
1329
+ const reducerWarningStateRef = useRef<{ missingEdges: Set<string>; missingNodes: Set<string> }>({
1330
+ missingEdges: new Set<string>(),
1331
+ missingNodes: new Set<string>(),
1332
+ });
1333
+
1334
+ displayGraphRef.current = displayGraph;
1335
+ displayStateRef.current = displayState;
1336
+ displayMetaRef.current = displayMeta;
1337
+ graphVersionRef.current = graphVersion;
1338
+ selectedNodeIdRef.current = selectedNodeId;
1339
+ focusedNodeIdRef.current = focusedNodeId;
1340
+ distanceVisualStateRef.current = distanceVisualState;
1341
+ viewModeRef.current = viewMode;
1342
+ onNodeClickRef.current = onNodeClick;
1343
+ onEdgeClickRef.current = onEdgeClick;
1344
+ onSceneRuntimeChangeRef.current = onSceneRuntimeChange;
1345
+ onCameraStateChangeRef.current = onCameraStateChange;
1346
+ effectsStateRef.current = effectsState;
1347
+
1348
+ const behaviors = useMemo<GraphBehavior[]>(
1349
+ () => [
1350
+ hoverActivationBehavior,
1351
+ clickSelectionBehavior,
1352
+ focusCameraBehavior,
1353
+ createSearchFocusBehavior(),
1354
+ createPathHighlightBehavior(),
1355
+ fitViewBehavior,
1356
+ createViewModeSwitchBehavior(),
1357
+ ],
1358
+ [],
1359
+ );
1360
+
1361
+ const interactionState = useMemo(
1362
+ () => createInteractionState(
1363
+ hoveredNodeId,
1364
+ selectedNodeId,
1365
+ focusedNodeId,
1366
+ selectedEdgeId,
1367
+ activePath,
1368
+ activePathEdgeIds,
1369
+ viewMode,
1370
+ zoomTier,
1371
+ isLayoutRunning,
1372
+ ),
1373
+ [
1374
+ activePath,
1375
+ activePathEdgeIds,
1376
+ focusedNodeId,
1377
+ hoveredNodeId,
1378
+ isLayoutRunning,
1379
+ selectedEdgeId,
1380
+ selectedNodeId,
1381
+ viewMode,
1382
+ zoomTier,
1383
+ ],
1384
+ );
1385
+ const interactionStateRef = useRef<GraphInteractionState>(interactionState);
1386
+ interactionStateRef.current = interactionState;
1387
+ const previousInteractionStateRef = useRef<GraphInteractionState | null>(null);
1388
+ const previousDistanceVisualStateRef = useRef<GraphDistanceVisualState | undefined>(undefined);
1389
+ useEffect(() => {
1390
+ if (!isLayoutRunning) {
1391
+ setLayoutSettledEpoch((epoch) => epoch + 1);
1392
+ }
1393
+ }, [displayGraph, graphVersion, isLayoutRunning]);
1394
+
1395
+ const shouldComputeCommunities = effectsState.communitiesEnabled || effectsState.semanticRegionsEnabled;
1396
+ const shouldComputeCentrality = effectsState.centralityEnabled || effectsState.semanticRegionsEnabled || effectsState.contoursEnabled;
1397
+ const analyticsBase = useMemo(
1398
+ () => computeGraphAnalyticsBase(displayGraph, {
1399
+ computeCommunities: shouldComputeCommunities,
1400
+ computeCentrality: shouldComputeCentrality,
1401
+ }),
1402
+ [displayGraph, shouldComputeCentrality, shouldComputeCommunities],
1403
+ );
1404
+ const reducerSceneState = useMemo(
1405
+ () => buildReducerSceneState(displayGraph, interactionState, displayState, analyticsSnapshot, distanceVisualState),
1406
+ [analyticsSnapshot, displayGraph, displayState, distanceVisualState, interactionState],
1407
+ );
1408
+ const reducerSceneStateRef = useRef<ReducerSceneState>(reducerSceneState);
1409
+ reducerSceneStateRef.current = reducerSceneState;
1410
+ const edgeClassDiagnostics = useMemo(
1411
+ () => buildFullGraphEdgeClassDiagnostics(displayGraph, reducerSceneState),
1412
+ [displayGraph, reducerSceneState],
1413
+ );
1414
+ const structureLayerGate = useMemo(
1415
+ () => evaluateGraphStructureLayerGate({
1416
+ mode: GRAPH_THEME.edges.fullGraphStructureLayer.mode,
1417
+ viewMode,
1418
+ isLayoutRunning,
1419
+ edgeDiagnostics: edgeClassDiagnostics,
1420
+ minimumLiteralEdges: GRAPH_THEME.edges.fullGraphStructureLayer.minimumLiteralEdges,
1421
+ }),
1422
+ [edgeClassDiagnostics, isLayoutRunning, viewMode],
1423
+ );
1424
+ const structureLayerCache = useMemo(() => {
1425
+ if (!structureLayerGate.enabled) {
1426
+ return null;
1427
+ }
1428
+ const cacheKey = createGraphStructureCacheKey({
1429
+ graphVersion,
1430
+ zoomTier,
1431
+ layoutSettledEpoch,
1432
+ overviewBackboneEdgeIds: reducerSceneState.overviewBackboneEdgeIds,
1433
+ });
1434
+ return buildGraphStructureCurveCache({
1435
+ graphRef: displayGraph,
1436
+ cacheKey,
1437
+ classifyEdge: (edgeId) => getFullGraphEdgeClass(displayGraph, edgeId, reducerSceneState),
1438
+ maxCurves: GRAPH_THEME.edges.fullGraphStructureLayer.maxCurves,
1439
+ curveStrength: GRAPH_THEME.edges.fullGraphStructureLayer.curveStrength,
1440
+ });
1441
+ }, [displayGraph, graphVersion, layoutSettledEpoch, reducerSceneState, structureLayerGate.enabled, zoomTier]);
1442
+ structureLayerCacheRef.current = structureLayerCache;
1443
+ const structureLayerDiagnostics = useMemo(
1444
+ () => getGraphStructureLayerDiagnostics({
1445
+ gate: structureLayerGate,
1446
+ cache: structureLayerCache,
1447
+ minimumCurves: GRAPH_THEME.edges.fullGraphStructureLayer.minimumCurves,
1448
+ canvasAvailable: Boolean(structureLayerCanvasRef.current),
1449
+ lastDrawAt: structureLayerLastDrawAtRef.current,
1450
+ }),
1451
+ [structureLayerCache, structureLayerGate],
1452
+ );
1453
+ structureLayerDiagnosticsRef.current = structureLayerDiagnostics;
1454
+ const displayFitSignature = useMemo<DisplayFitSignature>(() => ({
1455
+ graphVersion,
1456
+ viewMode,
1457
+ layoutMode: displayMeta.layoutMode,
1458
+ displayGraph,
1459
+ }), [displayGraph, displayMeta.layoutMode, graphVersion, viewMode]);
1460
+
1461
+ const animateCameraToBounds = useCallback((
1462
+ intent: "fit-display-graph" | "fit-selection-context" | "fit-focused-graph",
1463
+ targetBounds: SigmaCustomBBox | null,
1464
+ diagnostics?: Record<string, unknown>,
1465
+ ) => {
1466
+ const sigma = sigmaRef.current;
1467
+ if (!sigma) {
1468
+ return;
1469
+ }
1470
+
1471
+ if (sigma.getCustomBBox()) {
1472
+ sigma.setCustomBBox(null);
1473
+ }
1474
+
1475
+ const container = containerRef.current;
1476
+ const dimensions = sigma.getDimensions();
1477
+ const currentCameraState = sigma.getCamera().getState();
1478
+ const target = targetBounds
1479
+ ? computeCameraTargetFromBounds(sigma, targetBounds)
1480
+ : {
1481
+ x: 0.5,
1482
+ y: 0.5,
1483
+ ratio: 1,
1484
+ angle: currentCameraState.angle,
1485
+ };
1486
+ if (!target) {
1487
+ debugGraphRuntime("camera-fit-target-invalid", {
1488
+ intent,
1489
+ graphVersion: graphVersionRef.current,
1490
+ viewMode: viewModeRef.current,
1491
+ order: displayGraphRef.current.order,
1492
+ size: displayGraphRef.current.size,
1493
+ boundsX: targetBounds?.x ?? null,
1494
+ boundsY: targetBounds?.y ?? null,
1495
+ ...diagnostics,
1496
+ });
1497
+ return;
1498
+ }
1499
+
1500
+ debugGraphRuntime("camera-fit-target", {
1501
+ intent,
1502
+ graphVersion: graphVersionRef.current,
1503
+ viewMode: viewModeRef.current,
1504
+ order: displayGraphRef.current.order,
1505
+ size: displayGraphRef.current.size,
1506
+ selectionPreserved: Boolean(selectedNodeIdRef.current),
1507
+ containerWidth: container?.clientWidth ?? dimensions.width,
1508
+ containerHeight: container?.clientHeight ?? dimensions.height,
1509
+ targetX: target.x,
1510
+ targetY: target.y,
1511
+ targetRatio: target.ratio,
1512
+ boundsX: targetBounds?.x ?? null,
1513
+ boundsY: targetBounds?.y ?? null,
1514
+ ...diagnostics,
1515
+ });
1516
+
1517
+ void sigma.getCamera().animate(
1518
+ target,
1519
+ { duration: GRAPH_THEME.motion.cameraMs, easing: "quadraticOut" },
1520
+ );
1521
+ }, []);
1522
+
1523
+ const fitDisplayGraphInView = useCallback(() => {
1524
+ const sigma = sigmaRef.current;
1525
+ if (!sigma) {
1526
+ return;
1527
+ }
1528
+
1529
+ const bounds = computeDisplayedGraphBounds(sigma, displayGraphRef.current);
1530
+ if (!bounds) {
1531
+ debugGraphRuntime("camera-fit-display-graph-fallback", {
1532
+ graphVersion: graphVersionRef.current,
1533
+ viewMode: viewModeRef.current,
1534
+ reason: "display-bounds-unavailable",
1535
+ order: displayGraphRef.current.order,
1536
+ size: displayGraphRef.current.size,
1537
+ });
1538
+ animateCameraToBounds("fit-display-graph", null, {
1539
+ fallback: "reset-empty-bounds",
1540
+ });
1541
+ return;
1542
+ }
1543
+
1544
+ const displayBBox = expandGraphSpaceBounds(bounds, bounds, {
1545
+ paddingRatio: 0.14,
1546
+ minSpanRatio: 0.02,
1547
+ minSpanFloor: 0.04,
1548
+ });
1549
+ animateCameraToBounds("fit-display-graph", displayBBox, {
1550
+ boundsCount: bounds.count,
1551
+ minX: bounds.minX,
1552
+ maxX: bounds.maxX,
1553
+ minY: bounds.minY,
1554
+ maxY: bounds.maxY,
1555
+ });
1556
+ }, [animateCameraToBounds]);
1557
+
1558
+ const centerSelectionInView = useCallback(function centerSelectionInViewInternal(nodeId: string, attempt = 0) {
1559
+ const sigma = sigmaRef.current;
1560
+ if (!sigma) {
1561
+ return;
1562
+ }
1563
+
1564
+ const currentDisplayGraph = displayGraphRef.current;
1565
+ const selectionNodeIds = collectSelectionContextNodeIds(
1566
+ currentDisplayGraph,
1567
+ displayStateRef.current,
1568
+ nodeId,
1569
+ );
1570
+ if (selectionNodeIds.length === 0) {
1571
+ debugGraphRuntime("camera-selection-missing-node", {
1572
+ nodeId,
1573
+ graphVersion: graphVersionRef.current,
1574
+ viewMode: viewModeRef.current,
1575
+ });
1576
+ return;
1577
+ }
1578
+
1579
+ const selectedDisplayNodeId = selectionNodeIds[0];
1580
+ const selectedDisplayData = sigma.getNodeDisplayData(selectedDisplayNodeId);
1581
+ if (selectedDisplayData) {
1582
+ const viewportPoint = sigma.graphToViewport({
1583
+ x: selectedDisplayData.x,
1584
+ y: selectedDisplayData.y,
1585
+ });
1586
+ const dimensions = sigma.getDimensions();
1587
+ if (isPointNearViewport(viewportPoint, dimensions.width, dimensions.height, 96)) {
1588
+ debugGraphRuntime("camera-selection-visible-noop", {
1589
+ nodeId,
1590
+ selectedDisplayNodeId,
1591
+ graphVersion: graphVersionRef.current,
1592
+ viewMode: viewModeRef.current,
1593
+ viewportX: viewportPoint.x,
1594
+ viewportY: viewportPoint.y,
1595
+ });
1596
+ sigma.scheduleRefresh();
1597
+ return;
1598
+ }
1599
+ }
1600
+
1601
+ const bounds = computeDisplayedNodeBounds(sigma, selectionNodeIds);
1602
+ if (!bounds) {
1603
+ if (attempt < 3) {
1604
+ debugGraphRuntime("camera-selection-display-bounds-deferred", {
1605
+ nodeId,
1606
+ graphVersion: graphVersionRef.current,
1607
+ attempt: attempt + 1,
1608
+ viewMode: viewModeRef.current,
1609
+ contextCount: selectionNodeIds.length,
1610
+ });
1611
+ if (deferredFocusFrameRef.current !== null) {
1612
+ window.cancelAnimationFrame(deferredFocusFrameRef.current);
1613
+ }
1614
+ deferredFocusFrameRef.current = window.requestAnimationFrame(() => {
1615
+ deferredFocusFrameRef.current = null;
1616
+ centerSelectionInViewInternal(nodeId, attempt + 1);
1617
+ });
1618
+ } else {
1619
+ debugGraphRuntime("camera-selection-display-bounds-fallback-fit", {
1620
+ nodeId,
1621
+ graphVersion: graphVersionRef.current,
1622
+ viewMode: viewModeRef.current,
1623
+ contextCount: selectionNodeIds.length,
1624
+ });
1625
+ }
1626
+ return;
1627
+ }
1628
+
1629
+ const camera = sigma.getCamera();
1630
+ const currentCameraState = camera.getState();
1631
+ const target = {
1632
+ x: (bounds.minX + bounds.maxX) / 2,
1633
+ y: (bounds.minY + bounds.maxY) / 2,
1634
+ ratio: currentCameraState.ratio,
1635
+ angle: currentCameraState.angle,
1636
+ };
1637
+
1638
+ debugGraphRuntime("camera-selection-gentle-center", {
1639
+ nodeId,
1640
+ contextCount: bounds.count,
1641
+ boundsSource: "displayed-selection-context",
1642
+ minX: bounds.minX,
1643
+ maxX: bounds.maxX,
1644
+ minY: bounds.minY,
1645
+ maxY: bounds.maxY,
1646
+ targetX: target.x,
1647
+ targetY: target.y,
1648
+ preservedRatio: target.ratio,
1649
+ });
1650
+
1651
+ void camera.animate(
1652
+ target,
1653
+ { duration: GRAPH_THEME.motion.cameraMs, easing: "quadraticOut" },
1654
+ );
1655
+ }, []);
1656
+
1657
+ const centerGroupedSelectionInView = useCallback((nodeId: string) => {
1658
+ const sigma = sigmaRef.current;
1659
+ if (!sigma) {
1660
+ return;
1661
+ }
1662
+
1663
+ const currentDisplayGraph = displayGraphRef.current;
1664
+ const selectionNodeIds = collectSelectionContextNodeIds(
1665
+ currentDisplayGraph,
1666
+ displayStateRef.current,
1667
+ nodeId,
1668
+ );
1669
+ if (selectionNodeIds.length === 0) {
1670
+ debugGraphRuntime("camera-grouped-selection-missing-node", {
1671
+ nodeId,
1672
+ graphVersion: graphVersionRef.current,
1673
+ viewMode: viewModeRef.current,
1674
+ });
1675
+ return;
1676
+ }
1677
+
1678
+ const bounds = computeDisplayedNodeBounds(sigma, selectionNodeIds);
1679
+ if (!bounds) {
1680
+ debugGraphRuntime("camera-grouped-selection-display-bounds-missing", {
1681
+ nodeId,
1682
+ graphVersion: graphVersionRef.current,
1683
+ viewMode: viewModeRef.current,
1684
+ contextCount: selectionNodeIds.length,
1685
+ });
1686
+ return;
1687
+ }
1688
+
1689
+ const camera = sigma.getCamera();
1690
+ const currentCameraState = camera.getState();
1691
+ const target = {
1692
+ x: (bounds.minX + bounds.maxX) / 2,
1693
+ y: (bounds.minY + bounds.maxY) / 2,
1694
+ ratio: currentCameraState.ratio,
1695
+ angle: currentCameraState.angle,
1696
+ };
1697
+
1698
+ debugGraphRuntime("camera-grouped-selection-center", {
1699
+ nodeId,
1700
+ graphVersion: graphVersionRef.current,
1701
+ viewMode: viewModeRef.current,
1702
+ contextCount: bounds.count,
1703
+ targetX: target.x,
1704
+ targetY: target.y,
1705
+ preservedRatio: target.ratio,
1706
+ });
1707
+
1708
+ void camera.animate(
1709
+ target,
1710
+ { duration: GRAPH_THEME.motion.cameraMs, easing: "quadraticOut" },
1711
+ );
1712
+ }, []);
1713
+
1714
+ const focusNodeInView = useCallback((nodeId: string) => {
1715
+ const sigma = sigmaRef.current;
1716
+ if (!sigma) {
1717
+ return;
1718
+ }
1719
+
1720
+ const focusedView = viewModeRef.current === "focused"
1721
+ && Boolean(selectedNodeIdRef.current)
1722
+ && graph.hasNode(selectedNodeIdRef.current);
1723
+ if (focusedView) {
1724
+ const focusedBounds = computeDisplayedGraphBounds(sigma, displayGraphRef.current);
1725
+ const focusedBBox = focusedBounds
1726
+ ? expandGraphSpaceBounds(focusedBounds, focusedBounds, {
1727
+ paddingRatio: 0.14,
1728
+ minSpanRatio: 0.08,
1729
+ minSpanFloor: 0.02,
1730
+ })
1731
+ : null;
1732
+ animateCameraToBounds("fit-focused-graph", focusedBBox, {
1733
+ nodeId,
1734
+ focusedCount: focusedBounds?.count ?? 0,
1735
+ focusedMinX: focusedBounds?.minX ?? null,
1736
+ focusedMaxX: focusedBounds?.maxX ?? null,
1737
+ focusedMinY: focusedBounds?.minY ?? null,
1738
+ focusedMaxY: focusedBounds?.maxY ?? null,
1739
+ });
1740
+ return;
1741
+ }
1742
+
1743
+ centerSelectionInView(nodeId);
1744
+ }, [animateCameraToBounds, centerSelectionInView]);
1745
+
1746
+ const fitCurrentView = useCallback(() => {
1747
+ fitDisplayGraphInView();
1748
+ }, [fitDisplayGraphInView]);
1749
+
1750
+ const dispatchAction = useCallback((action: GraphBehaviorActionRequest) => {
1751
+ const context = behaviorContextRef.current;
1752
+ if (!context) {
1753
+ return;
1754
+ }
1755
+
1756
+ dispatchBehaviorAction(behaviors, context, action);
1757
+ }, [behaviors]);
1758
+
1759
+ const getBehaviorContext = useCallback((sigma?: Sigma | null): GraphBehaviorContext | null => {
1760
+ const runtimeSigma = sigma ?? sigmaRef.current;
1761
+ if (!runtimeSigma) {
1762
+ return null;
1763
+ }
1764
+
1765
+ const context: GraphBehaviorContext = {
1766
+ sigma: runtimeSigma,
1767
+ graph,
1768
+ displayGraph: displayGraphRef.current,
1769
+ getInteractionState: () => interactionStateRef.current,
1770
+ setHoveredNodeId,
1771
+ onNodeSelectionChange: (nodeId: string) => onNodeClickRef.current(nodeId),
1772
+ onEdgeSelectionChange: (edgeId: string) => onEdgeClickRef.current?.(edgeId),
1773
+ focusNodeInView,
1774
+ centerSelectionInView,
1775
+ centerGroupedSelectionInView,
1776
+ fitCurrentView,
1777
+ dispatchAction,
1778
+ };
1779
+ behaviorContextRef.current = context;
1780
+ return context;
1781
+ }, [centerGroupedSelectionInView, centerSelectionInView, dispatchAction, fitCurrentView, focusNodeInView]);
1782
+
1783
+ const dispatchToBehaviors = useCallback((
1784
+ hook: "onNodeEnter" | "onNodeLeave" | "onNodeClick" | "onEdgeClick" | "onStageClick" | "onCameraChange",
1785
+ ...args: unknown[]
1786
+ ) => {
1787
+ const context = getBehaviorContext();
1788
+ if (!context) {
1789
+ return;
1790
+ }
1791
+
1792
+ for (const behavior of behaviors) {
1793
+ const handler = behavior[hook];
1794
+ if (typeof handler === "function") {
1795
+ (handler as (...handlerArgs: unknown[]) => void)(context, ...args);
1796
+ }
1797
+ }
1798
+ }, [behaviors, getBehaviorContext]);
1799
+
1800
+ useImperativeHandle(ref, () => ({
1801
+ fitView: () => dispatchAction({ type: "fitView" }),
1802
+ focusNode: (nodeId: string) => dispatchAction({ type: "focusNode", nodeId }),
1803
+ zoomIn: () => {
1804
+ const sigma = sigmaRef.current;
1805
+ if (!sigma) {
1806
+ return;
1807
+ }
1808
+ debugGraphRuntime("camera-zoom-in", {
1809
+ graphVersion: graphVersionRef.current,
1810
+ ratioBefore: sigma.getCamera().getState().ratio,
1811
+ });
1812
+ sigma.getCamera().animatedZoom({ duration: GRAPH_THEME.motion.cameraMs });
1813
+ },
1814
+ zoomOut: () => {
1815
+ const sigma = sigmaRef.current;
1816
+ if (!sigma) {
1817
+ return;
1818
+ }
1819
+ debugGraphRuntime("camera-zoom-out", {
1820
+ graphVersion: graphVersionRef.current,
1821
+ ratioBefore: sigma.getCamera().getState().ratio,
1822
+ });
1823
+ sigma.getCamera().animatedUnzoom({ duration: GRAPH_THEME.motion.cameraMs });
1824
+ },
1825
+ requestRender: () => sigmaRef.current?.scheduleRefresh(),
1826
+ getCameraState: () => {
1827
+ const sigma = sigmaRef.current;
1828
+ if (!sigma) {
1829
+ return null;
1830
+ }
1831
+ const state = sigma.getCamera().getState();
1832
+ return { x: state.x, y: state.y, ratio: state.ratio };
1833
+ },
1834
+ }), [dispatchAction]);
1835
+
1836
+ const syncCameraState = useCallback((sigma: Sigma) => {
1837
+ const camera = sigma.getCamera();
1838
+ const state = camera.getState();
1839
+ const cameraState: GraphCameraState = {
1840
+ x: state.x,
1841
+ y: state.y,
1842
+ ratio: state.ratio,
1843
+ };
1844
+ const nextTier = getZoomTier(cameraState.ratio);
1845
+ setZoomTier((current) => (current === nextTier ? current : nextTier));
1846
+ onCameraStateChangeRef.current?.(cameraState);
1847
+ dispatchToBehaviors("onCameraChange", cameraState);
1848
+ debugGraphRuntime("camera-updated", {
1849
+ graphVersion: graphVersionRef.current,
1850
+ ratio: cameraState.ratio,
1851
+ });
1852
+ }, [dispatchToBehaviors]);
1853
+
1854
+ useEffect(() => {
1855
+ if (!graphReady || sigmaRef.current || !containerRef.current) {
1856
+ return;
1857
+ }
1858
+
1859
+ const sigma = new Sigma(displayGraphRef.current, containerRef.current, {
1860
+ ...SIGMA_SETTINGS,
1861
+ // #1009: initialize with the current toggle value rather than the
1862
+ // static default so that a user who disabled Edge Labels before
1863
+ // graph/Sigma initialization sees the correct state after mount.
1864
+ renderEdgeLabels: effectsStateRef.current.edgeLabelsEnabled,
1865
+ });
1866
+ sigmaRef.current = sigma;
1867
+ appliedGraphVersionRef.current = graphVersionRef.current;
1868
+
1869
+ const camera = sigma.getCamera();
1870
+ const runtime: GraphSceneRuntime = {
1871
+ renderer: "sigma",
1872
+ scene: sigma,
1873
+ graph,
1874
+ displayGraph: displayGraphRef.current,
1875
+ graphVersion: graphVersionRef.current,
1876
+ layoutMode: displayMetaRef.current.layoutMode,
1877
+ requestRender: () => sigma.scheduleRefresh(),
1878
+ getCameraState: () => {
1879
+ const state = camera.getState();
1880
+ return { x: state.x, y: state.y, ratio: state.ratio };
1881
+ },
1882
+ };
1883
+ runtimeRef.current = runtime;
1884
+ onSceneRuntimeChangeRef.current?.(runtime);
1885
+ debugGraphRuntime("sigma-created", {
1886
+ graphVersion: graphVersionRef.current,
1887
+ layoutMode: displayMetaRef.current.layoutMode,
1888
+ order: displayGraphRef.current.order,
1889
+ size: displayGraphRef.current.size,
1890
+ });
1891
+
1892
+ const context = getBehaviorContext(sigma);
1893
+ if (context) {
1894
+ for (const behavior of behaviors) {
1895
+ behavior.attach(context);
1896
+ }
1897
+ }
1898
+
1899
+ const handleResize = () => {
1900
+ if (containerRef.current && containerRef.current.offsetWidth > 0) {
1901
+ sigma.scheduleRefresh();
1902
+ }
1903
+ };
1904
+
1905
+ const resizeObserver = new ResizeObserver(handleResize);
1906
+ resizeObserver.observe(containerRef.current);
1907
+ sigmaResizeObserverRef.current = resizeObserver;
1908
+
1909
+ const handleCameraUpdated = () => syncCameraState(sigma);
1910
+ sigmaCameraSyncRef.current = handleCameraUpdated;
1911
+ camera.on("updated", handleCameraUpdated);
1912
+ sigma.on("clickNode", ({ node }) => dispatchToBehaviors("onNodeClick", node));
1913
+ sigma.on("clickEdge", ({ edge }) => {
1914
+ const currentDisplayGraph = displayGraphRef.current;
1915
+ const [source, target] = currentDisplayGraph.extremities(edge);
1916
+ const stableEdgeId = String(edge);
1917
+ const attrs = currentDisplayGraph.getEdgeAttributes(edge) as EdgeAttributes;
1918
+
1919
+ if (isEdgeInteractable(currentDisplayGraph, interactionStateRef.current, stableEdgeId, source, target, attrs)) {
1920
+ dispatchToBehaviors("onEdgeClick", stableEdgeId);
1921
+ }
1922
+ });
1923
+ sigma.on("clickStage", () => dispatchToBehaviors("onStageClick"));
1924
+ sigma.on("enterNode", ({ node }) => dispatchToBehaviors("onNodeEnter", node));
1925
+ sigma.on("leaveNode", ({ node }) => dispatchToBehaviors("onNodeLeave", node));
1926
+ sigma.on("enterEdge", ({ edge }) => {
1927
+ const container = containerRef.current;
1928
+ if (!container) {
1929
+ return;
1930
+ }
1931
+
1932
+ const currentDisplayGraph = displayGraphRef.current;
1933
+ const [source, target] = currentDisplayGraph.extremities(edge);
1934
+ const stableEdgeId = String(edge);
1935
+ const attrs = currentDisplayGraph.getEdgeAttributes(edge) as EdgeAttributes;
1936
+ container.style.cursor = isEdgeInteractable(currentDisplayGraph, interactionStateRef.current, stableEdgeId, source, target, attrs)
1937
+ ? "pointer"
1938
+ : "";
1939
+ });
1940
+ sigma.on("leaveEdge", () => {
1941
+ if (containerRef.current) {
1942
+ containerRef.current.style.cursor = "";
1943
+ }
1944
+ });
1945
+
1946
+ try {
1947
+ const structureCanvas = sigma.createCanvas("structure", {
1948
+ beforeLayer: "nodes",
1949
+ afterLayer: "edges",
1950
+ style: {
1951
+ pointerEvents: "none",
1952
+ },
1953
+ });
1954
+ structureLayerCanvasRef.current = structureCanvas;
1955
+ } catch (error) {
1956
+ debugGraphRuntime("structure-layer-create-failed", {
1957
+ error: error instanceof Error ? error.message : String(error),
1958
+ });
1959
+ structureLayerCanvasRef.current = null;
1960
+ }
1961
+
1962
+ requestAnimationFrame(() => {
1963
+ syncCameraState(sigma);
1964
+ });
1965
+ }, [behaviors, dispatchToBehaviors, getBehaviorContext, graphReady, syncCameraState]);
1966
+
1967
+ // #1009: renderEdgeLabels follows the Effects-panel toggle instead of
1968
+ // staying hardcoded — dense graphs get their label-free edges back.
1969
+ useEffect(() => {
1970
+ const sigma = sigmaRef.current;
1971
+ if (!sigma) {
1972
+ return;
1973
+ }
1974
+ sigma.setSetting("renderEdgeLabels", effectsState.edgeLabelsEnabled);
1975
+ sigma.scheduleRefresh();
1976
+ }, [effectsState.edgeLabelsEnabled]);
1977
+
1978
+ useEffect(() => {
1979
+ return () => {
1980
+ const sigma = sigmaRef.current;
1981
+ const context = behaviorContextRef.current;
1982
+ if (context) {
1983
+ for (const behavior of behaviors) {
1984
+ behavior.detach(context);
1985
+ }
1986
+ }
1987
+ if (containerRef.current) {
1988
+ containerRef.current.style.cursor = "";
1989
+ }
1990
+ sigmaResizeObserverRef.current?.disconnect();
1991
+ sigmaResizeObserverRef.current = null;
1992
+ if (sigma && sigmaCameraSyncRef.current) {
1993
+ sigma.getCamera().off("updated", sigmaCameraSyncRef.current);
1994
+ }
1995
+ sigmaCameraSyncRef.current = null;
1996
+ if (sigma) {
1997
+ debugGraphRuntime("sigma-killed", {
1998
+ graphVersion: graphVersionRef.current,
1999
+ });
2000
+ if (structureLayerCanvasRef.current) {
2001
+ try {
2002
+ sigma.killLayer("structure");
2003
+ } catch {
2004
+ // Sigma.kill() also cleans layers; ignore if already removed.
2005
+ }
2006
+ }
2007
+ structureLayerCanvasRef.current = null;
2008
+ sigma.kill();
2009
+ }
2010
+ if (deferredFocusFrameRef.current !== null) {
2011
+ window.cancelAnimationFrame(deferredFocusFrameRef.current);
2012
+ deferredFocusFrameRef.current = null;
2013
+ }
2014
+ runtimeRef.current = null;
2015
+ behaviorContextRef.current = null;
2016
+ sigmaRef.current = null;
2017
+ onSceneRuntimeChangeRef.current?.(null);
2018
+ };
2019
+ }, [behaviors]);
2020
+
2021
+ useEffect(() => {
2022
+ const sigma = sigmaRef.current;
2023
+ if (!graphReady || !sigma) {
2024
+ return;
2025
+ }
2026
+
2027
+ if (appliedGraphVersionRef.current === graphVersion && sigma.getGraph() === displayGraph) {
2028
+ return;
2029
+ }
2030
+
2031
+ debugGraphRuntime("sigma-set-graph", {
2032
+ graphVersion,
2033
+ displayFitViewMode: displayFitSignature.viewMode,
2034
+ displayFitLayoutMode: displayFitSignature.layoutMode,
2035
+ layoutMode: displayMeta.layoutMode,
2036
+ order: displayGraph.order,
2037
+ size: displayGraph.size,
2038
+ });
2039
+ if (deferredFocusFrameRef.current !== null) {
2040
+ window.cancelAnimationFrame(deferredFocusFrameRef.current);
2041
+ deferredFocusFrameRef.current = null;
2042
+ }
2043
+ reducerWarningStateRef.current.missingEdges.clear();
2044
+ reducerWarningStateRef.current.missingNodes.clear();
2045
+ sigma.setCustomBBox(null);
2046
+ sigma.setGraph(displayGraph);
2047
+ appliedGraphVersionRef.current = graphVersion;
2048
+ fittedDisplaySignatureRef.current = null;
2049
+ previousInteractionStateRef.current = null;
2050
+ previousDistanceVisualStateRef.current = undefined;
2051
+ behaviorContextRef.current = getBehaviorContext(sigma);
2052
+ if (runtimeRef.current) {
2053
+ runtimeRef.current.displayGraph = displayGraph;
2054
+ runtimeRef.current.graphVersion = graphVersion;
2055
+ runtimeRef.current.layoutMode = displayMeta.layoutMode;
2056
+ }
2057
+ sigma.scheduleRefresh();
2058
+ }, [displayFitSignature.layoutMode, displayFitSignature.viewMode, displayGraph, displayMeta.layoutMode, getBehaviorContext, graphReady, graphVersion]);
2059
+
2060
+ useEffect(() => {
2061
+ const sigma = sigmaRef.current;
2062
+ if (!graphReady || !sigma || isSameDisplayFitSignature(fittedDisplaySignatureRef.current, displayFitSignature)) {
2063
+ return;
2064
+ }
2065
+
2066
+ const frame = window.requestAnimationFrame(() => {
2067
+ if (
2068
+ !sigmaRef.current
2069
+ || graphVersionRef.current !== graphVersion
2070
+ || displayGraphRef.current !== displayFitSignature.displayGraph
2071
+ || viewModeRef.current !== displayFitSignature.viewMode
2072
+ || displayMetaRef.current.layoutMode !== displayFitSignature.layoutMode
2073
+ ) {
2074
+ return;
2075
+ }
2076
+
2077
+ fittedDisplaySignatureRef.current = displayFitSignature;
2078
+ debugGraphRuntime("display-fit-signature-applied", {
2079
+ graphVersion: displayFitSignature.graphVersion,
2080
+ viewMode: displayFitSignature.viewMode,
2081
+ layoutMode: displayFitSignature.layoutMode,
2082
+ order: displayFitSignature.displayGraph.order,
2083
+ size: displayFitSignature.displayGraph.size,
2084
+ });
2085
+ if (focusedNodeIdRef.current && viewModeRef.current === "focused") {
2086
+ debugGraphRuntime("initial-focused-fit", {
2087
+ graphVersion,
2088
+ nodeId: focusedNodeIdRef.current,
2089
+ });
2090
+ focusNodeInView(focusedNodeIdRef.current);
2091
+ return;
2092
+ }
2093
+
2094
+ debugGraphRuntime("initial-fit-view", {
2095
+ graphVersion,
2096
+ selectedNodeId: selectedNodeIdRef.current || null,
2097
+ viewMode: viewModeRef.current,
2098
+ });
2099
+ dispatchAction({ type: "fitView" });
2100
+ });
2101
+
2102
+ return () => {
2103
+ window.cancelAnimationFrame(frame);
2104
+ };
2105
+ }, [centerSelectionInView, dispatchAction, displayFitSignature, focusNodeInView, graphReady, graphVersion]);
2106
+
2107
+ useEffect(() => {
2108
+ const context = getBehaviorContext();
2109
+ if (!context) {
2110
+ return;
2111
+ }
2112
+
2113
+ for (const behavior of behaviors) {
2114
+ behavior.onStateChange?.(context, interactionState);
2115
+ }
2116
+
2117
+ for (const behavior of behaviors) {
2118
+ behavior.apply?.(context, interactionState);
2119
+ }
2120
+ onInteractionStateChange?.(interactionState);
2121
+ }, [behaviors, getBehaviorContext, interactionState, onInteractionStateChange]);
2122
+
2123
+ useEffect(() => {
2124
+ const sigma = sigmaRef.current;
2125
+ const container = containerRef.current;
2126
+ if (!sigma || !container) {
2127
+ setAnalyticsSnapshot(null);
2128
+ onAnalyticsChange?.(null);
2129
+ return;
2130
+ }
2131
+
2132
+ const visibleNodes = collectVisibleNodeSamples(
2133
+ sigma,
2134
+ displayGraph,
2135
+ container.clientWidth,
2136
+ container.clientHeight,
2137
+ ).map((sample) => sample.nodeId);
2138
+
2139
+ const analytics = buildGraphAnalyticsSnapshot({
2140
+ graphRef: displayGraph,
2141
+ interactionState,
2142
+ base: analyticsBase,
2143
+ visibleNodeIds: visibleNodes,
2144
+ });
2145
+ setAnalyticsSnapshot(analytics);
2146
+ onAnalyticsChange?.(analytics);
2147
+ }, [analyticsBase, displayGraph, interactionState, onAnalyticsChange]);
2148
+
2149
+ useEffect(() => {
2150
+ if (!onDiagnosticsChange) {
2151
+ return;
2152
+ }
2153
+
2154
+ const sigma = sigmaRef.current;
2155
+ const container = containerRef.current;
2156
+ const availability = buildEffectAvailability(
2157
+ interactionState,
2158
+ effectsState,
2159
+ temporalState,
2160
+ analyticsSnapshot,
2161
+ isLayoutRunning,
2162
+ sigma,
2163
+ container?.clientWidth ?? 0,
2164
+ container?.clientHeight ?? 0,
2165
+ );
2166
+ onDiagnosticsChange({
2167
+ effectAvailability: availability,
2168
+ edgeClasses: edgeClassDiagnostics,
2169
+ structureLayer: structureLayerDiagnosticsRef.current ?? structureLayerDiagnostics,
2170
+ distanceVisual: distanceVisualStateRef.current,
2171
+ });
2172
+ if (import.meta.env.DEV && effectsState.diagnosticsEnabled) {
2173
+ console.debug("[Edge Truth]", edgeClassDiagnostics);
2174
+ }
2175
+ }, [
2176
+ analyticsSnapshot,
2177
+ edgeClassDiagnostics,
2178
+ effectsState,
2179
+ interactionState,
2180
+ isLayoutRunning,
2181
+ onDiagnosticsChange,
2182
+ structureLayerDiagnostics,
2183
+ temporalState,
2184
+ distanceVisualState,
2185
+ ]);
2186
+
2187
+ useEffect(() => {
2188
+ previousInteractionStateRef.current = null;
2189
+ previousDistanceVisualStateRef.current = undefined;
2190
+ }, [displayGraph]);
2191
+
2192
+ useEffect(() => {
2193
+ const sigma = sigmaRef.current;
2194
+ if (!sigma) {
2195
+ return;
2196
+ }
2197
+
2198
+ const previousInteractionState = previousInteractionStateRef.current;
2199
+ const distanceVisualStateChanged = previousDistanceVisualStateRef.current !== distanceVisualState;
2200
+ const refreshTargets = !distanceVisualStateChanged && previousInteractionState
2201
+ ? collectInteractionRefreshTargets(displayGraph, previousInteractionState, interactionState)
2202
+ : undefined;
2203
+
2204
+ applySceneState(sigma, reducerSceneStateRef, reducerWarningStateRef, refreshTargets);
2205
+ previousInteractionStateRef.current = interactionState;
2206
+ previousDistanceVisualStateRef.current = distanceVisualState;
2207
+ }, [displayGraph, distanceVisualState, interactionState, reducerSceneStateRef]);
2208
+
2209
+ const drawStructureLayerFrame = useCallback(() => {
2210
+ const sigma = sigmaRef.current;
2211
+ const canvas = structureLayerCanvasRef.current;
2212
+ const cache = structureLayerCacheRef.current;
2213
+ const diagnostics = getGraphStructureLayerDiagnostics({
2214
+ gate: structureLayerGate,
2215
+ cache,
2216
+ minimumCurves: GRAPH_THEME.edges.fullGraphStructureLayer.minimumCurves,
2217
+ canvasAvailable: Boolean(canvas),
2218
+ lastDrawAt: structureLayerLastDrawAtRef.current,
2219
+ });
2220
+ structureLayerDiagnosticsRef.current = diagnostics;
2221
+
2222
+ if (!sigma || !canvas || !diagnostics.enabled || !cache) {
2223
+ clearGraphStructureLayer(canvas);
2224
+ return;
2225
+ }
2226
+
2227
+ const drawn = drawGraphStructureLayer({
2228
+ sigma,
2229
+ canvas,
2230
+ cache,
2231
+ });
2232
+ if (drawn) {
2233
+ structureLayerLastDrawAtRef.current = Date.now();
2234
+ structureLayerDiagnosticsRef.current = {
2235
+ ...diagnostics,
2236
+ lastDrawAt: structureLayerLastDrawAtRef.current,
2237
+ };
2238
+ }
2239
+ }, [structureLayerGate]);
2240
+
2241
+ useEffect(() => {
2242
+ const sigma = sigmaRef.current;
2243
+ if (!graphReady || !sigma) {
2244
+ return;
2245
+ }
2246
+
2247
+ const draw = () => drawStructureLayerFrame();
2248
+ sigma.on("afterRender", draw);
2249
+ draw();
2250
+
2251
+ return () => {
2252
+ sigma.off("afterRender", draw);
2253
+ };
2254
+ }, [drawStructureLayerFrame, graphReady, structureLayerCache]);
2255
+
2256
+ useEffect(() => {
2257
+ if (isLayoutRunning || viewMode !== "full") {
2258
+ clearGraphStructureLayer(structureLayerCanvasRef.current);
2259
+ }
2260
+ }, [isLayoutRunning, viewMode]);
2261
+
2262
+ const drawOverlayFrame = useCallback(() => {
2263
+ const sigma = sigmaRef.current;
2264
+ const overlay = overlayRef.current;
2265
+ const container = containerRef.current;
2266
+ if (!sigma || !overlay || !container) {
2267
+ return false;
2268
+ }
2269
+
2270
+ const rect = container.getBoundingClientRect();
2271
+ const pixelRatio = window.devicePixelRatio || 1;
2272
+ if (overlay.width !== Math.floor(rect.width * pixelRatio) || overlay.height !== Math.floor(rect.height * pixelRatio)) {
2273
+ overlay.width = Math.floor(rect.width * pixelRatio);
2274
+ overlay.height = Math.floor(rect.height * pixelRatio);
2275
+ overlay.style.width = `${rect.width}px`;
2276
+ overlay.style.height = `${rect.height}px`;
2277
+ }
2278
+
2279
+ const context = overlay.getContext("2d");
2280
+ if (!context) {
2281
+ return false;
2282
+ }
2283
+
2284
+ context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
2285
+ context.clearRect(0, 0, rect.width, rect.height);
2286
+
2287
+ const primaryNodeId = interactionState.hoveredNodeId || interactionState.selectedNodeId;
2288
+ const focusIds = primaryNodeId
2289
+ ? (
2290
+ displayGraph.hasNode(primaryNodeId)
2291
+ ? buildFocusSetInGraph(displayGraph, primaryNodeId)
2292
+ : new Set<string>()
2293
+ )
2294
+ : new Set<string>();
2295
+ const edgeEndpointIds = buildEdgeEndpointSet(displayGraph, interactionState.selectedEdgeId);
2296
+ const pathNodeIds = new Set(interactionState.activePath);
2297
+ const pathSegments = collectPathSegments(
2298
+ sigma,
2299
+ interactionState.activePath,
2300
+ interactionState.activePathEdgeIds,
2301
+ interactionState.zoomTier,
2302
+ rect.width,
2303
+ rect.height,
2304
+ );
2305
+ const visibleNodeSamples = collectVisibleNodeSamples(sigma, displayGraph, rect.width, rect.height);
2306
+ const effectAvailability = buildEffectAvailability(
2307
+ interactionState,
2308
+ effectsState,
2309
+ temporalState,
2310
+ analyticsSnapshot,
2311
+ isLayoutRunning,
2312
+ sigma,
2313
+ rect.width,
2314
+ rect.height,
2315
+ );
2316
+ const cameraRatio = sigma.getCamera().getState().ratio;
2317
+ const nodesToDecorate = new Set<string>([
2318
+ ...focusIds,
2319
+ ...edgeEndpointIds,
2320
+ ...pathNodeIds,
2321
+ ...(primaryNodeId ? [primaryNodeId] : []),
2322
+ ]);
2323
+ const lensFocusIds = new Set<string>();
2324
+ if (primaryNodeId) {
2325
+ reducerSceneStateRef.current.highlightedIncidentEdgeIds.forEach((edgeId) => {
2326
+ if (!displayGraph.hasEdge(edgeId)) {
2327
+ return;
2328
+ }
2329
+ const [source, target] = displayGraph.extremities(edgeId);
2330
+ const otherEndpointId = String(source) === primaryNodeId ? String(target) : String(source);
2331
+ if (displayGraph.hasNode(otherEndpointId)) {
2332
+ lensFocusIds.add(otherEndpointId);
2333
+ }
2334
+ });
2335
+ }
2336
+ const now = performance.now() / 1000;
2337
+
2338
+ if (!isLayoutRunning) {
2339
+ drawContourLayer(context, analyticsSnapshot, visibleNodeSamples, interactionState, effectsState);
2340
+ drawSemanticRegionsLayer(context, analyticsSnapshot, visibleNodeSamples, interactionState, effectsState);
2341
+ drawTemporalEmphasisLayer(context, visibleNodeSamples, temporalState, interactionState, effectsState);
2342
+ }
2343
+
2344
+ nodesToDecorate.forEach((nodeId) => {
2345
+ const displayData = sigma.getNodeDisplayData(nodeId);
2346
+ if (!displayData) return;
2347
+ if (!displayGraph.hasNode(nodeId)) {
2348
+ debugGraphRuntime("overlay-node-missing-from-display-graph", {
2349
+ nodeId,
2350
+ graphVersion: graphVersionRef.current,
2351
+ viewMode: viewModeRef.current,
2352
+ });
2353
+ return;
2354
+ }
2355
+ const attrs = displayGraph.getNodeAttributes(nodeId) as NodeAttributes;
2356
+ const state = resolveNodeVisualState(
2357
+ nodeId,
2358
+ interactionState.zoomTier,
2359
+ interactionState.hoveredNodeId,
2360
+ interactionState.selectedNodeId,
2361
+ interactionState.selectedEdgeId,
2362
+ focusIds,
2363
+ edgeEndpointIds,
2364
+ pathNodeIds,
2365
+ );
2366
+ const style = resolveNodeElementStyle(
2367
+ GRAPH_THEME,
2368
+ interactionState.zoomTier,
2369
+ state,
2370
+ attrs,
2371
+ attrs.label,
2372
+ cameraRatio,
2373
+ );
2374
+ const point = sigma.graphToViewport({ x: displayData.x, y: displayData.y });
2375
+ if (style.showHalo) {
2376
+ const radius = Math.max(
2377
+ displayData.size * GRAPH_THEME.overlays.glowRadiusMultiplier * (style.nodeVariant === "selected" ? 1.08 : 1),
2378
+ GRAPH_THEME.overlays.minGlowRadius,
2379
+ );
2380
+ drawGlowHalo(
2381
+ context,
2382
+ point.x,
2383
+ point.y,
2384
+ radius,
2385
+ withAlpha(
2386
+ style.haloColor,
2387
+ nodeId === primaryNodeId ? GRAPH_THEME.overlays.hoverGlowAlpha : GRAPH_THEME.overlays.pathGlowAlpha,
2388
+ ),
2389
+ );
2390
+ }
2391
+
2392
+ if (style.showBadge && style.badgeKind) {
2393
+ drawNodeBadge(context, point.x, point.y, displayData.size, style.badgeKind, style.badgeCount);
2394
+ }
2395
+ });
2396
+
2397
+ if (!isLayoutRunning && primaryNodeId && effectAvailability.lens.available) {
2398
+ drawLensLayer(context, sigma, primaryNodeId, lensFocusIds);
2399
+ }
2400
+
2401
+ drawPathEffectsLayer(context, pathSegments, effectsState, effectAvailability, now);
2402
+ return effectAvailability.pathPulse.available || effectAvailability.pathFlow.available;
2403
+ }, [analyticsSnapshot, displayGraph, effectsState, interactionState, isLayoutRunning, temporalState]);
2404
+
2405
+ useEffect(() => {
2406
+ const sigma = sigmaRef.current;
2407
+ const container = containerRef.current;
2408
+ if (!graphReady || !sigma || !container) {
2409
+ return;
2410
+ }
2411
+
2412
+ let frame = 0;
2413
+ let disposed = false;
2414
+
2415
+ const render = () => {
2416
+ if (disposed) {
2417
+ return;
2418
+ }
2419
+
2420
+ const animated = drawOverlayFrame();
2421
+ if (animated) {
2422
+ frame = window.requestAnimationFrame(render);
2423
+ }
2424
+ };
2425
+
2426
+ const redrawStatic = () => {
2427
+ if (frame !== 0) {
2428
+ return;
2429
+ }
2430
+ drawOverlayFrame();
2431
+ };
2432
+
2433
+ const camera = sigma.getCamera();
2434
+ const resizeObserver = new ResizeObserver(redrawStatic);
2435
+ resizeObserver.observe(container);
2436
+ camera.on("updated", redrawStatic);
2437
+
2438
+ render();
2439
+ return () => {
2440
+ disposed = true;
2441
+ resizeObserver.disconnect();
2442
+ camera.off("updated", redrawStatic);
2443
+ if (frame !== 0) {
2444
+ window.cancelAnimationFrame(frame);
2445
+ }
2446
+ };
2447
+ }, [drawOverlayFrame, graphReady]);
2448
+
2449
+ useEffect(() => {
2450
+ const sigma = sigmaRef.current;
2451
+ const layoutTargetGraph = displayMeta.layoutMode === "owned" ? displayGraph : graph;
2452
+ const usingGroupedOwnedLayout = displayMeta.layoutMode === "owned" && viewMode === "grouped";
2453
+ const targetKey = displayMeta.layoutMode === "owned"
2454
+ ? `display:${graphVersion}:${viewMode}`
2455
+ : `store:${displayMeta.layoutMode}`;
2456
+ const currentTargetKey = (fa2Ref.current as (FA2Layout & { __targetKey?: string }) | null)?.__targetKey;
2457
+
2458
+ if (!isLayoutRunning) {
2459
+ fa2Ref.current?.stop();
2460
+ if (fullGraphLayoutSettleTimeoutRef.current !== null) {
2461
+ window.clearTimeout(fullGraphLayoutSettleTimeoutRef.current);
2462
+ fullGraphLayoutSettleTimeoutRef.current = null;
2463
+ }
2464
+ if (groupedLayoutSettleTimeoutRef.current !== null) {
2465
+ window.clearTimeout(groupedLayoutSettleTimeoutRef.current);
2466
+ groupedLayoutSettleTimeoutRef.current = null;
2467
+ }
2468
+ if (layoutSyncFrameRef.current !== null) {
2469
+ window.cancelAnimationFrame(layoutSyncFrameRef.current);
2470
+ layoutSyncFrameRef.current = null;
2471
+ }
2472
+ return;
2473
+ }
2474
+
2475
+ if (!fa2Ref.current || currentTargetKey !== targetKey) {
2476
+ fa2Ref.current?.kill();
2477
+ const nextLayout = new FA2Layout(
2478
+ layoutTargetGraph,
2479
+ usingGroupedOwnedLayout ? GROUPED_FA2_SETTINGS : FA2_SETTINGS,
2480
+ ) as FA2Layout & { __targetKey?: string };
2481
+ nextLayout.__targetKey = targetKey;
2482
+ fa2Ref.current = nextLayout;
2483
+ debugGraphRuntime("layout-target-changed", {
2484
+ graphVersion,
2485
+ layoutMode: displayMeta.layoutMode,
2486
+ target: displayMeta.layoutMode === "owned" ? "display-owned" : displayMeta.layoutMode === "base" ? "base" : "store-for-mirror",
2487
+ order: layoutTargetGraph.order,
2488
+ size: layoutTargetGraph.size,
2489
+ });
2490
+ }
2491
+
2492
+ fa2Ref.current.start();
2493
+
2494
+ if (groupedLayoutSettleTimeoutRef.current !== null) {
2495
+ window.clearTimeout(groupedLayoutSettleTimeoutRef.current);
2496
+ groupedLayoutSettleTimeoutRef.current = null;
2497
+ }
2498
+ if (fullGraphLayoutSettleTimeoutRef.current !== null) {
2499
+ window.clearTimeout(fullGraphLayoutSettleTimeoutRef.current);
2500
+ fullGraphLayoutSettleTimeoutRef.current = null;
2501
+ }
2502
+
2503
+ if (usingGroupedOwnedLayout) {
2504
+ groupedLayoutSettleTimeoutRef.current = window.setTimeout(() => {
2505
+ fa2Ref.current?.stop();
2506
+ groupedLayoutSettleTimeoutRef.current = null;
2507
+ onLayoutRunningChange?.(false);
2508
+ debugGraphRuntime("grouped-layout-auto-settled", {
2509
+ graphVersion: graphVersionRef.current,
2510
+ viewMode: viewModeRef.current,
2511
+ order: displayGraphRef.current.order,
2512
+ size: displayGraphRef.current.size,
2513
+ });
2514
+ }, GRAPH_THEME.grouped.layout.settleMs);
2515
+ } else {
2516
+ fullGraphLayoutSettleTimeoutRef.current = window.setTimeout(() => {
2517
+ fa2Ref.current?.stop();
2518
+ fullGraphLayoutSettleTimeoutRef.current = null;
2519
+ onLayoutRunningChange?.(false);
2520
+ debugGraphRuntime("full-layout-auto-settled", {
2521
+ graphVersion: graphVersionRef.current,
2522
+ viewMode: viewModeRef.current,
2523
+ layoutMode: displayMetaRef.current.layoutMode,
2524
+ order: displayGraphRef.current.order,
2525
+ size: displayGraphRef.current.size,
2526
+ });
2527
+ }, FULL_GRAPH_LAYOUT_SETTLE_MS);
2528
+ }
2529
+
2530
+ if (displayMeta.layoutMode === "mirrored" && sigma) {
2531
+ let disposed = false;
2532
+
2533
+ const syncTick = () => {
2534
+ if (disposed || !sigmaRef.current || !isLayoutRunning) {
2535
+ return;
2536
+ }
2537
+
2538
+ const changed = syncDisplayNodePositionsFromStore(displayGraphRef.current, graph);
2539
+ if (changed > 0) {
2540
+ sigmaRef.current.scheduleRefresh();
2541
+ layoutSyncTickRef.current += 1;
2542
+ if (layoutSyncTickRef.current === 1 || layoutSyncTickRef.current % 30 === 0) {
2543
+ debugGraphRuntime("layout-mirror-sync", {
2544
+ graphVersion: graphVersionRef.current,
2545
+ changedNodes: changed,
2546
+ tick: layoutSyncTickRef.current,
2547
+ });
2548
+ }
2549
+ }
2550
+
2551
+ layoutSyncFrameRef.current = window.requestAnimationFrame(syncTick);
2552
+ };
2553
+
2554
+ layoutSyncTickRef.current = 0;
2555
+ layoutSyncFrameRef.current = window.requestAnimationFrame(syncTick);
2556
+
2557
+ return () => {
2558
+ disposed = true;
2559
+ if (fullGraphLayoutSettleTimeoutRef.current !== null) {
2560
+ window.clearTimeout(fullGraphLayoutSettleTimeoutRef.current);
2561
+ fullGraphLayoutSettleTimeoutRef.current = null;
2562
+ }
2563
+ if (groupedLayoutSettleTimeoutRef.current !== null) {
2564
+ window.clearTimeout(groupedLayoutSettleTimeoutRef.current);
2565
+ groupedLayoutSettleTimeoutRef.current = null;
2566
+ }
2567
+ if (layoutSyncFrameRef.current !== null) {
2568
+ window.cancelAnimationFrame(layoutSyncFrameRef.current);
2569
+ layoutSyncFrameRef.current = null;
2570
+ }
2571
+ fa2Ref.current?.stop();
2572
+ };
2573
+ }
2574
+
2575
+ return () => {
2576
+ if (fullGraphLayoutSettleTimeoutRef.current !== null) {
2577
+ window.clearTimeout(fullGraphLayoutSettleTimeoutRef.current);
2578
+ fullGraphLayoutSettleTimeoutRef.current = null;
2579
+ }
2580
+ if (groupedLayoutSettleTimeoutRef.current !== null) {
2581
+ window.clearTimeout(groupedLayoutSettleTimeoutRef.current);
2582
+ groupedLayoutSettleTimeoutRef.current = null;
2583
+ }
2584
+ fa2Ref.current?.stop();
2585
+ };
2586
+ }, [displayGraph, displayMeta.layoutMode, graphVersion, isLayoutRunning, viewMode]);
2587
+
2588
+ useEffect(() => {
2589
+ return () => {
2590
+ if (fullGraphLayoutSettleTimeoutRef.current !== null) {
2591
+ window.clearTimeout(fullGraphLayoutSettleTimeoutRef.current);
2592
+ fullGraphLayoutSettleTimeoutRef.current = null;
2593
+ }
2594
+ if (groupedLayoutSettleTimeoutRef.current !== null) {
2595
+ window.clearTimeout(groupedLayoutSettleTimeoutRef.current);
2596
+ groupedLayoutSettleTimeoutRef.current = null;
2597
+ }
2598
+ if (layoutSyncFrameRef.current !== null) {
2599
+ window.cancelAnimationFrame(layoutSyncFrameRef.current);
2600
+ layoutSyncFrameRef.current = null;
2601
+ }
2602
+ fa2Ref.current?.kill();
2603
+ fa2Ref.current = null;
2604
+ };
2605
+ }, []);
2606
+
2607
+ const handleFitView = useCallback(() => {
2608
+ fitCurrentView();
2609
+ }, [fitCurrentView]);
2610
+
2611
+ return (
2612
+ <div style={{ position: "relative", width: "100%", height: "100%" }}>
2613
+ <div
2614
+ ref={containerRef}
2615
+ className={className}
2616
+ style={{ width: "100%", height: "100%", background: "transparent" }}
2617
+ />
2618
+ <canvas
2619
+ ref={overlayRef}
2620
+ style={{
2621
+ position: "absolute",
2622
+ inset: 0,
2623
+ width: "100%",
2624
+ height: "100%",
2625
+ pointerEvents: "none",
2626
+ zIndex: 4,
2627
+ }}
2628
+ />
2629
+ {pluginOverlays.length ? (
2630
+ <div
2631
+ style={{
2632
+ position: "absolute",
2633
+ inset: 0,
2634
+ pointerEvents: "none",
2635
+ zIndex: 6,
2636
+ }}
2637
+ >
2638
+ {pluginOverlays.map((overlay, index) => (
2639
+ <div key={`graph-plugin-overlay-${index}`} style={{ position: "absolute", inset: 0 }}>
2640
+ {overlay}
2641
+ </div>
2642
+ ))}
2643
+ </div>
2644
+ ) : null}
2645
+ {showFitViewButton ? (
2646
+ <button
2647
+ id="graph-fit-view-btn"
2648
+ onClick={handleFitView}
2649
+ style={{
2650
+ position: "absolute",
2651
+ bottom: 24,
2652
+ left: 24,
2653
+ padding: "8px 16px",
2654
+ background: "linear-gradient(135deg, rgba(27, 79, 170, 0.9), rgba(53, 123, 255, 0.84))",
2655
+ color: "#fff",
2656
+ border: `1px solid ${GRAPH_THEME.palette.background.shellBorder}`,
2657
+ borderRadius: 10,
2658
+ cursor: "pointer",
2659
+ fontWeight: 700,
2660
+ zIndex: 10,
2661
+ backdropFilter: "blur(10px)",
2662
+ boxShadow: `0 10px 28px ${GRAPH_THEME.palette.background.shellGlow}`,
2663
+ fontSize: 12,
2664
+ letterSpacing: "0.01em",
2665
+ }}
2666
+ >
2667
+ Fit View
2668
+ </button>
2669
+ ) : null}
2670
+ </div>
2671
+ );
2672
+ }
2673
+ );