import { useCallback, useEffect, useMemo, useRef, useState, type ComponentType, type ReactNode } from "react"; import { Activity, Clock3, Eye, Focus, GitBranch, Layers3, Maximize2, Pause, Play, RefreshCw, Search, Users, ZoomIn, ZoomOut, } from "lucide-react"; import type Graph from "graphology"; import { batchMergeEdges, batchMergeNodes, graph } from "../../store/graphStore"; import { logEvent } from "../../store/registryStore"; import type { EdgeAttributes, NodeAttributes } from "../../store/graphStore"; import { curveGroupForPair } from "../../store/edgePairKeys.js"; import { InspectorPanel, MetricChip, SurfaceCard } from "../../ui/primitives"; import { lazy, Suspense } from "react"; import { SigmaSceneAdapter } from "./SigmaSceneAdapter"; import { useLoadGraph, useReloadGraph } from "./useLoadGraph"; import { GraphLoadingOverlay } from "./GraphLoadingOverlay"; import { createGraphLoadProgress, getGraphLoadTitle } from "./graphLoading"; import { GRAPH_THEME, withAlpha } from "./graphTheme"; import type { GraphEntityShapeVariant } from "./graphTheme"; import { buildHeatmapRenderSnapshot, buildStructuralDistanceSnapshot, checkGroupedViewAvailability, getDistanceBandColor, resolveDisplayGraph, resolveDisplayStateSnapshot, resolveGroupedDisplayNodeId, resolveGroupedDisplayStateSnapshot, summarizeDistanceBuckets } from "./graphSceneState"; import { type GraphPlugin, type GraphPluginActionRequest, type GraphPluginContext, type GraphPluginOverlayDescriptor, type GraphPluginPanelDescriptor, type GraphPluginToolbarItem, } from "./plugins"; import { explorationEffectsShouldLoad, neighborhoodPanelShouldLoad, temporalOverlayShouldLoad } from "./pluginRegistryPredicates"; import type { LinkPrediction, PathResponse } from "./GraphInspectorPanel"; import type { GraphSceneHandle, GraphSceneRuntime } from "./scene"; import type { GraphAnalyticsSnapshot, GraphDistanceVisualMode, GraphDistanceVisualState, GraphDisplayStateSnapshot, GraphDiagnosticsSnapshot, GraphEffectToggle, GraphEffectsState, GraphInteractionState, GraphLoadProgress, GraphLoadSummary, GraphRuntimeDiagnosticsSnapshot, GraphSelectedEdgeState, GraphSelectedNodeKind, GraphSelectedNodeState, GraphTemporalState, GraphViewMode, } from "./types"; type SearchResult = { node: { id: string; type: string; content: string; properties: Record; }; score: number; }; type TemporalBounds = { min?: string | null; max?: string | null; }; type ExploreLayoutState = { showInspector: boolean; showPluginDock: boolean; }; type ToolbarIconComponent = ComponentType<{ size?: number; strokeWidth?: number; "aria-hidden"?: boolean; }>; type GraphToolbarItem = { id: string; label: string; title?: string; active?: boolean; disabled?: boolean; tone?: "primary" | "secondary"; icon?: ToolbarIconComponent; ariaLabel?: string; compact?: boolean; onClick: () => void; }; type GraphToolbarGroup = { id: string; label?: string; variant?: "cluster" | "segmented"; items: GraphToolbarItem[]; }; type SemanticNeighborhoodResponse = { anchor_node: string; total: number; neighbors: Array<{ id: string; type: string; content: string; similarity: number; hop_distance?: number | null; }>; }; type LazyPluginRegistryEntry = { id: string; panelId: string; label: string; title: string; order: number; load: () => Promise; shouldLoad: (context: { panelState: Record; temporalState?: GraphTemporalState | null; }) => boolean; }; const PROVENANCE_KEYS = ["source", "source_url", "pmid", "pmids", "evidence", "provenance", "confidence"] as const; const EMPTY_DISTANCE_RECORD: Record = {}; const STRUCTURAL_DISTANCE_MAX_HOPS = 6; const HEATMAP_DISTANCE_MAX_HOPS = 3; const DEFAULT_EFFECTS_STATE: GraphEffectsState = { pathPulseEnabled: false, pathFlowEnabled: false, lensEnabled: false, temporalEmphasisEnabled: false, semanticRegionsEnabled: false, contoursEnabled: false, pathfindingEnabled: false, communitiesEnabled: false, centralityEnabled: false, legendEnabled: false, diagnosticsEnabled: false, lensMode: "neighborhood", effectQuality: "bounded", }; const LazyTimelinePanel = lazy(() => import("./TimelinePanel").then((module) => ({ default: module.TimelinePanel }))); const LazyGraphInspectorPanel = lazy(() => import("./GraphInspectorPanel").then((module) => ({ default: module.GraphInspectorPanel }))); const loadExplorationEffectsPlugin = () => import("./plugins/explorationEffectsPluginPhaseC").then((module) => module.explorationEffectsPluginPhaseC); const loadNeighborhoodPanelPlugin = () => import("./plugins/neighborhoodPanelPlugin").then((module) => module.neighborhoodPanelPlugin); const loadTemporalOverlayPlugin = () => import("./plugins/temporalOverlayPlugin").then((module) => module.temporalOverlayPlugin); const EMPTY_PATH: string[] = []; const COMPACT_TOOLBAR_CLUSTER_IDS = new Set(["camera", "utility"]); const ENTITY_VISUAL_KEY: Array<{ shape: GraphEntityShapeVariant; label: string }> = [ { shape: "biomolecule", label: "Biomolecule" }, { shape: "condition", label: "Condition" }, { shape: "compound", label: "Compound" }, { shape: "process", label: "Process" }, { shape: "community", label: "Community" }, { shape: "entity", label: "Other" }, ]; const DEBUG_GRAPH_WORKSPACE = import.meta.env.DEV; function debugGraphWorkspace(message: string, payload?: Record) { if (!DEBUG_GRAPH_WORKSPACE) { return; } console.debug(`[GraphWorkspace] ${message}`, payload ?? {}); } function getGraphSummarySignature(summary: GraphLoadSummary): string { return [ summary.nodeCount, summary.edgeCount, summary.layoutSource ?? "unknown", summary.layoutReady ? "ready" : "runtime", summary.loadTimeMs, ].join(":"); } function useDebounce(value: T, delay: number): T { const [debouncedValue, setDebouncedValue] = useState(value); useEffect(() => { const timeout = setTimeout(() => setDebouncedValue(value), delay); return () => clearTimeout(timeout); }, [delay, value]); return debouncedValue; } function iconForPluginToolbarItem(item: GraphPluginToolbarItem): ToolbarIconComponent { const normalized = `${item.id} ${item.label}`.toLowerCase(); if (normalized.includes("neighbor")) { return Users; } if (normalized.includes("temporal") || normalized.includes("time")) { return Clock3; } return Activity; } function ToolbarIcon({ icon: Icon, size = 15 }: { icon?: ToolbarIconComponent; size?: number }) { if (!Icon) { return null; } return ; } function ToolbarButton({ item, compact = false, className = "", }: { item: GraphToolbarItem; compact?: boolean; className?: string; }) { const isCompact = compact || item.compact; return ( ); } function ToolbarCluster({ label, items, compact = false, children, }: { label: string; items?: GraphToolbarItem[]; compact?: boolean; children?: ReactNode; }) { if ((!items || items.length === 0) && !children) { return null; } return (
{label}
{children} {items?.map((item) => ( ))}
); } function SegmentedModeControl({ items }: { items: GraphToolbarItem[] }) { if (!items.length) { return null; } return (
{items.map((item) => ( ))}
); } function SearchCommandBar({ value, disabled, onChange, onSubmit, }: { value: string; disabled: boolean; onChange: (value: string) => void; onSubmit: () => void; }) { return (
{ event.preventDefault(); if (!disabled) { onSubmit(); } }} > onChange(event.target.value)} placeholder="Search command, node, or concept" aria-label="Search graph nodes" /> ); } function EntityVisualKey() { return (
{ENTITY_VISUAL_KEY.map((item) => (
{item.label}
))}
); } const HUD_CSS = ` .palantir-bg { background: ${GRAPH_THEME.ui.scene.radialGlow}, ${GRAPH_THEME.ui.scene.background}; } .palantir-grid { position: absolute; inset: 0; background-image: linear-gradient(${GRAPH_THEME.ui.scene.grid} 1px, transparent 1px), linear-gradient(90deg, ${GRAPH_THEME.ui.scene.grid} 1px, transparent 1px), linear-gradient(${GRAPH_THEME.ui.scene.gridStrong} 1px, transparent 1px), linear-gradient(90deg, ${GRAPH_THEME.ui.scene.gridStrong} 1px, transparent 1px); background-size: 48px 48px, 48px 48px, 240px 240px, 240px 240px; pointer-events: none; z-index: 1; } .palantir-vignette { position: absolute; inset: 0; background: ${GRAPH_THEME.ui.scene.vignette}; pointer-events: none; z-index: 2; } .glass-header { background: ${GRAPH_THEME.ui.surface.cardSubtle}; border-bottom: 1px solid ${GRAPH_THEME.ui.surface.panelBorder}; backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); } .glass-hud { background: ${GRAPH_THEME.ui.surface.panel}; backdrop-filter: blur(14px) saturate(1.08); -webkit-backdrop-filter: blur(14px) saturate(1.08); border-left: 1px solid ${GRAPH_THEME.ui.surface.panelBorder}; box-shadow: ${GRAPH_THEME.ui.surface.shadow}; } .hud-scrollbar::-webkit-scrollbar { width: 6px; } .hud-scrollbar::-webkit-scrollbar-track { background: transparent; } .hud-scrollbar::-webkit-scrollbar-thumb { background: rgba(215, 209, 196, 0.22); border-radius: 6px; } .node-panel-collapse { border: 1px solid ${GRAPH_THEME.ui.surface.panelBorder}; border-radius: 12px; background: rgba(255, 255, 255, 0.026); overflow: hidden; } .node-panel-collapse + .node-panel-collapse { margin-top: 12px; } .node-panel-summary { list-style: none; cursor: pointer; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 12px 14px; color: ${GRAPH_THEME.ui.text.body}; font-size: 12px; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; } .node-panel-summary::-webkit-details-marker { display: none; } .node-panel-summary::after { content: "+"; color: ${GRAPH_THEME.ui.timeline.playhead}; font-size: 16px; line-height: 1; } .node-panel-collapse[open] .node-panel-summary::after { content: "−"; } .node-panel-body { padding: 0 14px 14px; } .graph-loading-overlay { position: absolute; inset: 0; z-index: 9; display: flex; align-items: center; justify-content: center; pointer-events: none; } .graph-loading-card { width: min(460px, calc(100% - 48px)); border-radius: 20px; padding: 22px 22px 18px; background: ${GRAPH_THEME.ui.surface.cardStrong}; border: 1px solid ${GRAPH_THEME.ui.surface.panelBorder}; box-shadow: ${GRAPH_THEME.ui.surface.shadow}; backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px); } .graph-loading-dots { display: inline-flex; gap: 8px; align-items: center; } .graph-loading-dot { width: 10px; height: 10px; border-radius: 999px; background: linear-gradient(135deg, ${GRAPH_THEME.ui.timeline.playhead}, ${GRAPH_THEME.palette.accent.selected}); box-shadow: 0 0 18px rgba(98, 226, 205, 0.28); animation: sem-loader-pulse 1.2s ease-in-out infinite; } .graph-loading-dot:nth-child(2) { animation-delay: 0.14s; } .graph-loading-dot:nth-child(3) { animation-delay: 0.28s; } @keyframes sem-loader-pulse { 0%, 100% { transform: translateY(0) scale(0.92); opacity: 0.55; } 50% { transform: translateY(-4px) scale(1.08); opacity: 1; } } .graph-loading-bar { width: 100%; height: 10px; border-radius: 999px; overflow: hidden; background: rgba(255, 255, 255, 0.052); border: 1px solid ${GRAPH_THEME.ui.surface.panelBorder}; } .graph-loading-bar > span { display: block; height: 100%; border-radius: 999px; background: linear-gradient(90deg, rgba(98, 226, 205, 0.9), rgba(233, 196, 122, 0.9)); box-shadow: 0 0 28px rgba(98, 226, 205, 0.22); transition: width 180ms ease; } .explore-shell { position: relative; width: 100%; height: 100%; overflow: hidden; padding: 12px; display: flex; flex-direction: column; gap: 10px; } .explore-command-deck { position: relative; z-index: 3; } .explore-command-grid { display: grid; grid-template-columns: minmax(260px, 0.9fr) minmax(0, 1.4fr); gap: 16px; } .explore-main-grid { position: relative; z-index: 3; min-height: 0; flex: 1; display: grid; grid-template-columns: minmax(0, 1fr) minmax(340px, 380px); gap: 12px; } .explore-scene-stack { min-height: 0; display: flex; flex-direction: column; gap: 14px; } .explore-scene-card { min-height: 0; flex: 1; display: flex; flex-direction: column; overflow: hidden; } .explore-scene-shell { position: relative; min-height: 0; flex: 1; overflow: hidden; border-radius: 20px 20px 0 0; background: ${GRAPH_THEME.ui.surface.stage}; } .explore-scene-stage { position: relative; z-index: 3; width: 100%; height: 100%; } .explore-scene-footer { position: relative; z-index: 3; border-top: 1px solid ${GRAPH_THEME.ui.timeline.border}; background: ${GRAPH_THEME.ui.timeline.background}; } .explore-plugin-dock { position: relative; z-index: 3; } .explore-plugin-dock-tabs { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; } .explore-plugin-dock-tab { border: 1px solid ${GRAPH_THEME.ui.control.defaultBorder}; background: ${GRAPH_THEME.ui.control.defaultBg}; color: ${GRAPH_THEME.ui.control.defaultText}; border-radius: 999px; padding: 6px 10px; font-size: 11px; font-weight: 600; cursor: pointer; } .explore-plugin-dock-tab[data-active="true"] { color: ${GRAPH_THEME.ui.control.activeText}; background: rgba(74, 181, 166, 0.18); border-color: ${GRAPH_THEME.ui.control.activeBorder}; } .explore-toolbar { display: flex; flex-direction: column; gap: 12px; } .explore-status-strip { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; } .explore-workflow-bar { display: grid; grid-template-columns: minmax(280px, 1.1fr) auto minmax(360px, 1.55fr); align-items: center; gap: 10px; } .explore-search-command { min-width: 0; height: 43px; display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 9px; padding: 4px 5px 4px 13px; border-radius: 16px; border: 1px solid ${GRAPH_THEME.ui.control.inputBorder}; background: linear-gradient(135deg, rgba(255,255,255,0.06), rgba(255,255,255,0.018)), ${GRAPH_THEME.ui.control.inputBg}; color: ${GRAPH_THEME.ui.text.muted}; box-shadow: inset 0 1px 0 rgba(255,255,255,0.045), 0 14px 30px rgba(0,0,0,0.16); } .explore-search-command:focus-within { border-color: ${GRAPH_THEME.ui.control.activeBorder}; 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); } .explore-search-command input { width: 100%; min-width: 0; border: 0; outline: 0; color: ${GRAPH_THEME.ui.text.strong}; background: transparent; font-size: 13px; } .explore-search-command input::placeholder { color: ${GRAPH_THEME.ui.text.subtle}; } .explore-search-command button { height: 33px; border: 1px solid ${GRAPH_THEME.ui.control.primaryBorder}; border-radius: 12px; padding: 0 13px; background: ${GRAPH_THEME.ui.control.primaryBg}; color: ${GRAPH_THEME.ui.control.primaryText}; font-size: 12px; font-weight: 800; cursor: pointer; box-shadow: inset 0 1px 0 rgba(255,255,255,0.08); } .explore-search-command button:disabled { cursor: not-allowed; opacity: 0.45; } .explore-mode-control { display: inline-grid; grid-template-columns: repeat(3, minmax(88px, auto)); align-items: center; padding: 4px; border-radius: 16px; border: 1px solid ${GRAPH_THEME.ui.control.defaultBorder}; background: rgba(255, 255, 255, 0.026); box-shadow: inset 0 1px 0 rgba(255,255,255,0.035); } .explore-mode-segment { min-width: 0; border-radius: 12px; } .explore-toolbelt { min-width: 0; display: flex; justify-content: flex-end; align-items: center; gap: 8px; flex-wrap: wrap; } .explore-tool-cluster { display: inline-flex; align-items: center; gap: 6px; min-height: 43px; padding: 4px; border-radius: 16px; border: 1px solid rgba(211, 205, 190, 0.075); background: rgba(255, 255, 255, 0.022); } .explore-tool-cluster-label { padding: 0 4px 0 7px; color: ${GRAPH_THEME.ui.text.subtle}; font-size: 10px; font-weight: 800; letter-spacing: 0.08em; text-transform: uppercase; white-space: nowrap; } .explore-tool-cluster-items { display: inline-flex; align-items: center; gap: 4px; flex-wrap: wrap; } .explore-tool-button { min-height: 33px; display: inline-flex; align-items: center; justify-content: center; gap: 7px; border: 1px solid ${GRAPH_THEME.ui.control.defaultBorder}; border-radius: 12px; padding: 7px 10px; background: ${GRAPH_THEME.ui.control.defaultBg}; color: ${GRAPH_THEME.ui.control.defaultText}; font-size: 12px; font-weight: 700; line-height: 1; cursor: pointer; white-space: nowrap; box-shadow: inset 0 1px 0 rgba(255,255,255,0.035); transition: background 140ms ease, border-color 140ms ease, color 140ms ease, transform 140ms ease; } .explore-tool-button:hover:not(:disabled) { background: ${GRAPH_THEME.ui.control.hoverBg}; border-color: rgba(211, 205, 190, 0.18); transform: translateY(-1px); } .explore-tool-button:disabled { cursor: not-allowed; color: ${GRAPH_THEME.ui.control.disabledText}; opacity: 0.48; transform: none; } .explore-tool-button[data-active="true"] { color: ${GRAPH_THEME.ui.control.activeText}; background: ${GRAPH_THEME.ui.control.activeBg}; border-color: ${GRAPH_THEME.ui.control.activeBorder}; box-shadow: 0 0 0 1px ${GRAPH_THEME.ui.control.focusRing}, inset 0 1px 0 rgba(255,255,255,0.06); } .explore-tool-button-primary { color: ${GRAPH_THEME.ui.control.primaryText}; background: ${GRAPH_THEME.ui.control.primaryBg}; border-color: ${GRAPH_THEME.ui.control.primaryBorder}; } .explore-tool-button[data-compact="true"] { min-width: 34px; padding-inline: 9px; } .explore-tool-button[data-compact="true"] .explore-tool-button-label { display: none; } .explore-entity-key { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; padding: 2px 1px 0; color: ${GRAPH_THEME.ui.text.subtle}; font-size: 10px; font-weight: 700; letter-spacing: 0.02em; } .explore-entity-key-item { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; } .explore-entity-key-mark { width: 13px; height: 13px; display: inline-block; border: 1px solid rgba(194, 214, 218, 0.42); background: rgba(73, 154, 150, 0.58); box-shadow: inset 0 1px 0 rgba(255,255,255,0.08); } .explore-entity-key-mark[data-shape="entity"] { border-radius: 999px; } .explore-entity-key-mark[data-shape="biomolecule"] { clip-path: polygon(50% 7%, 86% 28%, 86% 72%, 50% 93%, 14% 72%, 14% 28%); } .explore-entity-key-mark[data-shape="condition"] { border-radius: 5px; transform: rotate(45deg) scale(0.88); } .explore-entity-key-mark[data-shape="compound"] { width: 20px; border-radius: 999px; } .explore-entity-key-mark[data-shape="process"] { border-radius: 4px; clip-path: polygon(0 0, 86% 0, 100% 16%, 100% 100%, 0 100%); } .explore-entity-key-mark[data-shape="community"] { width: 15px; height: 15px; border-radius: 999px; background: rgba(96, 190, 180, 0.16); border-color: rgba(229, 213, 175, 0.54); } .explore-search-results { display: flex; flex-direction: column; gap: 8px; max-height: 260px; overflow-y: auto; padding-right: 4px; } .explore-inspector-shell { min-height: 0; display: flex; flex-direction: column; } .explore-inspector-card { min-height: 0; flex: 1; overflow: hidden; } .explore-inspector-scroll { height: 100%; overflow-y: auto; } @media (max-width: 1260px) { .explore-main-grid { grid-template-columns: 1fr; } .explore-workflow-bar { grid-template-columns: minmax(280px, 1fr) auto; } .explore-toolbelt { grid-column: 1 / -1; justify-content: flex-start; } } @media (max-width: 980px) { .explore-shell { padding: 10px; gap: 10px; } .explore-command-grid { grid-template-columns: 1fr; } .explore-workflow-bar { grid-template-columns: 1fr; } .explore-mode-control { width: 100%; grid-template-columns: repeat(3, minmax(0, 1fr)); } .explore-toolbelt { justify-content: flex-start; } .explore-tool-cluster { max-width: 100%; } } `; function getProvenanceCount(properties: Record) { return PROVENANCE_KEYS.reduce( (count, key) => (properties[key] !== undefined && properties[key] !== null ? count + 1 : count), 0, ); } function buildRealtimeNodeAttributes(payload: { id: string; type?: string; properties?: Record; }): NodeAttributes { const properties = payload.properties || {}; const label = String(properties.content || payload.id); const baseColor = GRAPH_THEME.palette.accent.path; const hasTemporalBounds = Boolean(properties.valid_from || properties.valid_until); const provenanceCount = getProvenanceCount(properties); return { label, x: Number(properties.x ?? Math.random() * 1000 - 500), y: Number(properties.y ?? Math.random() * 1000 - 500), nodeType: payload.type || "inferred", content: label, valid_from: (properties.valid_from as string | null | undefined) ?? null, valid_until: (properties.valid_until as string | null | undefined) ?? null, properties, size: 8, baseSize: 8, semanticGroup: payload.type || "inferred", color: baseColor, baseColor, mutedColor: withAlpha(baseColor, GRAPH_THEME.nodes.mutedAlpha), glowColor: withAlpha(baseColor, 0.36), visualPriority: 0.82, labelPriority: 0.82, strokeColor: GRAPH_THEME.palette.background.nodeBorder, borderColor: GRAPH_THEME.palette.background.nodeBorder, borderSize: 0.85, nodeVariant: "inferred", nodeShapeVariant: "inferred", badgeKind: hasTemporalBounds ? "temporal" : provenanceCount > 0 ? "provenance" : "inferred", badgeCount: provenanceCount || undefined, ringColor: GRAPH_THEME.nodes.selectedRing.color, haloColor: withAlpha(baseColor, 0.42), labelVisibilityPolicy: "local", }; } function buildRealtimeEdgeAttributes(payload: { id: string; familyId?: string; source_id: string; target_id: string; type?: string; weight?: number; properties?: Record; }): EdgeAttributes { const properties = payload.properties || {}; const isInferred = Boolean(properties.inferred); const isBidirectional = graph.hasDirectedEdge(payload.target_id, payload.source_id); const baseColor = isInferred ? GRAPH_THEME.palette.accent.path : GRAPH_THEME.palette.muted.edgeStructure; return { edgeId: payload.id, familyId: payload.familyId || payload.id, sourceId: payload.source_id, targetId: payload.target_id, weight: Number(payload.weight ?? 1), edgeType: payload.type || "related_to", properties, size: 1, baseSize: 1, color: baseColor, baseColor, mutedColor: GRAPH_THEME.palette.muted.edgeOverview, visualPriority: isInferred ? 0.95 : 0.5, isBidirectional, edgeFamily: isInferred ? "path" : isBidirectional ? "bidirectional" : "line", curveGroup: isBidirectional ? curveGroupForPair(payload.source_id, payload.target_id) : null, type: "line", edgeVariant: isInferred ? "pathSignal" : isBidirectional ? "bidirectionalCurve" : "directional", arrowVisibilityPolicy: isInferred ? "always" : "contextual", relationshipStrength: isInferred ? 0.95 : 0.52, isParallelPair: false, parallelIndex: 0, parallelCount: 1, familySize: 1, }; } function buildSelectedNodeState( nodeId: string, displayState: GraphDisplayStateSnapshot, ): GraphSelectedNodeState | null { if (!nodeId || !graph.hasNode(nodeId)) { return null; } const attributes = graph.getNodeAttributes(nodeId) as { label?: string; content?: string; nodeType?: string; color?: string; valid_from?: string | null; valid_until?: string | null; properties?: Record; }; return { id: nodeId, label: String(attributes.label ?? nodeId), content: String(attributes.content ?? attributes.label ?? nodeId), nodeType: String(attributes.nodeType ?? "Entity"), color: typeof attributes.color === "string" ? attributes.color : undefined, valid_from: attributes.valid_from ?? null, valid_until: attributes.valid_until ?? null, properties: attributes.properties ?? {}, neighborCount: graph.neighbors(nodeId).length, visibleNeighborCount: displayState.selectedVisibleNeighborIds.length, collapsedNeighborCount: displayState.selectedCollapsedNeighborIds.length, isNeighborhoodCollapsed: displayState.selectedCollapsedNeighborIds.length > 0, canCollapseNeighborhood: graph.neighbors(nodeId).length > 8, }; } type FocusResolution = { kind: GraphSelectedNodeKind; resolvedNodeId: string | null; reason: string | null; }; function buildSelectedEdgeState( edgeId: string, displayGraph: typeof graph | Graph, ): GraphSelectedEdgeState | null { if (!edgeId || !displayGraph.hasEdge(edgeId)) { return null; } const [displaySourceId, displayTargetId] = displayGraph.extremities(edgeId); const attributes = displayGraph.getEdgeAttributes(edgeId) as { edgeType?: string; weight?: number; properties?: Record; familyId?: string; rawEdgeIds?: string[]; isAggregated?: boolean; aggregateCount?: number; bundleKind?: "parallel" | "bidirectional" | "community"; dominantEdgeType?: string; representativeWeight?: number; }; const rawEdgeIds = attributes.rawEdgeIds?.length ? attributes.rawEdgeIds.map((rawEdgeId) => String(rawEdgeId)) : [edgeId]; const primaryRawEdgeId = rawEdgeIds.find((rawEdgeId) => graph.hasEdge(rawEdgeId)) ?? rawEdgeIds[0]; let sourceId = displaySourceId; let targetId = displayTargetId; if (primaryRawEdgeId && graph.hasEdge(primaryRawEdgeId)) { [sourceId, targetId] = graph.extremities(primaryRawEdgeId); } const sourceAttributes = graph.hasNode(sourceId) ? (graph.getNodeAttributes(sourceId) as { label?: string; content?: string }) : ({ label: displaySourceId } as { label?: string; content?: string }); const targetAttributes = graph.hasNode(targetId) ? (graph.getNodeAttributes(targetId) as { label?: string; content?: string }) : ({ label: displayTargetId } as { label?: string; content?: string }); const properties = attributes.properties ?? {}; const familyId = String(attributes.familyId || edgeId); let familySize = 0; let siblingCount = 0; rawEdgeIds.forEach((rawEdgeId) => { if (!graph.hasEdge(rawEdgeId)) { return; } const candidateAttrs = graph.getEdgeAttributes(rawEdgeId) as { familyId?: string }; const [candidateSource, candidateTarget] = graph.extremities(rawEdgeId); if (String(candidateAttrs.familyId || rawEdgeId) === familyId) { familySize += 1; } if (candidateSource === sourceId && candidateTarget === targetId) { siblingCount += 1; } }); if (attributes.isAggregated) { siblingCount = rawEdgeIds.filter((rawEdgeId) => graph.hasEdge(rawEdgeId)).length; familySize = Math.max(familySize, siblingCount); } return { id: edgeId, familyId, sourceId, sourceLabel: String(sourceAttributes.label ?? sourceAttributes.content ?? sourceId), targetId, targetLabel: String(targetAttributes.label ?? targetAttributes.content ?? targetId), edgeType: String(attributes.edgeType ?? "related_to"), weight: Number(attributes.weight ?? 1), properties, provenanceCount: getProvenanceCount(properties), familySize, siblingCount, isAggregated: Boolean(attributes.isAggregated), aggregateCount: Number(attributes.aggregateCount ?? rawEdgeIds.length), rawEdgeIds, bundleKind: attributes.bundleKind ?? null, dominantEdgeType: attributes.dominantEdgeType ?? null, representativeWeight: Number(attributes.representativeWeight ?? attributes.weight ?? 1), }; } function collectPluginPanels( plugins: GraphPlugin[], context: GraphPluginContext, ): GraphPluginPanelDescriptor[] { const panels: GraphPluginPanelDescriptor[] = []; for (const plugin of plugins) { try { const result = plugin.renderPanel?.(context); if (!result) { continue; } if (Array.isArray(result)) { panels.push(...result); } else { panels.push(result); } } catch (error) { console.error(`[GraphPlugin:${plugin.id}] panel render failed`, error); } } return panels.sort((left, right) => (left.order ?? 0) - (right.order ?? 0)); } function collectPluginOverlays( plugins: GraphPlugin[], context: GraphPluginContext, ): GraphPluginOverlayDescriptor[] { const overlays: GraphPluginOverlayDescriptor[] = []; for (const plugin of plugins) { try { const result = plugin.renderOverlay?.(context); if (!result) { continue; } if (Array.isArray(result)) { overlays.push(...result); } else { overlays.push(result); } } catch (error) { console.error(`[GraphPlugin:${plugin.id}] overlay render failed`, error); } } return overlays.sort((left, right) => { if ((left.layer ?? 0) !== (right.layer ?? 0)) { return (left.layer ?? 0) - (right.layer ?? 0); } return (left.order ?? 0) - (right.order ?? 0); }); } interface GraphWorkspaceProps { externalFocusNodeId?: string; externalFocusToken?: number; } export function GraphWorkspace({ externalFocusNodeId, externalFocusToken }: GraphWorkspaceProps = {}) { const [selectedNodeId, setSelectedNodeId] = useState(""); const [focusedNodeId, setFocusedNodeId] = useState(""); const [lastGroupedSelectedNodeId, setLastGroupedSelectedNodeId] = useState(""); const [selectedEdgeId, setSelectedEdgeId] = useState(""); const [isLayoutRunning, setIsLayoutRunning] = useState(false); const [graphReady, setGraphReady] = useState(false); const [graphVersion, setGraphVersion] = useState(0); const [viewMode, setViewMode] = useState("full"); const [aggregationEnabled] = useState(true); const [collapsedNeighborhoodNodeIds, setCollapsedNeighborhoodNodeIds] = useState([]); const [searchQuery, setSearchQuery] = useState(""); const [searchResults, setSearchResults] = useState([]); const [searchError, setSearchError] = useState(""); const [predictionType, setPredictionType] = useState(""); const [isRunningPredictions, setIsRunningPredictions] = useState(false); const [predictions, setPredictions] = useState([]); const [pathTargetId, setPathTargetId] = useState(""); const [pathResult, setPathResult] = useState(null); const [activeNodeCount, setActiveNodeCount] = useState(null); const [temporalBounds, setTemporalBounds] = useState(null); const [scrubberTime, setScrubberTime] = useState(null); // Deduplicates setScrubberTime calls by millisecond value so that React 18 // concurrent-mode re-renders with a new Date object for the same timestamp // do not churn temporalState and retrigger the diagnostics effect (issue #830). const lastScrubberMsRef = useRef(null); const onTimeChange = useCallback((time: Date) => { const ms = time.getTime(); if (ms === lastScrubberMsRef.current) { return; } lastScrubberMsRef.current = ms; setScrubberTime(time); }, []); const [loadingProgress, setLoadingProgress] = useState(null); const [pluginPanelState, setPluginPanelState] = useState>({ "effects-panel": false, "neighborhood-panel": false, "temporal-panel": false, }); const [activeDockPanelId, setActiveDockPanelId] = useState(null); const [pluginRuntimeVersion, setPluginRuntimeVersion] = useState(0); const [effectsState, setEffectsState] = useState(DEFAULT_EFFECTS_STATE); const [graphDiagnosticsState, setGraphDiagnosticsState] = useState(null); // Tracks the last accepted diagnostics outside React's state cycle, allowing // handleDiagnosticsChange to compare synchronously before calling setState. const lastDiagnosticsRef = useRef(null); const [graphAnalyticsState, setGraphAnalyticsState] = useState(null); const [loadedPlugins, setLoadedPlugins] = useState>({}); // FR-2: Egocentric depth-of-field const [egoModeEnabled, setEgoModeEnabled] = useState(false); const [egoMaxHops, setEgoMaxHops] = useState(3); // FR-3 frontend: Distance mode overlay const [distanceMode, setDistanceMode] = useState<"off" | "structural" | "semantic">("off"); // FR-5: Distance heatmap layout const [heatmapEnabled, setHeatmapEnabled] = useState(false); const [semanticDistanceState, setSemanticDistanceState] = useState<{ anchorNodeId: string | null; scores: Record; count: number; status: GraphDistanceVisualState["status"]; error: string | null; }>({ anchorNodeId: null, scores: EMPTY_DISTANCE_RECORD, count: 0, status: "idle", error: null, }); const debouncedTime = useDebounce(scrubberTime, 150); const prevActiveIdsRef = useRef>(new Set()); const sceneRef = useRef(null); const lastExternalFocusTokenRef = useRef(undefined); const pluginRuntimeRef = useRef(null); const appliedGraphSummarySignatureRef = useRef(null); const pluginInteractionStateRef = useRef({ hoveredNodeId: null, selectedNodeId: "", selectedEdgeId: "", focusedNodeId: "", activePath: [], activePathEdgeIds: [], viewMode: "full", zoomTier: "overview", isLayoutRunning: false, }); const reload = useReloadGraph(); const handleLoadProgress = useCallback((progress: GraphLoadProgress) => { setLoadingProgress(progress); if (progress.phase !== "ready" && progress.phase !== "stabilizing_layout") { setGraphReady(false); } }, []); const applyGraphReadySummary = useCallback((graphSummary: GraphLoadSummary) => { const signature = getGraphSummarySignature(graphSummary); if (appliedGraphSummarySignatureRef.current === signature) { return; } appliedGraphSummarySignatureRef.current = signature; setGraphReady(true); setGraphVersion((current) => current + 1); setIsLayoutRunning(!graphSummary.layoutReady); if (graphSummary.layoutReady) { setLoadingProgress(null); return; } setLoadingProgress(createGraphLoadProgress({ phase: "stabilizing_layout", progressKind: "indeterminate", nodesLoaded: graphSummary.nodeCount, nodesTotal: graphSummary.nodeCount, edgesLoaded: graphSummary.edgeCount, edgesTotal: graphSummary.edgeCount, message: "Settling runtime layout", showGraphBehind: true, layoutSource: graphSummary.layoutSource, layoutState: "bootstrapping", })); }, []); const { data: summary, isLoading, isFetching } = useLoadGraph({ enabled: true, onGraphReady: applyGraphReadySummary, onProgress: handleLoadProgress, }); useEffect(() => { if (isLayoutRunning) { return; } setLoadingProgress((current) => (current?.phase === "stabilizing_layout" ? null : current)); }, [isLayoutRunning]); useEffect(() => { if (!summary || graphReady) { return; } applyGraphReadySummary(summary); }, [applyGraphReadySummary, graphReady, summary]); useEffect(() => { let cancelled = false; const loadBounds = async () => { try { const response = await fetch("/api/temporal/bounds"); if (!response.ok || cancelled) return; const data: TemporalBounds = await response.json(); if (!cancelled) { setTemporalBounds(data); } } catch { if (!cancelled) { setTemporalBounds(null); } } }; loadBounds(); return () => { cancelled = true; }; }, [summary?.nodeCount, summary?.edgeCount]); useEffect(() => { if (!debouncedTime || isLoading) return; let cancelled = false; const applySnapshot = async () => { try { const at = debouncedTime.toISOString(); const response = await fetch(`/api/temporal/snapshot?at=${encodeURIComponent(at)}`); if (!response.ok || cancelled) return; const data: { active_node_ids: string[]; active_node_count: number } = await response.json(); if (cancelled) return; const nextActiveIds = new Set(data.active_node_ids); requestAnimationFrame(() => { if (cancelled) return; const previous = prevActiveIdsRef.current; previous.forEach((id) => { if (!nextActiveIds.has(id) && graph.hasNode(id)) { graph.setNodeAttribute(id, "hidden", true); } }); nextActiveIds.forEach((id) => { if (graph.hasNode(id)) { graph.setNodeAttribute(id, "hidden", false); } }); prevActiveIdsRef.current = nextActiveIds; setActiveNodeCount(data.active_node_count); setGraphVersion((current) => current + 1); sceneRef.current?.getRuntime()?.requestRender(); }); } catch (fetchError) { if (!cancelled) { console.error("[Temporal] Snapshot fetch failed", fetchError); } } }; applySnapshot(); return () => { cancelled = true; }; }, [debouncedTime, isLoading]); const resolveNodeIdForFocusedMode = useCallback(( nodeId: string, displayGraphCandidate?: GraphSceneRuntime["displayGraph"] | null, ): FocusResolution => { if (!nodeId) { return { kind: "none", resolvedNodeId: null, reason: "Select a node to inspect in Focused mode.", }; } if (graph.hasNode(nodeId)) { return { kind: "base", resolvedNodeId: nodeId, reason: null, }; } const currentDisplayGraph = displayGraphCandidate ?? pluginRuntimeRef.current?.displayGraph ?? graph; if (currentDisplayGraph.hasNode(nodeId)) { const displayAttrs = currentDisplayGraph.getNodeAttributes(nodeId) as NodeAttributes; const communityGroup = displayAttrs.properties?.__communityGroup as | { anchorNodeId?: string | null; sampleNodeIds?: string[]; } | undefined; const anchorNodeId = communityGroup?.anchorNodeId || communityGroup?.sampleNodeIds?.[0] || ""; if (anchorNodeId && graph.hasNode(anchorNodeId)) { return { kind: "grouped", resolvedNodeId: anchorNodeId, reason: null, }; } return { kind: "grouped", resolvedNodeId: null, reason: "Focused mode is unavailable for this grouped selection.", }; } return { kind: "unavailable", resolvedNodeId: null, reason: "Selected item is not available in the current graph.", }; }, []); const focusedSelectionResolution = useMemo( () => resolveNodeIdForFocusedMode(selectedNodeId, pluginRuntimeRef.current?.displayGraph), [pluginRuntimeVersion, resolveNodeIdForFocusedMode, selectedNodeId, viewMode], ); const inspectableNodeId = focusedSelectionResolution.resolvedNodeId ?? ""; const canActivateFocusedMode = Boolean(focusedSelectionResolution.resolvedNodeId); const { available: groupedViewAvailable, reason: groupedViewReason } = useMemo( () => checkGroupedViewAvailability(), [graphVersion], ); const groupedDisplayCandidate = useMemo( () => viewMode === "grouped" ? resolveDisplayGraph("", EMPTY_PATH, EMPTY_PATH, "grouped", { aggregationEnabled, collapsedNeighborhoodNodeIds, }) : null, [viewMode, aggregationEnabled, collapsedNeighborhoodNodeIds, graphVersion], ); const requestViewMode = useCallback((nextViewMode: GraphViewMode) => { if (nextViewMode === "focused") { const resolution = resolveNodeIdForFocusedMode(selectedNodeId, pluginRuntimeRef.current?.displayGraph); if (!resolution.resolvedNodeId) { return; } setFocusedNodeId(resolution.resolvedNodeId); setSelectedNodeId(resolution.resolvedNodeId); setViewMode("focused"); setIsLayoutRunning(false); return; } if (nextViewMode === "grouped") { if (!groupedViewAvailable) { debugGraphWorkspace("grouped-view-unavailable", { reason: groupedViewReason, graphVersion, }); return; } const groupedDisplayGraph = groupedDisplayCandidate?.graph ?? resolveDisplayGraph("", EMPTY_PATH, EMPTY_PATH, "grouped", { aggregationEnabled, collapsedNeighborhoodNodeIds, }).graph; const nextGroupedSelection = [ lastGroupedSelectedNodeId, selectedNodeId, focusedNodeId, ] .map((candidateId) => resolveGroupedDisplayNodeId(groupedDisplayGraph, candidateId)) .find((candidateId): candidateId is string => Boolean(candidateId)) ?? ""; setFocusedNodeId(""); setSelectedNodeId(nextGroupedSelection); if (nextGroupedSelection) { setLastGroupedSelectedNodeId(nextGroupedSelection); } setViewMode("grouped"); setIsLayoutRunning(true); return; } setFocusedNodeId(""); setSelectedNodeId((currentSelectedNodeId) => ( currentSelectedNodeId && graph.hasNode(currentSelectedNodeId) ? currentSelectedNodeId : "" )); setViewMode("full"); }, [ aggregationEnabled, collapsedNeighborhoodNodeIds, focusedNodeId, graphVersion, groupedDisplayCandidate, groupedViewAvailable, groupedViewReason, lastGroupedSelectedNodeId, resolveNodeIdForFocusedMode, selectedNodeId, ]); const focusNode = useCallback((nodeId: string) => { if (!nodeId) { setSelectedNodeId(""); setSelectedEdgeId(""); setPathResult(null); setSearchResults([]); setSearchError(""); return; } const currentDisplayGraph = pluginRuntimeRef.current?.displayGraph ?? graph; const nextSelectedNodeId = nodeId; if (!graph.hasNode(nodeId) && currentDisplayGraph.hasNode(nodeId)) { setLastGroupedSelectedNodeId(nodeId); } setSelectedNodeId(nextSelectedNodeId); setSelectedEdgeId(""); setPathResult(null); setSearchResults([]); setSearchError(""); if (viewMode === "focused" && graph.hasNode(nextSelectedNodeId)) { setFocusedNodeId(nextSelectedNodeId); setIsLayoutRunning(false); } }, [viewMode]); // Note: ego/heatmap/distanceMode effects re-run automatically when selectedNodeId changes useEffect(() => { if (!externalFocusNodeId || externalFocusToken == null) return; if (lastExternalFocusTokenRef.current === externalFocusToken) return; if (!graphReady || !graph.hasNode(externalFocusNodeId)) return; lastExternalFocusTokenRef.current = externalFocusToken; // Set state directly instead of going through focusNode(), which captures // a stale viewMode in its closure. setViewMode is called first so the node // is visible in the full graph before the scene pans to it. setViewMode("full"); setSelectedNodeId(externalFocusNodeId); setSelectedEdgeId(""); window.setTimeout(() => { sceneRef.current?.focusNode(externalFocusNodeId); }, 0); }, [externalFocusNodeId, externalFocusToken, graphReady]); const handleEdgeSelect = useCallback((edgeId: string) => { setSelectedEdgeId(edgeId); }, []); const handleSearch = useCallback(async () => { if (!searchQuery.trim()) { setSearchResults([]); return; } setSearchError(""); try { const response = await fetch("/api/graph/search", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ query: searchQuery, limit: 8 }), }); if (!response.ok) { throw new Error(`Search failed with status ${response.status}`); } const data = await response.json(); setSearchResults(data.results || []); } catch (searchFetchError) { setSearchError(searchFetchError instanceof Error ? searchFetchError.message : "Search failed"); } }, [searchQuery]); const handleRunPredictions = useCallback(async () => { if (!inspectableNodeId) return; setIsRunningPredictions(true); try { const response = await fetch("/api/enrich/links", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ node_id: inspectableNodeId, top_n: 6, candidate_type: predictionType || undefined, min_score: 0, }), }); if (!response.ok) { throw new Error(`Link prediction failed with status ${response.status}`); } const data = await response.json(); setPredictions(data.predictions || []); } catch (predictionError) { console.error("[GraphWorkspace] prediction failed", predictionError); setPredictions([]); } finally { setIsRunningPredictions(false); } }, [inspectableNodeId, predictionType]); const handleTracePath = useCallback(async () => { if (!inspectableNodeId || !pathTargetId.trim()) return; try { const pathParams = new URLSearchParams({ source: inspectableNodeId, target: pathTargetId.trim(), algorithm: "dijkstra", }); const response = await fetch( `/api/graph/path?${pathParams.toString()}` ); if (!response.ok) { throw new Error(`Path lookup failed with status ${response.status}`); } const data: PathResponse = await response.json(); setPathResult(data); if (data.path?.length) { const lastStep = data.path[data.path.length - 1]; if (graph.hasNode(lastStep)) { sceneRef.current?.focusNode(lastStep); } } } catch (pathError) { console.error("[GraphWorkspace] path trace failed", pathError); setPathResult(null); } }, [inspectableNodeId, pathTargetId]); const handleDownloadProvenance = useCallback(async (format: "json" | "markdown") => { if (!inspectableNodeId) return; const suffix = format === "markdown" ? "markdown" : "json"; const response = await fetch(`/api/provenance/report?node_id=${encodeURIComponent(inspectableNodeId)}&format=${suffix}`); if (!response.ok) { throw new Error(`Provenance report failed with status ${response.status}`); } const blob = await response.blob(); const url = window.URL.createObjectURL(blob); const anchor = document.createElement("a"); anchor.href = url; anchor.download = `${inspectableNodeId}_provenance.${format === "markdown" ? "md" : "json"}`; document.body.appendChild(anchor); anchor.click(); window.URL.revokeObjectURL(url); document.body.removeChild(anchor); }, [inspectableNodeId]); useEffect(() => { const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; const socket = new WebSocket(`${protocol}//${window.location.host}/ws/graph-updates`); socket.onmessage = (event) => { try { const message = JSON.parse(event.data); if (message.event === "connection_ack") { return; } if (message.event !== "graph_mutation") { return; } const eventType = message.data?.event_type; const payload = message.data?.payload; if (eventType === "ADD_NODE" && payload?.id) { batchMergeNodes([ { id: payload.id, attributes: buildRealtimeNodeAttributes(payload), }, ]); logEvent("add-node", `Added node ${payload.label ?? payload.id}${payload.nodeType ? ` (${payload.nodeType})` : ""} via realtime ws`, { nodeId: payload.id, nodeType: payload.nodeType }); setGraphVersion((current) => current + 1); sceneRef.current?.getRuntime()?.requestRender(); } if (eventType === "ADD_EDGE") { batchMergeEdges([ { id: String(payload.id), familyId: payload.familyId ? String(payload.familyId) : String(payload.id), source: payload.source_id, target: payload.target_id, attributes: buildRealtimeEdgeAttributes(payload), }, ]); 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 }); setGraphVersion((current) => current + 1); sceneRef.current?.getRuntime()?.requestRender(); } } catch (socketError) { console.error("[GraphWorkspace] websocket update failed", socketError); } }; return () => { socket.close(); }; }, []); useEffect(() => { setCollapsedNeighborhoodNodeIds([]); setFocusedNodeId(""); setLastGroupedSelectedNodeId(""); }, [summary?.edgeCount, summary?.nodeCount]); const activeDistanceMode: GraphDistanceVisualMode = egoModeEnabled ? "ego" : heatmapEnabled ? "heatmap" : distanceMode; const distanceAnchorNodeId = viewMode === "full" && selectedNodeId && graph.hasNode(selectedNodeId) ? selectedNodeId : ""; const distanceAnchorLabel = distanceAnchorNodeId ? String((graph.getNodeAttributes(distanceAnchorNodeId) as NodeAttributes).label || distanceAnchorNodeId) : null; const distanceMaxHops = activeDistanceMode === "ego" ? egoMaxHops : activeDistanceMode === "heatmap" ? HEATMAP_DISTANCE_MAX_HOPS : STRUCTURAL_DISTANCE_MAX_HOPS; const structuralDistances = useMemo( () => ( distanceAnchorNodeId && activeDistanceMode !== "off" ? buildStructuralDistanceSnapshot(graph, distanceAnchorNodeId, distanceMaxHops) : EMPTY_DISTANCE_RECORD ), [activeDistanceMode, distanceAnchorNodeId, distanceMaxHops, graphVersion], ); const distanceCounts = useMemo( () => summarizeDistanceBuckets(structuralDistances, graph.order), [structuralDistances, graphVersion], ); const heatmapRenderSnapshot = useMemo( () => ( activeDistanceMode === "heatmap" && distanceAnchorNodeId ? buildHeatmapRenderSnapshot(graph, distanceAnchorNodeId, structuralDistances, HEATMAP_DISTANCE_MAX_HOPS) : null ), [activeDistanceMode, distanceAnchorNodeId, graphVersion, structuralDistances], ); useEffect(() => { if (viewMode === "full") { return; } setEgoModeEnabled(false); setHeatmapEnabled(false); setDistanceMode("off"); }, [viewMode]); useEffect(() => { if (activeDistanceMode === "off" || !selectedNodeId || !graph.hasNode(selectedNodeId)) { setEgoModeEnabled(false); setHeatmapEnabled(false); setDistanceMode("off"); } }, [activeDistanceMode, graphVersion, selectedNodeId]); useEffect(() => { if (distanceMode !== "semantic" || !distanceAnchorNodeId) { setSemanticDistanceState({ anchorNodeId: distanceAnchorNodeId || null, scores: EMPTY_DISTANCE_RECORD, count: 0, status: distanceMode === "semantic" ? "unavailable" : "idle", error: distanceMode === "semantic" ? "Select a Full Graph node to load semantic distance." : null, }); return; } let cancelled = false; setSemanticDistanceState({ anchorNodeId: distanceAnchorNodeId, scores: EMPTY_DISTANCE_RECORD, count: 0, status: "loading", error: null, }); const loadSemanticNeighborhood = async () => { try { const semanticParams = new URLSearchParams({ node_id: distanceAnchorNodeId, top_k: "50", }); const response = await fetch( `/api/graph/semantic-neighborhood?${semanticParams.toString()}`, ); if (cancelled) { return; } if (!response.ok) { throw new Error(response.status === 503 ? "Semantic similarity is unavailable for this graph." : response.status === 404 ? "Selected node was not found by the semantic distance API." : `Semantic distance failed with status ${response.status}`); } const data: SemanticNeighborhoodResponse = await response.json(); const scores = data.neighbors.reduce>((nextScores, neighbor) => { if (Number.isFinite(neighbor.similarity)) { nextScores[neighbor.id] = neighbor.similarity; } return nextScores; }, {}); setSemanticDistanceState({ anchorNodeId: distanceAnchorNodeId, scores, count: Object.keys(scores).length, status: Object.keys(scores).length > 0 ? "ready" : "unavailable", error: Object.keys(scores).length > 0 ? null : "No semantic neighbors were returned for this node.", }); } catch (error) { if (cancelled) { return; } setSemanticDistanceState({ anchorNodeId: distanceAnchorNodeId, scores: EMPTY_DISTANCE_RECORD, count: 0, status: "error", error: error instanceof Error ? error.message : "Semantic distance could not be loaded.", }); } }; void loadSemanticNeighborhood(); return () => { cancelled = true; }; }, [distanceAnchorNodeId, distanceMode]); const distanceVisualState = useMemo(() => { if (activeDistanceMode === "off") { return { mode: "off", anchorNodeId: null, anchorLabel: null, maxHops: distanceMaxHops, structuralDistances: EMPTY_DISTANCE_RECORD, semanticScores: EMPTY_DISTANCE_RECORD, distanceCounts: undefined, outsideCount: 0, heatmapVisibleNodeIds: undefined, heatmapRingCounts: undefined, heatmapRenderedRingCounts: undefined, heatmapSaturationMode: undefined, semanticNeighborCount: 0, status: "idle", error: null, }; } if (viewMode !== "full") { return { mode: activeDistanceMode, anchorNodeId: null, anchorLabel: null, maxHops: distanceMaxHops, structuralDistances: EMPTY_DISTANCE_RECORD, semanticScores: EMPTY_DISTANCE_RECORD, distanceCounts: undefined, outsideCount: graph.order, heatmapVisibleNodeIds: undefined, heatmapRingCounts: undefined, heatmapRenderedRingCounts: undefined, heatmapSaturationMode: undefined, semanticNeighborCount: 0, status: "unavailable", error: "Distance intelligence is available in Full Graph mode.", }; } if (!distanceAnchorNodeId) { return { mode: activeDistanceMode, anchorNodeId: null, anchorLabel: null, maxHops: distanceMaxHops, structuralDistances: EMPTY_DISTANCE_RECORD, semanticScores: EMPTY_DISTANCE_RECORD, distanceCounts: undefined, outsideCount: graph.order, heatmapVisibleNodeIds: undefined, heatmapRingCounts: undefined, heatmapRenderedRingCounts: undefined, heatmapSaturationMode: undefined, semanticNeighborCount: 0, status: "unavailable", error: "Select a node to activate distance intelligence.", }; } if (activeDistanceMode === "semantic") { return { mode: "semantic", anchorNodeId: distanceAnchorNodeId, anchorLabel: distanceAnchorLabel, maxHops: distanceMaxHops, structuralDistances, semanticScores: semanticDistanceState.anchorNodeId === distanceAnchorNodeId ? semanticDistanceState.scores : EMPTY_DISTANCE_RECORD, distanceCounts, outsideCount: distanceCounts.outside, heatmapVisibleNodeIds: undefined, heatmapRingCounts: undefined, heatmapRenderedRingCounts: undefined, heatmapSaturationMode: undefined, semanticNeighborCount: semanticDistanceState.anchorNodeId === distanceAnchorNodeId ? semanticDistanceState.count : 0, status: semanticDistanceState.anchorNodeId === distanceAnchorNodeId ? semanticDistanceState.status : "loading", error: semanticDistanceState.anchorNodeId === distanceAnchorNodeId ? semanticDistanceState.error : null, }; } return { mode: activeDistanceMode, anchorNodeId: distanceAnchorNodeId, anchorLabel: distanceAnchorLabel, maxHops: distanceMaxHops, structuralDistances, semanticScores: EMPTY_DISTANCE_RECORD, distanceCounts, outsideCount: distanceCounts.outside, heatmapVisibleNodeIds: heatmapRenderSnapshot?.visibleNodeIds, heatmapRingCounts: heatmapRenderSnapshot?.ringCounts, heatmapRenderedRingCounts: heatmapRenderSnapshot?.renderedRingCounts, heatmapSaturationMode: heatmapRenderSnapshot?.saturationMode, semanticNeighborCount: 0, status: "ready", error: null, }; }, [ activeDistanceMode, distanceAnchorLabel, distanceAnchorNodeId, distanceCounts, distanceMaxHops, graph.order, heatmapRenderSnapshot, semanticDistanceState, structuralDistances, viewMode, ]); const showLoadingOverlay = !graphReady && (isLoading || isFetching || Boolean(loadingProgress)); const showSettlingStatus = graphReady && loadingProgress?.phase === "stabilizing_layout"; const hasGraphContent = Boolean(summary?.nodeCount); const activePath = pathResult?.path ?? EMPTY_PATH; const activePathEdgeIds = pathResult?.edge_ids ?? EMPTY_PATH; const structuralSelectedNodeId = useMemo(() => { if (viewMode === "focused") { return focusedNodeId && graph.hasNode(focusedNodeId) ? focusedNodeId : ""; } if (!selectedNodeId || !graph.hasNode(selectedNodeId)) { return ""; } return collapsedNeighborhoodNodeIds.includes(selectedNodeId) ? selectedNodeId : ""; }, [collapsedNeighborhoodNodeIds, focusedNodeId, selectedNodeId, viewMode]); const structuralActivePath = structuralSelectedNodeId ? activePath : EMPTY_PATH; const structuralActivePathEdgeIds = structuralSelectedNodeId ? activePathEdgeIds : EMPTY_PATH; const displayResult = useMemo( () => ( viewMode === "grouped" ? (groupedDisplayCandidate ?? resolveDisplayGraph("", EMPTY_PATH, EMPTY_PATH, "grouped", { aggregationEnabled, collapsedNeighborhoodNodeIds, })) : resolveDisplayGraph(structuralSelectedNodeId, structuralActivePath, structuralActivePathEdgeIds, viewMode, { aggregationEnabled, collapsedNeighborhoodNodeIds, }) ), [ aggregationEnabled, collapsedNeighborhoodNodeIds, groupedDisplayCandidate, structuralActivePath, structuralActivePathEdgeIds, structuralSelectedNodeId, viewMode, ], ); const displayState = useMemo( () => ( viewMode === "grouped" ? resolveGroupedDisplayStateSnapshot(displayResult.graph, selectedNodeId, { groupedViewAvailable, groupedViewReason, selectedNodeKind: focusedSelectionResolution.kind, resolvedFocusedNodeId: focusedSelectionResolution.resolvedNodeId, focusedUnavailableReason: focusedSelectionResolution.reason, }) : resolveDisplayStateSnapshot(selectedNodeId, activePath, viewMode, { aggregationEnabled, collapsedNeighborhoodNodeIds, groupedViewAvailable, groupedViewReason, selectedNodeKind: focusedSelectionResolution.kind, resolvedFocusedNodeId: focusedSelectionResolution.resolvedNodeId, focusedUnavailableReason: focusedSelectionResolution.reason, }) ), [ activePath, aggregationEnabled, collapsedNeighborhoodNodeIds, displayResult.graph, focusedSelectionResolution.kind, focusedSelectionResolution.reason, focusedSelectionResolution.resolvedNodeId, groupedViewAvailable, groupedViewReason, selectedNodeId, viewMode, ], ); const displayMeta = displayResult.meta; useEffect(() => { if (viewMode === "grouped" && !groupedViewAvailable) { debugGraphWorkspace("grouped-view-reset-to-full", { reason: groupedViewReason, graphVersion, }); setViewMode("full"); setFocusedNodeId(""); setSelectedNodeId((currentSelectedNodeId) => ( currentSelectedNodeId && graph.hasNode(currentSelectedNodeId) ? currentSelectedNodeId : "" )); } }, [graphVersion, groupedViewAvailable, groupedViewReason, viewMode]); useEffect(() => { if (viewMode === "focused" && (!focusedNodeId || !graph.hasNode(focusedNodeId))) { setViewMode("full"); setFocusedNodeId(""); } }, [focusedNodeId, graphVersion, viewMode]); const previousDisplayGraphRef = useRef(displayResult.graph); const previousDisplayStateRef = useRef(displayState); useEffect(() => { const graphRebuilt = previousDisplayGraphRef.current !== displayResult.graph; const displayStateChanged = previousDisplayStateRef.current !== displayState; debugGraphWorkspace("display-state-derived", { selectedNodeId, structuralSelectedNodeId, viewMode, graphRebuilt, displayStateChanged, aggregationEnabled, collapsedNeighborhoodActive: Boolean(structuralSelectedNodeId && collapsedNeighborhoodNodeIds.includes(structuralSelectedNodeId)), }); previousDisplayGraphRef.current = displayResult.graph; previousDisplayStateRef.current = displayState; }, [ aggregationEnabled, collapsedNeighborhoodNodeIds, displayResult.graph, displayState, selectedNodeId, structuralSelectedNodeId, viewMode, ]); const focusedSummary = useMemo(() => { if (!selectedNodeId || !graph.hasNode(selectedNodeId)) { if (viewMode === "grouped") { return displayState.groupedViewAvailable ? "Communities compressed into grouped structure view" : (displayState.groupedViewReason ?? "Grouped view is unavailable for the current graph"); } return null; } const localNeighborCount = graph.neighbors(selectedNodeId).length; if (viewMode === "focused") { const visibleNeighbors = displayState.selectedVisibleNeighborIds.length || Math.min(localNeighborCount, 16); return `${visibleNeighbors + 1} nodes in focused view`; } if (viewMode === "grouped") { return "Grouped structure view with direct community drill-in"; } if (displayState.selectedCollapsedNeighborIds.length > 0) { return `${displayState.selectedVisibleNeighborIds.length} visible neighbors, ${displayState.selectedCollapsedNeighborIds.length} collapsed`; } return `${localNeighborCount} direct neighbors highlighted`; }, [displayState, selectedNodeId, viewMode]); const graphSummary = summary as GraphLoadSummary | null; const selectedNodeState = useMemo( () => buildSelectedNodeState(selectedNodeId, displayState), [displayState, selectedNodeId, summary?.nodeCount, summary?.edgeCount], ); const selectedEdgeState = useMemo( () => buildSelectedEdgeState(selectedEdgeId, displayResult.graph), [displayResult.graph, selectedEdgeId, summary?.nodeCount, summary?.edgeCount], ); const temporalState = useMemo( () => ({ currentTime: scrubberTime, activeNodeCount, minDate: temporalBounds?.min ?? undefined, maxDate: temporalBounds?.max ?? undefined, }), [activeNodeCount, scrubberTime, temporalBounds?.max, temporalBounds?.min], ); const pluginRegistry = useMemo( () => [ { id: "exploration-effects", panelId: "effects-panel", label: "Effects", title: "Open exploration effects controls", order: 18, load: loadExplorationEffectsPlugin, shouldLoad: explorationEffectsShouldLoad, }, { id: "neighborhood-panel", panelId: "neighborhood-panel", label: "Neighbors", title: "Toggle neighborhood panel", order: 30, load: loadNeighborhoodPanelPlugin, shouldLoad: neighborhoodPanelShouldLoad, }, { id: "temporal-overlay", panelId: "temporal-panel", label: "Temporal", title: "Toggle temporal context panel", order: 40, load: loadTemporalOverlayPlugin, shouldLoad: temporalOverlayShouldLoad, }, ], [], ); const activePlugins = useMemo( () => pluginRegistry.map((entry) => loadedPlugins[entry.id]).filter((plugin): plugin is GraphPlugin => Boolean(plugin)), [loadedPlugins, pluginRegistry], ); useEffect(() => { let cancelled = false; pluginRegistry.forEach((entry) => { if (loadedPlugins[entry.id]) { return; } if (!entry.shouldLoad({ panelState: pluginPanelState })) { return; } void entry.load() .then((plugin) => { if (cancelled) { return; } setLoadedPlugins((current) => (current[entry.id] ? current : { ...current, [entry.id]: plugin })); }) .catch((error) => { console.error(`[GraphPlugin:${entry.id}] lazy load failed`, error); }); }); return () => { cancelled = true; }; }, [loadedPlugins, pluginPanelState, pluginRegistry]); const setEffectToggle = useCallback((effect: GraphEffectToggle, enabled: boolean | ((current: boolean) => boolean)) => { setEffectsState((current) => { const nextValue = typeof enabled === "function" ? enabled(current[effect]) : enabled; if (effect === "diagnosticsEnabled" && !GRAPH_THEME.effects.diagnostics.enabledInDev) { return current; } if (current[effect] === nextValue) { return current; } return { ...current, [effect]: nextValue, }; }); }, []); const handlePluginAction = useCallback((action: GraphPluginActionRequest) => { switch (action.type) { case "fitView": sceneRef.current?.fitView(); return; case "focusNode": sceneRef.current?.focusNode(action.nodeId); return; case "selectNode": focusNode(action.nodeId); return; case "setViewMode": requestViewMode(action.viewMode); return; case "collapseNeighborhood": if (!selectedNodeId) { return; } setCollapsedNeighborhoodNodeIds((current) => ( current.includes(selectedNodeId) ? current : [...current, selectedNodeId] )); return; case "expandNeighborhood": if (!selectedNodeId) { return; } setCollapsedNeighborhoodNodeIds((current) => current.filter((nodeId) => nodeId !== selectedNodeId)); return; case "toggleEffect": setEffectToggle(action.effect, (current) => !current); return; case "setEffect": setEffectToggle(action.effect, action.enabled); return; case "togglePanel": setPluginPanelState((current) => { const nextOpen = !current[action.panelId]; setActiveDockPanelId((previous) => { if (nextOpen) { return action.panelId; } return previous === action.panelId ? null : previous; }); return { ...current, [action.panelId]: nextOpen, }; }); return; case "openPanel": setActiveDockPanelId(action.panelId); setPluginPanelState((current) => ({ ...current, [action.panelId]: true, })); return; case "closePanel": setPluginPanelState((current) => ({ ...current, [action.panelId]: false, })); setActiveDockPanelId((previous) => (previous === action.panelId ? null : previous)); return; } }, [focusNode, requestViewMode, selectedNodeId, setEffectToggle]); const diagnosticsSnapshot = useMemo(() => { if (!GRAPH_THEME.effects.diagnostics.enabledInDev || !graphDiagnosticsState) { return null; } return { interactionState: pluginInteractionStateRef.current, activePluginIds: activePlugins.map((plugin) => plugin.id), openPanelIds: Object.entries(pluginPanelState) .filter(([, isOpen]) => isOpen) .map(([panelId]) => panelId), effectsState, edgeClasses: graphDiagnosticsState.edgeClasses, distanceVisual: graphDiagnosticsState.distanceVisual, effectAvailability: graphDiagnosticsState.effectAvailability, }; }, [activePlugins, effectsState, graphDiagnosticsState, pluginPanelState]); const pluginContext = useMemo(() => ({ get scene() { return pluginRuntimeRef.current; }, get graph() { return graph; }, get displayGraph() { return pluginRuntimeRef.current?.displayGraph ?? graph; }, theme: GRAPH_THEME, getInteractionState: () => pluginInteractionStateRef.current, getSelectedNodeState: () => selectedNodeState, getInspectorState: () => ({ selectedNodeId: selectedNodeId || null, ownsSelectionDetails: true, }), getGraphSummary: () => graphSummary, getTemporalState: () => temporalState, getEffectsState: () => effectsState, getDiagnosticsSnapshot: () => diagnosticsSnapshot, getAnalyticsSnapshot: () => graphAnalyticsState, getDisplayState: () => displayState, isPanelOpen: (panelId: string) => Boolean(pluginPanelState[panelId]), dispatchAction: handlePluginAction, }), [ displayState, graphAnalyticsState, diagnosticsSnapshot, effectsState, graphSummary, handlePluginAction, pluginPanelState, selectedNodeId, selectedNodeState, temporalState, ]); const handleSceneRuntimeChange = useCallback((runtime: GraphSceneRuntime | null) => { pluginRuntimeRef.current = runtime; if (!runtime) { setGraphAnalyticsState(null); } setPluginRuntimeVersion((version) => version + 1); }, []); const handleInteractionStateChange = useCallback((interactionState: GraphInteractionState) => { pluginInteractionStateRef.current = interactionState; for (const plugin of activePlugins) { try { plugin.onStateChange(pluginContext, interactionState); } catch (error) { console.error(`[GraphPlugin:${plugin.id}] state update failed`, error); } } }, [activePlugins, pluginContext]); const handleDiagnosticsChange = useCallback((diagnostics: GraphRuntimeDiagnosticsSnapshot) => { if (!GRAPH_THEME.effects.diagnostics.enabledInDev) { return; } // Compare against the last accepted snapshot synchronously before calling // setState. buildEffectAvailability always returns a new object, so an // unconditional setGraphDiagnosticsState on every call created a // render → diagnostics effect → setState → render cycle that exceeded // React's max update depth in dev mode (issue #830). const prev = lastDiagnosticsRef.current; if (prev !== null) { const EFFECT_KEYS = [ "pathPulse", "pathFlow", "lens", "temporalEmphasis", "semanticRegions", "contours", "pathfinding", "communities", "centrality", "legend", "diagnostics", ] as const; const prevEA = prev.effectAvailability; const nextEA = diagnostics.effectAvailability; const availabilityChanged = EFFECT_KEYS.some((key) => { const p = prevEA[key]; const n = nextEA[key]; return ( p.enabled !== n.enabled || p.available !== n.available || p.reason !== n.reason || p.detail !== n.detail || p.visibleSegments !== n.visibleSegments || p.segmentCap !== n.segmentCap ); }); const edgeClassesChanged = prev.edgeClasses?.updatedAt !== diagnostics.edgeClasses?.updatedAt; const structureLayerChanged = prev.structureLayer?.cacheKey !== diagnostics.structureLayer?.cacheKey || prev.structureLayer?.lastDrawAt !== diagnostics.structureLayer?.lastDrawAt || prev.structureLayer?.enabled !== diagnostics.structureLayer?.enabled || prev.structureLayer?.disabledReason !== diagnostics.structureLayer?.disabledReason || prev.structureLayer?.curveCount !== diagnostics.structureLayer?.curveCount || prev.structureLayer?.bridgeCurveCount !== diagnostics.structureLayer?.bridgeCurveCount || prev.structureLayer?.backboneCurveCount !== diagnostics.structureLayer?.backboneCurveCount; // distanceVisual is compared by reference: GraphCanvas passes the same // object when distances haven't changed. const distanceVisualChanged = prev.distanceVisual !== diagnostics.distanceVisual; if (!availabilityChanged && !edgeClassesChanged && !structureLayerChanged && !distanceVisualChanged) { return; } } lastDiagnosticsRef.current = diagnostics; setGraphDiagnosticsState(diagnostics); }, []); const handleAnalyticsChange = useCallback((analytics: GraphAnalyticsSnapshot | null) => { setGraphAnalyticsState(analytics); }, []); useEffect(() => { if (!pluginRuntimeRef.current) { return; } const mountedPlugins: GraphPlugin[] = []; for (const plugin of activePlugins) { try { plugin.mount(pluginContext); mountedPlugins.push(plugin); } catch (error) { console.error(`[GraphPlugin:${plugin.id}] mount failed`, error); } } return () => { for (const plugin of mountedPlugins.reverse()) { try { plugin.unmount(pluginContext); } catch (error) { console.error(`[GraphPlugin:${plugin.id}] unmount failed`, error); } } }; }, [activePlugins, pluginContext, pluginRuntimeVersion]); const pluginToolbarItems = useMemo( () => pluginRegistry.map((entry) => ({ id: `${entry.id}-toggle`, label: entry.label, title: entry.title, active: pluginContext.isPanelOpen(entry.panelId), order: entry.order, onClick: () => pluginContext.dispatchAction({ type: "togglePanel", panelId: entry.panelId }), })), [pluginContext, pluginRegistry], ); const pluginPanels = useMemo( () => collectPluginPanels(activePlugins, pluginContext), [activePlugins, pluginContext], ); const pluginOverlays = useMemo( () => collectPluginOverlays(activePlugins, pluginContext), [activePlugins, pluginContext], ); const pendingDockPanels = useMemo( () => pluginRegistry .filter((entry) => pluginPanelState[entry.panelId] && !loadedPlugins[entry.id]) .map((entry) => ({ id: entry.panelId, title: entry.label, placement: "bottom" as const, order: entry.order, content:
Loading {entry.label.toLowerCase()}…
, })), [loadedPlugins, pluginPanelState, pluginRegistry], ); const dockPanels = [...pluginPanels, ...pendingDockPanels] .filter((panel) => panel.placement === "bottom" || panel.placement === "side") .sort((left, right) => (left.order ?? 0) - (right.order ?? 0)); const openDockPanels = dockPanels.filter((panel) => pluginPanelState[panel.id]); const activeDockPanel = openDockPanels.find((panel) => panel.id === activeDockPanelId) ?? openDockPanels[0] ?? null; const layoutState: ExploreLayoutState = { showInspector: Boolean(selectedNodeId), showPluginDock: openDockPanels.length > 0, }; const openDockPanelIdsString = openDockPanels.map((p) => p.id).join("|"); const [prevOpenDockPanelIdsString, setPrevOpenDockPanelIdsString] = useState(openDockPanelIdsString); if (openDockPanelIdsString !== prevOpenDockPanelIdsString) { setPrevOpenDockPanelIdsString(openDockPanelIdsString); if (!openDockPanels.length) { if (activeDockPanelId !== null) setActiveDockPanelId(null); } else if (!activeDockPanelId || !openDockPanels.some((panel) => panel.id === activeDockPanelId)) { setActiveDockPanelId(openDockPanels[0].id); } } const viewModeItems = useMemo(() => { if (!hasGraphContent) { return []; } return [ { id: "view-full", label: "Full Graph", title: "Return to the full graph context", icon: Layers3, active: viewMode === "full", onClick: () => requestViewMode("full"), }, { id: "view-grouped", label: "Grouped View", title: displayState.groupedViewAvailable ? "Compress dense structure into detected communities" : (displayState.groupedViewReason ?? "Grouped view is unavailable until communities can be detected"), icon: GitBranch, active: viewMode === "grouped", disabled: !displayState.groupedViewAvailable, onClick: () => requestViewMode("grouped"), }, { id: "view-focused", label: "Focused", title: canActivateFocusedMode ? "Inspect the selected node in a focused local graph" : (focusedSelectionResolution.reason ?? "Focused mode is unavailable for the current selection"), icon: Focus, active: viewMode === "focused", disabled: viewMode !== "focused" && !canActivateFocusedMode, onClick: () => requestViewMode("focused"), }, ]; }, [ canActivateFocusedMode, displayState.groupedViewAvailable, displayState.groupedViewReason, focusedSelectionResolution.reason, hasGraphContent, requestViewMode, viewMode, ]); const cameraToolbarItems = useMemo(() => [ { id: "zoom-in", label: "Zoom In", title: "Zoom in (or scroll up on the canvas)", ariaLabel: "Zoom in", icon: ZoomIn, compact: true, onClick: () => sceneRef.current?.zoomIn(), }, { id: "zoom-out", label: "Zoom Out", title: "Zoom out (or scroll down on the canvas)", ariaLabel: "Zoom out", icon: ZoomOut, compact: true, onClick: () => sceneRef.current?.zoomOut(), }, { id: "fit-view", label: "Fit", title: "Reset the camera to fit the whole graph", ariaLabel: "Fit view", icon: Maximize2, compact: true, onClick: () => sceneRef.current?.fitView(), }, ], []); const layoutToolbarItems = useMemo(() => [ { id: "layout-toggle", label: isLayoutRunning ? "Pause" : "Run", title: "Toggle the layout worker", icon: isLayoutRunning ? Pause : Play, active: isLayoutRunning, disabled: showLoadingOverlay, onClick: () => setIsLayoutRunning((value) => !value), }, ], [isLayoutRunning, showLoadingOverlay]); const localToolbarItems = useMemo(() => { if (!selectedNodeState) { return []; } return [ { id: "collapse-neighborhood", label: "Collapse", title: "Hide lower-priority fanout around the selected node", icon: Eye, disabled: !selectedNodeState.canCollapseNeighborhood || selectedNodeState.isNeighborhoodCollapsed, onClick: () => handlePluginAction({ type: "collapseNeighborhood" }), }, { id: "expand-neighborhood", label: "Expand", title: "Restore the collapsed local neighborhood", icon: Users, disabled: !selectedNodeState.isNeighborhoodCollapsed, onClick: () => handlePluginAction({ type: "expandNeighborhood" }), }, ]; }, [handlePluginAction, selectedNodeState]); const analysisToolbarItems = useMemo( () => pluginToolbarItems.map((item) => ({ id: item.id, label: item.label, title: item.title, icon: iconForPluginToolbarItem(item), active: item.active, onClick: item.onClick, })), [pluginToolbarItems], ); const utilityToolbarItems = useMemo(() => [ { id: "reload", label: "Reload", title: "Reload the graph data", ariaLabel: "Reload graph data", icon: RefreshCw, compact: true, disabled: showLoadingOverlay, onClick: reload, }, ], [reload, showLoadingOverlay]); const searchDisabled = showLoadingOverlay || !searchQuery.trim(); const distanceToolbarItems = useMemo(() => { if (!hasGraphContent || viewMode !== "full" || !selectedNodeId || !graph.hasNode(selectedNodeId)) { return []; } return [ { id: "ego-mode", label: egoModeEnabled ? `Ego (${egoMaxHops}h)` : "Ego Mode", title: egoModeEnabled ? `Egocentric view: ${egoMaxHops} hops depth (click to toggle off)` : "Show depth-of-field fading around the selected node", active: egoModeEnabled, onClick: () => { setEgoModeEnabled((v) => !v); setHeatmapEnabled(false); setDistanceMode("off"); }, }, { id: "heatmap", label: "Heatmap", title: heatmapEnabled ? "Distance heatmap active (click to toggle off)" : "Color nodes by hop distance from selected node", active: heatmapEnabled, onClick: () => { setHeatmapEnabled((v) => !v); setEgoModeEnabled(false); setDistanceMode("off"); }, }, { id: "dist-structural", label: "Structural", title: "Color edges by structural (hop) distance", active: distanceMode === "structural", onClick: () => { setEgoModeEnabled(false); setHeatmapEnabled(false); setDistanceMode((m) => (m === "structural" ? "off" : "structural")); }, }, { id: "dist-semantic", label: "Semantic", title: "Color edges by semantic similarity to selected node", active: distanceMode === "semantic", onClick: () => { setEgoModeEnabled(false); setHeatmapEnabled(false); setDistanceMode((m) => (m === "semantic" ? "off" : "semantic")); }, }, ]; }, [distanceMode, egoMaxHops, egoModeEnabled, hasGraphContent, heatmapEnabled, selectedNodeId, viewMode]); const toolbarClusters = useMemo(() => [ { id: "camera", label: "Camera", items: cameraToolbarItems, }, { id: "layout", label: "Layout", items: layoutToolbarItems, }, { id: "local-structure", label: "Local", items: localToolbarItems, }, { id: "distance", label: "Distance", items: distanceToolbarItems, }, { id: "analysis", label: "Analysis", items: analysisToolbarItems, }, { id: "utility", label: "Utility", items: utilityToolbarItems, }, ].filter((group) => group.items.length > 0), [ analysisToolbarItems, cameraToolbarItems, distanceToolbarItems, layoutToolbarItems, localToolbarItems, utilityToolbarItems, ]); const sceneAdapterProps = { onNodeSelect: focusNode, onEdgeSelect: handleEdgeSelect, graphVersion, graphReady, displayGraph: displayResult.graph, displayMeta, displayState, selectedNodeId, focusedNodeId, selectedEdgeId, activePath, activePathEdgeIds, distanceVisualState, effectsState, temporalState, isLayoutRunning, layoutSource: graphSummary?.layoutSource, viewMode, showFitViewButton: false, pluginOverlays: pluginOverlays.map((overlay) => overlay.element), onRuntimeChange: handleSceneRuntimeChange, onInteractionStateChange: handleInteractionStateChange, onDiagnosticsChange: handleDiagnosticsChange, onAnalyticsChange: handleAnalyticsChange, } as const; const showDistanceStatus = distanceVisualState.mode !== "off"; const distanceReachableCount = Object.keys(distanceVisualState.structuralDistances).length; const visibleDistanceCounts = distanceVisualState.distanceCounts; const renderedHeatmapCounts = distanceVisualState.heatmapRenderedRingCounts; const formatRenderedCount = (truth: number, rendered: number | undefined) => { if (rendered == null || rendered >= truth) { return truth.toLocaleString(); } return `${truth.toLocaleString()} (${rendered.toLocaleString()} shown)`; }; const heatmapDistanceSummary = visibleDistanceCounts ? [ `${visibleDistanceCounts.anchor.toLocaleString()} anchor`, `${formatRenderedCount(visibleDistanceCounts.oneHop, renderedHeatmapCounts?.oneHop)} 1-hop`, `${formatRenderedCount(visibleDistanceCounts.twoHop, renderedHeatmapCounts?.twoHop)} 2-hop`, `${formatRenderedCount(visibleDistanceCounts.threeHopPlus, renderedHeatmapCounts?.threeHopPlus)} 3+ hop`, `${visibleDistanceCounts.outside.toLocaleString()} outside`, distanceVisualState.heatmapSaturationMode === "sampled" ? "Sampled for readability" : "", ].filter(Boolean).join(" · ") : `${distanceReachableCount.toLocaleString()} nodes within ${distanceVisualState.maxHops} hops`; const heatmapRenderedSummary = distanceVisualState.mode === "heatmap" && renderedHeatmapCounts ? [ `${renderedHeatmapCounts.anchor.toLocaleString()} anchor shown`, `${renderedHeatmapCounts.oneHop.toLocaleString()} 1-hop shown`, `${renderedHeatmapCounts.twoHop.toLocaleString()} 2-hop shown`, `${renderedHeatmapCounts.threeHopPlus.toLocaleString()} 3+ hop shown`, ].join(" · ") : null; const distanceLegendItems = distanceVisualState.mode === "heatmap" ? [ { label: "Anchor", color: getDistanceBandColor(0) }, { label: "1", color: getDistanceBandColor(1) }, { label: "2", color: getDistanceBandColor(2) }, { label: "3", color: getDistanceBandColor(3) }, { label: "Outside", color: withAlpha(GRAPH_THEME.palette.overview.nodeMuted, 0.38) }, ] : [ { label: "0h", color: getDistanceBandColor(0) }, { label: "1h", color: getDistanceBandColor(1) }, { label: "2-3h", color: getDistanceBandColor(3) }, { label: "4-6h", color: getDistanceBandColor(6) }, ]; return (
{(showLoadingOverlay || showSettlingStatus) && loadingProgress ? ( {getGraphLoadTitle(loadingProgress.phase)} ) : null} {summary ? ( {summary.nodeCount.toLocaleString()} nodes · {summary.edgeCount.toLocaleString()} edges ) : null} {activeNodeCount !== null ? ( {activeNodeCount.toLocaleString()} active ) : null} {focusedSummary ? {focusedSummary} : null}
void handleSearch()} />
{toolbarClusters.map((group) => ( ))}
{egoModeEnabled && (
Ego depth: setEgoMaxHops(Number(e.target.value))} style={{ width: 90, accentColor: "#79c0ff" }} title={`Ego depth: ${egoMaxHops} hops`} /> {egoMaxHops} hop{egoMaxHops !== 1 ? "s" : ""}
)} {showDistanceStatus ? (
Distance Intelligence {distanceVisualState.mode}
{distanceVisualState.anchorLabel ? ( Anchor: {distanceVisualState.anchorLabel} ) : null} {distanceVisualState.mode === "semantic" ? ( {distanceVisualState.status === "loading" ? "Loading semantic neighborhood..." : `${distanceVisualState.semanticNeighborCount ?? 0} semantic neighbors`} ) : distanceVisualState.mode === "heatmap" ? ( {heatmapDistanceSummary} ) : ( {distanceReachableCount.toLocaleString()} nodes within {distanceVisualState.maxHops} hops )} {distanceVisualState.status === "unavailable" || distanceVisualState.status === "error" ? ( {distanceVisualState.error} ) : null} {heatmapRenderedSummary ? ( {heatmapRenderedSummary} ) : null}
{distanceLegendItems.map((item) => ( {item.label} ))}
) : null} {searchError ?
{searchError}
: null} {searchResults.length ? (
{searchResults.map((result) => ( ))}
) : null} {selectedEdgeState ? (
Relationship
{selectedEdgeState.edgeType}
weight {selectedEdgeState.weight.toFixed(2)} {selectedEdgeState.isAggregated ? ( {selectedEdgeState.aggregateCount} bundled edge{selectedEdgeState.aggregateCount === 1 ? "" : "s"} ) : ( {selectedEdgeState.siblingCount} parallel lane{selectedEdgeState.siblingCount === 1 ? "" : "s"} )} {selectedEdgeState.familySize} family member{selectedEdgeState.familySize === 1 ? "" : "s"} {selectedEdgeState.bundleKind ? ( {selectedEdgeState.bundleKind} bundle ) : null} {selectedEdgeState.dominantEdgeType ? ( {selectedEdgeState.dominantEdgeType} ) : null} {selectedEdgeState.provenanceCount > 0 ? ( {selectedEdgeState.provenanceCount} provenance fields ) : null}
{Object.keys(selectedEdgeState.properties).length ? (
{Object.entries(selectedEdgeState.properties).slice(0, 4).map(([key, value]) => (
{key}
{typeof value === "object" ? JSON.stringify(value) : String(value)}
))}
) : null}
) : null}
{layoutState.showPluginDock ? (
{openDockPanels.map((panel) => ( ))} {activeDockPanel ? ( ) : null}
{activeDockPanel ? (
{activeDockPanel.content}
) : null}
) : null}
Loading timeline…
}>
{layoutState.showInspector ? (
Loading inspector…
}> void handleRunPredictions()} isRunningPredictions={isRunningPredictions} pathTargetId={pathTargetId} onPathTargetChange={setPathTargetId} onTracePath={() => void handleTracePath()} pathResult={pathResult} onDownloadProvenance={(format) => void handleDownloadProvenance(format)} onFocusNode={focusNode} />
) : null}
); } const actionButtonStyle: React.CSSProperties = { background: GRAPH_THEME.ui.control.primaryBg, color: GRAPH_THEME.ui.control.primaryText, border: `1px solid ${GRAPH_THEME.ui.control.primaryBorder}`, borderRadius: 12, padding: "9px 12px", cursor: "pointer", fontWeight: 700, fontSize: 12, display: "inline-flex", alignItems: "center", justifyContent: "center", boxShadow: "inset 0 1px 0 rgba(255,255,255,0.07), 0 10px 24px rgba(0,0,0,0.18)", }; const secondaryActionButtonStyle: React.CSSProperties = { ...actionButtonStyle, background: GRAPH_THEME.ui.control.defaultBg, border: `1px solid ${GRAPH_THEME.ui.control.defaultBorder}`, color: GRAPH_THEME.ui.control.defaultText, fontWeight: 600, }; const predictionCardStyle: React.CSSProperties = { textAlign: "left", padding: 12, background: "rgba(255, 255, 255, 0.035)", border: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}`, borderRadius: 10, cursor: "pointer", }; const selectedEdgeCardStyle: React.CSSProperties = { display: "flex", flexDirection: "column", gap: 12, padding: 14, background: "linear-gradient(135deg, rgba(98, 226, 205, 0.1), rgba(233, 196, 122, 0.045))", border: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}`, borderRadius: 14, boxShadow: "inset 0 1px 0 rgba(255,255,255,0.03)", }; const selectedEdgeNodeChipStyle: React.CSSProperties = { ...secondaryActionButtonStyle, minHeight: 32, padding: "7px 12px", whiteSpace: "nowrap", }; const distanceStatusStripStyle: React.CSSProperties = { display: "flex", alignItems: "center", gap: 12, flexWrap: "wrap", border: `1px solid ${GRAPH_THEME.ui.control.activeBorder}`, background: "linear-gradient(135deg, rgba(98, 226, 205, 0.09), rgba(227, 179, 65, 0.045))", borderRadius: 16, padding: "9px 12px", boxShadow: "inset 0 1px 0 rgba(255,255,255,0.045)", }; const distanceStatusTitleStyle: React.CSSProperties = { display: "flex", alignItems: "center", gap: 7, color: GRAPH_THEME.ui.text.strong, fontSize: 12, fontWeight: 800, letterSpacing: "0.04em", textTransform: "uppercase", }; const distanceModeBadgeStyle: React.CSSProperties = { padding: "2px 7px", borderRadius: 999, border: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}`, color: GRAPH_THEME.palette.accent.path, background: "rgba(215, 144, 86, 0.1)", fontSize: 10, }; const distanceStatusMetaStyle: React.CSSProperties = { display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap", color: GRAPH_THEME.ui.text.body, fontSize: 12, }; const distanceLegendStyle: React.CSSProperties = { display: "flex", alignItems: "center", gap: 8, marginLeft: "auto", color: GRAPH_THEME.ui.text.muted, fontSize: 11, }; const distanceLegendItemStyle: React.CSSProperties = { display: "inline-flex", alignItems: "center", gap: 4, }; const distanceLegendSwatchStyle: React.CSSProperties = { width: 8, height: 8, borderRadius: 999, }; const selectedEdgePropertyGridStyle: React.CSSProperties = { display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(160px, 1fr))", gap: 8, }; const selectedEdgePropertyCardStyle: React.CSSProperties = { borderRadius: 10, padding: 10, background: "rgba(255, 255, 255, 0.03)", border: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}`, }; const pluginDockContentStyle: React.CSSProperties = { borderRadius: 16, border: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}`, background: "rgba(255, 255, 255, 0.02)", padding: 14, }; const pluginLoadingStyle: React.CSSProperties = { color: GRAPH_THEME.ui.text.muted, fontSize: 12, padding: 10, }; const searchResultsStripStyle: React.CSSProperties = { display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))", gap: 10, maxHeight: 186, overflowY: "auto", }; const inspectorFallbackStyle: React.CSSProperties = { padding: 24, color: GRAPH_THEME.ui.text.muted, fontSize: 12, }; const timelineFallbackStyle: React.CSSProperties = { height: "90px", display: "flex", alignItems: "center", padding: "0 18px", color: GRAPH_THEME.ui.text.muted, fontSize: 12, borderTop: `1px solid ${GRAPH_THEME.ui.timeline.border}`, background: GRAPH_THEME.ui.timeline.background, };