import { useMemo, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { AlertCircle, ArrowLeft, CircleDot, GitBranch, Network, RefreshCw, Search, SlidersHorizontal, } from "lucide-react"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Progress } from "@/components/ui/progress"; import { Select } from "@/components/ui/select"; import { Skeleton } from "@/components/ui/skeleton"; import { useGraphNeighborhood } from "@/hooks/usePlatform"; 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", ]; 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 = 430; const centerY = 300; 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 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, ], ); const averageConfidence = visual.edges.length ? visual.edges.reduce((sum, edge) => sum + edge.confidence, 0) / visual.edges.length : 0; return (

Graph View

{project?.name ?? projectName} relation graph, candidates, and edge evidence.

{graph.isError && ( {(graph.error as Error).message} )}
setSearch(event.target.value)} className="pl-9" placeholder="Search node or type" />
Minimum confidence {formatPercent(minConfidence)}
setMinConfidence(Number(event.target.value))} className="w-full" />
Ontology Network {visual.nodes.length} nodes / {visual.edges.length} edges shown
{visual.types.slice(0, 8).map((type) => ( {type} ))}
{graph.isLoading ? ( ) : visual.nodes.length === 0 ? (
No graph is available for the current filters.
) : ( {visual.edges.map((edge) => { const { source, target } = edgeEndpoint(edge, visual.nodes); if (!source || !target) return null; const midX = (source.x + target.x) / 2; const midY = (source.y + target.y) / 2; return ( setSelected(edge)} > {edge.predicate} ); })} {visual.nodes.map((node) => ( setSelected(node)} > {node.name.length > 24 ? `${node.name.slice(0, 24)}...` : node.name} ))} )}
); } 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}
); }