import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from "react"; import { GraphLoadingOverlay } from "./GraphLoadingOverlay"; import { getGraphLoadTitle } from "./graphLoading"; import { useGraphData, useReloadGraphData } from "./useGraphData"; import type { ApiNode, GraphLayoutStatus, GraphLoadProgress, GraphPath, GraphSelectedNodeState, GraphStageHandle, GraphViewMode, } from "./types"; type SearchResult = { node: { id: string; type: string; content: string; properties: Record; }; score: number; }; type LinkPrediction = { target: string; type: string; label?: string; score: number; }; type PathResponse = { path: GraphPath; total_weight: number; hop_count: number; distance_band: "direct" | "near" | "mid-range" | "distant"; }; type TemporalBounds = { min?: string | null; max?: string | null; }; const GraphRuntimeStage = lazy(() => import("./GraphRuntimeStage").then((module) => ({ default: module.GraphRuntimeStage })), ); const TimelinePanel = lazy(() => import("./TimelinePanel").then((module) => ({ default: module.TimelinePanel })), ); const HUD_CSS = ` .palantir-bg { background: radial-gradient(circle at top, rgba(103, 182, 255, 0.1), transparent 24%), linear-gradient(180deg, #07111d 0%, #02060e 100%); } .palantir-grid { position: absolute; inset: 0; background-image: linear-gradient(rgba(88, 166, 255, 0.04) 1px, transparent 1px), linear-gradient(90deg, rgba(88, 166, 255, 0.04) 1px, transparent 1px); background-size: 44px 44px; pointer-events: none; z-index: 1; opacity: 0.78; } .palantir-vignette { position: absolute; inset: 0; background: radial-gradient(ellipse at center, transparent 34%, rgba(1, 4, 9, 0.88) 100%); pointer-events: none; z-index: 2; } .hud-scrollbar::-webkit-scrollbar { width: 6px; } .hud-scrollbar::-webkit-scrollbar-track { background: transparent; } .hud-scrollbar::-webkit-scrollbar-thumb { background: rgba(88, 166, 255, 0.25); border-radius: 6px; } .graph-shell-top { position: absolute; top: 18px; left: 18px; right: 18px; z-index: 10; display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; pointer-events: none; } .graph-status-card, .graph-command-card { pointer-events: auto; border: 1px solid rgba(132, 197, 255, 0.12); background: linear-gradient(180deg, rgba(7, 16, 29, 0.86), rgba(10, 22, 39, 0.72)), radial-gradient(circle at top, rgba(103, 182, 255, 0.08), transparent 50%); box-shadow: 0 18px 42px rgba(0, 0, 0, 0.28), inset 0 1px 0 rgba(255,255,255,0.04); backdrop-filter: blur(18px); } .graph-status-card { width: min(420px, 38vw); border-radius: 24px; padding: 16px 18px; } .graph-command-card { width: min(620px, 55vw); border-radius: 24px; padding: 14px; display: flex; flex-direction: column; gap: 12px; } .graph-status-label { display: inline-flex; align-items: center; gap: 8px; color: rgba(160, 191, 223, 0.88); font-size: 11px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 10px; } .graph-status-label::before { content: ""; width: 7px; height: 7px; border-radius: 999px; background: linear-gradient(135deg, #8ed3ff, #ffb36a); box-shadow: 0 0 12px rgba(142, 211, 255, 0.5); } .graph-status-title { color: #eef5ff; font-size: 20px; font-weight: 800; letter-spacing: -0.04em; margin-bottom: 6px; } .graph-status-copy { color: #8fa8c6; font-size: 12px; line-height: 1.55; margin-bottom: 14px; max-width: 40ch; } .graph-status-metrics, .graph-command-row, .graph-toggle-cluster, .graph-action-cluster { display: flex; gap: 8px; flex-wrap: wrap; } .graph-command-row { justify-content: space-between; align-items: center; gap: 10px; } .graph-search-shell { flex: 1; min-width: 260px; display: flex; align-items: center; gap: 10px; padding: 8px 10px 8px 14px; border-radius: 18px; border: 1px solid rgba(132, 197, 255, 0.12); background: rgba(0, 0, 0, 0.18); box-shadow: inset 0 1px 0 rgba(255,255,255,0.03); } .graph-search-shell input { flex: 1; min-width: 0; border: none !important; background: transparent !important; padding: 0 !important; margin: 0 !important; } .graph-search-shell input:focus { outline: none; } .graph-search-results { position: absolute; top: 120px; right: 18px; width: min(420px, calc(100vw - 132px)); max-height: 320px; overflow-y: auto; padding: 12px; border-radius: 20px; border: 1px solid rgba(132, 197, 255, 0.14); background: linear-gradient(180deg, rgba(8, 18, 33, 0.94), rgba(10, 21, 38, 0.86)); box-shadow: 0 18px 50px rgba(0,0,0,0.34); backdrop-filter: blur(18px); pointer-events: auto; z-index: 11; } .graph-search-results-label { color: #6f89ab; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 10px; } .graph-search-result-card { width: 100%; text-align: left; padding: 12px 14px; border-radius: 16px; border: 1px solid rgba(132, 197, 255, 0.08); background: rgba(255, 255, 255, 0.025); cursor: pointer; transition: transform 160ms ease, border-color 160ms ease, background 160ms ease; } .graph-search-result-card:hover { transform: translateY(-1px); border-color: rgba(132, 197, 255, 0.18); background: rgba(103, 182, 255, 0.08); } .graph-inspector { pointer-events: auto; position: absolute; right: 18px; top: 154px; bottom: 108px; width: 380px; overflow-y: auto; transition: transform 0.34s cubic-bezier(0.16,1,0.3,1), opacity 0.22s ease; border-radius: 28px; border: 1px solid rgba(132, 197, 255, 0.14); background: linear-gradient(180deg, rgba(8, 18, 33, 0.9), rgba(6, 12, 22, 0.88)), radial-gradient(circle at top, rgba(103, 182, 255, 0.08), transparent 40%); box-shadow: -18px 0 48px rgba(0, 0, 0, 0.32), inset 0 1px 0 rgba(255,255,255,0.04); backdrop-filter: blur(20px); } .graph-inspector[data-open='false'] { transform: translateX(calc(100% + 24px)); opacity: 0; } @keyframes sem-loader-pulse { 0%, 100% { transform: translateY(0) scale(0.92); opacity: 0.55; } 50% { transform: translateY(-4px) scale(1.08); opacity: 1; } } @media (max-width: 1220px) { .graph-shell-top { flex-direction: column; align-items: stretch; } .graph-status-card, .graph-command-card { width: auto; } .graph-search-results { top: 202px; right: 18px; left: 18px; width: auto; } } `; 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 sourceAttribution(properties: Record) { const keys = ["source", "source_url", "pmid", "pmids", "evidence", "provenance", "confidence"]; return keys .filter((key) => key in properties) .map((key) => ({ key, value: properties[key] })); } function toSelectedNodeState(node: ApiNode, neighborCount: number, fallbackColor = "#58a6ff"): GraphSelectedNodeState { return { id: node.id, label: node.content || node.id, content: node.content || node.id, nodeType: node.type, color: fallbackColor, valid_from: node.valid_from ?? null, valid_until: node.valid_until ?? null, properties: node.properties ?? {}, neighborCount, visibleNeighborCount: neighborCount, collapsedNeighborCount: 0, isNeighborhoodCollapsed: false, canCollapseNeighborhood: neighborCount > 8, }; } function TimelineFallback({ min, max }: TemporalBounds) { return (
Temporal scrubber {min || max ? "Preparing timeline runtime..." : "Temporal bounds loading..."}
); } function NodePanel({ node, predictions, predictionType, onPredictionTypeChange, onRunPredictions, pathTargetId, onPathTargetChange, onTracePath, pathResult, onDownloadProvenance, }: { node: GraphSelectedNodeState | null; predictions: LinkPrediction[]; predictionType: string; onPredictionTypeChange: (value: string) => void; onRunPredictions: () => void; pathTargetId: string; onPathTargetChange: (value: string) => void; onTracePath: () => void; pathResult: PathResponse | null; onDownloadProvenance: (format: "json" | "markdown") => void; }) { if (!node) { return (

Search for a node or click one in the canvas to inspect its properties.

); } const properties = node.properties ?? {}; const attribution = sourceAttribution(properties); const accentColor = node.color || "#58a6ff"; const propertyEntries = Object.entries(properties).filter(([key]) => !["x", "y", "valid_from", "valid_until", "content", "source", "source_url", "pmid", "pmids", "evidence", "provenance", "confidence"].includes(key)); return ( ); } export function GraphWorkspaceShell() { const [selectedNodeId, setSelectedNodeId] = useState(""); const [selectedNodeState, setSelectedNodeState] = useState(null); const [isLayoutRunning, setIsLayoutRunning] = useState(false); const [viewMode, setViewMode] = useState("full"); const [searchQuery, setSearchQuery] = useState(""); const [searchResults, setSearchResults] = useState([]); const [searchError, setSearchError] = useState(""); const [predictionType, setPredictionType] = useState(""); 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 — same fix as // GraphWorkspace.tsx (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 [isGraphStageReady, setIsGraphStageReady] = useState(false); const [layoutStatus, setLayoutStatus] = useState({ state: "idle", source: "runtime", hasCoordinates: false, layoutReady: false, displacement: null, elapsedMs: 0, stableSamples: 0, }); const debouncedTime = useDebounce(scrubberTime, 150); const stageRef = useRef(null); const reload = useReloadGraphData(); const { data: snapshot, isLoading, isFetching, isError, error } = useGraphData({ enabled: true, onProgress: setLoadingProgress }); const handleSelectedNodeStateChange = useCallback((state: GraphSelectedNodeState | null) => { setSelectedNodeState(state); }, []); const handleLayoutRunningChange = useCallback((running: boolean) => { setIsLayoutRunning(running); }, []); const handleActiveNodeCountChange = useCallback((count: number | null) => { setActiveNodeCount(count); }, []); const handleProgressChange = useCallback((progress: GraphLoadProgress | null) => { setLoadingProgress(progress); }, []); const handleRuntimeReady = useCallback(() => { setIsGraphStageReady(true); }, []); const handleLayoutStatusChange = useCallback((status: GraphLayoutStatus) => { setLayoutStatus(status); if (status.layoutReady) { setLoadingProgress(null); } }, []); const [prevFetchedAt, setPrevFetchedAt] = useState(snapshot?.fetchedAt); if (snapshot?.fetchedAt !== prevFetchedAt) { setPrevFetchedAt(snapshot?.fetchedAt); if (snapshot) { setIsGraphStageReady(false); setActiveNodeCount(null); setLayoutStatus({ state: snapshot.summary.layoutReady ? "interactive" : "idle", source: snapshot.summary.layoutSource ?? "runtime", hasCoordinates: snapshot.summary.hasCoordinates ?? false, layoutReady: snapshot.summary.layoutReady ?? false, displacement: null, elapsedMs: 0, stableSamples: 0, }); } } 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); } }; void loadBounds(); return () => { cancelled = true; }; }, [snapshot?.summary.nodeCount, snapshot?.summary.edgeCount]); const neighborCountMap = useMemo(() => { const map = new Map(); if (!snapshot) return map; for (const node of snapshot.nodes) map.set(node.id, 0); for (const edge of snapshot.edges) { map.set(edge.source, (map.get(edge.source) ?? 0) + 1); map.set(edge.target, (map.get(edge.target) ?? 0) + 1); } return map; }, [snapshot]); const visibleSelectedNode = useMemo(() => { if (!selectedNodeId) return null; if (selectedNodeState?.id === selectedNodeId) return selectedNodeState; const snapshotNode = snapshot?.nodes.find((candidate) => candidate.id === selectedNodeId); if (snapshotNode) return toSelectedNodeState(snapshotNode, neighborCountMap.get(snapshotNode.id) ?? 0); const searchNode = searchResults.find((candidate) => candidate.node.id === selectedNodeId)?.node; return searchNode ? { id: searchNode.id, label: searchNode.content || searchNode.id, content: searchNode.content || searchNode.id, nodeType: searchNode.type, color: "#58a6ff", valid_from: null, valid_until: null, properties: searchNode.properties ?? {}, neighborCount: 0, visibleNeighborCount: 0, collapsedNeighborCount: 0, isNeighborhoodCollapsed: false, canCollapseNeighborhood: false, } : null; }, [neighborCountMap, searchResults, selectedNodeId, selectedNodeState, snapshot]); const focusNode = useCallback((nodeId: string) => { setSelectedNodeId(nodeId); setPathResult(null); if (!nodeId) { setSelectedNodeState(null); setPredictions([]); return; } setSearchResults([]); setIsLayoutRunning(false); }, []); 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 || []); if (data.results?.length) { focusNode(data.results[0].node.id); } } catch (searchFetchError) { setSearchError(searchFetchError instanceof Error ? searchFetchError.message : "Search failed"); } }, [focusNode, searchQuery]); const handleRunPredictions = useCallback(async () => { if (!selectedNodeId) return; try { const response = await fetch("/api/enrich/links", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ node_id: selectedNodeId, 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("[GraphWorkspaceShell] prediction failed", predictionError); setPredictions([]); } }, [predictionType, selectedNodeId]); const handleTracePath = useCallback(async () => { if (!selectedNodeId || !pathTargetId.trim()) return; try { const pathParams = new URLSearchParams({ source: selectedNodeId, 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]; stageRef.current?.focusNode(lastStep); } } catch (pathError) { console.error("[GraphWorkspaceShell] path trace failed", pathError); setPathResult(null); } }, [pathTargetId, selectedNodeId]); const handleDownloadProvenance = useCallback(async (format: "json" | "markdown") => { if (!selectedNodeId) return; const suffix = format === "markdown" ? "markdown" : "json"; const response = await fetch(`/api/provenance/report?node_id=${encodeURIComponent(selectedNodeId)}&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 = `${selectedNodeId}_provenance.${format === "markdown" ? "md" : "json"}`; document.body.appendChild(anchor); anchor.click(); window.URL.revokeObjectURL(url); document.body.removeChild(anchor); }, [selectedNodeId]); const searchSummary = useMemo(() => { if (!searchResults.length) return null; return `${searchResults.length} search result${searchResults.length === 1 ? "" : "s"}`; }, [searchResults.length]); const focusedSummary = useMemo(() => { if (!visibleSelectedNode) return null; if (viewMode === "focused") { const visibleNeighbors = Math.min(visibleSelectedNode.neighborCount, 16); return `${visibleNeighbors + 1} nodes in focused view`; } return `${visibleSelectedNode.neighborCount} direct neighbors highlighted`; }, [viewMode, visibleSelectedNode]); const requestViewMode = useCallback((nextViewMode: GraphViewMode) => { if (nextViewMode === "focused") { if (!selectedNodeId) { return; } setViewMode("focused"); setIsLayoutRunning(false); return; } setViewMode("full"); }, [selectedNodeId]); const showLoadingOverlay = isLoading || isFetching || !isGraphStageReady || (layoutStatus.source === "runtime" && !layoutStatus.layoutReady && !selectedNodeId && viewMode === "full"); const layoutStatusLabel = useMemo(() => { if (layoutStatus.source === "provided" && layoutStatus.layoutReady) return "Persisted layout"; if (layoutStatus.source === "carried" && layoutStatus.layoutReady) return "Preserved layout"; if (layoutStatus.state === "bootstrapping") return "Bootstrapping layout"; if (layoutStatus.state === "running") return "Stabilizing layout"; if (layoutStatus.state === "failed") return "Layout timeout fallback"; return null; }, [layoutStatus]); return (
}>
Graph Studio
{visibleSelectedNode ? visibleSelectedNode.label : "Knowledge Explorer"}
{showLoadingOverlay && loadingProgress ? {getGraphLoadTitle(loadingProgress.phase)} : null} {layoutStatusLabel ? {layoutStatusLabel} : null} {snapshot ? {snapshot.summary.nodeCount.toLocaleString()} nodes · {snapshot.summary.edgeCount.toLocaleString()} edges : null} {activeNodeCount !== null ? {activeNodeCount.toLocaleString()} active : null} {searchSummary ? {searchSummary} : null} {focusedSummary ? {focusedSummary} : null} {isError ? {(error as Error).message} : null}
{selectedNodeId ? ( <> ) : ( Select a node to switch graph views )}
setSearchQuery(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter") { void handleSearch(); } }} placeholder="Search a node, e.g. Metformin" style={{ ...inputStyle, minWidth: 260 }} disabled={showLoadingOverlay && !selectedNodeId} />
{searchError ?
{searchError}
: null} {searchResults.length ? (
Search Results
{searchResults.map((result) => ( ))}
) : null}
void handleRunPredictions()} pathTargetId={pathTargetId} onPathTargetChange={setPathTargetId} onTracePath={() => void handleTracePath()} pathResult={pathResult} onDownloadProvenance={(format) => void handleDownloadProvenance(format)} />
); } const metricPillStyle: CSSProperties = { background: "rgba(88, 166, 255, 0.08)", color: "#8ed3ff", padding: "6px 11px", borderRadius: 999, fontSize: 12, fontWeight: 700, border: "1px solid rgba(88, 166, 255, 0.14)", }; const sectionStyle: CSSProperties = { display: "flex", flexDirection: "column", gap: 10, padding: 14, background: "linear-gradient(180deg, rgba(255,255,255,0.025), rgba(255,255,255,0.01))", border: "1px solid rgba(255, 255, 255, 0.06)", borderRadius: 16, }; const sectionTitleStyle: CSSProperties = { color: "#8fa8c6", fontSize: 11, fontWeight: 800, textTransform: "uppercase", letterSpacing: "0.08em", }; const inputStyle: CSSProperties = { width: "100%", background: "rgba(0, 0, 0, 0.24)", border: "1px solid rgba(88, 166, 255, 0.14)", color: "#fff", borderRadius: 12, padding: "10px 12px", fontSize: 13, }; const actionButtonStyle: CSSProperties = { background: "linear-gradient(180deg, rgba(53, 130, 245, 0.28), rgba(25, 88, 185, 0.18))", color: "#fff", border: "1px solid rgba(88, 166, 255, 0.2)", borderRadius: 12, padding: "10px 13px", cursor: "pointer", fontWeight: 700, fontSize: 12, display: "inline-flex", alignItems: "center", justifyContent: "center", boxShadow: "inset 0 1px 0 rgba(255,255,255,0.05)", }; const secondaryActionButtonStyle: CSSProperties = { ...actionButtonStyle, background: "rgba(255, 255, 255, 0.035)", border: "1px solid rgba(255, 255, 255, 0.06)", color: "#d6e5f8", fontWeight: 500, }; const predictionCardStyle: CSSProperties = { textAlign: "left", padding: 12, background: "rgba(88, 166, 255, 0.06)", border: "1px solid rgba(88, 166, 255, 0.1)", borderRadius: 14, cursor: "pointer", }; const pathStepStyle: CSSProperties = { color: "#e6edf3", fontSize: 13, padding: "8px 10px", background: "rgba(255, 255, 255, 0.03)", borderRadius: 8, }; const propertyCardStyle: CSSProperties = { background: "rgba(0, 0, 0, 0.18)", padding: "10px 12px", borderRadius: 12, border: "1px solid rgba(255, 255, 255, 0.05)", }; const emptyTextStyle: CSSProperties = { color: "#8b949e", fontSize: 12, lineHeight: 1.5, }; const subtleChipStyle: CSSProperties = { background: "rgba(255, 255, 255, 0.035)", color: "#9fb6d2", padding: "5px 9px", borderRadius: 999, fontSize: 11, border: "1px solid rgba(255, 255, 255, 0.06)", }; const collapseStyle: CSSProperties = { border: "1px solid rgba(255, 255, 255, 0.05)", borderRadius: 14, background: "rgba(0, 0, 0, 0.14)", overflow: "hidden", }; const summaryStyle: CSSProperties = { cursor: "pointer", listStyle: "none", padding: "12px 14px", color: "#c6d4e3", fontSize: 12, fontWeight: 700, letterSpacing: "0.04em", textTransform: "uppercase", };