mirror of
https://github.com/semantica-agi/semantica.git
synced 2026-08-29 04:26:20 +00:00
## Folder & Project
- Renamed `semantica-explorer/` → `explorer/` (cleaner path)
- Browser tab title: `Semantica Knowledge Explorer`
- Brand pill: `SEM` → `SKE` (tooltip: Semantica Knowledge Explorer)
- Nav rail label: `Explore` → `Knowledge Explorer`
- package.json name: `semantica-knowledge-explorer`
- Downgraded Vite 8 → Vite 5 for Node v20.17.0 compatibility
## App Shell
- Dynamic per-workspace kicker labels replacing static "Workspace" pill:
Graph Studio · Vocabulary Browser · Reasoning Engine · SPARQL Query ·
Decision Intelligence · Knowledge Audit · Graph Governance
## Enrich Workspace — 2 new tabs
### Entity Resolution tab
- Similarity threshold slider (0.50–0.99)
- Run Dedup Scan → POST /api/enrich/dedup
- Flagged pairs list with colour-coded score bars (red/amber/green)
- Expandable inline diff: primary vs duplicate side-by-side
- One-click Merge → POST /api/enrich/merge with logEvent dispatch
- Dismiss per pair; Clear all button
- Merge history sidebar pulled live from Registry store
### Registry tab (Document Registry)
- Live chronological audit log of all KG mutations in-session
- Colour-coded op-type badges: IMPORT · MERGE · ADD NODE · ADD EDGE ·
INFER · DELETE · EXPORT · VOCAB
- Filter pills to narrow by operation type
- Expandable JSON detail rows per entry
- Clear log button
- Entirely client-side via registryStore (no backend needed)
## Manage Workspace — 2 new tabs
### KG Overview tab
- Stats chips: total nodes, edges, graph density
- Node type breakdown bar chart (up to 8 types, colour-coded)
- Edge type breakdown bar chart from /api/graph/stats
- Top-10 most connected nodes ranked by degree
- Skeleton loading states + Refresh button
### Ontology Summary tab
- Read-only SKOS scheme tree (scheme → top concepts → narrower)
- Concept detail panel: labels, notation, description, narrower nav
- "Open Full Browser" button deep-links to Vocabulary Browser tab
## Decision Workspace polish
- CausalFlowDiagram: vertical node cards connected by relationship pills
- Outcome badges: colour-coded (green=approved, red=rejected, amber=deferred)
- Live filter input across decision ID, category, and outcome
- Animated skeleton loading while list fetches
## Graph Inspector polish
- PathFlowViz: clickable node chips connected by edge-type labels;
clicking a chip focuses that node in the canvas
- Link Prediction button shows spinner while computing
- Empty states for path trace and candidate links sections
## Registry dispatch — WebSocket
- ADD_NODE events → logEvent("add-node", …) in GraphWorkspace WS handler
- ADD_EDGE events → logEvent("add-edge", …) in GraphWorkspace WS handler
- Import, Export, Merge already dispatched logEvent on API response
## Graph visibility overhaul
### Edge colours (were nearly transparent, now clearly visible)
- edgeBackbone: rgba(…, 0.04) → rgba(…, 0.38)
- edgeStructure: rgba(…, 0.009) → rgba(…, 0.28)
- edgeInspection: rgba(…, 0.026) → rgba(…, 0.48)
- Muted edges: 0.009–0.02 → 0.12–0.26
- Focus edges: 0.16 → 0.42
### Edge sizes
- default minSize: 0.18 → 0.9 (always at least 1 pixel wide)
- path minSize: 1.8 → 2.4
- inactive/muted: hide:true → hide:false (dimmed not hidden)
### Node sizes
- default sizeMultiplier: 0.72 → 0.92
- default minSize: 0.68 → 3.5 (visible at all zoom levels)
- overview nodeScale: 0.66 → 0.88
- nodeTintMix (colour): 0.03 → 0.14
- nodeCoreMix (brightness): 0.52 → 0.72
### Label budget
- overview: 10 → 28 labels
- structure: 36 → 60 labels
- inspection: 80 → 120 labels
### Sigma settings
- renderEdgeLabels: false → true (relationship type on every edge)
- edgeLabelSize: — → 10
- labelRenderedSizeThreshold: 4 → 2
- labelDensity: 0.86 → 1.1
- hideLabelsOnMove: true → false (labels stay visible while panning)
- hideEdgesOnMove: true → false (edges stay visible while panning)
- minCameraRatio: — → 0.04 (prevents zooming inside a node)
- maxCameraRatio: — → 8 (graph stays visible when zoomed out)
### Zoom controls
- Added Zoom In (+) and Zoom Out (−) buttons to graph toolbar
- Smooth animated zoom via camera.animatedZoom / animatedUnzoom (200ms)
- Mouse scroll wheel clamped between minCameraRatio and maxCameraRatio
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
822 lines
34 KiB
TypeScript
822 lines
34 KiB
TypeScript
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<string, unknown>;
|
|
};
|
|
score: number;
|
|
};
|
|
|
|
type LinkPrediction = {
|
|
target: string;
|
|
type: string;
|
|
label?: string;
|
|
score: number;
|
|
};
|
|
|
|
type PathResponse = {
|
|
path: GraphPath;
|
|
total_weight: number;
|
|
};
|
|
|
|
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<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;
|
|
}
|
|
|
|
function sourceAttribution(properties: Record<string, unknown>) {
|
|
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,
|
|
};
|
|
}
|
|
|
|
function TimelineFallback({ min, max }: TemporalBounds) {
|
|
return (
|
|
<div
|
|
style={{
|
|
width: "100%",
|
|
height: "90px",
|
|
borderTop: "1px solid rgba(88, 166, 255, 0.2)",
|
|
background: "rgba(1, 4, 9, 0.88)",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
padding: "0 18px",
|
|
color: "#8fa8c6",
|
|
fontSize: 12,
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
<span>Temporal scrubber</span>
|
|
<span>{min || max ? "Preparing timeline runtime..." : "Temporal bounds loading..."}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div style={{ padding: 32, textAlign: "center" }}>
|
|
<p style={{ color: "#8b949e", fontSize: 14, margin: 0 }}>
|
|
Search for a node or click one in the canvas to inspect its properties.
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<aside style={{ padding: 24, display: "flex", flexDirection: "column", gap: 18 }}>
|
|
<div style={{ borderBottom: "1px solid rgba(88, 166, 255, 0.14)", paddingBottom: 16 }}>
|
|
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 8 }}>
|
|
<span style={{ background: accentColor, boxShadow: `0 0 10px ${accentColor}`, width: 8, height: 8, borderRadius: "50%" }} />
|
|
<span style={{ color: accentColor, fontSize: 12, fontWeight: 800, textTransform: "uppercase", letterSpacing: "0.08em" }}>{node.nodeType || "Entity"}</span>
|
|
</div>
|
|
<h3 style={{ margin: 0, color: "#fff", fontSize: 24, lineHeight: 1, fontWeight: 800, letterSpacing: "-0.04em", wordBreak: "break-word" }}>{node.label}</h3>
|
|
<div style={{ color: "#8b949e", fontSize: 12, marginTop: 8 }}>{node.id}</div>
|
|
<div style={{ display: "flex", gap: 8, flexWrap: "wrap", marginTop: 12 }}>
|
|
{node.valid_from || node.valid_until ? <span style={subtleChipStyle}>temporal</span> : null}
|
|
<span style={subtleChipStyle}>{node.neighborCount} neighbors</span>
|
|
{attribution.length ? <span style={subtleChipStyle}>{attribution.length} source fields</span> : null}
|
|
{predictions.length ? <span style={subtleChipStyle}>{predictions.length} candidate links</span> : null}
|
|
</div>
|
|
</div>
|
|
|
|
<section style={sectionStyle}>
|
|
<div style={sectionTitleStyle}>Actions</div>
|
|
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
|
<button style={{ ...actionButtonStyle, width: "100%", justifyContent: "center" }} onClick={onRunPredictions}>Run Link Prediction</button>
|
|
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
|
<button style={secondaryActionButtonStyle} onClick={() => onDownloadProvenance("json")}>Provenance JSON</button>
|
|
<button style={secondaryActionButtonStyle} onClick={() => onDownloadProvenance("markdown")}>Provenance MD</button>
|
|
</div>
|
|
</div>
|
|
<input value={predictionType} onChange={(event) => onPredictionTypeChange(event.target.value)} placeholder="Optional candidate type filter, e.g. disease" style={inputStyle} />
|
|
</section>
|
|
|
|
<section style={sectionStyle}>
|
|
<div style={sectionTitleStyle}>Trace Path</div>
|
|
<input value={pathTargetId} onChange={(event) => onPathTargetChange(event.target.value)} placeholder="Target node ID" style={inputStyle} />
|
|
<button style={actionButtonStyle} onClick={onTracePath}>Trace Causal Path</button>
|
|
{pathResult?.path?.length ? (
|
|
<div style={{ display: "flex", flexDirection: "column", gap: 6, marginTop: 10 }}>
|
|
{pathResult.path.map((step, index) => (
|
|
<div key={`${step}-${index}`} style={pathStepStyle}>{index + 1}. {step}</div>
|
|
))}
|
|
<div style={{ color: "#79c0ff", fontSize: 12, marginTop: 4 }}>total weight: {pathResult.total_weight.toFixed(3)}</div>
|
|
</div>
|
|
) : (
|
|
<div style={emptyTextStyle}>Choose a target or click a candidate prediction to prepare a path trace.</div>
|
|
)}
|
|
</section>
|
|
|
|
<details style={collapseStyle} open={predictions.length > 0}>
|
|
<summary style={summaryStyle}>Candidate Links</summary>
|
|
<div style={{ padding: "0 14px 14px" }}>
|
|
{predictions.length > 0 ? (
|
|
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
|
{predictions.map((prediction) => (
|
|
<button key={`${prediction.target}-${prediction.type}`} style={predictionCardStyle} onClick={() => onPathTargetChange(prediction.target)}>
|
|
<div style={{ color: "#fff", fontWeight: 600 }}>{prediction.label || prediction.target}</div>
|
|
<div style={{ color: "#8b949e", fontSize: 12 }}>{prediction.type}</div>
|
|
<div style={{ color: "#58a6ff", fontSize: 12, marginTop: 4 }}>confidence {prediction.score.toFixed(3)}</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div style={emptyTextStyle}>Run link prediction to surface likely next-hop relationships.</div>
|
|
)}
|
|
</div>
|
|
</details>
|
|
|
|
<details style={collapseStyle}>
|
|
<summary style={summaryStyle}>Source Attribution</summary>
|
|
<div style={{ padding: "0 14px 14px" }}>
|
|
{attribution.length ? (
|
|
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
|
{attribution.map(({ key, value }) => (
|
|
<div key={key} style={propertyCardStyle}>
|
|
<div style={{ color: "rgba(88, 166, 255, 0.7)", fontSize: 11, marginBottom: 4 }}>{key}</div>
|
|
<div style={{ color: "#e6edf3", fontSize: 13, wordBreak: "break-word" }}>{typeof value === "object" ? JSON.stringify(value) : String(value)}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div style={emptyTextStyle}>No explicit attribution metadata was found on this node.</div>
|
|
)}
|
|
</div>
|
|
</details>
|
|
|
|
<details style={collapseStyle}>
|
|
<summary style={summaryStyle}>Properties</summary>
|
|
<div style={{ padding: "0 14px 14px" }}>
|
|
{propertyEntries.length ? (
|
|
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
|
{propertyEntries.map(([key, value]) => (
|
|
<div key={key} style={propertyCardStyle}>
|
|
<div style={{ color: "rgba(88, 166, 255, 0.7)", fontSize: 11, marginBottom: 4 }}>{key}</div>
|
|
<div style={{ color: "#e6edf3", fontSize: 13, wordBreak: "break-word" }}>{typeof value === "object" ? JSON.stringify(value) : String(value)}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div style={emptyTextStyle}>No additional properties are attached to this node.</div>
|
|
)}
|
|
</div>
|
|
</details>
|
|
</aside>
|
|
);
|
|
}
|
|
|
|
export function GraphWorkspaceShell() {
|
|
const [selectedNodeId, setSelectedNodeId] = useState("");
|
|
const [selectedNodeState, setSelectedNodeState] = useState<GraphSelectedNodeState | null>(null);
|
|
const [isLayoutRunning, setIsLayoutRunning] = useState(false);
|
|
const [viewMode, setViewMode] = useState<GraphViewMode>("full");
|
|
const [searchQuery, setSearchQuery] = useState("");
|
|
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
|
|
const [searchError, setSearchError] = useState("");
|
|
const [predictionType, setPredictionType] = useState("");
|
|
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 [isGraphStageReady, setIsGraphStageReady] = useState(false);
|
|
const [layoutStatus, setLayoutStatus] = useState<GraphLayoutStatus>({
|
|
state: "idle",
|
|
source: "runtime",
|
|
hasCoordinates: false,
|
|
layoutReady: false,
|
|
displacement: null,
|
|
elapsedMs: 0,
|
|
stableSamples: 0,
|
|
});
|
|
|
|
const debouncedTime = useDebounce(scrubberTime, 150);
|
|
const stageRef = useRef<GraphStageHandle>(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);
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
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,
|
|
});
|
|
}
|
|
}, [snapshot?.fetchedAt]);
|
|
|
|
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<string, number>();
|
|
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,
|
|
}
|
|
: 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 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];
|
|
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 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 (
|
|
<div className="palantir-bg" style={{ position: "relative", width: "100%", height: "100%", overflow: "hidden", display: "flex", flexDirection: "column" }}>
|
|
<style>{HUD_CSS}</style>
|
|
<div className="palantir-grid" />
|
|
<div className="palantir-vignette" />
|
|
|
|
<div style={{ flex: 1, position: "relative", zIndex: 3, minHeight: 0 }}>
|
|
<Suspense fallback={null}>
|
|
<GraphRuntimeStage
|
|
ref={stageRef}
|
|
snapshot={snapshot}
|
|
selectedNodeId={selectedNodeId}
|
|
activePath={pathResult?.path ?? []}
|
|
onNodeSelect={focusNode}
|
|
onSelectedNodeStateChange={handleSelectedNodeStateChange}
|
|
isLayoutRunning={isLayoutRunning}
|
|
onLayoutRunningChange={handleLayoutRunningChange}
|
|
viewMode={viewMode}
|
|
temporalTime={debouncedTime}
|
|
onActiveNodeCountChange={handleActiveNodeCountChange}
|
|
onProgressChange={handleProgressChange}
|
|
onLayoutStatusChange={handleLayoutStatusChange}
|
|
onRuntimeReady={handleRuntimeReady}
|
|
/>
|
|
</Suspense>
|
|
<GraphLoadingOverlay
|
|
progress={loadingProgress}
|
|
visible={showLoadingOverlay}
|
|
showGraphBehind={Boolean(loadingProgress?.showGraphBehind || isGraphStageReady)}
|
|
/>
|
|
</div>
|
|
|
|
<Suspense fallback={<TimelineFallback min={temporalBounds?.min ?? null} max={temporalBounds?.max ?? null} />}>
|
|
<TimelinePanel
|
|
onTimeChange={setScrubberTime}
|
|
minDate={temporalBounds?.min ?? undefined}
|
|
maxDate={temporalBounds?.max ?? undefined}
|
|
/>
|
|
</Suspense>
|
|
|
|
<div style={{ position: "absolute", inset: 0, pointerEvents: "none", zIndex: 10 }}>
|
|
<div className="graph-shell-top">
|
|
<section className="graph-status-card">
|
|
<div className="graph-status-label">Graph Studio</div>
|
|
<div className="graph-status-title">{visibleSelectedNode ? visibleSelectedNode.label : "Knowledge Explorer"}</div>
|
|
<div className="graph-status-metrics">
|
|
{showLoadingOverlay && loadingProgress ? <span style={{ ...metricPillStyle, color: "#a9ddff" }}>{getGraphLoadTitle(loadingProgress.phase)}</span> : null}
|
|
{layoutStatusLabel ? <span style={{ ...metricPillStyle, color: "#a9ddff" }}>{layoutStatusLabel}</span> : null}
|
|
{snapshot ? <span style={metricPillStyle}>{snapshot.summary.nodeCount.toLocaleString()} nodes · {snapshot.summary.edgeCount.toLocaleString()} edges</span> : null}
|
|
{activeNodeCount !== null ? <span style={{ ...metricPillStyle, color: "#4fd49c", borderColor: "rgba(79, 212, 156, 0.22)" }}>{activeNodeCount.toLocaleString()} active</span> : null}
|
|
{searchSummary ? <span style={metricPillStyle}>{searchSummary}</span> : null}
|
|
{focusedSummary ? <span style={{ ...metricPillStyle, color: "#f2b66d", borderColor: "rgba(242, 182, 109, 0.24)" }}>{focusedSummary}</span> : null}
|
|
{isError ? <span style={{ ...metricPillStyle, color: "#ff8f85", borderColor: "rgba(255, 123, 114, 0.22)" }}>{(error as Error).message}</span> : null}
|
|
</div>
|
|
</section>
|
|
|
|
<section className="graph-command-card">
|
|
<div className="graph-command-row">
|
|
<div className="graph-toggle-cluster">
|
|
{selectedNodeId ? (
|
|
<>
|
|
<button onClick={() => setViewMode("focused")} style={{ ...actionButtonStyle, background: viewMode === "focused" ? "rgba(31, 111, 235, 0.38)" : actionButtonStyle.background, borderColor: viewMode === "focused" ? "rgba(127, 208, 255, 0.42)" : "rgba(88, 166, 255, 0.2)" }}>Focused View</button>
|
|
<button onClick={() => setViewMode("full")} style={{ ...actionButtonStyle, background: viewMode === "full" ? "rgba(31, 111, 235, 0.38)" : actionButtonStyle.background, borderColor: viewMode === "full" ? "rgba(127, 208, 255, 0.42)" : "rgba(88, 166, 255, 0.2)" }}>Full Graph</button>
|
|
</>
|
|
) : (
|
|
<span style={{ color: "#7f95b3", fontSize: 12 }}>Select a node to switch graph views</span>
|
|
)}
|
|
</div>
|
|
|
|
<div className="graph-action-cluster">
|
|
<button onClick={() => setIsLayoutRunning((value) => !value)} style={secondaryActionButtonStyle} disabled={isLoading || isFetching}>
|
|
{isLayoutRunning ? "Pause Layout" : "Run Layout"}
|
|
</button>
|
|
<button onClick={() => { setIsGraphStageReady(false); reload(); }} style={secondaryActionButtonStyle} disabled={isLoading || isFetching}>
|
|
Reload
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="graph-command-row">
|
|
<div className="graph-search-shell">
|
|
<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, minWidth: 260 }}
|
|
disabled={showLoadingOverlay && !selectedNodeId}
|
|
/>
|
|
<button onClick={() => void handleSearch()} style={actionButtonStyle} disabled={showLoadingOverlay && !selectedNodeId}>Search</button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
{searchError ? <div style={{ position: "absolute", top: 144, right: 34, color: "#ff7b72", fontSize: 12, pointerEvents: "auto" }}>{searchError}</div> : null}
|
|
{searchResults.length ? (
|
|
<div className="graph-search-results hud-scrollbar">
|
|
<div className="graph-search-results-label">Search Results</div>
|
|
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
|
{searchResults.map((result) => (
|
|
<button key={result.node.id} className="graph-search-result-card" onClick={() => focusNode(result.node.id)}>
|
|
<div style={{ color: "#fff", fontWeight: 700 }}>{result.node.content || result.node.id}</div>
|
|
<div style={{ color: "#8b949e", fontSize: 12 }}>{result.node.type}</div>
|
|
<div style={{ color: "#58a6ff", fontSize: 12, marginTop: 4 }}>score {result.score.toFixed(3)}</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="graph-inspector hud-scrollbar" data-open={selectedNodeId ? "true" : "false"}>
|
|
<NodePanel
|
|
node={visibleSelectedNode}
|
|
predictions={predictions}
|
|
predictionType={predictionType}
|
|
onPredictionTypeChange={setPredictionType}
|
|
onRunPredictions={() => void handleRunPredictions()}
|
|
pathTargetId={pathTargetId}
|
|
onPathTargetChange={setPathTargetId}
|
|
onTracePath={() => void handleTracePath()}
|
|
pathResult={pathResult}
|
|
onDownloadProvenance={(format) => void handleDownloadProvenance(format)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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",
|
|
};
|