import type { CSSProperties } from "react"; import { Loader2 } from "lucide-react"; import { graph } from "../../store/graphStore"; import { GRAPH_THEME, withAlpha } from "./graphTheme"; import type { GraphSelectedNodeKind } from "./types"; import { MarkdownContentViewer } from "./MarkdownContentViewer"; export type LinkPrediction = { target: string; type: string; label?: string; score: number; }; export type PathResponse = { path: string[]; edge_ids?: string[]; total_weight: number; hop_count: number; distance_band: "direct" | "near" | "mid-range" | "distant"; // FR-1 distance intelligence enrichment semantic_similarity?: number | null; path_coherence_score?: number | null; confidence_decay?: number | null; bottleneck_node?: string | null; alternative_path_count?: number; interpretation?: string; }; export interface GraphInspectorPanelProps { nodeId: string; inspectableNodeId?: string | null; selectedNodeKind?: GraphSelectedNodeKind; canActivateFocused?: boolean; focusedUnavailableReason?: string | null; predictions: LinkPrediction[]; predictionType: string; onPredictionTypeChange: (value: string) => void; onRunPredictions: () => void; isRunningPredictions?: boolean; pathTargetId: string; onPathTargetChange: (value: string) => void; onTracePath: () => void; pathResult: PathResponse | null; onDownloadProvenance: (format: "json" | "markdown") => void; onFocusNode?: (nodeId: string) => void; } const PROVENANCE_KEYS = ["source", "source_url", "pmid", "pmids", "evidence", "provenance", "confidence"] as const; function sourceAttribution(properties: Record) { return PROVENANCE_KEYS .filter((key) => key in properties) .map((key) => ({ key, value: properties[key] })); } /* ─── Path Distance Intelligence Panel ──────────────────────────── */ const BAND_COLORS: Record = { direct: "#3fb950", near: "#79c0ff", "mid-range": "#e3b341", distant: "#ff7b72", }; function PathDistanceIntelPanel({ result }: { result: PathResponse }) { const bandColor = BAND_COLORS[result.distance_band] ?? "#8b949e"; const hasMetrics = result.confidence_decay != null || result.semantic_similarity != null || result.path_coherence_score != null || result.bottleneck_node != null; return (
{/* distance band + alt paths */}
{result.distance_band} · {result.hop_count} hop{result.hop_count !== 1 ? "s" : ""} {(result.alternative_path_count ?? 0) > 0 && ( {result.alternative_path_count} alt path{result.alternative_path_count !== 1 ? "s" : ""} )}
{/* metric grid */} {hasMetrics &&
{result.confidence_decay != null && (
Confidence Decay
0.6 ? "#3fb950" : result.confidence_decay > 0.3 ? "#e3b341" : "#ff7b72", }} > {(result.confidence_decay * 100).toFixed(1)}%
0.6 ? "#3fb950" : result.confidence_decay > 0.3 ? "#e3b341" : "#ff7b72", }} />
)} {result.semantic_similarity != null && (
Semantic Sim.
{(result.semantic_similarity * 100).toFixed(1)}%
)} {result.path_coherence_score != null && (
Path Coherence
{(result.path_coherence_score * 100).toFixed(1)}%
)} {result.bottleneck_node && (
Bottleneck
{getNodeLabel(result.bottleneck_node)}
)}
} {/* interpretation */} {result.interpretation && (
{result.interpretation}
)}
); } /* ─── Path Flow Visualizer ──────────────────────────────────────── */ function getNodeLabel(nodeId: string): string { if (!graph.hasNode(nodeId)) return nodeId; const attrs = graph.getNodeAttributes(nodeId) as { label?: string; content?: string }; return String(attrs.label ?? attrs.content ?? nodeId); } function getEdgeLabelBetween(sourceId: string, targetId: string, edgeIds?: string[]): string { // Try to find the specific edge from edgeIds first if (edgeIds) { for (const edgeId of edgeIds) { if (graph.hasEdge(edgeId)) { const [src, tgt] = graph.extremities(edgeId); if ((src === sourceId && tgt === targetId) || (src === targetId && tgt === sourceId)) { const attrs = graph.getEdgeAttributes(edgeId) as { edgeType?: string }; return attrs.edgeType ?? "→"; } } } } // Fallback: find any edge between the pair if (graph.hasNode(sourceId) && graph.hasNode(targetId)) { let label = "→"; graph.forEachEdge(sourceId, targetId, (_edgeId, attrs) => { const edgeAttrs = attrs as { edgeType?: string }; if (edgeAttrs.edgeType) label = edgeAttrs.edgeType; }); return label; } return "→"; } function PathFlowViz({ path, edgeIds, totalWeight, bottleneckNodeId, onFocusNode, }: { path: string[]; edgeIds?: string[]; totalWeight: number; bottleneckNodeId?: string | null; onFocusNode?: (nodeId: string) => void; }) { if (path.length === 0) { return
No path found between the selected nodes.
; } return (
{/* Horizontal scrollable chip flow */}
{path.map((nodeId, index) => { const label = getNodeLabel(nodeId); const edgeLabel = index < path.length - 1 ? getEdgeLabelBetween(nodeId, path[index + 1], edgeIds) : null; return (
{/* Node chip */} {/* Edge connector */} {edgeLabel !== null ? (
{edgeLabel}
) : null}
); })}
{/* Weight badge */}
Total weight: {totalWeight.toFixed(3)} · {path.length} hops
); } /* ─── Main Panel ─────────────────────────────────────────────────── */ export function GraphInspectorPanel({ nodeId, inspectableNodeId, selectedNodeKind = "none", canActivateFocused = false, focusedUnavailableReason = null, predictions, predictionType, onPredictionTypeChange, onRunPredictions, isRunningPredictions = false, pathTargetId, onPathTargetChange, onTracePath, pathResult, onDownloadProvenance, onFocusNode, }: GraphInspectorPanelProps) { if (!nodeId) { return (

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

); } const resolvedNodeId = inspectableNodeId && graph.hasNode(inspectableNodeId) ? inspectableNodeId : null; const directlyInspectable = graph.hasNode(nodeId); const effectiveNodeId = directlyInspectable ? nodeId : resolvedNodeId; const actionNodeId = directlyInspectable ? nodeId : resolvedNodeId; const groupedDisplaySelection = selectedNodeKind === "grouped" && !directlyInspectable; if (!effectiveNodeId) { return ( ); } const attributes = graph.getNodeAttributes(effectiveNodeId) as { color?: string; content?: string; label?: string; nodeType?: string; valid_from?: string | null; valid_until?: string | null; properties?: Record; }; const properties = attributes?.properties ?? {}; const attribution = sourceAttribution(properties); const accentColor = attributes?.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), ); const nodeContent = (typeof attributes?.content === "string" && attributes.content) ? attributes.content : (typeof properties.content === "string" && properties.content) ? properties.content : ""; return ( ); } /* ─── styles ─────────────────────────────────────────────────────── */ const inputStyle: CSSProperties = { width: "100%", background: GRAPH_THEME.ui.control.inputBg, border: `1px solid ${GRAPH_THEME.ui.control.inputBorder}`, color: GRAPH_THEME.ui.text.strong, borderRadius: 12, padding: "11px 13px", fontSize: 13, boxShadow: "inset 0 1px 0 rgba(255,255,255,0.035)", }; const groupedSelectionNoticeStyle: CSSProperties = { marginTop: 12, padding: "10px 12px", background: "rgba(98, 226, 205, 0.07)", border: `1px solid ${GRAPH_THEME.ui.control.activeBorder}`, borderRadius: 12, }; const actionButtonStyle: CSSProperties = { background: GRAPH_THEME.ui.control.primaryBg, color: GRAPH_THEME.ui.control.primaryText, border: `1px solid ${GRAPH_THEME.ui.control.primaryBorder}`, borderRadius: 12, padding: "9px 12px", cursor: "pointer", fontWeight: 700, fontSize: 12, display: "inline-flex", alignItems: "center", justifyContent: "center", boxShadow: "inset 0 1px 0 rgba(255,255,255,0.07), 0 10px 24px rgba(0,0,0,0.18)", }; const secondaryActionButtonStyle: CSSProperties = { ...actionButtonStyle, background: GRAPH_THEME.ui.control.defaultBg, border: `1px solid ${GRAPH_THEME.ui.control.defaultBorder}`, color: GRAPH_THEME.ui.control.defaultText, fontWeight: 600, }; const predictionCardStyle: CSSProperties = { textAlign: "left", padding: "10px 12px", background: "rgba(255, 255, 255, 0.035)", border: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}`, borderRadius: 10, cursor: "pointer", width: "100%", }; const propertyCardStyle: CSSProperties = { background: "rgba(255, 255, 255, 0.028)", padding: "10px 12px", borderRadius: 10, border: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}`, }; const emptyTextStyle: CSSProperties = { color: GRAPH_THEME.ui.text.muted, fontSize: 12, lineHeight: 1.5, }; const subtleChipStyle: CSSProperties = { background: "rgba(255, 255, 255, 0.04)", color: GRAPH_THEME.ui.text.body, padding: "4px 8px", borderRadius: 999, fontSize: 11, border: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}`, }; const sectionStyle: CSSProperties = { display: "flex", flexDirection: "column", gap: 10, padding: 14, background: GRAPH_THEME.ui.surface.cardSubtle, border: `1px solid ${GRAPH_THEME.ui.surface.panelBorder}`, borderRadius: 14, }; const sectionTitleStyle: CSSProperties = { color: GRAPH_THEME.ui.text.muted, fontSize: 11, fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.08em", }; const pathFlowContainerStyle: CSSProperties = { display: "flex", alignItems: "center", gap: 0, flexWrap: "wrap", rowGap: 8, }; const pathNodeChipStyle: CSSProperties = { display: "inline-flex", alignItems: "center", gap: 6, padding: "5px 10px", borderRadius: 999, background: "rgba(98, 226, 205, 0.08)", border: `1px solid ${GRAPH_THEME.ui.control.activeBorder}`, color: GRAPH_THEME.ui.text.strong, fontSize: 12, fontWeight: 600, maxWidth: 160, }; const pathNodeIndexStyle: CSSProperties = { display: "inline-flex", alignItems: "center", justifyContent: "center", width: 16, height: 16, borderRadius: "50%", background: GRAPH_THEME.ui.timeline.playheadSoft, color: GRAPH_THEME.ui.timeline.playhead, fontSize: 9, fontWeight: 800, flexShrink: 0, }; const pathEdgeConnectorStyle: CSSProperties = { display: "inline-flex", alignItems: "center", gap: 2, flexShrink: 0, }; const pathEdgeLabelStyle: CSSProperties = { fontSize: 9, fontWeight: 700, color: GRAPH_THEME.ui.text.subtle, letterSpacing: "0.04em", textTransform: "uppercase", maxWidth: 70, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", }; const metricCardStyle: CSSProperties = { background: "rgba(0,0,0,0.18)", borderRadius: 8, padding: "8px 10px", border: "1px solid rgba(255,255,255,0.05)", display: "flex", flexDirection: "column", gap: 3, }; const metricLabelStyle: CSSProperties = { color: "rgba(88,166,255,0.65)", fontSize: 10, fontWeight: 700, letterSpacing: "0.06em", textTransform: "uppercase", }; const metricValueStyle: CSSProperties = { fontSize: 14, fontWeight: 700, color: "#e6edf3", }; const metricBarTrackStyle: CSSProperties = { height: 3, borderRadius: 999, background: "rgba(255,255,255,0.07)", overflow: "hidden", marginTop: 4, }; const metricBarFillStyle: CSSProperties = { height: "100%", borderRadius: 999, transition: "width 300ms ease", };