@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,3584 @@
1
+ import { useCallback, useEffect, useId, useMemo, useRef, useState, type ComponentType, type ReactNode } from "react";
2
+ import {
3
+ Activity,
4
+ Clock3,
5
+ Eye,
6
+ Focus,
7
+ GitBranch,
8
+ Layers3,
9
+ Maximize2,
10
+ Pause,
11
+ Play,
12
+ RefreshCw,
13
+ Search,
14
+ Users,
15
+ X,
16
+ ZoomIn,
17
+ ZoomOut,
18
+ } from "lucide-react";
19
+
20
+ import type Graph from "graphology";
21
+ import { batchMergeEdges, batchMergeNodes, graph } from "../../store/graphStore";
22
+ import { logEvent } from "../../store/registryStore";
23
+ import type { EdgeAttributes, NodeAttributes } from "../../store/graphStore";
24
+ import { InspectorPanel, MetricChip, SurfaceCard } from "../../ui/primitives";
25
+ import { lazy, Suspense } from "react";
26
+ import { SigmaSceneAdapter } from "./SigmaSceneAdapter";
27
+ import { useLoadGraph, useReloadGraph } from "./useLoadGraph";
28
+ import { GraphLoadingOverlay } from "./GraphLoadingOverlay";
29
+ import { createGraphLoadProgress, getGraphLoadTitle } from "./graphLoading";
30
+ import { GRAPH_THEME, withAlpha } from "./graphTheme";
31
+ import { buildGraphColorLegend, type GraphColorLegendItem } from "./graphColorLegend";
32
+ import { focusedUnavailableReasonText, groupedViewReasonText } from "./graphViewCopy";
33
+ import { localGraphRequiresDraftConfirm } from "./localGraphTransition";
34
+ import { buildHeatmapRenderSnapshot, buildStructuralDistanceSnapshot, checkGroupedViewAvailability, getDistanceBandColor, resolveDisplayGraph, resolveDisplayStateSnapshot, resolveGroupedDisplayNodeId, resolveGroupedDisplayStateSnapshot, summarizeDistanceBuckets } from "./graphSceneState";
35
+ import {
36
+ type GraphPlugin,
37
+ type GraphPluginActionRequest,
38
+ type GraphPluginContext,
39
+ type GraphPluginOverlayDescriptor,
40
+ type GraphPluginPanelDescriptor,
41
+ type GraphPluginToolbarItem,
42
+ } from "./plugins";
43
+ import { explorationEffectsShouldLoad, neighborhoodPanelShouldLoad, temporalOverlayShouldLoad } from "./pluginRegistryPredicates";
44
+ import { shouldFetchTemporalBounds, shouldFetchTemporalSnapshot } from "./temporalLifecyclePredicates";
45
+ import { createTemporalSnapshotGuards, type TemporalSnapshotResponse } from "./temporalSnapshotGuards";
46
+ import { SMALL_GRAPH_MAX_NODES } from "./smallGraphLayout";
47
+ import { buildRealtimeEdgeAttributes } from "./realtimeGraphAttributes";
48
+ import type { LinkPrediction, PathResponse } from "./GraphInspectorPanel";
49
+ import type { MarkdownApplyResult } from "./markdownResourceClient";
50
+ import {
51
+ NodeMarkdownRefreshGuard,
52
+ buildNodeMarkdownAttributeUpdate,
53
+ readNodeMarkdownAttributeUpdate,
54
+ } from "./nodeMarkdownSync";
55
+ import type { GraphSceneHandle, GraphSceneRuntime } from "./scene";
56
+ import type {
57
+ FocusedUnavailableReason,
58
+ GraphAnalyticsSnapshot,
59
+ GraphDistanceVisualMode,
60
+ GraphDistanceVisualState,
61
+ GraphDisplayStateSnapshot,
62
+ GraphDiagnosticsSnapshot,
63
+ GraphEffectToggle,
64
+ GraphEffectsState,
65
+ GraphInteractionState,
66
+ GraphLayoutViewMode,
67
+ GraphLoadProgress,
68
+ GraphLoadSummary,
69
+ GraphRuntimeDiagnosticsSnapshot,
70
+ GraphSelectedEdgeState,
71
+ GraphSelectedNodeKind,
72
+ GraphSelectedNodeState,
73
+ GraphTemporalState,
74
+ GraphViewMode,
75
+ } from "./types";
76
+
77
+ type SearchResult = {
78
+ node: {
79
+ id: string;
80
+ type: string;
81
+ content: string;
82
+ properties: Record<string, unknown>;
83
+ };
84
+ score: number;
85
+ };
86
+
87
+ type TemporalBounds = {
88
+ min?: string | null;
89
+ max?: string | null;
90
+ };
91
+
92
+ type ExploreLayoutState = {
93
+ showInspector: boolean;
94
+ showPluginDock: boolean;
95
+ };
96
+
97
+ type ToolbarIconComponent = ComponentType<{
98
+ size?: number;
99
+ strokeWidth?: number;
100
+ "aria-hidden"?: boolean;
101
+ }>;
102
+
103
+ type GraphToolbarItem = {
104
+ id: string;
105
+ label: string;
106
+ title?: string;
107
+ active?: boolean;
108
+ disabled?: boolean;
109
+ tone?: "primary" | "secondary";
110
+ icon?: ToolbarIconComponent;
111
+ ariaLabel?: string;
112
+ compact?: boolean;
113
+ onClick: () => void;
114
+ };
115
+
116
+ type GraphToolbarGroup = {
117
+ id: string;
118
+ label?: string;
119
+ variant?: "cluster" | "segmented";
120
+ items: GraphToolbarItem[];
121
+ };
122
+
123
+ type SemanticNeighborhoodResponse = {
124
+ anchor_node: string;
125
+ total: number;
126
+ neighbors: Array<{
127
+ id: string;
128
+ type: string;
129
+ content: string;
130
+ similarity: number;
131
+ hop_distance?: number | null;
132
+ }>;
133
+ };
134
+
135
+ type LazyPluginRegistryEntry = {
136
+ id: string;
137
+ panelId: string;
138
+ label: string;
139
+ title: string;
140
+ order: number;
141
+ load: () => Promise<GraphPlugin>;
142
+ shouldLoad: (context: {
143
+ panelState: Record<string, boolean>;
144
+ temporalState?: GraphTemporalState | null;
145
+ }) => boolean;
146
+ };
147
+
148
+ const PROVENANCE_KEYS = ["source", "source_url", "pmid", "pmids", "evidence", "provenance", "confidence"] as const;
149
+ const EMPTY_DISTANCE_RECORD: Record<string, number> = {};
150
+ const STRUCTURAL_DISTANCE_MAX_HOPS = 6;
151
+ const HEATMAP_DISTANCE_MAX_HOPS = 3;
152
+ const DEFAULT_EFFECTS_STATE: GraphEffectsState = {
153
+ pathPulseEnabled: false,
154
+ pathFlowEnabled: false,
155
+ lensEnabled: false,
156
+ temporalEmphasisEnabled: false,
157
+ semanticRegionsEnabled: false,
158
+ contoursEnabled: false,
159
+ pathfindingEnabled: false,
160
+ communitiesEnabled: false,
161
+ centralityEnabled: false,
162
+ legendEnabled: false,
163
+ edgeLabelsEnabled: true,
164
+ diagnosticsEnabled: false,
165
+ lensMode: "neighborhood",
166
+ effectQuality: "bounded",
167
+ };
168
+ const LazyTimelinePanel = lazy(() => import("./TimelinePanel").then((module) => ({ default: module.TimelinePanel })));
169
+ const LazyGraphInspectorPanel = lazy(() => import("./GraphInspectorPanel").then((module) => ({ default: module.GraphInspectorPanel })));
170
+
171
+ const loadExplorationEffectsPlugin = () => import("./plugins/explorationEffectsPluginPhaseC").then((module) => module.explorationEffectsPluginPhaseC);
172
+ const loadNeighborhoodPanelPlugin = () => import("./plugins/neighborhoodPanelPlugin").then((module) => module.neighborhoodPanelPlugin);
173
+ const loadTemporalOverlayPlugin = () => import("./plugins/temporalOverlayPlugin").then((module) => module.temporalOverlayPlugin);
174
+ const EMPTY_PATH: string[] = [];
175
+ const COMPACT_TOOLBAR_CLUSTER_IDS = new Set(["camera", "utility"]);
176
+
177
+ const DEBUG_GRAPH_WORKSPACE = import.meta.env.DEV;
178
+
179
+ function debugGraphWorkspace(message: string, payload?: Record<string, unknown>) {
180
+ if (!DEBUG_GRAPH_WORKSPACE) {
181
+ return;
182
+ }
183
+ console.debug(`[GraphWorkspace] ${message}`, payload ?? {});
184
+ }
185
+
186
+ function getGraphSummarySignature(summary: GraphLoadSummary): string {
187
+ return [
188
+ summary.nodeCount,
189
+ summary.edgeCount,
190
+ summary.layoutSource ?? "unknown",
191
+ summary.layoutReady ? "ready" : "runtime",
192
+ summary.loadTimeMs,
193
+ ].join(":");
194
+ }
195
+
196
+ function useDebounce<T>(value: T, delay: number): T {
197
+ const [debouncedValue, setDebouncedValue] = useState<T>(value);
198
+ useEffect(() => {
199
+ const timeout = setTimeout(() => setDebouncedValue(value), delay);
200
+ return () => clearTimeout(timeout);
201
+ }, [delay, value]);
202
+ return debouncedValue;
203
+ }
204
+
205
+ function iconForPluginToolbarItem(item: GraphPluginToolbarItem): ToolbarIconComponent {
206
+ const normalized = `${item.id} ${item.label}`.toLowerCase();
207
+ if (normalized.includes("neighbor")) {
208
+ return Users;
209
+ }
210
+ if (normalized.includes("temporal") || normalized.includes("time")) {
211
+ return Clock3;
212
+ }
213
+ return Activity;
214
+ }
215
+
216
+ function ToolbarIcon({ icon: Icon, size = 15 }: { icon?: ToolbarIconComponent; size?: number }) {
217
+ if (!Icon) {
218
+ return null;
219
+ }
220
+
221
+ return <Icon size={size} strokeWidth={2.15} aria-hidden />;
222
+ }
223
+
224
+ function ToolbarButton({
225
+ item,
226
+ compact = false,
227
+ className = "",
228
+ toggle = false,
229
+ }: {
230
+ item: GraphToolbarItem;
231
+ compact?: boolean;
232
+ className?: string;
233
+ toggle?: boolean;
234
+ }) {
235
+ const isCompact = compact || item.compact;
236
+ return (
237
+ <button
238
+ type="button"
239
+ className={`explore-tool-button ${item.tone === "primary" ? "explore-tool-button-primary" : ""} ${className}`}
240
+ data-active={item.active ? "true" : "false"}
241
+ data-compact={isCompact ? "true" : "false"}
242
+ aria-pressed={toggle ? item.active === true : undefined}
243
+ onClick={item.onClick}
244
+ title={item.title}
245
+ aria-label={item.ariaLabel ?? item.label}
246
+ disabled={item.disabled}
247
+ >
248
+ <ToolbarIcon icon={item.icon} size={isCompact ? 14 : 15} />
249
+ <span className="explore-tool-button-label">{item.label}</span>
250
+ </button>
251
+ );
252
+ }
253
+
254
+ function ToolbarCluster({
255
+ label,
256
+ items,
257
+ compact = false,
258
+ children,
259
+ }: {
260
+ label: string;
261
+ items?: GraphToolbarItem[];
262
+ compact?: boolean;
263
+ children?: ReactNode;
264
+ }) {
265
+ if ((!items || items.length === 0) && !children) {
266
+ return null;
267
+ }
268
+
269
+ return (
270
+ <div className="explore-tool-cluster" aria-label={label}>
271
+ <div className="explore-tool-cluster-label">{label}</div>
272
+ <div className="explore-tool-cluster-items">
273
+ {children}
274
+ {items?.map((item) => (
275
+ <ToolbarButton key={item.id} item={item} compact={compact} />
276
+ ))}
277
+ </div>
278
+ </div>
279
+ );
280
+ }
281
+
282
+ function SegmentedModeControl({ items }: { items: GraphToolbarItem[] }) {
283
+ if (!items.length) {
284
+ return null;
285
+ }
286
+
287
+ return (
288
+ <div className="explore-mode-control" role="group" aria-label="Graph view mode">
289
+ {items.map((item) => (
290
+ <ToolbarButton key={item.id} item={item} className="explore-mode-segment" toggle />
291
+ ))}
292
+ </div>
293
+ );
294
+ }
295
+
296
+ const SUGGESTION_DEBOUNCE_MS = 250;
297
+ const SUGGESTION_LIMIT = 6;
298
+
299
+ function SearchCommandBar({
300
+ value,
301
+ disabled,
302
+ onChange,
303
+ onSubmit,
304
+ onSelectSuggestion,
305
+ }: {
306
+ value: string;
307
+ disabled: boolean;
308
+ onChange: (value: string) => void;
309
+ onSubmit: () => void;
310
+ onSelectSuggestion: (result: SearchResult) => void;
311
+ }) {
312
+ const [suggestions, setSuggestions] = useState<SearchResult[]>([]);
313
+ const [suggestionsOpen, setSuggestionsOpen] = useState(false);
314
+ const [highlightedIndex, setHighlightedIndex] = useState(-1);
315
+ const abortRef = useRef<AbortController | null>(null);
316
+ const debounceRef = useRef<number | null>(null);
317
+ const listboxId = useId();
318
+
319
+ useEffect(() => {
320
+ if (debounceRef.current !== null) {
321
+ window.clearTimeout(debounceRef.current);
322
+ }
323
+
324
+ const query = value.trim();
325
+ if (disabled || !query) {
326
+ abortRef.current?.abort();
327
+ setSuggestions([]);
328
+ setSuggestionsOpen(false);
329
+ setHighlightedIndex(-1);
330
+ return;
331
+ }
332
+
333
+ debounceRef.current = window.setTimeout(() => {
334
+ abortRef.current?.abort();
335
+ const controller = new AbortController();
336
+ abortRef.current = controller;
337
+
338
+ fetch("/api/graph/search", {
339
+ method: "POST",
340
+ headers: { "Content-Type": "application/json" },
341
+ body: JSON.stringify({ query, limit: SUGGESTION_LIMIT }),
342
+ signal: controller.signal,
343
+ })
344
+ .then((response) => {
345
+ if (!response.ok) {
346
+ throw new Error(`Search failed with status ${response.status}`);
347
+ }
348
+ return response.json();
349
+ })
350
+ .then((data: { results?: SearchResult[] }) => {
351
+ setSuggestions(data.results ?? []);
352
+ setSuggestionsOpen(true);
353
+ setHighlightedIndex(-1);
354
+ })
355
+ .catch((suggestionError: unknown) => {
356
+ if (suggestionError instanceof DOMException && suggestionError.name === "AbortError") {
357
+ return;
358
+ }
359
+ setSuggestions([]);
360
+ setSuggestionsOpen(false);
361
+ setHighlightedIndex(-1);
362
+ });
363
+ }, SUGGESTION_DEBOUNCE_MS);
364
+
365
+ return () => {
366
+ if (debounceRef.current !== null) {
367
+ window.clearTimeout(debounceRef.current);
368
+ }
369
+ abortRef.current?.abort();
370
+ };
371
+ }, [value, disabled]);
372
+
373
+ const closeSuggestions = () => {
374
+ setSuggestionsOpen(false);
375
+ setHighlightedIndex(-1);
376
+ };
377
+
378
+ const selectSuggestion = (result: SearchResult) => {
379
+ setSuggestions([]);
380
+ closeSuggestions();
381
+ onSelectSuggestion(result);
382
+ };
383
+
384
+ return (
385
+ <form
386
+ className="explore-search-command"
387
+ role="combobox"
388
+ aria-expanded={suggestionsOpen && suggestions.length > 0}
389
+ aria-haspopup="listbox"
390
+ aria-owns={listboxId}
391
+ onSubmit={(event) => {
392
+ event.preventDefault();
393
+ if (disabled) return;
394
+ if (suggestionsOpen && highlightedIndex >= 0 && suggestions[highlightedIndex]) {
395
+ selectSuggestion(suggestions[highlightedIndex]);
396
+ return;
397
+ }
398
+ closeSuggestions();
399
+ onSubmit();
400
+ }}
401
+ >
402
+ <Search size={17} strokeWidth={2.15} aria-hidden />
403
+ <input
404
+ value={value}
405
+ onChange={(event) => onChange(event.target.value)}
406
+ onFocus={() => {
407
+ if (suggestions.length > 0) {
408
+ setSuggestionsOpen(true);
409
+ }
410
+ }}
411
+ onBlur={() => {
412
+ window.setTimeout(closeSuggestions, 120);
413
+ }}
414
+ onKeyDown={(event) => {
415
+ if (!suggestionsOpen || suggestions.length === 0) return;
416
+ if (event.key === "ArrowDown") {
417
+ event.preventDefault();
418
+ setHighlightedIndex((current) => (current + 1) % suggestions.length);
419
+ } else if (event.key === "ArrowUp") {
420
+ event.preventDefault();
421
+ setHighlightedIndex((current) => (current <= 0 ? suggestions.length - 1 : current - 1));
422
+ } else if (event.key === "Escape") {
423
+ event.preventDefault();
424
+ closeSuggestions();
425
+ }
426
+ }}
427
+ placeholder="Search command, node, or concept"
428
+ aria-label="Search graph nodes"
429
+ aria-autocomplete="list"
430
+ aria-controls={listboxId}
431
+ aria-activedescendant={highlightedIndex >= 0 ? `${listboxId}-${highlightedIndex}` : undefined}
432
+ />
433
+ <button type="submit" disabled={disabled} aria-label="Search for the current query">
434
+ Search
435
+ </button>
436
+
437
+ {suggestionsOpen && suggestions.length > 0 ? (
438
+ <ul id={listboxId} role="listbox" className="explore-search-suggestions" aria-label="Search suggestions">
439
+ {suggestions.map((result, index) => (
440
+ <li
441
+ key={result.node.id}
442
+ id={`${listboxId}-${index}`}
443
+ role="option"
444
+ aria-selected={index === highlightedIndex}
445
+ data-highlighted={index === highlightedIndex}
446
+ onMouseDown={(event) => {
447
+ event.preventDefault();
448
+ selectSuggestion(result);
449
+ }}
450
+ onMouseEnter={() => setHighlightedIndex(index)}
451
+ >
452
+ <span className="explore-search-suggestion-label">{result.node.content || result.node.id}</span>
453
+ <span className="explore-search-suggestion-type">{result.node.type}</span>
454
+ </li>
455
+ ))}
456
+ </ul>
457
+ ) : null}
458
+ </form>
459
+ );
460
+ }
461
+
462
+ function SemanticColorLegend({ items }: { items: GraphColorLegendItem[] }) {
463
+ if (!items.length) return null;
464
+ return (
465
+ <div className="explore-color-legend" role="group" aria-label="Node colors">
466
+ <span className="explore-color-legend-label" title="Base semantic colors; selection, zoom, and distance effects can change node appearance.">
467
+ Node colors
468
+ </span>
469
+ <ul className="explore-color-legend-items">
470
+ {items.map((item) => (
471
+ <li key={item.id} className="explore-color-legend-item" title={`${item.group}: ${item.count.toLocaleString()} nodes`}>
472
+ <span className="explore-color-legend-mark" style={{ backgroundColor: item.color }} aria-hidden="true" />
473
+ <span className="explore-color-legend-name">{item.group}</span>
474
+ </li>
475
+ ))}
476
+ </ul>
477
+ </div>
478
+ );
479
+ }
480
+
481
+ const HUD_CSS = `
482
+ .palantir-bg {
483
+ background:
484
+ ${GRAPH_THEME.ui.scene.radialGlow},
485
+ ${GRAPH_THEME.ui.scene.background};
486
+ }
487
+ .palantir-grid {
488
+ position: absolute;
489
+ inset: 0;
490
+ background-image:
491
+ linear-gradient(${GRAPH_THEME.ui.scene.grid} 1px, transparent 1px),
492
+ linear-gradient(90deg, ${GRAPH_THEME.ui.scene.grid} 1px, transparent 1px),
493
+ linear-gradient(${GRAPH_THEME.ui.scene.gridStrong} 1px, transparent 1px),
494
+ linear-gradient(90deg, ${GRAPH_THEME.ui.scene.gridStrong} 1px, transparent 1px);
495
+ background-size: 48px 48px, 48px 48px, 240px 240px, 240px 240px;
496
+ pointer-events: none;
497
+ z-index: 1;
498
+ }
499
+ .palantir-vignette {
500
+ position: absolute;
501
+ inset: 0;
502
+ background: ${GRAPH_THEME.ui.scene.vignette};
503
+ pointer-events: none;
504
+ z-index: 2;
505
+ }
506
+ .glass-header {
507
+ background: ${GRAPH_THEME.ui.surface.cardSubtle};
508
+ border-bottom: 1px solid ${GRAPH_THEME.ui.surface.panelBorder};
509
+ backdrop-filter: blur(12px);
510
+ -webkit-backdrop-filter: blur(12px);
511
+ }
512
+ .glass-hud {
513
+ background: ${GRAPH_THEME.ui.surface.panel};
514
+ backdrop-filter: blur(14px) saturate(1.08);
515
+ -webkit-backdrop-filter: blur(14px) saturate(1.08);
516
+ border-left: 1px solid ${GRAPH_THEME.ui.surface.panelBorder};
517
+ box-shadow: ${GRAPH_THEME.ui.surface.shadow};
518
+ }
519
+ .hud-scrollbar::-webkit-scrollbar { width: 6px; }
520
+ .hud-scrollbar::-webkit-scrollbar-track { background: transparent; }
521
+ .hud-scrollbar::-webkit-scrollbar-thumb { background: rgba(215, 209, 196, 0.22); border-radius: 6px; }
522
+ .node-panel-collapse {
523
+ border: 1px solid ${GRAPH_THEME.ui.surface.panelBorder};
524
+ border-radius: 12px;
525
+ background: rgba(255, 255, 255, 0.026);
526
+ overflow: hidden;
527
+ }
528
+ .node-panel-collapse + .node-panel-collapse {
529
+ margin-top: 12px;
530
+ }
531
+ .node-panel-summary {
532
+ list-style: none;
533
+ cursor: pointer;
534
+ display: flex;
535
+ align-items: center;
536
+ justify-content: space-between;
537
+ gap: 12px;
538
+ padding: 12px 14px;
539
+ color: ${GRAPH_THEME.ui.text.body};
540
+ font-size: 12px;
541
+ font-weight: 700;
542
+ letter-spacing: 0.04em;
543
+ text-transform: uppercase;
544
+ }
545
+ .node-panel-summary::-webkit-details-marker {
546
+ display: none;
547
+ }
548
+ .node-panel-summary::after {
549
+ content: "+";
550
+ color: ${GRAPH_THEME.ui.timeline.playhead};
551
+ font-size: 16px;
552
+ line-height: 1;
553
+ }
554
+ .node-panel-collapse[open] .node-panel-summary::after {
555
+ content: "−";
556
+ }
557
+ .node-panel-body {
558
+ padding: 0 14px 14px;
559
+ }
560
+ .graph-loading-overlay {
561
+ position: absolute;
562
+ inset: 0;
563
+ z-index: 9;
564
+ display: flex;
565
+ align-items: center;
566
+ justify-content: center;
567
+ pointer-events: none;
568
+ }
569
+ .graph-loading-card {
570
+ width: min(460px, calc(100% - 48px));
571
+ border-radius: 20px;
572
+ padding: 22px 22px 18px;
573
+ background: ${GRAPH_THEME.ui.surface.cardStrong};
574
+ border: 1px solid ${GRAPH_THEME.ui.surface.panelBorder};
575
+ box-shadow: ${GRAPH_THEME.ui.surface.shadow};
576
+ backdrop-filter: blur(18px);
577
+ -webkit-backdrop-filter: blur(18px);
578
+ }
579
+ .graph-loading-dots {
580
+ display: inline-flex;
581
+ gap: 8px;
582
+ align-items: center;
583
+ }
584
+ .graph-loading-dot {
585
+ width: 10px;
586
+ height: 10px;
587
+ border-radius: 999px;
588
+ background: linear-gradient(135deg, ${GRAPH_THEME.ui.timeline.playhead}, ${GRAPH_THEME.palette.accent.selected});
589
+ box-shadow: 0 0 18px rgba(98, 226, 205, 0.28);
590
+ animation: sem-loader-pulse 1.2s ease-in-out infinite;
591
+ }
592
+ .graph-loading-dot:nth-child(2) {
593
+ animation-delay: 0.14s;
594
+ }
595
+ .graph-loading-dot:nth-child(3) {
596
+ animation-delay: 0.28s;
597
+ }
598
+ @keyframes sem-loader-pulse {
599
+ 0%, 100% {
600
+ transform: translateY(0) scale(0.92);
601
+ opacity: 0.55;
602
+ }
603
+ 50% {
604
+ transform: translateY(-4px) scale(1.08);
605
+ opacity: 1;
606
+ }
607
+ }
608
+ .graph-loading-bar {
609
+ width: 100%;
610
+ height: 10px;
611
+ border-radius: 999px;
612
+ overflow: hidden;
613
+ background: rgba(255, 255, 255, 0.052);
614
+ border: 1px solid ${GRAPH_THEME.ui.surface.panelBorder};
615
+ }
616
+ .graph-loading-bar > span {
617
+ display: block;
618
+ height: 100%;
619
+ border-radius: 999px;
620
+ background: linear-gradient(90deg, rgba(98, 226, 205, 0.9), rgba(233, 196, 122, 0.9));
621
+ box-shadow: 0 0 28px rgba(98, 226, 205, 0.22);
622
+ transition: width 180ms ease;
623
+ }
624
+ .explore-shell {
625
+ position: relative;
626
+ width: 100%;
627
+ height: 100%;
628
+ overflow: hidden;
629
+ padding: 12px;
630
+ display: flex;
631
+ flex-direction: column;
632
+ gap: 10px;
633
+ }
634
+ .explore-command-deck {
635
+ position: relative;
636
+ z-index: 3;
637
+ }
638
+ .explore-command-grid {
639
+ display: grid;
640
+ grid-template-columns: minmax(260px, 0.9fr) minmax(0, 1.4fr);
641
+ gap: 16px;
642
+ }
643
+ .explore-main-grid {
644
+ position: relative;
645
+ z-index: 3;
646
+ min-height: 0;
647
+ flex: 1;
648
+ display: grid;
649
+ grid-template-columns: minmax(0, 1fr) minmax(340px, 380px);
650
+ gap: 12px;
651
+ }
652
+ .explore-scene-stack {
653
+ min-height: 0;
654
+ display: flex;
655
+ flex-direction: column;
656
+ gap: 14px;
657
+ }
658
+ .explore-scene-card {
659
+ min-height: 0;
660
+ flex: 1;
661
+ display: flex;
662
+ flex-direction: column;
663
+ overflow: hidden;
664
+ }
665
+ .explore-scene-shell {
666
+ position: relative;
667
+ min-height: 0;
668
+ flex: 1;
669
+ overflow: hidden;
670
+ border-radius: 20px 20px 0 0;
671
+ background: ${GRAPH_THEME.ui.surface.stage};
672
+ }
673
+ .explore-scene-stage {
674
+ position: relative;
675
+ z-index: 3;
676
+ width: 100%;
677
+ height: 100%;
678
+ }
679
+ .explore-scene-footer {
680
+ position: relative;
681
+ z-index: 3;
682
+ border-top: 1px solid ${GRAPH_THEME.ui.timeline.border};
683
+ background: ${GRAPH_THEME.ui.timeline.background};
684
+ }
685
+ .explore-plugin-dock {
686
+ position: relative;
687
+ z-index: 3;
688
+ }
689
+ .explore-plugin-dock-tabs {
690
+ display: flex;
691
+ align-items: center;
692
+ gap: 6px;
693
+ flex-wrap: wrap;
694
+ }
695
+ .explore-plugin-dock-tab {
696
+ border: 1px solid ${GRAPH_THEME.ui.control.defaultBorder};
697
+ background: ${GRAPH_THEME.ui.control.defaultBg};
698
+ color: ${GRAPH_THEME.ui.control.defaultText};
699
+ border-radius: 999px;
700
+ padding: 6px 10px;
701
+ font-size: 11px;
702
+ font-weight: 600;
703
+ cursor: pointer;
704
+ }
705
+ .explore-plugin-dock-tab[data-active="true"] {
706
+ color: ${GRAPH_THEME.ui.control.activeText};
707
+ background: rgba(74, 181, 166, 0.18);
708
+ border-color: ${GRAPH_THEME.ui.control.activeBorder};
709
+ }
710
+ .explore-toolbar {
711
+ display: flex;
712
+ flex-direction: column;
713
+ gap: 12px;
714
+ }
715
+ .explore-status-strip {
716
+ display: flex;
717
+ align-items: center;
718
+ gap: 7px;
719
+ flex-wrap: wrap;
720
+ }
721
+ .explore-workflow-bar {
722
+ display: grid;
723
+ grid-template-columns: minmax(280px, 1.1fr) auto minmax(360px, 1.55fr);
724
+ align-items: center;
725
+ gap: 10px;
726
+ }
727
+ .explore-search-command {
728
+ position: relative;
729
+ min-width: 0;
730
+ height: 43px;
731
+ display: grid;
732
+ grid-template-columns: auto minmax(0, 1fr) auto;
733
+ align-items: center;
734
+ gap: 9px;
735
+ padding: 4px 5px 4px 13px;
736
+ border-radius: 16px;
737
+ border: 1px solid ${GRAPH_THEME.ui.control.inputBorder};
738
+ background:
739
+ linear-gradient(135deg, rgba(255,255,255,0.06), rgba(255,255,255,0.018)),
740
+ ${GRAPH_THEME.ui.control.inputBg};
741
+ color: ${GRAPH_THEME.ui.text.muted};
742
+ box-shadow: inset 0 1px 0 rgba(255,255,255,0.045), 0 14px 30px rgba(0,0,0,0.16);
743
+ }
744
+ .explore-search-suggestions {
745
+ position: absolute;
746
+ top: calc(100% + 6px);
747
+ left: 0;
748
+ right: 0;
749
+ z-index: 30;
750
+ margin: 0;
751
+ padding: 6px;
752
+ list-style: none;
753
+ max-height: 288px;
754
+ overflow-y: auto;
755
+ border-radius: 14px;
756
+ border: 1px solid ${GRAPH_THEME.ui.control.inputBorder};
757
+ background: ${GRAPH_THEME.ui.surface.cardStrong};
758
+ box-shadow: 0 18px 40px rgba(0,0,0,0.32), inset 0 1px 0 rgba(255,255,255,0.04);
759
+ }
760
+ .explore-search-suggestions li {
761
+ display: flex;
762
+ align-items: baseline;
763
+ justify-content: space-between;
764
+ gap: 10px;
765
+ padding: 8px 10px;
766
+ border-radius: 10px;
767
+ cursor: pointer;
768
+ color: ${GRAPH_THEME.ui.text.body};
769
+ }
770
+ .explore-search-suggestions li[data-highlighted="true"] {
771
+ background: ${GRAPH_THEME.ui.control.hoverBg};
772
+ }
773
+ .explore-search-suggestion-label {
774
+ min-width: 0;
775
+ overflow: hidden;
776
+ text-overflow: ellipsis;
777
+ white-space: nowrap;
778
+ font-size: 13px;
779
+ font-weight: 600;
780
+ }
781
+ .explore-search-suggestion-type {
782
+ flex-shrink: 0;
783
+ font-size: 11px;
784
+ color: ${GRAPH_THEME.ui.text.subtle};
785
+ text-transform: uppercase;
786
+ letter-spacing: 0.04em;
787
+ }
788
+ .explore-search-command:focus-within {
789
+ border-color: ${GRAPH_THEME.ui.control.activeBorder};
790
+ box-shadow: inset 0 1px 0 rgba(255,255,255,0.06), 0 0 0 1px ${GRAPH_THEME.ui.control.focusRing}, 0 16px 32px rgba(0,0,0,0.18);
791
+ }
792
+ .explore-search-command input {
793
+ width: 100%;
794
+ min-width: 0;
795
+ border: 0;
796
+ outline: 0;
797
+ color: ${GRAPH_THEME.ui.text.strong};
798
+ background: transparent;
799
+ font-size: 13px;
800
+ }
801
+ .explore-search-command input::placeholder {
802
+ color: ${GRAPH_THEME.ui.text.subtle};
803
+ }
804
+ .explore-search-command button {
805
+ height: 33px;
806
+ border: 1px solid ${GRAPH_THEME.ui.control.primaryBorder};
807
+ border-radius: 12px;
808
+ padding: 0 13px;
809
+ background: ${GRAPH_THEME.ui.control.primaryBg};
810
+ color: ${GRAPH_THEME.ui.control.primaryText};
811
+ font-size: 12px;
812
+ font-weight: 800;
813
+ cursor: pointer;
814
+ box-shadow: inset 0 1px 0 rgba(255,255,255,0.08);
815
+ }
816
+ .explore-search-command button:disabled {
817
+ cursor: not-allowed;
818
+ opacity: 0.45;
819
+ }
820
+ .explore-mode-control {
821
+ display: inline-grid;
822
+ grid-template-columns: repeat(3, minmax(88px, auto));
823
+ align-items: center;
824
+ padding: 4px;
825
+ border-radius: 16px;
826
+ border: 1px solid ${GRAPH_THEME.ui.control.defaultBorder};
827
+ background: rgba(255, 255, 255, 0.026);
828
+ box-shadow: inset 0 1px 0 rgba(255,255,255,0.035);
829
+ }
830
+ .explore-mode-segment {
831
+ min-width: 0;
832
+ border-radius: 12px;
833
+ }
834
+ .explore-toolbelt {
835
+ min-width: 0;
836
+ display: flex;
837
+ justify-content: flex-end;
838
+ align-items: center;
839
+ gap: 8px;
840
+ flex-wrap: wrap;
841
+ }
842
+ .explore-tool-cluster {
843
+ display: inline-flex;
844
+ align-items: center;
845
+ gap: 6px;
846
+ min-height: 43px;
847
+ padding: 4px;
848
+ border-radius: 16px;
849
+ border: 1px solid rgba(211, 205, 190, 0.075);
850
+ background: rgba(255, 255, 255, 0.022);
851
+ }
852
+ .explore-tool-cluster-label {
853
+ padding: 0 4px 0 7px;
854
+ color: ${GRAPH_THEME.ui.text.subtle};
855
+ font-size: 10px;
856
+ font-weight: 800;
857
+ letter-spacing: 0.08em;
858
+ text-transform: uppercase;
859
+ white-space: nowrap;
860
+ }
861
+ .explore-tool-cluster-items {
862
+ display: inline-flex;
863
+ align-items: center;
864
+ gap: 4px;
865
+ flex-wrap: wrap;
866
+ }
867
+ .explore-tool-button {
868
+ min-height: 33px;
869
+ display: inline-flex;
870
+ align-items: center;
871
+ justify-content: center;
872
+ gap: 7px;
873
+ border: 1px solid ${GRAPH_THEME.ui.control.defaultBorder};
874
+ border-radius: 12px;
875
+ padding: 7px 10px;
876
+ background: ${GRAPH_THEME.ui.control.defaultBg};
877
+ color: ${GRAPH_THEME.ui.control.defaultText};
878
+ font-size: 12px;
879
+ font-weight: 700;
880
+ line-height: 1;
881
+ cursor: pointer;
882
+ white-space: nowrap;
883
+ box-shadow: inset 0 1px 0 rgba(255,255,255,0.035);
884
+ transition: background 140ms ease, border-color 140ms ease, color 140ms ease, transform 140ms ease;
885
+ }
886
+ .explore-tool-button:hover:not(:disabled) {
887
+ background: ${GRAPH_THEME.ui.control.hoverBg};
888
+ border-color: rgba(211, 205, 190, 0.18);
889
+ transform: translateY(-1px);
890
+ }
891
+ .explore-tool-button:disabled {
892
+ cursor: not-allowed;
893
+ color: ${GRAPH_THEME.ui.control.disabledText};
894
+ opacity: 0.48;
895
+ transform: none;
896
+ }
897
+ .explore-tool-button[data-active="true"] {
898
+ color: ${GRAPH_THEME.ui.control.activeText};
899
+ background: ${GRAPH_THEME.ui.control.activeBg};
900
+ border-color: ${GRAPH_THEME.ui.control.activeBorder};
901
+ box-shadow: 0 0 0 1px ${GRAPH_THEME.ui.control.focusRing}, inset 0 1px 0 rgba(255,255,255,0.06);
902
+ }
903
+ .explore-tool-button-primary {
904
+ color: ${GRAPH_THEME.ui.control.primaryText};
905
+ background: ${GRAPH_THEME.ui.control.primaryBg};
906
+ border-color: ${GRAPH_THEME.ui.control.primaryBorder};
907
+ }
908
+ .explore-tool-button[data-compact="true"] {
909
+ min-width: 34px;
910
+ padding-inline: 9px;
911
+ }
912
+ .explore-tool-button[data-compact="true"] .explore-tool-button-label {
913
+ display: none;
914
+ }
915
+ .explore-color-legend {
916
+ display: flex;
917
+ align-items: baseline;
918
+ gap: 12px;
919
+ padding: 2px 1px 0;
920
+ color: ${GRAPH_THEME.ui.text.subtle};
921
+ font-size: 11px;
922
+ font-weight: 600;
923
+ }
924
+ .explore-color-legend-label {
925
+ flex-shrink: 0;
926
+ color: ${GRAPH_THEME.ui.text.muted};
927
+ }
928
+ .explore-color-legend-items {
929
+ display: flex;
930
+ flex-wrap: wrap;
931
+ gap: 8px 14px;
932
+ min-width: 0;
933
+ max-height: 76px;
934
+ overflow-y: auto;
935
+ margin: 0;
936
+ padding: 0;
937
+ list-style: none;
938
+ }
939
+ .explore-color-legend-item {
940
+ display: inline-flex;
941
+ align-items: center;
942
+ gap: 6px;
943
+ min-width: 0;
944
+ max-width: 100%;
945
+ }
946
+ .explore-color-legend-name {
947
+ overflow-wrap: anywhere;
948
+ }
949
+ .explore-color-legend-mark {
950
+ width: 10px;
951
+ height: 10px;
952
+ flex-shrink: 0;
953
+ border-radius: 50%;
954
+ box-shadow: inset 0 0 0 1px rgba(255,255,255,0.16);
955
+ }
956
+ .explore-search-results {
957
+ display: flex;
958
+ flex-direction: column;
959
+ gap: 8px;
960
+ max-height: 260px;
961
+ overflow-y: auto;
962
+ padding-right: 4px;
963
+ }
964
+ .explore-inspector-shell {
965
+ min-height: 0;
966
+ display: flex;
967
+ flex-direction: column;
968
+ }
969
+ .explore-inspector-card {
970
+ min-height: 0;
971
+ flex: 1;
972
+ overflow: hidden;
973
+ }
974
+ .explore-inspector-scroll {
975
+ height: 100%;
976
+ overflow-y: auto;
977
+ }
978
+ @media (max-width: 1260px) {
979
+ .explore-main-grid {
980
+ grid-template-columns: 1fr;
981
+ }
982
+ .explore-workflow-bar {
983
+ grid-template-columns: minmax(280px, 1fr) auto;
984
+ }
985
+ .explore-toolbelt {
986
+ grid-column: 1 / -1;
987
+ justify-content: flex-start;
988
+ }
989
+ }
990
+ @media (max-width: 980px) {
991
+ .explore-shell {
992
+ padding: 10px;
993
+ gap: 10px;
994
+ }
995
+ .explore-command-grid {
996
+ grid-template-columns: 1fr;
997
+ }
998
+ .explore-workflow-bar {
999
+ grid-template-columns: 1fr;
1000
+ }
1001
+ .explore-mode-control {
1002
+ width: 100%;
1003
+ grid-template-columns: repeat(3, minmax(0, 1fr));
1004
+ }
1005
+ .explore-toolbelt {
1006
+ justify-content: flex-start;
1007
+ }
1008
+ .explore-tool-cluster {
1009
+ max-width: 100%;
1010
+ }
1011
+ }
1012
+ `;
1013
+
1014
+ function getProvenanceCount(properties: Record<string, unknown>) {
1015
+ return PROVENANCE_KEYS.reduce(
1016
+ (count, key) => (properties[key] !== undefined && properties[key] !== null ? count + 1 : count),
1017
+ 0,
1018
+ );
1019
+ }
1020
+
1021
+ function buildRealtimeNodeAttributes(payload: {
1022
+ id: string;
1023
+ type?: string;
1024
+ properties?: Record<string, unknown>;
1025
+ }): NodeAttributes {
1026
+ const properties = payload.properties || {};
1027
+ const label = String(properties.content || payload.id);
1028
+ const baseColor = GRAPH_THEME.palette.accent.path;
1029
+ const hasTemporalBounds = Boolean(properties.valid_from || properties.valid_until);
1030
+ const provenanceCount = getProvenanceCount(properties);
1031
+
1032
+ return {
1033
+ label,
1034
+ x: Number(properties.x ?? Math.random() * 1000 - 500),
1035
+ y: Number(properties.y ?? Math.random() * 1000 - 500),
1036
+ nodeType: payload.type || "inferred",
1037
+ content: label,
1038
+ valid_from: (properties.valid_from as string | null | undefined) ?? null,
1039
+ valid_until: (properties.valid_until as string | null | undefined) ?? null,
1040
+ properties,
1041
+ size: 8,
1042
+ baseSize: 8,
1043
+ semanticGroup: payload.type || "inferred",
1044
+ color: baseColor,
1045
+ baseColor,
1046
+ mutedColor: withAlpha(baseColor, GRAPH_THEME.nodes.mutedAlpha),
1047
+ glowColor: withAlpha(baseColor, 0.36),
1048
+ visualPriority: 0.82,
1049
+ labelPriority: 0.82,
1050
+ strokeColor: GRAPH_THEME.palette.background.nodeBorder,
1051
+ borderColor: GRAPH_THEME.palette.background.nodeBorder,
1052
+ borderSize: 0.85,
1053
+ nodeVariant: "inferred",
1054
+ nodeShapeVariant: "inferred",
1055
+ badgeKind: hasTemporalBounds ? "temporal" : provenanceCount > 0 ? "provenance" : "inferred",
1056
+ badgeCount: provenanceCount || undefined,
1057
+ ringColor: GRAPH_THEME.nodes.selectedRing.color,
1058
+ haloColor: withAlpha(baseColor, 0.42),
1059
+ labelVisibilityPolicy: "local",
1060
+ };
1061
+ }
1062
+
1063
+ function synchronizeRealtimeSmallGraphEdges(isSmallGraph: boolean): void {
1064
+ graph.forEachEdge((edgeId) => {
1065
+ graph.setEdgeAttribute(edgeId, "isSmallGraph", isSmallGraph);
1066
+ });
1067
+ }
1068
+
1069
+ function buildSelectedNodeState(
1070
+ nodeId: string,
1071
+ displayState: GraphDisplayStateSnapshot,
1072
+ ): GraphSelectedNodeState | null {
1073
+ if (!nodeId || !graph.hasNode(nodeId)) {
1074
+ return null;
1075
+ }
1076
+
1077
+ const attributes = graph.getNodeAttributes(nodeId) as {
1078
+ label?: string;
1079
+ content?: string;
1080
+ nodeType?: string;
1081
+ color?: string;
1082
+ valid_from?: string | null;
1083
+ valid_until?: string | null;
1084
+ properties?: Record<string, unknown>;
1085
+ };
1086
+
1087
+ return {
1088
+ id: nodeId,
1089
+ label: String(attributes.label ?? nodeId),
1090
+ content: String(attributes.content ?? attributes.label ?? nodeId),
1091
+ nodeType: String(attributes.nodeType ?? "Entity"),
1092
+ color: typeof attributes.color === "string" ? attributes.color : undefined,
1093
+ valid_from: attributes.valid_from ?? null,
1094
+ valid_until: attributes.valid_until ?? null,
1095
+ properties: attributes.properties ?? {},
1096
+ neighborCount: graph.neighbors(nodeId).length,
1097
+ visibleNeighborCount: displayState.selectedVisibleNeighborIds.length,
1098
+ collapsedNeighborCount: displayState.selectedCollapsedNeighborIds.length,
1099
+ isNeighborhoodCollapsed: displayState.selectedCollapsedNeighborIds.length > 0,
1100
+ canCollapseNeighborhood: graph.neighbors(nodeId).length > 8,
1101
+ };
1102
+ }
1103
+
1104
+ type FocusResolution = {
1105
+ kind: GraphSelectedNodeKind;
1106
+ resolvedNodeId: string | null;
1107
+ reason: FocusedUnavailableReason | null;
1108
+ };
1109
+
1110
+ function buildSelectedEdgeState(
1111
+ edgeId: string,
1112
+ displayGraph: typeof graph | Graph<NodeAttributes, EdgeAttributes>,
1113
+ ): GraphSelectedEdgeState | null {
1114
+ if (!edgeId || !displayGraph.hasEdge(edgeId)) {
1115
+ return null;
1116
+ }
1117
+
1118
+ const [displaySourceId, displayTargetId] = displayGraph.extremities(edgeId);
1119
+ const attributes = displayGraph.getEdgeAttributes(edgeId) as {
1120
+ edgeType?: string;
1121
+ weight?: number;
1122
+ properties?: Record<string, unknown>;
1123
+ familyId?: string;
1124
+ rawEdgeIds?: string[];
1125
+ isAggregated?: boolean;
1126
+ aggregateCount?: number;
1127
+ bundleKind?: "parallel" | "bidirectional" | "community";
1128
+ dominantEdgeType?: string;
1129
+ representativeWeight?: number;
1130
+ };
1131
+ const rawEdgeIds = attributes.rawEdgeIds?.length ? attributes.rawEdgeIds.map((rawEdgeId) => String(rawEdgeId)) : [edgeId];
1132
+ const primaryRawEdgeId = rawEdgeIds.find((rawEdgeId) => graph.hasEdge(rawEdgeId)) ?? rawEdgeIds[0];
1133
+ let sourceId = displaySourceId;
1134
+ let targetId = displayTargetId;
1135
+ if (primaryRawEdgeId && graph.hasEdge(primaryRawEdgeId)) {
1136
+ [sourceId, targetId] = graph.extremities(primaryRawEdgeId);
1137
+ }
1138
+ const sourceAttributes = graph.hasNode(sourceId)
1139
+ ? (graph.getNodeAttributes(sourceId) as { label?: string; content?: string })
1140
+ : ({ label: displaySourceId } as { label?: string; content?: string });
1141
+ const targetAttributes = graph.hasNode(targetId)
1142
+ ? (graph.getNodeAttributes(targetId) as { label?: string; content?: string })
1143
+ : ({ label: displayTargetId } as { label?: string; content?: string });
1144
+ const properties = attributes.properties ?? {};
1145
+ const familyId = String(attributes.familyId || edgeId);
1146
+ let familySize = 0;
1147
+ let siblingCount = 0;
1148
+ rawEdgeIds.forEach((rawEdgeId) => {
1149
+ if (!graph.hasEdge(rawEdgeId)) {
1150
+ return;
1151
+ }
1152
+ const candidateAttrs = graph.getEdgeAttributes(rawEdgeId) as { familyId?: string };
1153
+ const [candidateSource, candidateTarget] = graph.extremities(rawEdgeId);
1154
+ if (String(candidateAttrs.familyId || rawEdgeId) === familyId) {
1155
+ familySize += 1;
1156
+ }
1157
+ if (candidateSource === sourceId && candidateTarget === targetId) {
1158
+ siblingCount += 1;
1159
+ }
1160
+ });
1161
+
1162
+ if (attributes.isAggregated) {
1163
+ siblingCount = rawEdgeIds.filter((rawEdgeId) => graph.hasEdge(rawEdgeId)).length;
1164
+ familySize = Math.max(familySize, siblingCount);
1165
+ }
1166
+
1167
+ return {
1168
+ id: edgeId,
1169
+ familyId,
1170
+ sourceId,
1171
+ sourceLabel: String(sourceAttributes.label ?? sourceAttributes.content ?? sourceId),
1172
+ targetId,
1173
+ targetLabel: String(targetAttributes.label ?? targetAttributes.content ?? targetId),
1174
+ edgeType: String(attributes.edgeType ?? "related_to"),
1175
+ weight: Number(attributes.weight ?? 1),
1176
+ properties,
1177
+ provenanceCount: getProvenanceCount(properties),
1178
+ familySize,
1179
+ siblingCount,
1180
+ isAggregated: Boolean(attributes.isAggregated),
1181
+ aggregateCount: Number(attributes.aggregateCount ?? rawEdgeIds.length),
1182
+ rawEdgeIds,
1183
+ bundleKind: attributes.bundleKind ?? null,
1184
+ dominantEdgeType: attributes.dominantEdgeType ?? null,
1185
+ representativeWeight: Number(attributes.representativeWeight ?? attributes.weight ?? 1),
1186
+ };
1187
+ }
1188
+
1189
+ function collectPluginPanels(
1190
+ plugins: GraphPlugin[],
1191
+ context: GraphPluginContext,
1192
+ ): GraphPluginPanelDescriptor[] {
1193
+ const panels: GraphPluginPanelDescriptor[] = [];
1194
+
1195
+ for (const plugin of plugins) {
1196
+ try {
1197
+ const result = plugin.renderPanel?.(context);
1198
+ if (!result) {
1199
+ continue;
1200
+ }
1201
+ if (Array.isArray(result)) {
1202
+ panels.push(...result);
1203
+ } else {
1204
+ panels.push(result);
1205
+ }
1206
+ } catch (error) {
1207
+ console.error(`[GraphPlugin:${plugin.id}] panel render failed`, error);
1208
+ }
1209
+ }
1210
+
1211
+ return panels.sort((left, right) => (left.order ?? 0) - (right.order ?? 0));
1212
+ }
1213
+
1214
+ function collectPluginOverlays(
1215
+ plugins: GraphPlugin[],
1216
+ context: GraphPluginContext,
1217
+ ): GraphPluginOverlayDescriptor[] {
1218
+ const overlays: GraphPluginOverlayDescriptor[] = [];
1219
+
1220
+ for (const plugin of plugins) {
1221
+ try {
1222
+ const result = plugin.renderOverlay?.(context);
1223
+ if (!result) {
1224
+ continue;
1225
+ }
1226
+ if (Array.isArray(result)) {
1227
+ overlays.push(...result);
1228
+ } else {
1229
+ overlays.push(result);
1230
+ }
1231
+ } catch (error) {
1232
+ console.error(`[GraphPlugin:${plugin.id}] overlay render failed`, error);
1233
+ }
1234
+ }
1235
+
1236
+ return overlays.sort((left, right) => {
1237
+ if ((left.layer ?? 0) !== (right.layer ?? 0)) {
1238
+ return (left.layer ?? 0) - (right.layer ?? 0);
1239
+ }
1240
+ return (left.order ?? 0) - (right.order ?? 0);
1241
+ });
1242
+ }
1243
+
1244
+ interface GraphWorkspaceProps {
1245
+ externalFocusNodeId?: string;
1246
+ externalFocusToken?: number;
1247
+ onDirtyChange?: (dirty: boolean) => void;
1248
+ }
1249
+
1250
+ export function GraphWorkspace({ externalFocusNodeId, externalFocusToken, onDirtyChange }: GraphWorkspaceProps = {}) {
1251
+ const [selectedNodeId, setSelectedNodeId] = useState("");
1252
+ const [focusedNodeId, setFocusedNodeId] = useState("");
1253
+ const [lastGroupedSelectedNodeId, setLastGroupedSelectedNodeId] = useState("");
1254
+ const [selectedEdgeId, setSelectedEdgeId] = useState("");
1255
+ const [isLayoutRunning, setIsLayoutRunning] = useState(false);
1256
+ const [graphReady, setGraphReady] = useState(false);
1257
+ const [graphVersion, setGraphVersion] = useState(0);
1258
+ const [markdownDraftDirty, setMarkdownDraftDirty] = useState(false);
1259
+ const markdownRefreshGuard = useMemo(() => new NodeMarkdownRefreshGuard(), []);
1260
+ const handleMarkdownDirtyChange = useCallback((dirty: boolean) => {
1261
+ setMarkdownDraftDirty(dirty);
1262
+ onDirtyChange?.(dirty);
1263
+ }, [onDirtyChange]);
1264
+ const [viewMode, setViewMode] = useState<GraphViewMode>("full");
1265
+ const [aggregationEnabled] = useState(true);
1266
+ const [collapsedNeighborhoodNodeIds, setCollapsedNeighborhoodNodeIds] = useState<string[]>([]);
1267
+ const [searchQuery, setSearchQuery] = useState("");
1268
+ const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
1269
+ const [searchError, setSearchError] = useState("");
1270
+ const [predictionType, setPredictionType] = useState("");
1271
+ const [isRunningPredictions, setIsRunningPredictions] = useState(false);
1272
+ const [predictions, setPredictions] = useState<LinkPrediction[]>([]);
1273
+ const [pathTargetId, setPathTargetId] = useState("");
1274
+ const [pathResult, setPathResult] = useState<PathResponse | null>(null);
1275
+ const [activeNodeCount, setActiveNodeCount] = useState<number | null>(null);
1276
+ const [temporalBounds, setTemporalBounds] = useState<TemporalBounds | null>(null);
1277
+ const [scrubberTime, setScrubberTime] = useState<Date | null>(null);
1278
+ // Deduplicates setScrubberTime calls by millisecond value so that React 18
1279
+ // concurrent-mode re-renders with a new Date object for the same timestamp
1280
+ // do not churn temporalState and retrigger the diagnostics effect (issue #830).
1281
+ const lastScrubberMsRef = useRef<number | null>(null);
1282
+ const onTimeChange = useCallback((time: Date) => {
1283
+ const ms = time.getTime();
1284
+ if (ms === lastScrubberMsRef.current) {
1285
+ return;
1286
+ }
1287
+ lastScrubberMsRef.current = ms;
1288
+ setScrubberTime(time);
1289
+ }, []);
1290
+ const [loadingProgress, setLoadingProgress] = useState<GraphLoadProgress | null>(null);
1291
+ const [pluginPanelState, setPluginPanelState] = useState<Record<string, boolean>>({
1292
+ "effects-panel": false,
1293
+ "neighborhood-panel": false,
1294
+ "temporal-panel": false,
1295
+ });
1296
+ const [activeDockPanelId, setActiveDockPanelId] = useState<string | null>(null);
1297
+ const [pluginRuntimeVersion, setPluginRuntimeVersion] = useState(0);
1298
+ const [effectsState, setEffectsState] = useState<GraphEffectsState>(DEFAULT_EFFECTS_STATE);
1299
+ const [graphDiagnosticsState, setGraphDiagnosticsState] = useState<GraphRuntimeDiagnosticsSnapshot | null>(null);
1300
+ // Tracks the last accepted diagnostics outside React's state cycle, allowing
1301
+ // handleDiagnosticsChange to compare synchronously before calling setState.
1302
+ const lastDiagnosticsRef = useRef<GraphRuntimeDiagnosticsSnapshot | null>(null);
1303
+ const [graphAnalyticsState, setGraphAnalyticsState] = useState<GraphAnalyticsSnapshot | null>(null);
1304
+ const [loadedPlugins, setLoadedPlugins] = useState<Record<string, GraphPlugin>>({});
1305
+
1306
+ // FR-2: Egocentric depth-of-field
1307
+ const [egoModeEnabled, setEgoModeEnabled] = useState(false);
1308
+ const [egoMaxHops, setEgoMaxHops] = useState(3);
1309
+ // FR-3 frontend: Distance mode overlay
1310
+ const [distanceMode, setDistanceMode] = useState<"off" | "structural" | "semantic">("off");
1311
+ // FR-5: Distance heatmap layout
1312
+ const [heatmapEnabled, setHeatmapEnabled] = useState(false);
1313
+ const [semanticDistanceState, setSemanticDistanceState] = useState<{
1314
+ anchorNodeId: string | null;
1315
+ scores: Record<string, number>;
1316
+ count: number;
1317
+ status: GraphDistanceVisualState["status"];
1318
+ error: string | null;
1319
+ }>({
1320
+ anchorNodeId: null,
1321
+ scores: EMPTY_DISTANCE_RECORD,
1322
+ count: 0,
1323
+ status: "idle",
1324
+ error: null,
1325
+ });
1326
+
1327
+ const debouncedTime = useDebounce(scrubberTime, 150);
1328
+ const prevActiveIdsRef = useRef<Set<string>>(new Set());
1329
+ const sceneRef = useRef<GraphSceneHandle>(null);
1330
+ const lastExternalFocusTokenRef = useRef<number | undefined>(undefined);
1331
+ const pluginRuntimeRef = useRef<GraphSceneRuntime | null>(null);
1332
+ const appliedGraphSummarySignatureRef = useRef<string | null>(null);
1333
+ const smallGraphModeRef = useRef(false);
1334
+ const pluginInteractionStateRef = useRef<GraphInteractionState>({
1335
+ hoveredNodeId: null,
1336
+ selectedNodeId: "",
1337
+ selectedEdgeId: "",
1338
+ focusedNodeId: "",
1339
+ activePath: [],
1340
+ activePathEdgeIds: [],
1341
+ viewMode: "full",
1342
+ zoomTier: "overview",
1343
+ isLayoutRunning: false,
1344
+ });
1345
+ const reload = useReloadGraph();
1346
+
1347
+ const handleLoadProgress = useCallback((progress: GraphLoadProgress) => {
1348
+ setLoadingProgress(progress);
1349
+ if (progress.phase !== "ready" && progress.phase !== "stabilizing_layout") {
1350
+ setGraphReady(false);
1351
+ }
1352
+ }, []);
1353
+
1354
+ const applyGraphReadySummary = useCallback((graphSummary: GraphLoadSummary) => {
1355
+ const signature = getGraphSummarySignature(graphSummary);
1356
+ if (appliedGraphSummarySignatureRef.current === signature) {
1357
+ return;
1358
+ }
1359
+
1360
+ appliedGraphSummarySignatureRef.current = signature;
1361
+ smallGraphModeRef.current = Boolean(
1362
+ graphSummary.layoutReady
1363
+ && !graphSummary.hasCoordinates
1364
+ && graphSummary.nodeCount > 0
1365
+ && graphSummary.nodeCount <= SMALL_GRAPH_MAX_NODES,
1366
+ );
1367
+ setGraphReady(true);
1368
+ setGraphVersion((current) => current + 1);
1369
+ setIsLayoutRunning(!graphSummary.layoutReady);
1370
+
1371
+ if (graphSummary.layoutReady) {
1372
+ setLoadingProgress(null);
1373
+ return;
1374
+ }
1375
+
1376
+ setLoadingProgress(createGraphLoadProgress({
1377
+ phase: "stabilizing_layout",
1378
+ progressKind: "indeterminate",
1379
+ nodesLoaded: graphSummary.nodeCount,
1380
+ nodesTotal: graphSummary.nodeCount,
1381
+ edgesLoaded: graphSummary.edgeCount,
1382
+ edgesTotal: graphSummary.edgeCount,
1383
+ message: "Settling runtime layout",
1384
+ showGraphBehind: true,
1385
+ layoutSource: graphSummary.layoutSource,
1386
+ layoutState: "bootstrapping",
1387
+ }));
1388
+ }, []);
1389
+
1390
+ const {
1391
+ data: summary,
1392
+ isLoading,
1393
+ isFetching,
1394
+ isError: isGraphLoadError,
1395
+ error: graphLoadError,
1396
+ refetch: refetchGraph,
1397
+ } = useLoadGraph({
1398
+ enabled: true,
1399
+ onGraphReady: applyGraphReadySummary,
1400
+ onProgress: handleLoadProgress,
1401
+ });
1402
+
1403
+ const graphLoadErrorMessage = isGraphLoadError
1404
+ ? (graphLoadError instanceof Error ? graphLoadError.message : "Unknown error while loading the graph.")
1405
+ : null;
1406
+
1407
+ const handleRetryGraphLoad = useCallback(() => {
1408
+ setLoadingProgress(null);
1409
+ void refetchGraph();
1410
+ }, [refetchGraph]);
1411
+
1412
+ useEffect(() => {
1413
+ if (isLayoutRunning) {
1414
+ return;
1415
+ }
1416
+
1417
+ setLoadingProgress((current) => (current?.phase === "stabilizing_layout" ? null : current));
1418
+ }, [isLayoutRunning]);
1419
+
1420
+ useEffect(() => {
1421
+ if (!summary || graphReady) {
1422
+ return;
1423
+ }
1424
+
1425
+ applyGraphReadySummary(summary);
1426
+ }, [applyGraphReadySummary, graphReady, summary]);
1427
+
1428
+ const canFetchTemporalBounds = shouldFetchTemporalBounds(summary);
1429
+ const canFetchTemporalSnapshot = shouldFetchTemporalSnapshot({
1430
+ debouncedTime,
1431
+ isLoading,
1432
+ summary,
1433
+ });
1434
+
1435
+ useEffect(() => {
1436
+ if (!canFetchTemporalBounds) {
1437
+ return;
1438
+ }
1439
+
1440
+ let cancelled = false;
1441
+ const loadBounds = async () => {
1442
+ try {
1443
+ const response = await fetch("/api/temporal/bounds");
1444
+ if (!response.ok || cancelled) return;
1445
+ const data: TemporalBounds = await response.json();
1446
+ if (!cancelled) {
1447
+ setTemporalBounds(data);
1448
+ }
1449
+ } catch {
1450
+ if (!cancelled) {
1451
+ setTemporalBounds(null);
1452
+ }
1453
+ }
1454
+ };
1455
+ loadBounds();
1456
+ return () => {
1457
+ cancelled = true;
1458
+ };
1459
+ }, [
1460
+ canFetchTemporalBounds,
1461
+ summary?.nodeCount,
1462
+ summary?.edgeCount,
1463
+ ]);
1464
+
1465
+ // Guards the snapshot lifecycle: at most one in-flight request per scrubber
1466
+ // position (identical-`at` polls are deduplicated, breaking the idle/play
1467
+ // polling loop), applied snapshots are cached and re-applied on revisit, and
1468
+ // a response applies only while the scrubber is still on its position
1469
+ // (out-of-order responses cannot clobber the active-node count).
1470
+ const temporalSnapshotGuardsRef = useRef<ReturnType<typeof createTemporalSnapshotGuards> | null>(null);
1471
+ if (temporalSnapshotGuardsRef.current === null) {
1472
+ temporalSnapshotGuardsRef.current = createTemporalSnapshotGuards();
1473
+ }
1474
+ const temporalSnapshotGuards = temporalSnapshotGuardsRef.current;
1475
+
1476
+ // A new graph summary means the graph data was replaced (reload/retry);
1477
+ // snapshots cached against the previous graph are stale, so reset all state.
1478
+ useEffect(() => {
1479
+ temporalSnapshotGuards.reset();
1480
+ }, [summary]);
1481
+
1482
+ useEffect(() => {
1483
+ if (!canFetchTemporalSnapshot) {
1484
+ return;
1485
+ }
1486
+
1487
+ if (!debouncedTime) {
1488
+ return;
1489
+ }
1490
+
1491
+ const atMs = debouncedTime.getTime();
1492
+ const { seq, cached } = temporalSnapshotGuards.begin(atMs);
1493
+ if (seq === null) {
1494
+ // An identical request is already in flight: one request per position.
1495
+ return;
1496
+ }
1497
+
1498
+ let cancelled = false;
1499
+
1500
+ const applyData = (data: TemporalSnapshotResponse) => {
1501
+ const nextActiveIds = new Set(data.active_node_ids);
1502
+ requestAnimationFrame(() => {
1503
+ if (cancelled) return;
1504
+ if (!temporalSnapshotGuards.shouldApply(atMs, seq)) {
1505
+ // The scrubber moved on (or this request was superseded): release the
1506
+ // position so a return to it refetches instead of stalling.
1507
+ temporalSnapshotGuards.finish(atMs, seq);
1508
+ return;
1509
+ }
1510
+ const previous = prevActiveIdsRef.current;
1511
+ previous.forEach((id) => {
1512
+ if (!nextActiveIds.has(id) && graph.hasNode(id)) {
1513
+ graph.setNodeAttribute(id, "hidden", true);
1514
+ }
1515
+ });
1516
+ nextActiveIds.forEach((id) => {
1517
+ if (graph.hasNode(id)) {
1518
+ graph.setNodeAttribute(id, "hidden", false);
1519
+ }
1520
+ });
1521
+ prevActiveIdsRef.current = nextActiveIds;
1522
+ setActiveNodeCount(data.active_node_count);
1523
+ setGraphVersion((current) => current + 1);
1524
+ sceneRef.current?.getRuntime()?.requestRender();
1525
+ temporalSnapshotGuards.apply(atMs, seq, data);
1526
+ });
1527
+ };
1528
+
1529
+ if (cached) {
1530
+ // Returning to a position whose snapshot was already applied: re-apply
1531
+ // the cached result without a network request.
1532
+ applyData(cached);
1533
+ return;
1534
+ }
1535
+
1536
+ const applySnapshot = async () => {
1537
+ try {
1538
+ const at = debouncedTime.toISOString();
1539
+ const response = await fetch(`/api/temporal/snapshot?at=${encodeURIComponent(at)}`);
1540
+ if (!response.ok) {
1541
+ // A failed request must be retryable if the scrubber returns.
1542
+ if (!cancelled) temporalSnapshotGuards.finish(atMs, seq);
1543
+ return;
1544
+ }
1545
+ if (cancelled) return;
1546
+
1547
+ const data: TemporalSnapshotResponse = await response.json();
1548
+ if (cancelled) return;
1549
+ applyData(data);
1550
+ } catch (fetchError) {
1551
+ temporalSnapshotGuards.finish(atMs, seq);
1552
+ if (!cancelled) {
1553
+ console.error("[Temporal] Snapshot fetch failed", fetchError);
1554
+ }
1555
+ }
1556
+ };
1557
+
1558
+ applySnapshot();
1559
+ return () => {
1560
+ cancelled = true;
1561
+ // A cancelled request must be retryable when its position is revisited.
1562
+ temporalSnapshotGuards.finish(atMs, seq);
1563
+ };
1564
+ }, [
1565
+ canFetchTemporalSnapshot,
1566
+ debouncedTime,
1567
+ ]);
1568
+
1569
+ const resolveNodeIdForFocusedMode = useCallback((
1570
+ nodeId: string,
1571
+ displayGraphCandidate?: GraphSceneRuntime["displayGraph"] | null,
1572
+ ): FocusResolution => {
1573
+ if (!nodeId) {
1574
+ return {
1575
+ kind: "none",
1576
+ resolvedNodeId: null,
1577
+ reason: { code: "no-selection" },
1578
+ };
1579
+ }
1580
+
1581
+ if (graph.hasNode(nodeId)) {
1582
+ return {
1583
+ kind: "base",
1584
+ resolvedNodeId: nodeId,
1585
+ reason: null,
1586
+ };
1587
+ }
1588
+
1589
+ const currentDisplayGraph = displayGraphCandidate ?? pluginRuntimeRef.current?.displayGraph ?? graph;
1590
+ if (currentDisplayGraph.hasNode(nodeId)) {
1591
+ const displayAttrs = currentDisplayGraph.getNodeAttributes(nodeId) as NodeAttributes;
1592
+ const communityGroup = displayAttrs.properties?.__communityGroup as
1593
+ | {
1594
+ anchorNodeId?: string | null;
1595
+ sampleNodeIds?: string[];
1596
+ }
1597
+ | undefined;
1598
+
1599
+ const anchorNodeId = communityGroup?.anchorNodeId || communityGroup?.sampleNodeIds?.[0] || "";
1600
+ if (anchorNodeId && graph.hasNode(anchorNodeId)) {
1601
+ return {
1602
+ kind: "grouped",
1603
+ resolvedNodeId: anchorNodeId,
1604
+ reason: null,
1605
+ };
1606
+ }
1607
+
1608
+ return {
1609
+ kind: "grouped",
1610
+ resolvedNodeId: null,
1611
+ reason: { code: "grouped-unresolvable" },
1612
+ };
1613
+ }
1614
+
1615
+ return {
1616
+ kind: "unavailable",
1617
+ resolvedNodeId: null,
1618
+ reason: { code: "not-in-graph" },
1619
+ };
1620
+ }, []);
1621
+
1622
+ const focusedSelectionResolution = useMemo(
1623
+ () => resolveNodeIdForFocusedMode(selectedNodeId, pluginRuntimeRef.current?.displayGraph),
1624
+ [pluginRuntimeVersion, resolveNodeIdForFocusedMode, selectedNodeId, viewMode],
1625
+ );
1626
+ const inspectableNodeId = focusedSelectionResolution.resolvedNodeId ?? "";
1627
+ const canActivateFocusedMode = Boolean(focusedSelectionResolution.resolvedNodeId);
1628
+ const { available: groupedViewAvailable, reason: groupedViewReason } = useMemo(
1629
+ () => checkGroupedViewAvailability(),
1630
+ [graphVersion],
1631
+ );
1632
+ const groupedDisplayCandidate = useMemo(
1633
+ () => viewMode === "grouped"
1634
+ ? resolveDisplayGraph("", EMPTY_PATH, EMPTY_PATH, "grouped", {
1635
+ aggregationEnabled,
1636
+ collapsedNeighborhoodNodeIds,
1637
+ })
1638
+ : null,
1639
+ [viewMode, aggregationEnabled, collapsedNeighborhoodNodeIds, graphVersion],
1640
+ );
1641
+ const confirmDiscardMarkdownDraft = useCallback(() => {
1642
+ if (!markdownDraftDirty) return true;
1643
+ const discard = window.confirm(
1644
+ "Discard the unapplied Markdown draft and leave this node?",
1645
+ );
1646
+ return discard;
1647
+ }, [markdownDraftDirty]);
1648
+
1649
+
1650
+ const enterLocalGraph = useCallback((nodeId: string) => {
1651
+ const resolution = resolveNodeIdForFocusedMode(nodeId, pluginRuntimeRef.current?.displayGraph);
1652
+ if (!resolution.resolvedNodeId) {
1653
+ return;
1654
+ }
1655
+
1656
+ const nextNodeId = resolution.resolvedNodeId;
1657
+ const requiresDraftConfirm = localGraphRequiresDraftConfirm(viewMode, selectedNodeId, nextNodeId);
1658
+ if (requiresDraftConfirm && !confirmDiscardMarkdownDraft()) {
1659
+ return;
1660
+ }
1661
+
1662
+ setFocusedNodeId(nextNodeId);
1663
+ setSelectedNodeId(nextNodeId);
1664
+ if (requiresDraftConfirm) {
1665
+ setSelectedEdgeId("");
1666
+ setPathResult(null);
1667
+ setSearchResults([]);
1668
+ setSearchError("");
1669
+ }
1670
+ setViewMode("focused");
1671
+ setIsLayoutRunning(false);
1672
+ }, [confirmDiscardMarkdownDraft, resolveNodeIdForFocusedMode, selectedNodeId, viewMode]);
1673
+
1674
+ const setLayoutViewMode = useCallback((nextViewMode: GraphLayoutViewMode) => {
1675
+ if (nextViewMode !== viewMode && !confirmDiscardMarkdownDraft()) return;
1676
+
1677
+ if (nextViewMode === "grouped") {
1678
+ if (!groupedViewAvailable) {
1679
+ debugGraphWorkspace("grouped-view-unavailable", {
1680
+ reason: groupedViewReason,
1681
+ graphVersion,
1682
+ });
1683
+ return;
1684
+ }
1685
+
1686
+ const groupedDisplayGraph = groupedDisplayCandidate?.graph
1687
+ ?? resolveDisplayGraph("", EMPTY_PATH, EMPTY_PATH, "grouped", {
1688
+ aggregationEnabled,
1689
+ collapsedNeighborhoodNodeIds,
1690
+ }).graph;
1691
+ const nextGroupedSelection = [
1692
+ lastGroupedSelectedNodeId,
1693
+ selectedNodeId,
1694
+ focusedNodeId,
1695
+ ]
1696
+ .map((candidateId) => resolveGroupedDisplayNodeId(groupedDisplayGraph, candidateId))
1697
+ .find((candidateId): candidateId is string => Boolean(candidateId))
1698
+ ?? "";
1699
+
1700
+ setFocusedNodeId("");
1701
+ setSelectedNodeId(nextGroupedSelection);
1702
+ if (nextGroupedSelection) {
1703
+ setLastGroupedSelectedNodeId(nextGroupedSelection);
1704
+ }
1705
+ setViewMode("grouped");
1706
+ setIsLayoutRunning(true);
1707
+ return;
1708
+ }
1709
+
1710
+ setFocusedNodeId("");
1711
+ setSelectedNodeId((currentSelectedNodeId) => (
1712
+ currentSelectedNodeId && graph.hasNode(currentSelectedNodeId) ? currentSelectedNodeId : ""
1713
+ ));
1714
+ setViewMode("full");
1715
+ }, [
1716
+ confirmDiscardMarkdownDraft,
1717
+ aggregationEnabled,
1718
+ collapsedNeighborhoodNodeIds,
1719
+ focusedNodeId,
1720
+ graphVersion,
1721
+ groupedDisplayCandidate,
1722
+ groupedViewAvailable,
1723
+ groupedViewReason,
1724
+ lastGroupedSelectedNodeId,
1725
+ selectedNodeId,
1726
+ viewMode,
1727
+ ]);
1728
+
1729
+ const focusNode = useCallback((nodeId: string) => {
1730
+ if (nodeId !== selectedNodeId && !confirmDiscardMarkdownDraft()) return;
1731
+ if (!nodeId) {
1732
+ setSelectedNodeId("");
1733
+ setSelectedEdgeId("");
1734
+ setPathResult(null);
1735
+ setSearchResults([]);
1736
+ setSearchError("");
1737
+ return;
1738
+ }
1739
+
1740
+ const currentDisplayGraph = pluginRuntimeRef.current?.displayGraph ?? graph;
1741
+ const nextSelectedNodeId = nodeId;
1742
+
1743
+ if (!graph.hasNode(nodeId) && currentDisplayGraph.hasNode(nodeId)) {
1744
+ setLastGroupedSelectedNodeId(nodeId);
1745
+ }
1746
+
1747
+ setSelectedNodeId(nextSelectedNodeId);
1748
+ setSelectedEdgeId("");
1749
+ setPathResult(null);
1750
+ setSearchResults([]);
1751
+ setSearchError("");
1752
+ if (viewMode === "focused" && graph.hasNode(nextSelectedNodeId)) {
1753
+ setFocusedNodeId(nextSelectedNodeId);
1754
+ setIsLayoutRunning(false);
1755
+ }
1756
+ }, [confirmDiscardMarkdownDraft, selectedNodeId, viewMode]); // Note: ego/heatmap/distanceMode effects re-run automatically when selectedNodeId changes
1757
+
1758
+ useEffect(() => {
1759
+ if (!externalFocusNodeId || externalFocusToken == null) return;
1760
+ if (lastExternalFocusTokenRef.current === externalFocusToken) return;
1761
+ if (!graphReady || !graph.hasNode(externalFocusNodeId)) return;
1762
+ if (
1763
+ externalFocusNodeId !== selectedNodeId
1764
+ && !confirmDiscardMarkdownDraft()
1765
+ ) return;
1766
+ lastExternalFocusTokenRef.current = externalFocusToken;
1767
+
1768
+ // Set state directly instead of going through focusNode(), which captures
1769
+ // a stale viewMode in its closure. setViewMode is called first so the node
1770
+ // is visible in the full graph before the scene pans to it.
1771
+ setViewMode("full");
1772
+ setSelectedNodeId(externalFocusNodeId);
1773
+ setSelectedEdgeId("");
1774
+ window.setTimeout(() => {
1775
+ sceneRef.current?.focusNode(externalFocusNodeId);
1776
+ }, 0);
1777
+ }, [
1778
+ confirmDiscardMarkdownDraft,
1779
+ externalFocusNodeId,
1780
+ externalFocusToken,
1781
+ graphReady,
1782
+ selectedNodeId,
1783
+ ]);
1784
+
1785
+ const handleEdgeSelect = useCallback((edgeId: string) => {
1786
+ setSelectedEdgeId(edgeId);
1787
+ }, []);
1788
+
1789
+ const handleSearch = useCallback(async () => {
1790
+ if (!searchQuery.trim()) {
1791
+ setSearchResults([]);
1792
+ return;
1793
+ }
1794
+ setSearchError("");
1795
+ try {
1796
+ const response = await fetch("/api/graph/search", {
1797
+ method: "POST",
1798
+ headers: { "Content-Type": "application/json" },
1799
+ body: JSON.stringify({ query: searchQuery, limit: 8 }),
1800
+ });
1801
+ if (!response.ok) {
1802
+ throw new Error(`Search failed with status ${response.status}`);
1803
+ }
1804
+ const data = await response.json();
1805
+ setSearchResults(data.results || []);
1806
+ } catch (searchFetchError) {
1807
+ setSearchError(searchFetchError instanceof Error ? searchFetchError.message : "Search failed");
1808
+ }
1809
+ }, [searchQuery]);
1810
+
1811
+ const handleClearSearchResults = useCallback(() => {
1812
+ setSearchResults([]);
1813
+ setSearchError("");
1814
+ }, []);
1815
+
1816
+ const handleRunPredictions = useCallback(async () => {
1817
+ if (!inspectableNodeId) return;
1818
+ setIsRunningPredictions(true);
1819
+ try {
1820
+ const response = await fetch("/api/enrich/links", {
1821
+ method: "POST",
1822
+ headers: { "Content-Type": "application/json" },
1823
+ body: JSON.stringify({
1824
+ node_id: inspectableNodeId,
1825
+ top_n: 6,
1826
+ candidate_type: predictionType || undefined,
1827
+ min_score: 0,
1828
+ }),
1829
+ });
1830
+ if (!response.ok) {
1831
+ throw new Error(`Link prediction failed with status ${response.status}`);
1832
+ }
1833
+ const data = await response.json();
1834
+ setPredictions(data.predictions || []);
1835
+ } catch (predictionError) {
1836
+ console.error("[GraphWorkspace] prediction failed", predictionError);
1837
+ setPredictions([]);
1838
+ } finally {
1839
+ setIsRunningPredictions(false);
1840
+ }
1841
+ }, [inspectableNodeId, predictionType]);
1842
+
1843
+ const handleTracePath = useCallback(async () => {
1844
+ if (!inspectableNodeId || !pathTargetId.trim()) return;
1845
+ try {
1846
+ const pathParams = new URLSearchParams({
1847
+ source: inspectableNodeId,
1848
+ target: pathTargetId.trim(),
1849
+ algorithm: "dijkstra",
1850
+ });
1851
+ const response = await fetch(
1852
+ `/api/graph/path?${pathParams.toString()}`
1853
+ );
1854
+ if (!response.ok) {
1855
+ throw new Error(`Path lookup failed with status ${response.status}`);
1856
+ }
1857
+ const data: PathResponse = await response.json();
1858
+ setPathResult(data);
1859
+ if (data.path?.length) {
1860
+ const lastStep = data.path[data.path.length - 1];
1861
+ if (graph.hasNode(lastStep)) {
1862
+ sceneRef.current?.focusNode(lastStep);
1863
+ }
1864
+ }
1865
+ } catch (pathError) {
1866
+ console.error("[GraphWorkspace] path trace failed", pathError);
1867
+ setPathResult(null);
1868
+ }
1869
+ }, [inspectableNodeId, pathTargetId]);
1870
+
1871
+ const handleDownloadProvenance = useCallback(async (format: "json" | "markdown") => {
1872
+ if (!inspectableNodeId) return;
1873
+ const suffix = format === "markdown" ? "markdown" : "json";
1874
+ const response = await fetch(`/api/provenance/report?node_id=${encodeURIComponent(inspectableNodeId)}&format=${suffix}`);
1875
+ if (!response.ok) {
1876
+ throw new Error(`Provenance report failed with status ${response.status}`);
1877
+ }
1878
+ const blob = await response.blob();
1879
+ const url = window.URL.createObjectURL(blob);
1880
+ const anchor = document.createElement("a");
1881
+ anchor.href = url;
1882
+ anchor.download = `${inspectableNodeId}_provenance.${format === "markdown" ? "md" : "json"}`;
1883
+ document.body.appendChild(anchor);
1884
+ anchor.click();
1885
+ window.URL.revokeObjectURL(url);
1886
+ document.body.removeChild(anchor);
1887
+ }, [inspectableNodeId]);
1888
+
1889
+ const handleMarkdownApplied = useCallback((result: MarkdownApplyResult) => {
1890
+ if (result.resource.kind !== "context-node") return;
1891
+ if (!graph.hasNode(result.resource.id)) return;
1892
+ const syncGeneration = markdownRefreshGuard.begin(result.resource.id);
1893
+ const attributes = graph.getNodeAttributes(result.resource.id) as NodeAttributes;
1894
+ graph.mergeNodeAttributes(
1895
+ result.resource.id,
1896
+ buildNodeMarkdownAttributeUpdate(
1897
+ result.resource.id,
1898
+ result.body,
1899
+ attributes.properties ?? {},
1900
+ ),
1901
+ );
1902
+ setGraphVersion((current) => current + 1);
1903
+ sceneRef.current?.getRuntime()?.requestRender();
1904
+
1905
+ void readNodeMarkdownAttributeUpdate(result.resource.id)
1906
+ .then((savedAttributes) => {
1907
+ if (
1908
+ !markdownRefreshGuard.isCurrent(result.resource.id, syncGeneration)
1909
+ || !graph.hasNode(result.resource.id)
1910
+ ) return;
1911
+ graph.mergeNodeAttributes(result.resource.id, savedAttributes);
1912
+ setGraphVersion((current) => current + 1);
1913
+ sceneRef.current?.getRuntime()?.requestRender();
1914
+ })
1915
+ .catch((syncError) => {
1916
+ console.error("[GraphWorkspace] applied node refresh failed", syncError);
1917
+ });
1918
+ }, [markdownRefreshGuard]);
1919
+
1920
+ useEffect(() => {
1921
+ const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
1922
+ const socket = new WebSocket(`${protocol}//${window.location.host}/ws/graph-updates`);
1923
+
1924
+ socket.onmessage = (event) => {
1925
+ try {
1926
+ const message = JSON.parse(event.data);
1927
+ if (message.event === "connection_ack") {
1928
+ return;
1929
+ }
1930
+ if (message.event !== "graph_mutation") {
1931
+ return;
1932
+ }
1933
+ const eventType = message.data?.event_type;
1934
+ const payload = message.data?.payload;
1935
+ if (eventType === "ADD_NODE" && payload?.id) {
1936
+ batchMergeNodes([
1937
+ {
1938
+ id: payload.id,
1939
+ attributes: buildRealtimeNodeAttributes(payload),
1940
+ },
1941
+ ]);
1942
+ if (graph.order > SMALL_GRAPH_MAX_NODES) {
1943
+ smallGraphModeRef.current = false;
1944
+ }
1945
+ synchronizeRealtimeSmallGraphEdges(smallGraphModeRef.current);
1946
+ logEvent("add-node", `Added node ${payload.label ?? payload.id}${payload.nodeType ? ` (${payload.nodeType})` : ""} via realtime ws`, { nodeId: payload.id, nodeType: payload.nodeType });
1947
+ setGraphVersion((current) => current + 1);
1948
+ sceneRef.current?.getRuntime()?.requestRender();
1949
+ }
1950
+ if (eventType === "UPDATE_NODE" && payload?.id && graph.hasNode(payload.id)) {
1951
+ markdownRefreshGuard.invalidate(payload.id);
1952
+ const properties = payload.properties ?? {};
1953
+ const current = graph.getNodeAttributes(payload.id) as NodeAttributes;
1954
+ const content = typeof properties.content === "string" ? properties.content : "";
1955
+ graph.mergeNodeAttributes(payload.id, {
1956
+ ...buildNodeMarkdownAttributeUpdate(payload.id, content, properties),
1957
+ nodeType: payload.type ?? current.nodeType,
1958
+ valid_from: properties.valid_from ?? null,
1959
+ valid_until: properties.valid_until ?? null,
1960
+ });
1961
+ logEvent(
1962
+ "update-node",
1963
+ `Updated node ${payload.id} via realtime ws`,
1964
+ { nodeId: payload.id, nodeType: payload.type },
1965
+ );
1966
+ setGraphVersion((version) => version + 1);
1967
+ sceneRef.current?.getRuntime()?.requestRender();
1968
+ }
1969
+ if (eventType === "ADD_EDGE") {
1970
+ const isSmallGraph = smallGraphModeRef.current;
1971
+ batchMergeEdges([
1972
+ {
1973
+ id: String(payload.id),
1974
+ familyId: payload.familyId ? String(payload.familyId) : String(payload.id),
1975
+ source: payload.source_id,
1976
+ target: payload.target_id,
1977
+ attributes: buildRealtimeEdgeAttributes(payload, {
1978
+ isBidirectional: graph.hasDirectedEdge(payload.target_id, payload.source_id),
1979
+ isSmallGraph,
1980
+ }),
1981
+ },
1982
+ ]);
1983
+ logEvent("add-edge", `Added edge ${payload.edgeType ?? payload.id} (${payload.source_id} → ${payload.target_id}) via realtime ws`, { edgeId: payload.id, edgeType: payload.edgeType, source: payload.source_id, target: payload.target_id });
1984
+ setGraphVersion((current) => current + 1);
1985
+ sceneRef.current?.getRuntime()?.requestRender();
1986
+ }
1987
+ } catch (socketError) {
1988
+ console.error("[GraphWorkspace] websocket update failed", socketError);
1989
+ }
1990
+ };
1991
+
1992
+ return () => {
1993
+ socket.close();
1994
+ };
1995
+ }, [markdownRefreshGuard]);
1996
+
1997
+ useEffect(() => {
1998
+ setCollapsedNeighborhoodNodeIds([]);
1999
+ setFocusedNodeId("");
2000
+ setLastGroupedSelectedNodeId("");
2001
+ }, [summary?.edgeCount, summary?.nodeCount]);
2002
+
2003
+ const activeDistanceMode: GraphDistanceVisualMode = egoModeEnabled
2004
+ ? "ego"
2005
+ : heatmapEnabled
2006
+ ? "heatmap"
2007
+ : distanceMode;
2008
+ const distanceAnchorNodeId = viewMode === "full" && selectedNodeId && graph.hasNode(selectedNodeId)
2009
+ ? selectedNodeId
2010
+ : "";
2011
+ const distanceAnchorLabel = distanceAnchorNodeId
2012
+ ? String((graph.getNodeAttributes(distanceAnchorNodeId) as NodeAttributes).label || distanceAnchorNodeId)
2013
+ : null;
2014
+ const distanceMaxHops = activeDistanceMode === "ego"
2015
+ ? egoMaxHops
2016
+ : activeDistanceMode === "heatmap"
2017
+ ? HEATMAP_DISTANCE_MAX_HOPS
2018
+ : STRUCTURAL_DISTANCE_MAX_HOPS;
2019
+ const structuralDistances = useMemo(
2020
+ () => (
2021
+ distanceAnchorNodeId && activeDistanceMode !== "off"
2022
+ ? buildStructuralDistanceSnapshot(graph, distanceAnchorNodeId, distanceMaxHops)
2023
+ : EMPTY_DISTANCE_RECORD
2024
+ ),
2025
+ [activeDistanceMode, distanceAnchorNodeId, distanceMaxHops, graphVersion],
2026
+ );
2027
+ const distanceCounts = useMemo(
2028
+ () => summarizeDistanceBuckets(structuralDistances, graph.order),
2029
+ [structuralDistances, graphVersion],
2030
+ );
2031
+ const heatmapRenderSnapshot = useMemo(
2032
+ () => (
2033
+ activeDistanceMode === "heatmap" && distanceAnchorNodeId
2034
+ ? buildHeatmapRenderSnapshot(graph, distanceAnchorNodeId, structuralDistances, HEATMAP_DISTANCE_MAX_HOPS)
2035
+ : null
2036
+ ),
2037
+ [activeDistanceMode, distanceAnchorNodeId, graphVersion, structuralDistances],
2038
+ );
2039
+
2040
+ useEffect(() => {
2041
+ if (viewMode === "full") {
2042
+ return;
2043
+ }
2044
+ setEgoModeEnabled(false);
2045
+ setHeatmapEnabled(false);
2046
+ setDistanceMode("off");
2047
+ }, [viewMode]);
2048
+
2049
+ useEffect(() => {
2050
+ if (activeDistanceMode === "off" || !selectedNodeId || !graph.hasNode(selectedNodeId)) {
2051
+ setEgoModeEnabled(false);
2052
+ setHeatmapEnabled(false);
2053
+ setDistanceMode("off");
2054
+ }
2055
+ }, [activeDistanceMode, graphVersion, selectedNodeId]);
2056
+
2057
+ useEffect(() => {
2058
+ if (distanceMode !== "semantic" || !distanceAnchorNodeId) {
2059
+ setSemanticDistanceState({
2060
+ anchorNodeId: distanceAnchorNodeId || null,
2061
+ scores: EMPTY_DISTANCE_RECORD,
2062
+ count: 0,
2063
+ status: distanceMode === "semantic" ? "unavailable" : "idle",
2064
+ error: distanceMode === "semantic" ? "Select a Full Graph node to load semantic distance." : null,
2065
+ });
2066
+ return;
2067
+ }
2068
+
2069
+ let cancelled = false;
2070
+ setSemanticDistanceState({
2071
+ anchorNodeId: distanceAnchorNodeId,
2072
+ scores: EMPTY_DISTANCE_RECORD,
2073
+ count: 0,
2074
+ status: "loading",
2075
+ error: null,
2076
+ });
2077
+
2078
+ const loadSemanticNeighborhood = async () => {
2079
+ try {
2080
+ const semanticParams = new URLSearchParams({
2081
+ node_id: distanceAnchorNodeId,
2082
+ top_k: "50",
2083
+ });
2084
+ const response = await fetch(
2085
+ `/api/graph/semantic-neighborhood?${semanticParams.toString()}`,
2086
+ );
2087
+ if (cancelled) {
2088
+ return;
2089
+ }
2090
+ if (!response.ok) {
2091
+ throw new Error(response.status === 503
2092
+ ? "Semantic similarity is unavailable for this graph."
2093
+ : response.status === 404
2094
+ ? "Selected node was not found by the semantic distance API."
2095
+ : `Semantic distance failed with status ${response.status}`);
2096
+ }
2097
+
2098
+ const data: SemanticNeighborhoodResponse = await response.json();
2099
+ const scores = data.neighbors.reduce<Record<string, number>>((nextScores, neighbor) => {
2100
+ if (Number.isFinite(neighbor.similarity)) {
2101
+ nextScores[neighbor.id] = neighbor.similarity;
2102
+ }
2103
+ return nextScores;
2104
+ }, {});
2105
+
2106
+ setSemanticDistanceState({
2107
+ anchorNodeId: distanceAnchorNodeId,
2108
+ scores,
2109
+ count: Object.keys(scores).length,
2110
+ status: Object.keys(scores).length > 0 ? "ready" : "unavailable",
2111
+ error: Object.keys(scores).length > 0 ? null : "No semantic neighbors were returned for this node.",
2112
+ });
2113
+ } catch (error) {
2114
+ if (cancelled) {
2115
+ return;
2116
+ }
2117
+ setSemanticDistanceState({
2118
+ anchorNodeId: distanceAnchorNodeId,
2119
+ scores: EMPTY_DISTANCE_RECORD,
2120
+ count: 0,
2121
+ status: "error",
2122
+ error: error instanceof Error ? error.message : "Semantic distance could not be loaded.",
2123
+ });
2124
+ }
2125
+ };
2126
+
2127
+ void loadSemanticNeighborhood();
2128
+ return () => {
2129
+ cancelled = true;
2130
+ };
2131
+ }, [distanceAnchorNodeId, distanceMode]);
2132
+
2133
+ const distanceVisualState = useMemo<GraphDistanceVisualState>(() => {
2134
+ if (activeDistanceMode === "off") {
2135
+ return {
2136
+ mode: "off",
2137
+ anchorNodeId: null,
2138
+ anchorLabel: null,
2139
+ maxHops: distanceMaxHops,
2140
+ structuralDistances: EMPTY_DISTANCE_RECORD,
2141
+ semanticScores: EMPTY_DISTANCE_RECORD,
2142
+ distanceCounts: undefined,
2143
+ outsideCount: 0,
2144
+ heatmapVisibleNodeIds: undefined,
2145
+ heatmapRingCounts: undefined,
2146
+ heatmapRenderedRingCounts: undefined,
2147
+ heatmapSaturationMode: undefined,
2148
+ semanticNeighborCount: 0,
2149
+ status: "idle",
2150
+ error: null,
2151
+ };
2152
+ }
2153
+
2154
+ if (viewMode !== "full") {
2155
+ return {
2156
+ mode: activeDistanceMode,
2157
+ anchorNodeId: null,
2158
+ anchorLabel: null,
2159
+ maxHops: distanceMaxHops,
2160
+ structuralDistances: EMPTY_DISTANCE_RECORD,
2161
+ semanticScores: EMPTY_DISTANCE_RECORD,
2162
+ distanceCounts: undefined,
2163
+ outsideCount: graph.order,
2164
+ heatmapVisibleNodeIds: undefined,
2165
+ heatmapRingCounts: undefined,
2166
+ heatmapRenderedRingCounts: undefined,
2167
+ heatmapSaturationMode: undefined,
2168
+ semanticNeighborCount: 0,
2169
+ status: "unavailable",
2170
+ error: "Distance intelligence is available in Full Graph mode.",
2171
+ };
2172
+ }
2173
+
2174
+ if (!distanceAnchorNodeId) {
2175
+ return {
2176
+ mode: activeDistanceMode,
2177
+ anchorNodeId: null,
2178
+ anchorLabel: null,
2179
+ maxHops: distanceMaxHops,
2180
+ structuralDistances: EMPTY_DISTANCE_RECORD,
2181
+ semanticScores: EMPTY_DISTANCE_RECORD,
2182
+ distanceCounts: undefined,
2183
+ outsideCount: graph.order,
2184
+ heatmapVisibleNodeIds: undefined,
2185
+ heatmapRingCounts: undefined,
2186
+ heatmapRenderedRingCounts: undefined,
2187
+ heatmapSaturationMode: undefined,
2188
+ semanticNeighborCount: 0,
2189
+ status: "unavailable",
2190
+ error: "Select a node to activate distance intelligence.",
2191
+ };
2192
+ }
2193
+
2194
+ if (activeDistanceMode === "semantic") {
2195
+ return {
2196
+ mode: "semantic",
2197
+ anchorNodeId: distanceAnchorNodeId,
2198
+ anchorLabel: distanceAnchorLabel,
2199
+ maxHops: distanceMaxHops,
2200
+ structuralDistances,
2201
+ semanticScores: semanticDistanceState.anchorNodeId === distanceAnchorNodeId
2202
+ ? semanticDistanceState.scores
2203
+ : EMPTY_DISTANCE_RECORD,
2204
+ distanceCounts,
2205
+ outsideCount: distanceCounts.outside,
2206
+ heatmapVisibleNodeIds: undefined,
2207
+ heatmapRingCounts: undefined,
2208
+ heatmapRenderedRingCounts: undefined,
2209
+ heatmapSaturationMode: undefined,
2210
+ semanticNeighborCount: semanticDistanceState.anchorNodeId === distanceAnchorNodeId
2211
+ ? semanticDistanceState.count
2212
+ : 0,
2213
+ status: semanticDistanceState.anchorNodeId === distanceAnchorNodeId
2214
+ ? semanticDistanceState.status
2215
+ : "loading",
2216
+ error: semanticDistanceState.anchorNodeId === distanceAnchorNodeId
2217
+ ? semanticDistanceState.error
2218
+ : null,
2219
+ };
2220
+ }
2221
+
2222
+ return {
2223
+ mode: activeDistanceMode,
2224
+ anchorNodeId: distanceAnchorNodeId,
2225
+ anchorLabel: distanceAnchorLabel,
2226
+ maxHops: distanceMaxHops,
2227
+ structuralDistances,
2228
+ semanticScores: EMPTY_DISTANCE_RECORD,
2229
+ distanceCounts,
2230
+ outsideCount: distanceCounts.outside,
2231
+ heatmapVisibleNodeIds: heatmapRenderSnapshot?.visibleNodeIds,
2232
+ heatmapRingCounts: heatmapRenderSnapshot?.ringCounts,
2233
+ heatmapRenderedRingCounts: heatmapRenderSnapshot?.renderedRingCounts,
2234
+ heatmapSaturationMode: heatmapRenderSnapshot?.saturationMode,
2235
+ semanticNeighborCount: 0,
2236
+ status: "ready",
2237
+ error: null,
2238
+ };
2239
+ }, [
2240
+ activeDistanceMode,
2241
+ distanceAnchorLabel,
2242
+ distanceAnchorNodeId,
2243
+ distanceCounts,
2244
+ distanceMaxHops,
2245
+ graph.order,
2246
+ heatmapRenderSnapshot,
2247
+ semanticDistanceState,
2248
+ structuralDistances,
2249
+ viewMode,
2250
+ ]);
2251
+
2252
+ const showLoadingOverlay = !graphReady && (isLoading || isFetching || Boolean(loadingProgress) || isGraphLoadError);
2253
+ const showSettlingStatus = graphReady && loadingProgress?.phase === "stabilizing_layout";
2254
+ const hasGraphContent = Boolean(summary?.nodeCount);
2255
+ const activePath = pathResult?.path ?? EMPTY_PATH;
2256
+ const activePathEdgeIds = pathResult?.edge_ids ?? EMPTY_PATH;
2257
+ const structuralSelectedNodeId = useMemo(() => {
2258
+ if (viewMode === "focused") {
2259
+ return focusedNodeId && graph.hasNode(focusedNodeId) ? focusedNodeId : "";
2260
+ }
2261
+ if (!selectedNodeId || !graph.hasNode(selectedNodeId)) {
2262
+ return "";
2263
+ }
2264
+ return collapsedNeighborhoodNodeIds.includes(selectedNodeId) ? selectedNodeId : "";
2265
+ }, [collapsedNeighborhoodNodeIds, focusedNodeId, selectedNodeId, viewMode]);
2266
+ const structuralActivePath = structuralSelectedNodeId ? activePath : EMPTY_PATH;
2267
+ const structuralActivePathEdgeIds = structuralSelectedNodeId ? activePathEdgeIds : EMPTY_PATH;
2268
+ const displayResult = useMemo(() => {
2269
+ // The displayed graph is an aggregated clone. Rebuild it after domain
2270
+ // mutations so applied Markdown labels do not remain stale on the canvas.
2271
+ void graphVersion;
2272
+ return viewMode === "grouped"
2273
+ ? (groupedDisplayCandidate ?? resolveDisplayGraph("", EMPTY_PATH, EMPTY_PATH, "grouped", {
2274
+ aggregationEnabled,
2275
+ collapsedNeighborhoodNodeIds,
2276
+ }))
2277
+ : resolveDisplayGraph(structuralSelectedNodeId, structuralActivePath, structuralActivePathEdgeIds, viewMode, {
2278
+ aggregationEnabled,
2279
+ collapsedNeighborhoodNodeIds,
2280
+ });
2281
+ }, [
2282
+ aggregationEnabled,
2283
+ collapsedNeighborhoodNodeIds,
2284
+ graphVersion,
2285
+ groupedDisplayCandidate,
2286
+ structuralActivePath,
2287
+ structuralActivePathEdgeIds,
2288
+ structuralSelectedNodeId,
2289
+ viewMode,
2290
+ ]);
2291
+ const colorLegendItems = useMemo(() => {
2292
+ // Store mutations can preserve graph identity while changing its attributes.
2293
+ void graphVersion;
2294
+ return buildGraphColorLegend(displayResult.graph);
2295
+ }, [displayResult.graph, graphVersion]);
2296
+ const displayState = useMemo(
2297
+ () => (
2298
+ viewMode === "grouped"
2299
+ ? resolveGroupedDisplayStateSnapshot(displayResult.graph, selectedNodeId, {
2300
+ groupedViewAvailable,
2301
+ groupedViewReason,
2302
+ selectedNodeKind: focusedSelectionResolution.kind,
2303
+ resolvedFocusedNodeId: focusedSelectionResolution.resolvedNodeId,
2304
+ focusedUnavailableReason: focusedSelectionResolution.reason,
2305
+ })
2306
+ : resolveDisplayStateSnapshot(selectedNodeId, activePath, viewMode, {
2307
+ aggregationEnabled,
2308
+ collapsedNeighborhoodNodeIds,
2309
+ groupedViewAvailable,
2310
+ groupedViewReason,
2311
+ selectedNodeKind: focusedSelectionResolution.kind,
2312
+ resolvedFocusedNodeId: focusedSelectionResolution.resolvedNodeId,
2313
+ focusedUnavailableReason: focusedSelectionResolution.reason,
2314
+ })
2315
+ ),
2316
+ [
2317
+ activePath,
2318
+ aggregationEnabled,
2319
+ collapsedNeighborhoodNodeIds,
2320
+ displayResult.graph,
2321
+ focusedSelectionResolution.kind,
2322
+ focusedSelectionResolution.reason,
2323
+ focusedSelectionResolution.resolvedNodeId,
2324
+ groupedViewAvailable,
2325
+ groupedViewReason,
2326
+ selectedNodeId,
2327
+ viewMode,
2328
+ ],
2329
+ );
2330
+ const displayMeta = displayResult.meta;
2331
+ useEffect(() => {
2332
+ if (viewMode === "grouped" && !groupedViewAvailable) {
2333
+ debugGraphWorkspace("grouped-view-reset-to-full", {
2334
+ reason: groupedViewReason,
2335
+ graphVersion,
2336
+ });
2337
+ setViewMode("full");
2338
+ setFocusedNodeId("");
2339
+ setSelectedNodeId((currentSelectedNodeId) => (
2340
+ currentSelectedNodeId && graph.hasNode(currentSelectedNodeId) ? currentSelectedNodeId : ""
2341
+ ));
2342
+ }
2343
+ }, [graphVersion, groupedViewAvailable, groupedViewReason, viewMode]);
2344
+ useEffect(() => {
2345
+ if (viewMode === "focused" && (!focusedNodeId || !graph.hasNode(focusedNodeId))) {
2346
+ setViewMode("full");
2347
+ setFocusedNodeId("");
2348
+ }
2349
+ }, [focusedNodeId, graphVersion, viewMode]);
2350
+ const previousDisplayGraphRef = useRef(displayResult.graph);
2351
+ const previousDisplayStateRef = useRef(displayState);
2352
+ useEffect(() => {
2353
+ const graphRebuilt = previousDisplayGraphRef.current !== displayResult.graph;
2354
+ const displayStateChanged = previousDisplayStateRef.current !== displayState;
2355
+ debugGraphWorkspace("display-state-derived", {
2356
+ selectedNodeId,
2357
+ structuralSelectedNodeId,
2358
+ viewMode,
2359
+ graphRebuilt,
2360
+ displayStateChanged,
2361
+ aggregationEnabled,
2362
+ collapsedNeighborhoodActive: Boolean(structuralSelectedNodeId && collapsedNeighborhoodNodeIds.includes(structuralSelectedNodeId)),
2363
+ });
2364
+ previousDisplayGraphRef.current = displayResult.graph;
2365
+ previousDisplayStateRef.current = displayState;
2366
+ }, [
2367
+ aggregationEnabled,
2368
+ collapsedNeighborhoodNodeIds,
2369
+ displayResult.graph,
2370
+ displayState,
2371
+ selectedNodeId,
2372
+ structuralSelectedNodeId,
2373
+ viewMode,
2374
+ ]);
2375
+ const focusedSummary = useMemo(() => {
2376
+ if (!selectedNodeId || !graph.hasNode(selectedNodeId)) {
2377
+ if (viewMode === "grouped") {
2378
+ return displayState.groupedViewAvailable
2379
+ ? "Communities compressed into grouped structure view"
2380
+ : (groupedViewReasonText(displayState.groupedViewReason) ?? groupedViewReasonText({ code: "communities-undetected" }));
2381
+ }
2382
+ return null;
2383
+ }
2384
+
2385
+ const localNeighborCount = graph.neighbors(selectedNodeId).length;
2386
+ if (viewMode === "focused") {
2387
+ const visibleNeighbors = displayState.selectedVisibleNeighborIds.length || Math.min(localNeighborCount, 16);
2388
+ return `${visibleNeighbors + 1} nodes in focused view`;
2389
+ }
2390
+
2391
+ if (viewMode === "grouped") {
2392
+ return "Grouped structure view with direct community drill-in";
2393
+ }
2394
+
2395
+ if (displayState.selectedCollapsedNeighborIds.length > 0) {
2396
+ return `${displayState.selectedVisibleNeighborIds.length} visible neighbors, ${displayState.selectedCollapsedNeighborIds.length} collapsed`;
2397
+ }
2398
+
2399
+ return `${localNeighborCount} direct neighbors highlighted`;
2400
+ }, [displayState, selectedNodeId, viewMode]);
2401
+ const graphSummary = summary as GraphLoadSummary | null;
2402
+ const selectedNodeState = useMemo(
2403
+ () => buildSelectedNodeState(selectedNodeId, displayState),
2404
+ [displayState, selectedNodeId, summary?.nodeCount, summary?.edgeCount],
2405
+ );
2406
+ const selectedEdgeState = useMemo(
2407
+ () => buildSelectedEdgeState(selectedEdgeId, displayResult.graph),
2408
+ [displayResult.graph, selectedEdgeId, summary?.nodeCount, summary?.edgeCount],
2409
+ );
2410
+ const temporalState = useMemo(
2411
+ () => ({
2412
+ currentTime: scrubberTime,
2413
+ activeNodeCount,
2414
+ minDate: temporalBounds?.min ?? undefined,
2415
+ maxDate: temporalBounds?.max ?? undefined,
2416
+ }),
2417
+ [activeNodeCount, scrubberTime, temporalBounds?.max, temporalBounds?.min],
2418
+ );
2419
+
2420
+ const pluginRegistry = useMemo<LazyPluginRegistryEntry[]>(
2421
+ () => [
2422
+ {
2423
+ id: "exploration-effects",
2424
+ panelId: "effects-panel",
2425
+ label: "Effects",
2426
+ title: "Open exploration effects controls",
2427
+ order: 18,
2428
+ load: loadExplorationEffectsPlugin,
2429
+ shouldLoad: explorationEffectsShouldLoad,
2430
+ },
2431
+ {
2432
+ id: "neighborhood-panel",
2433
+ panelId: "neighborhood-panel",
2434
+ label: "Neighbors",
2435
+ title: "Toggle neighborhood panel",
2436
+ order: 30,
2437
+ load: loadNeighborhoodPanelPlugin,
2438
+ shouldLoad: neighborhoodPanelShouldLoad,
2439
+ },
2440
+ {
2441
+ id: "temporal-overlay",
2442
+ panelId: "temporal-panel",
2443
+ label: "Temporal",
2444
+ title: "Toggle temporal context panel",
2445
+ order: 40,
2446
+ load: loadTemporalOverlayPlugin,
2447
+ shouldLoad: temporalOverlayShouldLoad,
2448
+ },
2449
+ ],
2450
+ [],
2451
+ );
2452
+ const activePlugins = useMemo(
2453
+ () => pluginRegistry.map((entry) => loadedPlugins[entry.id]).filter((plugin): plugin is GraphPlugin => Boolean(plugin)),
2454
+ [loadedPlugins, pluginRegistry],
2455
+ );
2456
+
2457
+ useEffect(() => {
2458
+ let cancelled = false;
2459
+
2460
+ pluginRegistry.forEach((entry) => {
2461
+ if (loadedPlugins[entry.id]) {
2462
+ return;
2463
+ }
2464
+
2465
+ if (!entry.shouldLoad({ panelState: pluginPanelState })) {
2466
+ return;
2467
+ }
2468
+
2469
+ void entry.load()
2470
+ .then((plugin) => {
2471
+ if (cancelled) {
2472
+ return;
2473
+ }
2474
+ setLoadedPlugins((current) => (current[entry.id] ? current : { ...current, [entry.id]: plugin }));
2475
+ })
2476
+ .catch((error) => {
2477
+ console.error(`[GraphPlugin:${entry.id}] lazy load failed`, error);
2478
+ });
2479
+ });
2480
+
2481
+ return () => {
2482
+ cancelled = true;
2483
+ };
2484
+ }, [loadedPlugins, pluginPanelState, pluginRegistry]);
2485
+
2486
+ const setEffectToggle = useCallback((effect: GraphEffectToggle, enabled: boolean | ((current: boolean) => boolean)) => {
2487
+ setEffectsState((current) => {
2488
+ const nextValue = typeof enabled === "function" ? enabled(current[effect]) : enabled;
2489
+ if (effect === "diagnosticsEnabled" && !GRAPH_THEME.effects.diagnostics.enabledInDev) {
2490
+ return current;
2491
+ }
2492
+ if (current[effect] === nextValue) {
2493
+ return current;
2494
+ }
2495
+ return {
2496
+ ...current,
2497
+ [effect]: nextValue,
2498
+ };
2499
+ });
2500
+ }, []);
2501
+
2502
+ const handlePluginAction = useCallback((action: GraphPluginActionRequest) => {
2503
+ switch (action.type) {
2504
+ case "fitView":
2505
+ sceneRef.current?.fitView();
2506
+ return;
2507
+ case "focusNode":
2508
+ sceneRef.current?.focusNode(action.nodeId);
2509
+ return;
2510
+ case "selectNode":
2511
+ focusNode(action.nodeId);
2512
+ return;
2513
+ case "setViewMode":
2514
+ setLayoutViewMode(action.viewMode);
2515
+ return;
2516
+ case "enterLocalGraph":
2517
+ enterLocalGraph(action.nodeId);
2518
+ return;
2519
+ case "collapseNeighborhood":
2520
+ if (!selectedNodeId) {
2521
+ return;
2522
+ }
2523
+ setCollapsedNeighborhoodNodeIds((current) => (
2524
+ current.includes(selectedNodeId) ? current : [...current, selectedNodeId]
2525
+ ));
2526
+ return;
2527
+ case "expandNeighborhood":
2528
+ if (!selectedNodeId) {
2529
+ return;
2530
+ }
2531
+ setCollapsedNeighborhoodNodeIds((current) => current.filter((nodeId) => nodeId !== selectedNodeId));
2532
+ return;
2533
+ case "toggleEffect":
2534
+ setEffectToggle(action.effect, (current) => !current);
2535
+ return;
2536
+ case "setEffect":
2537
+ setEffectToggle(action.effect, action.enabled);
2538
+ return;
2539
+ case "togglePanel":
2540
+ setPluginPanelState((current) => {
2541
+ const nextOpen = !current[action.panelId];
2542
+ setActiveDockPanelId((previous) => {
2543
+ if (nextOpen) {
2544
+ return action.panelId;
2545
+ }
2546
+ return previous === action.panelId ? null : previous;
2547
+ });
2548
+ return {
2549
+ ...current,
2550
+ [action.panelId]: nextOpen,
2551
+ };
2552
+ });
2553
+ return;
2554
+ case "openPanel":
2555
+ setActiveDockPanelId(action.panelId);
2556
+ setPluginPanelState((current) => ({
2557
+ ...current,
2558
+ [action.panelId]: true,
2559
+ }));
2560
+ return;
2561
+ case "closePanel":
2562
+ setPluginPanelState((current) => ({
2563
+ ...current,
2564
+ [action.panelId]: false,
2565
+ }));
2566
+ setActiveDockPanelId((previous) => (previous === action.panelId ? null : previous));
2567
+ return;
2568
+ }
2569
+ }, [enterLocalGraph, focusNode, selectedNodeId, setEffectToggle, setLayoutViewMode]);
2570
+
2571
+ const diagnosticsSnapshot = useMemo<GraphDiagnosticsSnapshot | null>(() => {
2572
+ if (!GRAPH_THEME.effects.diagnostics.enabledInDev || !graphDiagnosticsState) {
2573
+ return null;
2574
+ }
2575
+
2576
+ return {
2577
+ interactionState: pluginInteractionStateRef.current,
2578
+ activePluginIds: activePlugins.map((plugin) => plugin.id),
2579
+ openPanelIds: Object.entries(pluginPanelState)
2580
+ .filter(([, isOpen]) => isOpen)
2581
+ .map(([panelId]) => panelId),
2582
+ effectsState,
2583
+ edgeClasses: graphDiagnosticsState.edgeClasses,
2584
+ distanceVisual: graphDiagnosticsState.distanceVisual,
2585
+ effectAvailability: graphDiagnosticsState.effectAvailability,
2586
+ };
2587
+ }, [activePlugins, effectsState, graphDiagnosticsState, pluginPanelState]);
2588
+
2589
+ const pluginContext = useMemo<GraphPluginContext>(() => ({
2590
+ get scene() {
2591
+ return pluginRuntimeRef.current;
2592
+ },
2593
+ get graph() {
2594
+ return graph;
2595
+ },
2596
+ get displayGraph() {
2597
+ return pluginRuntimeRef.current?.displayGraph ?? graph;
2598
+ },
2599
+ theme: GRAPH_THEME,
2600
+ getInteractionState: () => pluginInteractionStateRef.current,
2601
+ getSelectedNodeState: () => selectedNodeState,
2602
+ getInspectorState: () => ({
2603
+ selectedNodeId: selectedNodeId || null,
2604
+ ownsSelectionDetails: true,
2605
+ }),
2606
+ getGraphSummary: () => graphSummary,
2607
+ getTemporalState: () => temporalState,
2608
+ getEffectsState: () => effectsState,
2609
+ getDiagnosticsSnapshot: () => diagnosticsSnapshot,
2610
+ getAnalyticsSnapshot: () => graphAnalyticsState,
2611
+ getDisplayState: () => displayState,
2612
+ isPanelOpen: (panelId: string) => Boolean(pluginPanelState[panelId]),
2613
+ dispatchAction: handlePluginAction,
2614
+ }), [
2615
+ displayState,
2616
+ graphAnalyticsState,
2617
+ diagnosticsSnapshot,
2618
+ effectsState,
2619
+ graphSummary,
2620
+ handlePluginAction,
2621
+ pluginPanelState,
2622
+ selectedNodeId,
2623
+ selectedNodeState,
2624
+ temporalState,
2625
+ ]);
2626
+
2627
+ const handleSceneRuntimeChange = useCallback((runtime: GraphSceneRuntime | null) => {
2628
+ pluginRuntimeRef.current = runtime;
2629
+ if (!runtime) {
2630
+ setGraphAnalyticsState(null);
2631
+ }
2632
+ setPluginRuntimeVersion((version) => version + 1);
2633
+ }, []);
2634
+
2635
+ const handleInteractionStateChange = useCallback((interactionState: GraphInteractionState) => {
2636
+ pluginInteractionStateRef.current = interactionState;
2637
+ for (const plugin of activePlugins) {
2638
+ try {
2639
+ plugin.onStateChange(pluginContext, interactionState);
2640
+ } catch (error) {
2641
+ console.error(`[GraphPlugin:${plugin.id}] state update failed`, error);
2642
+ }
2643
+ }
2644
+ }, [activePlugins, pluginContext]);
2645
+
2646
+ const handleDiagnosticsChange = useCallback((diagnostics: GraphRuntimeDiagnosticsSnapshot) => {
2647
+ if (!GRAPH_THEME.effects.diagnostics.enabledInDev) {
2648
+ return;
2649
+ }
2650
+
2651
+ // Compare against the last accepted snapshot synchronously before calling
2652
+ // setState. buildEffectAvailability always returns a new object, so an
2653
+ // unconditional setGraphDiagnosticsState on every call created a
2654
+ // render → diagnostics effect → setState → render cycle that exceeded
2655
+ // React's max update depth in dev mode (issue #830).
2656
+ const prev = lastDiagnosticsRef.current;
2657
+ if (prev !== null) {
2658
+ const EFFECT_KEYS = [
2659
+ "pathPulse", "pathFlow", "lens", "temporalEmphasis", "semanticRegions",
2660
+ "contours", "pathfinding", "communities", "centrality", "legend", "diagnostics",
2661
+ ] as const;
2662
+ const prevEA = prev.effectAvailability;
2663
+ const nextEA = diagnostics.effectAvailability;
2664
+ const availabilityChanged = EFFECT_KEYS.some((key) => {
2665
+ const p = prevEA[key];
2666
+ const n = nextEA[key];
2667
+ return (
2668
+ p.enabled !== n.enabled ||
2669
+ p.available !== n.available ||
2670
+ p.reason !== n.reason ||
2671
+ p.detail !== n.detail ||
2672
+ p.visibleSegments !== n.visibleSegments ||
2673
+ p.segmentCap !== n.segmentCap
2674
+ );
2675
+ });
2676
+
2677
+ const edgeClassesChanged =
2678
+ prev.edgeClasses?.updatedAt !== diagnostics.edgeClasses?.updatedAt;
2679
+
2680
+ const structureLayerChanged =
2681
+ prev.structureLayer?.cacheKey !== diagnostics.structureLayer?.cacheKey ||
2682
+ prev.structureLayer?.lastDrawAt !== diagnostics.structureLayer?.lastDrawAt ||
2683
+ prev.structureLayer?.enabled !== diagnostics.structureLayer?.enabled ||
2684
+ prev.structureLayer?.disabledReason !== diagnostics.structureLayer?.disabledReason ||
2685
+ prev.structureLayer?.curveCount !== diagnostics.structureLayer?.curveCount ||
2686
+ prev.structureLayer?.bridgeCurveCount !== diagnostics.structureLayer?.bridgeCurveCount ||
2687
+ prev.structureLayer?.backboneCurveCount !== diagnostics.structureLayer?.backboneCurveCount;
2688
+
2689
+ // distanceVisual is compared by reference: GraphCanvas passes the same
2690
+ // object when distances haven't changed.
2691
+ const distanceVisualChanged = prev.distanceVisual !== diagnostics.distanceVisual;
2692
+
2693
+ if (!availabilityChanged && !edgeClassesChanged && !structureLayerChanged && !distanceVisualChanged) {
2694
+ return;
2695
+ }
2696
+ }
2697
+
2698
+ lastDiagnosticsRef.current = diagnostics;
2699
+ setGraphDiagnosticsState(diagnostics);
2700
+ }, []);
2701
+
2702
+ const handleAnalyticsChange = useCallback((analytics: GraphAnalyticsSnapshot | null) => {
2703
+ setGraphAnalyticsState(analytics);
2704
+ }, []);
2705
+
2706
+ useEffect(() => {
2707
+ if (!pluginRuntimeRef.current) {
2708
+ return;
2709
+ }
2710
+
2711
+ const mountedPlugins: GraphPlugin[] = [];
2712
+ for (const plugin of activePlugins) {
2713
+ try {
2714
+ plugin.mount(pluginContext);
2715
+ mountedPlugins.push(plugin);
2716
+ } catch (error) {
2717
+ console.error(`[GraphPlugin:${plugin.id}] mount failed`, error);
2718
+ }
2719
+ }
2720
+
2721
+ return () => {
2722
+ for (const plugin of mountedPlugins.reverse()) {
2723
+ try {
2724
+ plugin.unmount(pluginContext);
2725
+ } catch (error) {
2726
+ console.error(`[GraphPlugin:${plugin.id}] unmount failed`, error);
2727
+ }
2728
+ }
2729
+ };
2730
+ }, [activePlugins, pluginContext, pluginRuntimeVersion]);
2731
+
2732
+ const pluginToolbarItems = useMemo<GraphPluginToolbarItem[]>(
2733
+ () => pluginRegistry.map((entry) => ({
2734
+ id: `${entry.id}-toggle`,
2735
+ label: entry.label,
2736
+ title: entry.title,
2737
+ active: pluginContext.isPanelOpen(entry.panelId),
2738
+ order: entry.order,
2739
+ onClick: () => pluginContext.dispatchAction({ type: "togglePanel", panelId: entry.panelId }),
2740
+ })),
2741
+ [pluginContext, pluginRegistry],
2742
+ );
2743
+ const pluginPanels = useMemo(
2744
+ () => collectPluginPanels(activePlugins, pluginContext),
2745
+ [activePlugins, pluginContext],
2746
+ );
2747
+ const pluginOverlays = useMemo(
2748
+ () => collectPluginOverlays(activePlugins, pluginContext),
2749
+ [activePlugins, pluginContext],
2750
+ );
2751
+ const pendingDockPanels = useMemo<GraphPluginPanelDescriptor[]>(
2752
+ () => pluginRegistry
2753
+ .filter((entry) => pluginPanelState[entry.panelId] && !loadedPlugins[entry.id])
2754
+ .map((entry) => ({
2755
+ id: entry.panelId,
2756
+ title: entry.label,
2757
+ placement: "bottom" as const,
2758
+ order: entry.order,
2759
+ content: <div style={pluginLoadingStyle}>Loading {entry.label.toLowerCase()}…</div>,
2760
+ })),
2761
+ [loadedPlugins, pluginPanelState, pluginRegistry],
2762
+ );
2763
+ const dockPanels = [...pluginPanels, ...pendingDockPanels]
2764
+ .filter((panel) => panel.placement === "bottom" || panel.placement === "side")
2765
+ .sort((left, right) => (left.order ?? 0) - (right.order ?? 0));
2766
+ const openDockPanels = dockPanels.filter((panel) => pluginPanelState[panel.id]);
2767
+ const activeDockPanel =
2768
+ openDockPanels.find((panel) => panel.id === activeDockPanelId)
2769
+ ?? openDockPanels[0]
2770
+ ?? null;
2771
+ const layoutState: ExploreLayoutState = {
2772
+ showInspector: Boolean(selectedNodeId),
2773
+ showPluginDock: openDockPanels.length > 0,
2774
+ };
2775
+
2776
+ const openDockPanelIdsString = openDockPanels.map((p) => p.id).join("|");
2777
+ const [prevOpenDockPanelIdsString, setPrevOpenDockPanelIdsString] = useState(openDockPanelIdsString);
2778
+
2779
+ if (openDockPanelIdsString !== prevOpenDockPanelIdsString) {
2780
+ setPrevOpenDockPanelIdsString(openDockPanelIdsString);
2781
+ if (!openDockPanels.length) {
2782
+ if (activeDockPanelId !== null) setActiveDockPanelId(null);
2783
+ } else if (!activeDockPanelId || !openDockPanels.some((panel) => panel.id === activeDockPanelId)) {
2784
+ setActiveDockPanelId(openDockPanels[0].id);
2785
+ }
2786
+ }
2787
+
2788
+ const viewModeItems = useMemo<GraphToolbarItem[]>(() => {
2789
+ if (!hasGraphContent) {
2790
+ return [];
2791
+ }
2792
+
2793
+ return [
2794
+ {
2795
+ id: "view-full",
2796
+ label: "Full Graph",
2797
+ title: "Return to the full graph context",
2798
+ icon: Layers3,
2799
+ active: viewMode === "full",
2800
+ onClick: () => setLayoutViewMode("full"),
2801
+ },
2802
+ {
2803
+ id: "view-grouped",
2804
+ label: "Grouped View",
2805
+ title: displayState.groupedViewAvailable
2806
+ ? "Compress dense structure into detected communities"
2807
+ : (groupedViewReasonText(displayState.groupedViewReason)
2808
+ ?? groupedViewReasonText({ code: "communities-undetected" })
2809
+ ?? undefined),
2810
+ icon: GitBranch,
2811
+ active: viewMode === "grouped",
2812
+ disabled: !displayState.groupedViewAvailable,
2813
+ onClick: () => setLayoutViewMode("grouped"),
2814
+ },
2815
+ {
2816
+ id: "view-focused",
2817
+ label: "Focus",
2818
+ title: canActivateFocusedMode
2819
+ ? "Inspect the selected node in a focused local graph"
2820
+ : (focusedUnavailableReasonText(focusedSelectionResolution.reason) ?? undefined),
2821
+ icon: Focus,
2822
+ active: viewMode === "focused",
2823
+ disabled: viewMode !== "focused" && !canActivateFocusedMode,
2824
+ onClick: () => {
2825
+ const nodeId = focusedSelectionResolution.resolvedNodeId;
2826
+ if (nodeId) {
2827
+ enterLocalGraph(nodeId);
2828
+ }
2829
+ },
2830
+ },
2831
+ ];
2832
+ }, [
2833
+ canActivateFocusedMode,
2834
+ displayState.groupedViewAvailable,
2835
+ displayState.groupedViewReason,
2836
+ enterLocalGraph,
2837
+ focusedSelectionResolution.reason,
2838
+ focusedSelectionResolution.resolvedNodeId,
2839
+ hasGraphContent,
2840
+ setLayoutViewMode,
2841
+ viewMode,
2842
+ ]);
2843
+
2844
+ const cameraToolbarItems = useMemo<GraphToolbarItem[]>(() => [
2845
+ {
2846
+ id: "zoom-in",
2847
+ label: "Zoom In",
2848
+ title: "Zoom in (or scroll up on the canvas)",
2849
+ ariaLabel: "Zoom in",
2850
+ icon: ZoomIn,
2851
+ compact: true,
2852
+ onClick: () => sceneRef.current?.zoomIn(),
2853
+ },
2854
+ {
2855
+ id: "zoom-out",
2856
+ label: "Zoom Out",
2857
+ title: "Zoom out (or scroll down on the canvas)",
2858
+ ariaLabel: "Zoom out",
2859
+ icon: ZoomOut,
2860
+ compact: true,
2861
+ onClick: () => sceneRef.current?.zoomOut(),
2862
+ },
2863
+ {
2864
+ id: "fit-view",
2865
+ label: "Fit",
2866
+ title: "Reset the camera to fit the whole graph",
2867
+ ariaLabel: "Fit view",
2868
+ icon: Maximize2,
2869
+ compact: true,
2870
+ onClick: () => sceneRef.current?.fitView(),
2871
+ },
2872
+ ], []);
2873
+
2874
+ const layoutToolbarItems = useMemo<GraphToolbarItem[]>(() => [
2875
+ {
2876
+ id: "layout-toggle",
2877
+ label: isLayoutRunning ? "Pause" : "Run",
2878
+ title: "Toggle the layout worker",
2879
+ icon: isLayoutRunning ? Pause : Play,
2880
+ active: isLayoutRunning,
2881
+ disabled: showLoadingOverlay,
2882
+ onClick: () => setIsLayoutRunning((value) => !value),
2883
+ },
2884
+ ], [isLayoutRunning, showLoadingOverlay]);
2885
+
2886
+ const localToolbarItems = useMemo<GraphToolbarItem[]>(() => {
2887
+ if (!selectedNodeState) {
2888
+ return [];
2889
+ }
2890
+
2891
+ return [
2892
+ {
2893
+ id: "collapse-neighborhood",
2894
+ label: "Collapse",
2895
+ title: "Hide lower-priority fanout around the selected node",
2896
+ icon: Eye,
2897
+ disabled: !selectedNodeState.canCollapseNeighborhood || selectedNodeState.isNeighborhoodCollapsed,
2898
+ onClick: () => handlePluginAction({ type: "collapseNeighborhood" }),
2899
+ },
2900
+ {
2901
+ id: "expand-neighborhood",
2902
+ label: "Expand",
2903
+ title: "Restore the collapsed local neighborhood",
2904
+ icon: Users,
2905
+ disabled: !selectedNodeState.isNeighborhoodCollapsed,
2906
+ onClick: () => handlePluginAction({ type: "expandNeighborhood" }),
2907
+ },
2908
+ ];
2909
+ }, [handlePluginAction, selectedNodeState]);
2910
+
2911
+ const analysisToolbarItems = useMemo<GraphToolbarItem[]>(
2912
+ () => pluginToolbarItems.map((item) => ({
2913
+ id: item.id,
2914
+ label: item.label,
2915
+ title: item.title,
2916
+ icon: iconForPluginToolbarItem(item),
2917
+ active: item.active,
2918
+ onClick: item.onClick,
2919
+ })),
2920
+ [pluginToolbarItems],
2921
+ );
2922
+
2923
+ const utilityToolbarItems = useMemo<GraphToolbarItem[]>(() => [
2924
+ {
2925
+ id: "reload",
2926
+ label: "Reload",
2927
+ title: "Reload the graph data",
2928
+ ariaLabel: "Reload graph data",
2929
+ icon: RefreshCw,
2930
+ compact: true,
2931
+ disabled: showLoadingOverlay,
2932
+ onClick: reload,
2933
+ },
2934
+ ], [reload, showLoadingOverlay]);
2935
+
2936
+ const searchDisabled = showLoadingOverlay || !searchQuery.trim();
2937
+
2938
+ const distanceToolbarItems = useMemo<GraphToolbarItem[]>(() => {
2939
+ if (!hasGraphContent || viewMode !== "full" || !selectedNodeId || !graph.hasNode(selectedNodeId)) {
2940
+ return [];
2941
+ }
2942
+
2943
+ return [
2944
+ {
2945
+ id: "ego-mode",
2946
+ label: egoModeEnabled ? `Ego (${egoMaxHops}h)` : "Ego Mode",
2947
+ title: egoModeEnabled
2948
+ ? `Egocentric view: ${egoMaxHops} hops depth (click to toggle off)`
2949
+ : "Show depth-of-field fading around the selected node",
2950
+ active: egoModeEnabled,
2951
+ onClick: () => {
2952
+ setEgoModeEnabled((v) => !v);
2953
+ setHeatmapEnabled(false);
2954
+ setDistanceMode("off");
2955
+ },
2956
+ },
2957
+ {
2958
+ id: "heatmap",
2959
+ label: "Heatmap",
2960
+ title: heatmapEnabled
2961
+ ? "Distance heatmap active (click to toggle off)"
2962
+ : "Color nodes by hop distance from selected node",
2963
+ active: heatmapEnabled,
2964
+ onClick: () => {
2965
+ setHeatmapEnabled((v) => !v);
2966
+ setEgoModeEnabled(false);
2967
+ setDistanceMode("off");
2968
+ },
2969
+ },
2970
+ {
2971
+ id: "dist-structural",
2972
+ label: "Structural",
2973
+ title: "Color edges by structural (hop) distance",
2974
+ active: distanceMode === "structural",
2975
+ onClick: () => {
2976
+ setEgoModeEnabled(false);
2977
+ setHeatmapEnabled(false);
2978
+ setDistanceMode((m) => (m === "structural" ? "off" : "structural"));
2979
+ },
2980
+ },
2981
+ {
2982
+ id: "dist-semantic",
2983
+ label: "Semantic",
2984
+ title: "Color edges by semantic similarity to selected node",
2985
+ active: distanceMode === "semantic",
2986
+ onClick: () => {
2987
+ setEgoModeEnabled(false);
2988
+ setHeatmapEnabled(false);
2989
+ setDistanceMode((m) => (m === "semantic" ? "off" : "semantic"));
2990
+ },
2991
+ },
2992
+ ];
2993
+ }, [distanceMode, egoMaxHops, egoModeEnabled, hasGraphContent, heatmapEnabled, selectedNodeId, viewMode]);
2994
+
2995
+ const toolbarClusters = useMemo<GraphToolbarGroup[]>(() => [
2996
+ {
2997
+ id: "camera",
2998
+ label: "Camera",
2999
+ items: cameraToolbarItems,
3000
+ },
3001
+ {
3002
+ id: "layout",
3003
+ label: "Layout",
3004
+ items: layoutToolbarItems,
3005
+ },
3006
+ {
3007
+ id: "local-structure",
3008
+ label: "Local",
3009
+ items: localToolbarItems,
3010
+ },
3011
+ {
3012
+ id: "distance",
3013
+ label: "Distance",
3014
+ items: distanceToolbarItems,
3015
+ },
3016
+ {
3017
+ id: "analysis",
3018
+ label: "Analysis",
3019
+ items: analysisToolbarItems,
3020
+ },
3021
+ {
3022
+ id: "utility",
3023
+ label: "Utility",
3024
+ items: utilityToolbarItems,
3025
+ },
3026
+ ].filter((group) => group.items.length > 0), [
3027
+ analysisToolbarItems,
3028
+ cameraToolbarItems,
3029
+ distanceToolbarItems,
3030
+ layoutToolbarItems,
3031
+ localToolbarItems,
3032
+ utilityToolbarItems,
3033
+ ]);
3034
+
3035
+ const sceneAdapterProps = {
3036
+ onNodeSelect: focusNode,
3037
+ onEdgeSelect: handleEdgeSelect,
3038
+ graphVersion,
3039
+ graphReady,
3040
+ displayGraph: displayResult.graph,
3041
+ displayMeta,
3042
+ displayState,
3043
+ selectedNodeId,
3044
+ focusedNodeId,
3045
+ selectedEdgeId,
3046
+ activePath,
3047
+ activePathEdgeIds,
3048
+ distanceVisualState,
3049
+ effectsState,
3050
+ temporalState,
3051
+ isLayoutRunning,
3052
+ layoutSource: graphSummary?.layoutSource,
3053
+ viewMode,
3054
+ showFitViewButton: false,
3055
+ pluginOverlays: pluginOverlays.map((overlay) => overlay.element),
3056
+ onRuntimeChange: handleSceneRuntimeChange,
3057
+ onInteractionStateChange: handleInteractionStateChange,
3058
+ onDiagnosticsChange: handleDiagnosticsChange,
3059
+ onAnalyticsChange: handleAnalyticsChange,
3060
+ } as const;
3061
+ const showDistanceStatus = distanceVisualState.mode !== "off";
3062
+ const distanceReachableCount = Object.keys(distanceVisualState.structuralDistances).length;
3063
+ const visibleDistanceCounts = distanceVisualState.distanceCounts;
3064
+ const renderedHeatmapCounts = distanceVisualState.heatmapRenderedRingCounts;
3065
+ const formatRenderedCount = (truth: number, rendered: number | undefined) => {
3066
+ if (rendered == null || rendered >= truth) {
3067
+ return truth.toLocaleString();
3068
+ }
3069
+ return `${truth.toLocaleString()} (${rendered.toLocaleString()} shown)`;
3070
+ };
3071
+ const heatmapDistanceSummary = visibleDistanceCounts
3072
+ ? [
3073
+ `${visibleDistanceCounts.anchor.toLocaleString()} anchor`,
3074
+ `${formatRenderedCount(visibleDistanceCounts.oneHop, renderedHeatmapCounts?.oneHop)} 1-hop`,
3075
+ `${formatRenderedCount(visibleDistanceCounts.twoHop, renderedHeatmapCounts?.twoHop)} 2-hop`,
3076
+ `${formatRenderedCount(visibleDistanceCounts.threeHopPlus, renderedHeatmapCounts?.threeHopPlus)} 3+ hop`,
3077
+ `${visibleDistanceCounts.outside.toLocaleString()} outside`,
3078
+ distanceVisualState.heatmapSaturationMode === "sampled" ? "Sampled for readability" : "",
3079
+ ].filter(Boolean).join(" · ")
3080
+ : `${distanceReachableCount.toLocaleString()} nodes within ${distanceVisualState.maxHops} hops`;
3081
+ const heatmapRenderedSummary = distanceVisualState.mode === "heatmap" && renderedHeatmapCounts
3082
+ ? [
3083
+ `${renderedHeatmapCounts.anchor.toLocaleString()} anchor shown`,
3084
+ `${renderedHeatmapCounts.oneHop.toLocaleString()} 1-hop shown`,
3085
+ `${renderedHeatmapCounts.twoHop.toLocaleString()} 2-hop shown`,
3086
+ `${renderedHeatmapCounts.threeHopPlus.toLocaleString()} 3+ hop shown`,
3087
+ ].join(" · ")
3088
+ : null;
3089
+ const distanceLegendItems = distanceVisualState.mode === "heatmap"
3090
+ ? [
3091
+ { label: "Anchor", color: getDistanceBandColor(0) },
3092
+ { label: "1", color: getDistanceBandColor(1) },
3093
+ { label: "2", color: getDistanceBandColor(2) },
3094
+ { label: "3", color: getDistanceBandColor(3) },
3095
+ { label: "Outside", color: withAlpha(GRAPH_THEME.palette.overview.nodeMuted, 0.38) },
3096
+ ]
3097
+ : [
3098
+ { label: "0h", color: getDistanceBandColor(0) },
3099
+ { label: "1h", color: getDistanceBandColor(1) },
3100
+ { label: "2-3h", color: getDistanceBandColor(3) },
3101
+ { label: "4-6h", color: getDistanceBandColor(6) },
3102
+ ];
3103
+
3104
+ return (
3105
+ <div className="palantir-bg" style={{ position: "relative", width: "100%", height: "100%", overflow: "hidden" }}>
3106
+ <style>{HUD_CSS}</style>
3107
+ <div className="palantir-grid" />
3108
+ <div className="palantir-vignette" />
3109
+
3110
+ <div className="explore-shell">
3111
+ <section className="explore-command-deck">
3112
+ <SurfaceCard tone="subtle">
3113
+ <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
3114
+ <div className="explore-toolbar">
3115
+ <div className="explore-status-strip">
3116
+ {(showLoadingOverlay || showSettlingStatus) && loadingProgress ? (
3117
+ <MetricChip>{getGraphLoadTitle(loadingProgress.phase)}</MetricChip>
3118
+ ) : null}
3119
+ {summary ? (
3120
+ <MetricChip>{summary.nodeCount.toLocaleString()} nodes · {summary.edgeCount.toLocaleString()} edges</MetricChip>
3121
+ ) : null}
3122
+ {activeNodeCount !== null ? (
3123
+ <MetricChip tone="success">{activeNodeCount.toLocaleString()} active</MetricChip>
3124
+ ) : null}
3125
+ {focusedSummary ? <MetricChip tone="warm">{focusedSummary}</MetricChip> : null}
3126
+ </div>
3127
+ <div className="explore-workflow-bar">
3128
+ <SearchCommandBar
3129
+ value={searchQuery}
3130
+ disabled={searchDisabled}
3131
+ onChange={setSearchQuery}
3132
+ onSubmit={() => void handleSearch()}
3133
+ onSelectSuggestion={(result) => {
3134
+ setSearchQuery("");
3135
+ focusNode(result.node.id);
3136
+ }}
3137
+ />
3138
+ <SegmentedModeControl items={viewModeItems} />
3139
+ <div className="explore-toolbelt">
3140
+ {toolbarClusters.map((group) => (
3141
+ <ToolbarCluster
3142
+ key={group.id}
3143
+ label={group.label ?? group.id}
3144
+ items={group.items}
3145
+ compact={COMPACT_TOOLBAR_CLUSTER_IDS.has(group.id)}
3146
+ />
3147
+ ))}
3148
+ </div>
3149
+ </div>
3150
+ {!showDistanceStatus ? <SemanticColorLegend items={colorLegendItems} /> : null}
3151
+ </div>
3152
+
3153
+ {egoModeEnabled && (
3154
+ <div style={{ display: "flex", alignItems: "center", gap: 10, fontSize: 12, color: "#a0b4cc" }}>
3155
+ <span style={{ fontWeight: 600, color: "#ff8fa0" }}>Ego depth:</span>
3156
+ <input
3157
+ type="range"
3158
+ min={1}
3159
+ max={8}
3160
+ value={egoMaxHops}
3161
+ onChange={(e) => setEgoMaxHops(Number(e.target.value))}
3162
+ style={{ width: 90, accentColor: "#ff8fa0" }}
3163
+ title={`Ego depth: ${egoMaxHops} hops`}
3164
+ />
3165
+ <span style={{ fontFamily: "monospace", color: "#e6f2ff" }}>{egoMaxHops} hop{egoMaxHops !== 1 ? "s" : ""}</span>
3166
+ </div>
3167
+ )}
3168
+
3169
+ {showDistanceStatus ? (
3170
+ <div style={distanceStatusStripStyle}>
3171
+ <div style={distanceStatusTitleStyle}>
3172
+ <Activity size={14} aria-hidden />
3173
+ <span>Distance Intelligence</span>
3174
+ <span style={distanceModeBadgeStyle}>{distanceVisualState.mode}</span>
3175
+ </div>
3176
+ <div style={distanceStatusMetaStyle}>
3177
+ {distanceVisualState.anchorLabel ? (
3178
+ <span>Anchor: <strong>{distanceVisualState.anchorLabel}</strong></span>
3179
+ ) : null}
3180
+ {distanceVisualState.mode === "semantic" ? (
3181
+ <span>
3182
+ {distanceVisualState.status === "loading"
3183
+ ? "Loading semantic neighborhood..."
3184
+ : `${distanceVisualState.semanticNeighborCount ?? 0} semantic neighbors`}
3185
+ </span>
3186
+ ) : distanceVisualState.mode === "heatmap" ? (
3187
+ <span>{heatmapDistanceSummary}</span>
3188
+ ) : (
3189
+ <span>{distanceReachableCount.toLocaleString()} nodes within {distanceVisualState.maxHops} hops</span>
3190
+ )}
3191
+ {distanceVisualState.status === "unavailable" || distanceVisualState.status === "error" ? (
3192
+ <span style={{ color: GRAPH_THEME.ui.control.dangerText }}>{distanceVisualState.error}</span>
3193
+ ) : null}
3194
+ {heatmapRenderedSummary ? (
3195
+ <span style={{ color: GRAPH_THEME.ui.text.muted }}>{heatmapRenderedSummary}</span>
3196
+ ) : null}
3197
+ </div>
3198
+ <div style={distanceLegendStyle}>
3199
+ {distanceLegendItems.map((item) => (
3200
+ <span key={item.label} style={distanceLegendItemStyle}>
3201
+ <span style={{ ...distanceLegendSwatchStyle, background: item.color }} />
3202
+ {item.label}
3203
+ </span>
3204
+ ))}
3205
+ </div>
3206
+ </div>
3207
+ ) : null}
3208
+
3209
+ {searchError ? <div style={{ color: "#ff7b72", fontSize: 12 }}>{searchError}</div> : null}
3210
+
3211
+ {searchResults.length ? (
3212
+ <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
3213
+ <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12 }}>
3214
+ <span style={{ color: "#b08b93", fontSize: 12 }}>
3215
+ {searchResults.length} result{searchResults.length === 1 ? "" : "s"}
3216
+ </span>
3217
+ <button
3218
+ type="button"
3219
+ onClick={handleClearSearchResults}
3220
+ style={{ ...secondaryActionButtonStyle, minHeight: 26, padding: "4px 9px", gap: 5 }}
3221
+ aria-label="Dismiss search results"
3222
+ >
3223
+ <X size={12} strokeWidth={2.4} />
3224
+ Dismiss
3225
+ </button>
3226
+ </div>
3227
+ <div className="explore-search-results hud-scrollbar" style={searchResultsStripStyle}>
3228
+ {searchResults.map((result) => (
3229
+ <button key={result.node.id} style={predictionCardStyle} onClick={() => focusNode(result.node.id)}>
3230
+ <div style={{ display: "flex", justifyContent: "space-between", gap: 12 }}>
3231
+ <div style={{ minWidth: 0 }}>
3232
+ <div style={{ color: "#fff", fontWeight: 600 }}>{result.node.content || result.node.id}</div>
3233
+ <div style={{ color: "#b08b93", fontSize: 12 }}>{result.node.type}</div>
3234
+ </div>
3235
+ <div style={{ color: "#ff2d4b", fontSize: 12, whiteSpace: "nowrap" }}>
3236
+ {Math.round(result.score)}
3237
+ </div>
3238
+ </div>
3239
+ </button>
3240
+ ))}
3241
+ </div>
3242
+ </div>
3243
+ ) : null}
3244
+
3245
+ {selectedEdgeState ? (
3246
+ <div style={selectedEdgeCardStyle}>
3247
+ <div style={{ display: "flex", justifyContent: "space-between", gap: 12, alignItems: "flex-start" }}>
3248
+ <div style={{ minWidth: 0 }}>
3249
+ <div style={{ color: "rgba(255, 107, 129, 0.76)", fontSize: 11, fontWeight: 700, letterSpacing: "0.08em", textTransform: "uppercase" }}>
3250
+ Relationship
3251
+ </div>
3252
+ <div style={{ color: "#f4f8ff", fontSize: 15, fontWeight: 700, marginTop: 6 }}>
3253
+ {selectedEdgeState.edgeType}
3254
+ </div>
3255
+ </div>
3256
+ <button
3257
+ onClick={() => setSelectedEdgeId("")}
3258
+ style={{ ...secondaryActionButtonStyle, minHeight: 30, padding: "6px 10px" }}
3259
+ >
3260
+ Close
3261
+ </button>
3262
+ </div>
3263
+
3264
+ <div style={{ display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap" }}>
3265
+ <button style={selectedEdgeNodeChipStyle} onClick={() => focusNode(selectedEdgeState.sourceId)}>
3266
+ {selectedEdgeState.sourceLabel}
3267
+ </button>
3268
+ <span style={{ color: "#7fa7ce", fontSize: 12 }}>→</span>
3269
+ <button style={selectedEdgeNodeChipStyle} onClick={() => focusNode(selectedEdgeState.targetId)}>
3270
+ {selectedEdgeState.targetLabel}
3271
+ </button>
3272
+ </div>
3273
+
3274
+ <div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
3275
+ <MetricChip tone="warm">weight {selectedEdgeState.weight.toFixed(2)}</MetricChip>
3276
+ {selectedEdgeState.isAggregated ? (
3277
+ <MetricChip tone="success">
3278
+ {selectedEdgeState.aggregateCount} bundled edge{selectedEdgeState.aggregateCount === 1 ? "" : "s"}
3279
+ </MetricChip>
3280
+ ) : (
3281
+ <MetricChip>{selectedEdgeState.siblingCount} parallel lane{selectedEdgeState.siblingCount === 1 ? "" : "s"}</MetricChip>
3282
+ )}
3283
+ <MetricChip>{selectedEdgeState.familySize} family member{selectedEdgeState.familySize === 1 ? "" : "s"}</MetricChip>
3284
+ {selectedEdgeState.bundleKind ? (
3285
+ <MetricChip>{selectedEdgeState.bundleKind} bundle</MetricChip>
3286
+ ) : null}
3287
+ {selectedEdgeState.dominantEdgeType ? (
3288
+ <MetricChip>{selectedEdgeState.dominantEdgeType}</MetricChip>
3289
+ ) : null}
3290
+ {selectedEdgeState.provenanceCount > 0 ? (
3291
+ <MetricChip>{selectedEdgeState.provenanceCount} provenance fields</MetricChip>
3292
+ ) : null}
3293
+ </div>
3294
+
3295
+ {Object.keys(selectedEdgeState.properties).length ? (
3296
+ <div style={selectedEdgePropertyGridStyle}>
3297
+ {Object.entries(selectedEdgeState.properties).slice(0, 4).map(([key, value]) => (
3298
+ <div key={key} style={selectedEdgePropertyCardStyle}>
3299
+ <div style={{ color: "rgba(255, 107, 129, 0.68)", fontSize: 10, letterSpacing: "0.06em", textTransform: "uppercase" }}>
3300
+ {key}
3301
+ </div>
3302
+ <div style={{ color: "#dce7f4", fontSize: 12, marginTop: 4, wordBreak: "break-word" }}>
3303
+ {typeof value === "object" ? JSON.stringify(value) : String(value)}
3304
+ </div>
3305
+ </div>
3306
+ ))}
3307
+ </div>
3308
+ ) : null}
3309
+ </div>
3310
+ ) : null}
3311
+ </div>
3312
+ </SurfaceCard>
3313
+ </section>
3314
+
3315
+ <div
3316
+ className="explore-main-grid"
3317
+ style={{
3318
+ gridTemplateColumns: layoutState.showInspector ? "minmax(0, 1fr) minmax(320px, 360px)" : "minmax(0, 1fr)",
3319
+ }}
3320
+ >
3321
+ <div className="explore-scene-stack">
3322
+ <SurfaceCard padding="none" className="explore-scene-card">
3323
+ <div className="explore-scene-shell">
3324
+ <div className="palantir-grid" />
3325
+ <div className="palantir-vignette" />
3326
+ <div className="explore-scene-stage">
3327
+ <SigmaSceneAdapter
3328
+ ref={sceneRef}
3329
+ {...sceneAdapterProps}
3330
+ />
3331
+ <GraphLoadingOverlay
3332
+ progress={loadingProgress}
3333
+ visible={showLoadingOverlay}
3334
+ showGraphBehind={hasGraphContent || Boolean(loadingProgress?.showGraphBehind)}
3335
+ error={graphLoadErrorMessage}
3336
+ onRetry={handleRetryGraphLoad}
3337
+ />
3338
+ </div>
3339
+ </div>
3340
+
3341
+ {layoutState.showPluginDock ? (
3342
+ <div className="explore-plugin-dock">
3343
+ <SurfaceCard tone="subtle" style={{ borderRadius: 0, borderLeft: "none", borderRight: "none", borderBottom: "none" }}>
3344
+ <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
3345
+ <div style={{ display: "flex", justifyContent: "space-between", gap: 12, alignItems: "center" }}>
3346
+ <div className="explore-plugin-dock-tabs">
3347
+ {openDockPanels.map((panel) => (
3348
+ <button
3349
+ key={panel.id}
3350
+ className="explore-plugin-dock-tab"
3351
+ data-active={activeDockPanel?.id === panel.id}
3352
+ onClick={() => setActiveDockPanelId(panel.id)}
3353
+ >
3354
+ {panel.title}
3355
+ </button>
3356
+ ))}
3357
+ {activeDockPanel ? (
3358
+ <button
3359
+ className="explore-plugin-dock-tab"
3360
+ onClick={() => handlePluginAction({ type: "closePanel", panelId: activeDockPanel.id })}
3361
+ >
3362
+ ×
3363
+ </button>
3364
+ ) : null}
3365
+ </div>
3366
+ </div>
3367
+ {activeDockPanel ? (
3368
+ <div style={pluginDockContentStyle}>{activeDockPanel.content}</div>
3369
+ ) : null}
3370
+ </div>
3371
+ </SurfaceCard>
3372
+ </div>
3373
+ ) : null}
3374
+
3375
+ <div className="explore-scene-footer">
3376
+ <Suspense fallback={<div style={timelineFallbackStyle}>Loading timeline…</div>}>
3377
+ <LazyTimelinePanel
3378
+ onTimeChange={onTimeChange}
3379
+ minDate={temporalBounds?.min ?? undefined}
3380
+ maxDate={temporalBounds?.max ?? undefined}
3381
+ />
3382
+ </Suspense>
3383
+ </div>
3384
+ </SurfaceCard>
3385
+ </div>
3386
+
3387
+ {layoutState.showInspector ? (
3388
+ <div className="explore-inspector-shell">
3389
+ <InspectorPanel open={layoutState.showInspector} className="explore-inspector-card">
3390
+ <div className="explore-inspector-scroll hud-scrollbar">
3391
+ <Suspense fallback={<div style={inspectorFallbackStyle}>Loading inspector…</div>}>
3392
+ <LazyGraphInspectorPanel
3393
+ nodeId={selectedNodeId}
3394
+ inspectableNodeId={inspectableNodeId || null}
3395
+ selectedNodeKind={displayState.selectedNodeKind}
3396
+ canActivateFocused={canActivateFocusedMode}
3397
+ focusedUnavailableReason={displayState.focusedUnavailableReason}
3398
+ predictions={predictions}
3399
+ predictionType={predictionType}
3400
+ onPredictionTypeChange={setPredictionType}
3401
+ onRunPredictions={() => void handleRunPredictions()}
3402
+ isRunningPredictions={isRunningPredictions}
3403
+ pathTargetId={pathTargetId}
3404
+ onPathTargetChange={setPathTargetId}
3405
+ onTracePath={() => void handleTracePath()}
3406
+ pathResult={pathResult}
3407
+ onDownloadProvenance={(format) => void handleDownloadProvenance(format)}
3408
+ onFocusNode={focusNode}
3409
+ onMarkdownApplied={handleMarkdownApplied}
3410
+ onMarkdownDirtyChange={handleMarkdownDirtyChange}
3411
+ />
3412
+ </Suspense>
3413
+ </div>
3414
+ </InspectorPanel>
3415
+ </div>
3416
+ ) : null}
3417
+ </div>
3418
+ </div>
3419
+ </div>
3420
+ );
3421
+ }
3422
+
3423
+ const actionButtonStyle: React.CSSProperties = {
3424
+ background: GRAPH_THEME.ui.control.primaryBg,
3425
+ color: GRAPH_THEME.ui.control.primaryText,
3426
+ border: `1px solid ${GRAPH_THEME.ui.control.primaryBorder}`,
3427
+ borderRadius: 12,
3428
+ padding: "9px 12px",
3429
+ cursor: "pointer",
3430
+ fontWeight: 700,
3431
+ fontSize: 12,
3432
+ display: "inline-flex",
3433
+ alignItems: "center",
3434
+ justifyContent: "center",
3435
+ boxShadow: "inset 0 1px 0 rgba(255,255,255,0.07), 0 10px 24px rgba(0,0,0,0.18)",
3436
+ };
3437
+
3438
+ const secondaryActionButtonStyle: React.CSSProperties = {
3439
+ ...actionButtonStyle,
3440
+ background: GRAPH_THEME.ui.control.defaultBg,
3441
+ border: `1px solid ${GRAPH_THEME.ui.control.defaultBorder}`,
3442
+ color: GRAPH_THEME.ui.control.defaultText,
3443
+ fontWeight: 600,
3444
+ };
3445
+
3446
+ const predictionCardStyle: React.CSSProperties = {
3447
+ textAlign: "left",
3448
+ padding: 12,
3449
+ background: "rgba(255, 255, 255, 0.035)",
3450
+ border: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}`,
3451
+ borderRadius: 10,
3452
+ cursor: "pointer",
3453
+ };
3454
+
3455
+ const selectedEdgeCardStyle: React.CSSProperties = {
3456
+ display: "flex",
3457
+ flexDirection: "column",
3458
+ gap: 12,
3459
+ padding: 14,
3460
+ background: "linear-gradient(135deg, rgba(98, 226, 205, 0.1), rgba(233, 196, 122, 0.045))",
3461
+ border: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}`,
3462
+ borderRadius: 14,
3463
+ boxShadow: "inset 0 1px 0 rgba(255,255,255,0.03)",
3464
+ };
3465
+
3466
+ const selectedEdgeNodeChipStyle: React.CSSProperties = {
3467
+ ...secondaryActionButtonStyle,
3468
+ minHeight: 32,
3469
+ padding: "7px 12px",
3470
+ whiteSpace: "nowrap",
3471
+ };
3472
+
3473
+ const distanceStatusStripStyle: React.CSSProperties = {
3474
+ display: "flex",
3475
+ alignItems: "center",
3476
+ gap: 12,
3477
+ flexWrap: "wrap",
3478
+ border: `1px solid ${GRAPH_THEME.ui.control.activeBorder}`,
3479
+ background: "linear-gradient(135deg, rgba(98, 226, 205, 0.09), rgba(227, 179, 65, 0.045))",
3480
+ borderRadius: 16,
3481
+ padding: "9px 12px",
3482
+ boxShadow: "inset 0 1px 0 rgba(255,255,255,0.045)",
3483
+ };
3484
+
3485
+ const distanceStatusTitleStyle: React.CSSProperties = {
3486
+ display: "flex",
3487
+ alignItems: "center",
3488
+ gap: 7,
3489
+ color: GRAPH_THEME.ui.text.strong,
3490
+ fontSize: 12,
3491
+ fontWeight: 800,
3492
+ letterSpacing: "0.04em",
3493
+ textTransform: "uppercase",
3494
+ };
3495
+
3496
+ const distanceModeBadgeStyle: React.CSSProperties = {
3497
+ padding: "2px 7px",
3498
+ borderRadius: 999,
3499
+ border: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}`,
3500
+ color: GRAPH_THEME.palette.accent.path,
3501
+ background: "rgba(215, 144, 86, 0.1)",
3502
+ fontSize: 10,
3503
+ };
3504
+
3505
+ const distanceStatusMetaStyle: React.CSSProperties = {
3506
+ display: "flex",
3507
+ alignItems: "center",
3508
+ gap: 10,
3509
+ flexWrap: "wrap",
3510
+ color: GRAPH_THEME.ui.text.body,
3511
+ fontSize: 12,
3512
+ };
3513
+
3514
+ const distanceLegendStyle: React.CSSProperties = {
3515
+ display: "flex",
3516
+ alignItems: "center",
3517
+ gap: 8,
3518
+ marginLeft: "auto",
3519
+ color: GRAPH_THEME.ui.text.muted,
3520
+ fontSize: 11,
3521
+ };
3522
+
3523
+ const distanceLegendItemStyle: React.CSSProperties = {
3524
+ display: "inline-flex",
3525
+ alignItems: "center",
3526
+ gap: 4,
3527
+ };
3528
+
3529
+ const distanceLegendSwatchStyle: React.CSSProperties = {
3530
+ width: 8,
3531
+ height: 8,
3532
+ borderRadius: 999,
3533
+ };
3534
+
3535
+ const selectedEdgePropertyGridStyle: React.CSSProperties = {
3536
+ display: "grid",
3537
+ gridTemplateColumns: "repeat(auto-fit, minmax(160px, 1fr))",
3538
+ gap: 8,
3539
+ };
3540
+
3541
+ const selectedEdgePropertyCardStyle: React.CSSProperties = {
3542
+ borderRadius: 10,
3543
+ padding: 10,
3544
+ background: "rgba(255, 255, 255, 0.03)",
3545
+ border: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}`,
3546
+ };
3547
+
3548
+ const pluginDockContentStyle: React.CSSProperties = {
3549
+ borderRadius: 16,
3550
+ border: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}`,
3551
+ background: "rgba(255, 255, 255, 0.02)",
3552
+ padding: 14,
3553
+ };
3554
+
3555
+ const pluginLoadingStyle: React.CSSProperties = {
3556
+ color: GRAPH_THEME.ui.text.muted,
3557
+ fontSize: 12,
3558
+ padding: 10,
3559
+ };
3560
+
3561
+ const searchResultsStripStyle: React.CSSProperties = {
3562
+ display: "grid",
3563
+ gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))",
3564
+ gap: 10,
3565
+ maxHeight: 186,
3566
+ overflowY: "auto",
3567
+ };
3568
+
3569
+ const inspectorFallbackStyle: React.CSSProperties = {
3570
+ padding: 24,
3571
+ color: GRAPH_THEME.ui.text.muted,
3572
+ fontSize: 12,
3573
+ };
3574
+
3575
+ const timelineFallbackStyle: React.CSSProperties = {
3576
+ height: "90px",
3577
+ display: "flex",
3578
+ alignItems: "center",
3579
+ padding: "0 18px",
3580
+ color: GRAPH_THEME.ui.text.muted,
3581
+ fontSize: 12,
3582
+ borderTop: `1px solid ${GRAPH_THEME.ui.timeline.border}`,
3583
+ background: GRAPH_THEME.ui.timeline.background,
3584
+ };