Files
semantica/explorer/src/workspaces/GraphWorkspace/GraphWorkspace.tsx
T
KaifAhmad1andClaude Sonnet 4.6 670027fd22 fix(explorer): resolve 3 code-review bugs in GraphWorkspace, DecisionWorkspace, and index.css
- GraphWorkspace: set isRunningPredictions=true before link-prediction fetch
  and false in finally block; pass isRunningPredictions prop to
  LazyGraphInspectorPanel so the inspector button disables and shows a
  spinner during the request (was declared but never wired — broke
  noUnusedLocals TypeScript build)

- DecisionWorkspace: add AbortController to the /api/decisions useEffect
  so the fetch is cancelled on unmount; add per-call AbortController to
  handleSelectDecision for /api/decisions/:id/chain; add res.ok guards
  before .json() on both fetches; encodeURIComponent on decision_id to
  prevent path-injection edge cases

- index.css: add missing @keyframes skeleton-pulse rule (0%/100% opacity
  0.45, 50% opacity 0.85) — KGOverviewTab skeletonBarStyle referenced
  this animation but it was never defined, leaving skeleton bars static

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 14:16:59 +05:30

1744 lines
58 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
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 GraphPlugin,
type GraphPluginActionRequest,
type GraphPluginContext,
type GraphPluginOverlayDescriptor,
type GraphPluginPanelDescriptor,
type GraphPluginToolbarItem,
} from "./plugins";
import type { LinkPrediction, PathResponse } from "./GraphInspectorPanel";
import type { GraphSceneHandle, GraphSceneRuntime } from "./scene";
import type {
GraphAnalyticsSnapshot,
GraphDiagnosticsSnapshot,
GraphEffectToggle,
GraphEffectsState,
GraphInteractionState,
GraphLoadProgress,
GraphLoadSummary,
GraphSelectedEdgeState,
GraphSelectedNodeState,
GraphTemporalState,
GraphViewMode,
} from "./types";
type SearchResult = {
node: {
id: string;
type: string;
content: string;
properties: Record<string, unknown>;
};
score: number;
};
type TemporalBounds = {
min?: string | null;
max?: string | null;
};
type ExploreLayoutState = {
showInspector: boolean;
showPluginDock: boolean;
};
type GraphToolbarItem = {
id: string;
label: string;
title?: string;
active?: boolean;
disabled?: boolean;
tone?: "primary" | "secondary";
onClick: () => void;
};
type GraphToolbarGroup = {
id: string;
items: GraphToolbarItem[];
};
type LazyPluginRegistryEntry = {
id: string;
panelId: string;
label: string;
title: string;
order: number;
load: () => Promise<GraphPlugin>;
shouldLoad: (context: {
panelState: Record<string, boolean>;
temporalState: GraphTemporalState | null;
}) => boolean;
};
const PROVENANCE_KEYS = ["source", "source_url", "pmid", "pmids", "evidence", "provenance", "confidence"] as const;
const DEFAULT_EFFECTS_STATE: GraphEffectsState = {
pathPulseEnabled: false,
pathFlowEnabled: false,
lensEnabled: false,
temporalEmphasisEnabled: false,
semanticRegionsEnabled: true,
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);
function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const timeout = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timeout);
}, [delay, value]);
return debouncedValue;
}
const HUD_CSS = `
.palantir-bg {
background:
radial-gradient(circle at top, rgba(77, 157, 255, 0.08), transparent 22%),
linear-gradient(180deg, #060b17 0%, #02060d 100%);
}
.palantir-grid {
position: absolute;
inset: 0;
background-image:
linear-gradient(rgba(88, 166, 255, 0.038) 1px, transparent 1px),
linear-gradient(90deg, rgba(88, 166, 255, 0.038) 1px, transparent 1px);
background-size: 42px 42px;
pointer-events: none;
z-index: 1;
}
.palantir-vignette {
position: absolute;
inset: 0;
background: radial-gradient(ellipse at center, transparent 34%, rgba(1, 4, 10, 0.78) 100%);
pointer-events: none;
z-index: 2;
}
.glass-header {
background: linear-gradient(180deg, rgba(7, 14, 25, 0.88) 0%, rgba(10, 18, 31, 0.62) 100%);
border-bottom: 1px solid rgba(112, 196, 255, 0.1);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
}
.glass-hud {
background: linear-gradient(135deg, rgba(8, 15, 27, 0.76), rgba(10, 19, 32, 0.58));
backdrop-filter: blur(14px) saturate(1.08);
-webkit-backdrop-filter: blur(14px) saturate(1.08);
border-left: 1px solid rgba(112, 196, 255, 0.14);
box-shadow: -10px 0 28px rgba(0, 0, 0, 0.34), inset 1px 0 0 rgba(255, 255, 255, 0.04);
}
.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; }
.node-panel-collapse {
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 12px;
background: rgba(0, 0, 0, 0.14);
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: #c6d4e3;
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: rgba(127, 208, 255, 0.8);
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: linear-gradient(135deg, rgba(7, 17, 31, 0.9), rgba(14, 28, 48, 0.78));
border: 1px solid rgba(127, 208, 255, 0.16);
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.38), inset 0 1px 0 rgba(255,255,255,0.04);
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, rgba(127, 208, 255, 0.96), rgba(242, 182, 109, 0.96));
box-shadow: 0 0 18px rgba(127, 208, 255, 0.35);
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.06);
border: 1px solid rgba(127, 208, 255, 0.1);
}
.graph-loading-bar > span {
display: block;
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, rgba(74, 163, 255, 0.9), rgba(127, 208, 255, 0.95), rgba(242, 182, 109, 0.92));
box-shadow: 0 0 28px rgba(74, 163, 255, 0.3);
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;
}
.explore-scene-stage {
position: relative;
z-index: 3;
width: 100%;
height: 100%;
}
.explore-scene-footer {
position: relative;
z-index: 3;
border-top: 1px solid rgba(112, 196, 255, 0.1);
background: linear-gradient(180deg, rgba(5, 11, 20, 0.94), rgba(5, 11, 20, 0.82));
}
.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 rgba(127, 208, 255, 0.14);
background: rgba(255, 255, 255, 0.03);
color: #a9bfd7;
border-radius: 999px;
padding: 6px 10px;
font-size: 11px;
font-weight: 600;
cursor: pointer;
}
.explore-plugin-dock-tab[data-active="true"] {
color: #eef6ff;
background: rgba(74, 163, 255, 0.16);
border-color: rgba(127, 208, 255, 0.28);
}
.explore-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
flex-wrap: wrap;
}
.explore-toolbar-groups {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
flex: 1;
justify-content: flex-end;
}
.explore-toolbar-group {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 3px;
border-radius: 14px;
border: 1px solid rgba(127, 208, 255, 0.08);
background: rgba(255, 255, 255, 0.02);
}
.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;
}
}
@media (max-width: 980px) {
.explore-shell {
padding: 10px;
gap: 10px;
}
.explore-command-grid {
grid-template-columns: 1fr;
}
}
`;
function getProvenanceCount(properties: Record<string, unknown>) {
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<string, unknown>;
}): 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<string, unknown>;
}): 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): 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<string, unknown>;
};
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,
};
}
function buildSelectedEdgeState(edgeId: string): GraphSelectedEdgeState | null {
if (!edgeId || !graph.hasEdge(edgeId)) {
return null;
}
const [sourceId, targetId] = graph.extremities(edgeId);
const attributes = graph.getEdgeAttributes(edgeId) as {
edgeType?: string;
weight?: number;
properties?: Record<string, unknown>;
familyId?: string;
};
const sourceAttributes = graph.getNodeAttributes(sourceId) as { label?: string; content?: string };
const targetAttributes = graph.getNodeAttributes(targetId) as { label?: string; content?: string };
const properties = attributes.properties ?? {};
const familyId = String(attributes.familyId || edgeId);
let familySize = 0;
let siblingCount = 0;
graph.forEachEdge((candidateEdgeId, candidateAttrs) => {
const edgeAttrs = candidateAttrs as { familyId?: string };
const [candidateSource, candidateTarget] = graph.extremities(candidateEdgeId);
if (String(edgeAttrs.familyId || candidateEdgeId) === familyId) {
familySize += 1;
}
if (candidateSource === sourceId && candidateTarget === targetId) {
siblingCount += 1;
}
});
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,
};
}
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);
});
}
export function GraphWorkspace() {
const [selectedNodeId, setSelectedNodeId] = useState("");
const [selectedEdgeId, setSelectedEdgeId] = useState("");
const [isLayoutRunning, setIsLayoutRunning] = useState(false);
const [viewMode, setViewMode] = useState<GraphViewMode>("focused");
const [searchQuery, setSearchQuery] = useState("");
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
const [searchError, setSearchError] = useState("");
const [predictionType, setPredictionType] = useState("");
const [isRunningPredictions, setIsRunningPredictions] = useState(false);
const [predictions, setPredictions] = useState<LinkPrediction[]>([]);
const [pathTargetId, setPathTargetId] = useState("");
const [pathResult, setPathResult] = useState<PathResponse | null>(null);
const [activeNodeCount, setActiveNodeCount] = useState<number | null>(null);
const [temporalBounds, setTemporalBounds] = useState<TemporalBounds | null>(null);
const [scrubberTime, setScrubberTime] = useState<Date | null>(null);
const [loadingProgress, setLoadingProgress] = useState<GraphLoadProgress | null>(null);
const [pluginPanelState, setPluginPanelState] = useState<Record<string, boolean>>({
"effects-panel": false,
"neighborhood-panel": false,
"temporal-panel": false,
});
const [activeDockPanelId, setActiveDockPanelId] = useState<string | null>(null);
const [pluginRuntimeVersion, setPluginRuntimeVersion] = useState(0);
const [effectsState, setEffectsState] = useState<GraphEffectsState>(DEFAULT_EFFECTS_STATE);
const [graphDiagnosticsState, setGraphDiagnosticsState] = useState<GraphDiagnosticsSnapshot["effectAvailability"] | null>(null);
const [graphAnalyticsState, setGraphAnalyticsState] = useState<GraphAnalyticsSnapshot | null>(null);
const [loadedPlugins, setLoadedPlugins] = useState<Record<string, GraphPlugin>>({});
const debouncedTime = useDebounce(scrubberTime, 150);
const prevActiveIdsRef = useRef<Set<string>>(new Set());
const sceneRef = useRef<GraphSceneHandle>(null);
const pluginRuntimeRef = useRef<GraphSceneRuntime | null>(null);
const settlingOverlayTimeoutRef = useRef<number | null>(null);
const pluginInteractionStateRef = useRef<GraphInteractionState>({
hoveredNodeId: null,
selectedNodeId: "",
selectedEdgeId: "",
focusedNodeId: "",
activePath: [],
activePathEdgeIds: [],
viewMode: "focused",
zoomTier: "overview",
isLayoutRunning: false,
});
const reload = useReloadGraph();
const { data: summary, isLoading, isFetching } = useLoadGraph({
enabled: true,
onGraphReady: (graphSummary) => {
setIsLayoutRunning(!graphSummary.layoutReady);
if (settlingOverlayTimeoutRef.current !== null) {
window.clearTimeout(settlingOverlayTimeoutRef.current);
settlingOverlayTimeoutRef.current = null;
}
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",
}));
settlingOverlayTimeoutRef.current = window.setTimeout(() => {
setLoadingProgress((current) => (current?.phase === "stabilizing_layout" ? null : current));
settlingOverlayTimeoutRef.current = null;
}, 900);
},
onProgress: setLoadingProgress,
});
useEffect(() => {
return () => {
if (settlingOverlayTimeoutRef.current !== null) {
window.clearTimeout(settlingOverlayTimeoutRef.current);
}
};
}, []);
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);
sceneRef.current?.getRuntime()?.requestRender();
});
} catch (fetchError) {
if (!cancelled) {
console.error("[Temporal] Snapshot fetch failed", fetchError);
}
}
};
applySnapshot();
return () => {
cancelled = true;
};
}, [debouncedTime, isLoading]);
const focusNode = useCallback((nodeId: string) => {
setSelectedNodeId(nodeId);
setSelectedEdgeId("");
setPathResult(null);
setSearchResults([]);
setSearchError("");
if (nodeId) {
setIsLayoutRunning(false);
}
}, []);
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 (!selectedNodeId) return;
setIsRunningPredictions(true);
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("[GraphWorkspace] prediction failed", predictionError);
setPredictions([]);
} finally {
setIsRunningPredictions(false);
}
}, [predictionType, selectedNodeId]);
const handleTracePath = useCallback(async () => {
if (!selectedNodeId || !pathTargetId.trim()) return;
try {
const response = await fetch(
`/api/graph/node/${encodeURIComponent(selectedNodeId)}/path?target=${encodeURIComponent(pathTargetId.trim())}&algorithm=dijkstra`
);
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);
}
}, [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]);
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 });
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 });
sceneRef.current?.getRuntime()?.requestRender();
}
} catch (socketError) {
console.error("[GraphWorkspace] websocket update failed", socketError);
}
};
return () => {
socket.close();
};
}, []);
const focusedSummary = useMemo(() => {
if (!selectedNodeId || !graph.hasNode(selectedNodeId)) {
return null;
}
const localNeighborCount = graph.neighbors(selectedNodeId).length;
if (viewMode === "focused") {
const visibleNeighbors = Math.min(localNeighborCount, 16);
return `${visibleNeighbors + 1} nodes in focused view`;
}
return `${localNeighborCount} direct neighbors highlighted`;
}, [selectedNodeId, viewMode]);
const showLoadingOverlay = isLoading || isFetching || loadingProgress?.phase === "stabilizing_layout";
const hasGraphContent = Boolean(summary?.nodeCount);
const activePath = pathResult?.path ?? [];
const activePathEdgeIds = pathResult?.edge_ids ?? [];
const graphSummary = summary as GraphLoadSummary | null;
const selectedNodeState = useMemo(
() => buildSelectedNodeState(selectedNodeId),
[selectedNodeId, summary?.nodeCount, summary?.edgeCount],
);
const selectedEdgeState = useMemo(
() => buildSelectedEdgeState(selectedEdgeId),
[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<LazyPluginRegistryEntry[]>(
() => [
{
id: "exploration-effects",
panelId: "effects-panel",
label: "Effects",
title: "Open exploration effects controls",
order: 18,
load: loadExplorationEffectsPlugin,
shouldLoad: ({ panelState }) => Boolean(panelState["effects-panel"]),
},
{
id: "neighborhood-panel",
panelId: "neighborhood-panel",
label: "Neighbors",
title: "Toggle neighborhood panel",
order: 30,
load: loadNeighborhoodPanelPlugin,
shouldLoad: ({ panelState }) => Boolean(panelState["neighborhood-panel"]),
},
{
id: "temporal-overlay",
panelId: "temporal-panel",
label: "Temporal",
title: "Toggle temporal context panel",
order: 40,
load: loadTemporalOverlayPlugin,
shouldLoad: ({ panelState, temporalState }) => Boolean(panelState["temporal-panel"] || temporalState?.currentTime),
},
],
[],
);
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, temporalState })) {
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, temporalState]);
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":
setViewMode(action.viewMode);
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, setEffectToggle]);
const diagnosticsSnapshot = useMemo<GraphDiagnosticsSnapshot | null>(() => {
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,
effectAvailability: graphDiagnosticsState,
};
}, [activePlugins, effectsState, graphDiagnosticsState, pluginPanelState]);
const pluginContext = useMemo<GraphPluginContext>(() => ({
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,
isPanelOpen: (panelId: string) => Boolean(pluginPanelState[panelId]),
dispatchAction: handlePluginAction,
}), [
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((effectAvailability: GraphDiagnosticsSnapshot["effectAvailability"]) => {
if (!GRAPH_THEME.effects.diagnostics.enabledInDev) {
return;
}
setGraphDiagnosticsState(effectAvailability);
}, []);
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<GraphPluginToolbarItem[]>(
() => 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<GraphPluginPanelDescriptor[]>(
() => 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: <div style={pluginLoadingStyle}>Loading {entry.label.toLowerCase()}</div>,
})),
[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,
};
useEffect(() => {
if (!openDockPanels.length) {
setActiveDockPanelId(null);
return;
}
if (!activeDockPanelId || !openDockPanels.some((panel) => panel.id === activeDockPanelId)) {
setActiveDockPanelId(openDockPanels[0].id);
}
}, [activeDockPanelId, openDockPanels]);
const coreToolbarGroups = useMemo<GraphToolbarGroup[]>(() => {
const groups: GraphToolbarGroup[] = [];
if (selectedNodeId) {
groups.push({
id: "view-mode",
items: [
{
id: "view-focused",
label: "Focused",
title: "Inspect the selected node in a focused local graph",
active: viewMode === "focused",
onClick: () => setViewMode("focused"),
},
{
id: "view-full",
label: "Full Graph",
title: "Return to the full graph context",
active: viewMode === "full",
onClick: () => setViewMode("full"),
},
],
});
}
groups.push({
id: "graph-actions",
items: [
{
id: "search-submit",
label: "Search",
title: "Search for the current query",
tone: "primary",
disabled: showLoadingOverlay || !searchQuery.trim(),
onClick: () => void handleSearch(),
},
{
id: "zoom-in",
label: " Zoom In",
title: "Zoom in (or scroll up on the canvas)",
onClick: () => {
const runtime = sceneRef.current?.getRuntime();
if (runtime?.renderer === "sigma") {
const camera = (runtime.scene as import("sigma").default).getCamera();
camera.animatedZoom({ duration: 200 });
}
},
},
{
id: "zoom-out",
label: " Zoom Out",
title: "Zoom out (or scroll down on the canvas)",
onClick: () => {
const runtime = sceneRef.current?.getRuntime();
if (runtime?.renderer === "sigma") {
const camera = (runtime.scene as import("sigma").default).getCamera();
camera.animatedUnzoom({ duration: 200 });
}
},
},
{
id: "fit-view",
label: "Fit View",
title: "Reset the camera to fit the whole graph",
onClick: () => sceneRef.current?.fitView(),
},
{
id: "layout-toggle",
label: isLayoutRunning ? "Pause Layout" : "Run Layout",
title: "Toggle the layout worker",
active: isLayoutRunning,
disabled: showLoadingOverlay,
onClick: () => setIsLayoutRunning((value) => !value),
},
{
id: "reload",
label: "Reload",
title: "Reload the graph data",
disabled: showLoadingOverlay,
onClick: reload,
},
],
});
if (pluginToolbarItems.length) {
groups.push({
id: "plugin-tools",
items: pluginToolbarItems.map((item) => ({
id: item.id,
label: item.label,
title: item.title,
active: item.active,
onClick: item.onClick,
})),
});
}
return groups;
}, [isLayoutRunning, pluginToolbarItems, reload, searchQuery, selectedNodeId, showLoadingOverlay, viewMode]);
const sceneAdapterProps = {
onNodeSelect: focusNode,
onEdgeSelect: handleEdgeSelect,
selectedNodeId,
selectedEdgeId,
activePath,
activePathEdgeIds,
effectsState,
temporalState,
isLayoutRunning,
viewMode,
showFitViewButton: false,
pluginOverlays: pluginOverlays.map((overlay) => overlay.element),
onRuntimeChange: handleSceneRuntimeChange,
onInteractionStateChange: handleInteractionStateChange,
onDiagnosticsChange: handleDiagnosticsChange,
onAnalyticsChange: handleAnalyticsChange,
} as const;
return (
<div className="palantir-bg" style={{ position: "relative", width: "100%", height: "100%", overflow: "hidden" }}>
<style>{HUD_CSS}</style>
<div className="palantir-grid" />
<div className="palantir-vignette" />
<div className="explore-shell">
<section className="explore-command-deck">
<SurfaceCard tone="subtle">
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<div className="explore-toolbar">
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", alignItems: "center" }}>
{showLoadingOverlay && loadingProgress ? (
<MetricChip>{getGraphLoadTitle(loadingProgress.phase)}</MetricChip>
) : null}
{summary ? (
<MetricChip>{summary.nodeCount.toLocaleString()} nodes · {summary.edgeCount.toLocaleString()} edges</MetricChip>
) : null}
{activeNodeCount !== null ? (
<MetricChip tone="success">{activeNodeCount.toLocaleString()} active</MetricChip>
) : null}
{focusedSummary ? <MetricChip tone="warm">{focusedSummary}</MetricChip> : null}
</div>
<div className="explore-toolbar-groups">
<div style={{ minWidth: 280, flex: "1 1 320px" }}>
<input
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
void handleSearch();
}
}}
placeholder="Search a node, e.g. Metformin"
style={{ ...inputStyle, margin: 0, minHeight: 38 }}
/>
</div>
{coreToolbarGroups.map((group) => (
<div key={group.id} className="explore-toolbar-group">
{group.items.map((item) => {
const baseStyle = item.tone === "primary" ? actionButtonStyle : secondaryActionButtonStyle;
return (
<button
key={item.id}
onClick={item.onClick}
title={item.title}
disabled={item.disabled}
style={{
...baseStyle,
minHeight: 36,
padding: "8px 12px",
background: item.active
? "rgba(31, 111, 235, 0.28)"
: baseStyle.background,
border: item.active
? "1px solid rgba(127, 208, 255, 0.35)"
: baseStyle.border,
color: item.active ? "#e6f2ff" : baseStyle.color,
boxShadow: item.active ? `0 0 0 1px rgba(127, 208, 255, 0.12)` : baseStyle.boxShadow,
}}
>
{item.label}
</button>
);
})}
</div>
))}
</div>
</div>
{searchError ? <div style={{ color: "#ff7b72", fontSize: 12 }}>{searchError}</div> : null}
{searchResults.length ? (
<div className="explore-search-results hud-scrollbar" style={searchResultsStripStyle}>
{searchResults.map((result) => (
<button key={result.node.id} style={predictionCardStyle} onClick={() => focusNode(result.node.id)}>
<div style={{ display: "flex", justifyContent: "space-between", gap: 12 }}>
<div style={{ minWidth: 0 }}>
<div style={{ color: "#fff", fontWeight: 600 }}>{result.node.content || result.node.id}</div>
<div style={{ color: "#8b949e", fontSize: 12 }}>{result.node.type}</div>
</div>
<div style={{ color: "#58a6ff", fontSize: 12, whiteSpace: "nowrap" }}>
{result.score.toFixed(3)}
</div>
</div>
</button>
))}
</div>
) : null}
{selectedEdgeState ? (
<div style={selectedEdgeCardStyle}>
<div style={{ display: "flex", justifyContent: "space-between", gap: 12, alignItems: "flex-start" }}>
<div style={{ minWidth: 0 }}>
<div style={{ color: "rgba(127, 208, 255, 0.76)", fontSize: 11, fontWeight: 700, letterSpacing: "0.08em", textTransform: "uppercase" }}>
Relationship
</div>
<div style={{ color: "#f4f8ff", fontSize: 15, fontWeight: 700, marginTop: 6 }}>
{selectedEdgeState.edgeType}
</div>
</div>
<button
onClick={() => setSelectedEdgeId("")}
style={{ ...secondaryActionButtonStyle, minHeight: 30, padding: "6px 10px" }}
>
Close
</button>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 10, flexWrap: "wrap" }}>
<button style={selectedEdgeNodeChipStyle} onClick={() => focusNode(selectedEdgeState.sourceId)}>
{selectedEdgeState.sourceLabel}
</button>
<span style={{ color: "#7fa7ce", fontSize: 12 }}></span>
<button style={selectedEdgeNodeChipStyle} onClick={() => focusNode(selectedEdgeState.targetId)}>
{selectedEdgeState.targetLabel}
</button>
</div>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
<MetricChip tone="warm">weight {selectedEdgeState.weight.toFixed(2)}</MetricChip>
<MetricChip>{selectedEdgeState.siblingCount} parallel lane{selectedEdgeState.siblingCount === 1 ? "" : "s"}</MetricChip>
<MetricChip>{selectedEdgeState.familySize} family member{selectedEdgeState.familySize === 1 ? "" : "s"}</MetricChip>
{selectedEdgeState.provenanceCount > 0 ? (
<MetricChip>{selectedEdgeState.provenanceCount} provenance fields</MetricChip>
) : null}
</div>
{Object.keys(selectedEdgeState.properties).length ? (
<div style={selectedEdgePropertyGridStyle}>
{Object.entries(selectedEdgeState.properties).slice(0, 4).map(([key, value]) => (
<div key={key} style={selectedEdgePropertyCardStyle}>
<div style={{ color: "rgba(127, 208, 255, 0.68)", fontSize: 10, letterSpacing: "0.06em", textTransform: "uppercase" }}>
{key}
</div>
<div style={{ color: "#dce7f4", fontSize: 12, marginTop: 4, wordBreak: "break-word" }}>
{typeof value === "object" ? JSON.stringify(value) : String(value)}
</div>
</div>
))}
</div>
) : null}
</div>
) : null}
</div>
</SurfaceCard>
</section>
<div
className="explore-main-grid"
style={{
gridTemplateColumns: layoutState.showInspector ? "minmax(0, 1fr) minmax(320px, 360px)" : "minmax(0, 1fr)",
}}
>
<div className="explore-scene-stack">
<SurfaceCard padding="none" className="explore-scene-card">
<div className="explore-scene-shell">
<div className="palantir-grid" />
<div className="palantir-vignette" />
<div className="explore-scene-stage">
<SigmaSceneAdapter
ref={sceneRef}
{...sceneAdapterProps}
/>
<GraphLoadingOverlay
progress={loadingProgress}
visible={showLoadingOverlay}
showGraphBehind={hasGraphContent || Boolean(loadingProgress?.showGraphBehind)}
/>
</div>
</div>
{layoutState.showPluginDock ? (
<div className="explore-plugin-dock">
<SurfaceCard tone="subtle" style={{ borderRadius: 0, borderLeft: "none", borderRight: "none", borderBottom: "none" }}>
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<div style={{ display: "flex", justifyContent: "space-between", gap: 12, alignItems: "center" }}>
<div className="explore-plugin-dock-tabs">
{openDockPanels.map((panel) => (
<button
key={panel.id}
className="explore-plugin-dock-tab"
data-active={activeDockPanel?.id === panel.id}
onClick={() => setActiveDockPanelId(panel.id)}
>
{panel.title}
</button>
))}
{activeDockPanel ? (
<button
className="explore-plugin-dock-tab"
onClick={() => handlePluginAction({ type: "closePanel", panelId: activeDockPanel.id })}
>
×
</button>
) : null}
</div>
</div>
{activeDockPanel ? (
<div style={pluginDockContentStyle}>{activeDockPanel.content}</div>
) : null}
</div>
</SurfaceCard>
</div>
) : null}
<div className="explore-scene-footer">
<Suspense fallback={<div style={timelineFallbackStyle}>Loading timeline</div>}>
<LazyTimelinePanel
onTimeChange={setScrubberTime}
minDate={temporalBounds?.min ?? undefined}
maxDate={temporalBounds?.max ?? undefined}
/>
</Suspense>
</div>
</SurfaceCard>
</div>
{layoutState.showInspector ? (
<div className="explore-inspector-shell">
<InspectorPanel open={layoutState.showInspector} className="explore-inspector-card">
<div className="explore-inspector-scroll hud-scrollbar">
<Suspense fallback={<div style={inspectorFallbackStyle}>Loading inspector</div>}>
<LazyGraphInspectorPanel
nodeId={selectedNodeId}
predictions={predictions}
predictionType={predictionType}
onPredictionTypeChange={setPredictionType}
onRunPredictions={() => void handleRunPredictions()}
isRunningPredictions={isRunningPredictions}
pathTargetId={pathTargetId}
onPathTargetChange={setPathTargetId}
onTracePath={() => void handleTracePath()}
pathResult={pathResult}
onDownloadProvenance={(format) => void handleDownloadProvenance(format)}
/>
</Suspense>
</div>
</InspectorPanel>
</div>
) : null}
</div>
</div>
</div>
);
}
const inputStyle: React.CSSProperties = {
width: "100%",
background: "rgba(4, 10, 18, 0.5)",
border: `1px solid ${GRAPH_THEME.palette.background.shellBorder}`,
color: "#edf5ff",
borderRadius: 12,
padding: "11px 13px",
fontSize: 13,
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.03)",
};
const actionButtonStyle: React.CSSProperties = {
background: "linear-gradient(135deg, rgba(24, 63, 133, 0.42), rgba(35, 85, 176, 0.28))",
color: "#fff",
border: `1px solid ${GRAPH_THEME.palette.background.shellBorder}`,
borderRadius: 12,
padding: "9px 12px",
cursor: "pointer",
fontWeight: 700,
fontSize: 12,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
boxShadow: `0 8px 22px ${GRAPH_THEME.palette.background.shellGlow}`,
};
const secondaryActionButtonStyle: React.CSSProperties = {
...actionButtonStyle,
background: "rgba(255, 255, 255, 0.03)",
border: "1px solid rgba(255, 255, 255, 0.08)",
color: "#c6d4e3",
fontWeight: 600,
};
const predictionCardStyle: React.CSSProperties = {
textAlign: "left",
padding: 12,
background: "rgba(88, 166, 255, 0.08)",
border: "1px solid rgba(88, 166, 255, 0.12)",
borderRadius: 10,
cursor: "pointer",
};
const selectedEdgeCardStyle: React.CSSProperties = {
display: "flex",
flexDirection: "column",
gap: 12,
padding: 14,
background: "linear-gradient(135deg, rgba(88, 166, 255, 0.1), rgba(88, 166, 255, 0.04))",
border: "1px solid rgba(127, 208, 255, 0.16)",
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 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 rgba(255, 255, 255, 0.06)",
};
const pluginDockContentStyle: React.CSSProperties = {
borderRadius: 16,
border: "1px solid rgba(255, 255, 255, 0.06)",
background: "rgba(255, 255, 255, 0.02)",
padding: 14,
};
const pluginLoadingStyle: React.CSSProperties = {
color: "#8ea4be",
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: "#8ea4be",
fontSize: 12,
};
const timelineFallbackStyle: React.CSSProperties = {
height: "90px",
display: "flex",
alignItems: "center",
padding: "0 18px",
color: "#8ea4be",
fontSize: 12,
borderTop: "1px solid rgba(88, 166, 255, 0.2)",
background: "rgba(1, 4, 9, 0.88)",
};