import { useMemo, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { AlertCircle, CircleDot, Eye, EyeOff, GitBranch, Keyboard, Layers, Map as MapIcon, Network, RefreshCw, } from "lucide-react"; import { Card, CardContent, CardHeader, CardTitle, } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Progress } from "@/components/ui/progress"; import { Skeleton } from "@/components/ui/skeleton"; import { Drawer } from "@/components/ui/drawer"; import { EmptyState } from "@/components/ui/empty-state"; import { Tooltip } from "@/components/ui/tooltip"; import { FilterPanel, FilterSearch, FilterSelect, FilterRange, FilterToggle, FilterChips, type ActiveChip, } from "@/components/ui/filter-panel"; import { ShortcutsOverlay, type ShortcutGroup, } from "@/components/ui/shortcuts-overlay"; import { PageHeader } from "@/components/layout/PageHeader"; import { useGraphNeighborhood } from "@/hooks/usePlatform"; import { useKeyboardShortcuts } from "@/hooks/useKeyboardShortcuts"; import { useProject } from "@/hooks/useProjects"; import { GraphEdge, GraphNode } from "@/lib/api/platform"; import { formatDateTime, formatPercent, humanizeValue } from "@/lib/display"; const COLORS = [ "#2563eb", "#059669", "#d97706", "#7c3aed", "#db2777", "#0891b2", "#4b5563", "#dc2626", ]; const VIEW_W = 860; const VIEW_H = 600; interface VisualNode { id: string; name: string; type: string; x: number; y: number; radius: number; color: string; source: "entity" | "literal"; raw?: GraphNode; } interface VisualEdge { id: string; source: string; target: string; predicate: string; confidence: number; raw: GraphEdge; } function nodeColor(type: string, types: string[]): string { const index = Math.max(types.indexOf(type), 0); return COLORS[index % COLORS.length]; } function buildGraph( nodes: GraphNode[], edges: GraphEdge[], options: { search: string; predicate: string; entityType: string; minConfidence: number; }, ) { const search = options.search.trim().toLowerCase(); const types = Array.from( new Set(nodes.map((node) => node.type || "Entity").concat("Literal")), ).sort(); const nodeMap = new Map(); nodes.forEach((node) => { const type = node.type || "Entity"; nodeMap.set(String(node.id), { id: String(node.id), name: node.name, type, x: 0, y: 0, radius: 18, color: nodeColor(type, types), source: "entity", raw: node, }); }); const visualEdges: VisualEdge[] = []; edges.forEach((edge) => { const confidence = edge.confidence ?? 0; if (confidence < options.minConfidence) return; if (options.predicate && edge.predicate !== options.predicate) return; const source = String(edge.source); const target = edge.target !== null && edge.target !== undefined ? String(edge.target) : `literal-${edge.claim_id}`; if (!nodeMap.has(source)) return; if (!nodeMap.has(target)) { const label = humanizeValue(edge.target_value ?? edge.object?.name); nodeMap.set(target, { id: target, name: label || "(empty value)", type: "Literal", x: 0, y: 0, radius: 14, color: nodeColor("Literal", types), source: "literal", }); } visualEdges.push({ id: String(edge.claim_id), source, target, predicate: edge.predicate, confidence, raw: edge, }); }); let visualNodes = Array.from(nodeMap.values()).filter((node) => { if (options.entityType && node.type !== options.entityType) return false; if (!search) return true; return ( node.name.toLowerCase().includes(search) || node.type.toLowerCase().includes(search) ); }); const visibleIds = new Set(visualNodes.map((node) => node.id)); const filteredEdges = visualEdges.filter( (edge) => visibleIds.has(edge.source) && visibleIds.has(edge.target), ); const connectedIds = new Set(); filteredEdges.forEach((edge) => { connectedIds.add(edge.source); connectedIds.add(edge.target); }); if (options.predicate || options.minConfidence > 0 || search) { visualNodes = visualNodes.filter((node) => connectedIds.has(node.id)); } const centerX = VIEW_W / 2; const centerY = VIEW_H / 2; const radius = Math.max(160, Math.min(260, visualNodes.length * 13)); visualNodes.forEach((node, index) => { const angle = (Math.PI * 2 * index) / Math.max(visualNodes.length, 1); node.x = centerX + Math.cos(angle) * radius; node.y = centerY + Math.sin(angle) * radius; }); return { nodes: visualNodes, edges: filteredEdges, types }; } function edgeEndpoint(edge: VisualEdge, nodes: VisualNode[]) { const source = nodes.find((node) => node.id === edge.source); const target = nodes.find((node) => node.id === edge.target); return { source, target }; } export default function GraphViewPage() { const navigate = useNavigate(); const { projectId } = useParams<{ projectId: string }>(); const projectName = projectId ?? ""; const { data: project } = useProject(projectName); const [statusScope, setStatusScope] = useState("validated_claim"); const [includeCandidates, setIncludeCandidates] = useState(true); const [search, setSearch] = useState(""); const [predicate, setPredicate] = useState(""); const [entityType, setEntityType] = useState(""); const [minConfidence, setMinConfidence] = useState(0); const [selected, setSelected] = useState(null); const [drawerOpen, setDrawerOpen] = useState(false); const [hiddenTypes, setHiddenTypes] = useState>(new Set()); const [showLegend, setShowLegend] = useState(true); const [showMinimap, setShowMinimap] = useState(true); const [shortcutsOpen, setShortcutsOpen] = useState(false); const graph = useGraphNeighborhood(projectName, { includeCandidates: includeCandidates && statusScope === "validated_claim", limit: 300, status: includeCandidates && statusScope === "validated_claim" ? undefined : statusScope, }); const predicates = useMemo( () => Array.from( new Set((graph.data?.edges ?? []).map((edge) => edge.predicate)), ).sort(), [graph.data?.edges], ); const visual = useMemo( () => buildGraph(graph.data?.nodes ?? [], graph.data?.edges ?? [], { search, predicate, entityType, minConfidence, }), [ entityType, graph.data?.edges, graph.data?.nodes, minConfidence, predicate, search, ], ); // Filter out types the user toggled off in the legend const visibleNodes = useMemo( () => visual.nodes.filter((n) => !hiddenTypes.has(n.type)), [visual.nodes, hiddenTypes], ); const visibleNodeIds = useMemo( () => new Set(visibleNodes.map((n) => n.id)), [visibleNodes], ); const visibleEdges = useMemo( () => visual.edges.filter( (e) => visibleNodeIds.has(e.source) && visibleNodeIds.has(e.target), ), [visual.edges, visibleNodeIds], ); const typeCounts = useMemo(() => { const counts = new Map(); visual.nodes.forEach((n) => counts.set(n.type, (counts.get(n.type) ?? 0) + 1), ); return counts; }, [visual.nodes]); const averageConfidence = visibleEdges.length ? visibleEdges.reduce((sum, edge) => sum + edge.confidence, 0) / visibleEdges.length : 0; const selectNode = (node: VisualNode | VisualEdge) => { setSelected(node); setDrawerOpen(true); }; const toggleType = (type: string) => { setHiddenTypes((prev) => { const next = new Set(prev); if (next.has(type)) next.delete(type); else next.add(type); return next; }); }; // Page-level shortcuts (disabled while drawer or overlay is open) useKeyboardShortcuts( { l: () => setShowLegend((v) => !v), m: () => setShowMinimap((v) => !v), r: () => graph.refetch(), "Shift+?": () => setShortcutsOpen(true), Escape: () => { if (drawerOpen) { setDrawerOpen(false); setSelected(null); } }, }, { disabled: shortcutsOpen }, ); const pageShortcutGroups: ShortcutGroup[] = [ { title: "Graph View", items: [ { keys: "l", label: "Toggle legend" }, { keys: "m", label: "Toggle minimap" }, { keys: "r", label: "Refresh graph" }, { keys: "Escape", label: "Close detail drawer" }, ], }, { title: "Global", items: [ { keys: "Mod+K", label: "Command palette" }, { keys: "Shift+?", label: "Show this panel" }, { keys: "g g", label: "Jump to Graph View" }, ], }, ]; return ( <> } />
{graph.isError && (
{(graph.error as Error).message}
)} {/* Filter bar — unified FilterPanel */} {(() => { const statusLabels: Record = { validated_claim: "Validated", candidate_claim: "Candidate", rule_candidate: "Rule candidate", active: "Active", all: "All statuses", }; const chips: ActiveChip[] = []; if (search) chips.push({ id: "search", label: "Search", value: `"${search}"`, onClear: () => setSearch(""), }); if (entityType) chips.push({ id: "type", label: "Type", value: entityType, onClear: () => setEntityType(""), }); if (predicate) chips.push({ id: "predicate", label: "Predicate", value: predicate, onClear: () => setPredicate(""), }); if (statusScope !== "validated_claim") chips.push({ id: "status", label: "Status", value: statusLabels[statusScope] ?? statusScope, onClear: () => setStatusScope("validated_claim"), }); if (minConfidence > 0) chips.push({ id: "conf", label: "Min confidence", value: formatPercent(minConfidence), onClear: () => setMinConfidence(0), }); if (!includeCandidates && statusScope === "validated_claim") chips.push({ id: "blend", label: "Blend candidates", value: "off", onClear: () => setIncludeCandidates(true), }); const clearAll = () => { setSearch(""); setEntityType(""); setPredicate(""); setStatusScope("validated_claim"); setMinConfidence(0); setIncludeCandidates(true); }; return ( 0 ? : undefined} onClearAll={chips.length > 0 ? clearAll : undefined} > ({ value: t, label: t, hint: typeCounts.get(t), }))} /> ({ value: p, label: p }))} /> setStatusScope(v || "validated_claim")} placeholder="Validated" allowEmpty options={Object.entries(statusLabels) .filter(([k]) => k !== "validated_claim") .map(([k, v]) => ({ value: k, label: v }))} /> `${Math.round(v * 100)}%`} /> ); })()} {/* Canvas */}
Ontology Network {visibleNodes.length} nodes / {visibleEdges.length} edges shown
{graph.isLoading ? ( ) : visibleNodes.length === 0 ? ( ) : ( <> {visibleEdges.map((edge) => { const { source, target } = edgeEndpoint(edge, visibleNodes); if (!source || !target) return null; const midX = (source.x + target.x) / 2; const midY = (source.y + target.y) / 2; const isSelected = selected && "predicate" in selected && selected.id === edge.id; return ( selectNode(edge)} > {edge.predicate} ); })} {visibleNodes.map((node) => { const isSelected = selected && "id" in selected && selected.id === node.id; return ( selectNode(node)} > {node.name.length > 24 ? `${node.name.slice(0, 24)}...` : node.name} ); })} {/* Legend overlay (top-right) */} {showLegend && ( )} {/* Minimap overlay (bottom-right) */} {showMinimap && visibleNodes.length > 0 && ( )} )}
{/* Bottom stats row */}
Graph Health Predicate Counts
    {predicates.map((name) => { const count = (graph.data?.edges ?? []).filter( (edge) => edge.predicate === name, ).length; return (
  • {name} {count}
  • ); })} {predicates.length === 0 && (
  • No predicates found.
  • )}
{/* Node / Edge detail drawer */} { setDrawerOpen(open); if (!open) setSelected(null); }} size="md" title={ selected ? "predicate" in selected ? `Edge · ${selected.predicate}` : `Node · ${selected.name}` : "" } description={ selected && "predicate" in selected ? "Claim evidence and confidence" : "Entity details and metadata" } headerExtra={ selected && ( {"predicate" in selected ? selected.predicate : selected.type} ) } footer={ } > {selected && "predicate" in selected && (
)} {selected && !("predicate" in selected) && (
)}
); } /* ============================================================ * Legend Panel — overlay, toggle visibility per type * ========================================================== */ function LegendPanel({ types, typeCounts, hiddenTypes, onToggle, }: { types: string[]; typeCounts: Map; hiddenTypes: Set; onToggle: (type: string) => void; }) { return (
Legend
    {types.map((type) => { const hidden = hiddenTypes.has(type); const count = typeCounts.get(type) ?? 0; return (
  • ); })}
); } /* ============================================================ * Minimap — scaled overview of the whole graph * ========================================================== */ function Minimap({ nodes, edges, }: { nodes: VisualNode[]; edges: VisualEdge[]; }) { const W = 180; const H = 130; // Bounds of visible nodes (with padding so circles don't clip) const pad = 24; const xs = nodes.map((n) => n.x); const ys = nodes.map((n) => n.y); const minX = Math.min(...xs) - pad; const maxX = Math.max(...xs) + pad; const minY = Math.min(...ys) - pad; const maxY = Math.max(...ys) + pad; const w = Math.max(maxX - minX, 1); const h = Math.max(maxY - minY, 1); const scale = Math.min(W / w, H / h); const offsetX = (W - w * scale) / 2; const offsetY = (H - h * scale) / 2; const tx = (x: number) => (x - minX) * scale + offsetX; const ty = (y: number) => (y - minY) * scale + offsetY; return (
Minimap {nodes.length}·{edges.length}
); } function Detail({ label, value }: { label: string; value: unknown }) { return (
{label}
{humanizeValue(value)}
); } function HealthRow({ label, value }: { label: string; value: number | string }) { return (
{label} {typeof value === "number" ? value.toLocaleString() : value}
); }