import { useEffect, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { AlertCircle, ChevronDown, ChevronRight, 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; const GRAPH_PADDING = 64; const CLUSTER_CELL_W = 220; const CLUSTER_CELL_H = 176; const NODE_MIN_R = 8; const NODE_MAX_R = 16; const LITERAL_MIN_R = 6; const LITERAL_MAX_R = 11; interface GraphCanvas { width: number; height: number; } interface VisualNode { id: string; name: string; type: string; x: number; y: number; radius: number; color: string; source: "entity" | "literal"; isHub?: boolean; 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 clamp(value: number, min: number, max: number) { return Math.min(Math.max(value, min), max); } function hashToUnit(input: string) { let hash = 2166136261; for (let i = 0; i < input.length; i += 1) { hash ^= input.charCodeAt(i); hash = Math.imul(hash, 16777619); } return (hash >>> 0) / 4294967295; } function predicateAngle(predicate: string) { const normalized = predicate.toLowerCase(); if (normalized.includes("brand")) return -Math.PI / 6; if (normalized.includes("price") || normalized.includes("amount")) { return Math.PI / 2; } if (normalized.includes("note") || normalized.includes("description")) { return Math.PI; } if (normalized.includes("occasion") || normalized.includes("tag")) { return -Math.PI / 2; } return hashToUnit(predicate) * Math.PI * 2; } function layoutGraph(nodes: VisualNode[], edges: VisualEdge[]): GraphCanvas { const count = nodes.length; if (!count) return { width: VIEW_W, height: VIEW_H }; nodes.forEach((node) => { node.isHub = false; }); if (count === 1) { const centerX = VIEW_W / 2; const centerY = VIEW_H / 2; nodes[0].x = centerX; nodes[0].y = centerY; return { width: VIEW_W, height: VIEW_H }; } const nodeById = new Map(nodes.map((node) => [node.id, node])); const outgoing = new Map(); const incoming = new Map(); const degree = new Map(); edges.forEach((edge) => { outgoing.set(edge.source, [...(outgoing.get(edge.source) ?? []), edge]); incoming.set(edge.target, [...(incoming.get(edge.target) ?? []), edge]); degree.set(edge.source, (degree.get(edge.source) ?? 0) + 1); degree.set(edge.target, (degree.get(edge.target) ?? 0) + 1); }); let centers = nodes.filter( (node) => node.source === "entity" && (outgoing.get(node.id)?.length ?? 0) > 0, ); if (!centers.length) { centers = [...nodes] .sort((a, b) => (degree.get(b.id) ?? 0) - (degree.get(a.id) ?? 0)) .slice(0, Math.max(1, Math.ceil(Math.sqrt(count)))); } centers.sort((a, b) => { const scoreA = (outgoing.get(a.id)?.length ?? 0) * 3 + (degree.get(a.id) ?? 0); const scoreB = (outgoing.get(b.id)?.length ?? 0) * 3 + (degree.get(b.id) ?? 0); return scoreB - scoreA || a.name.localeCompare(b.name); }); const centerIds = new Set(centers.map((node) => node.id)); centers.forEach((node) => { node.isHub = true; node.radius = clamp(node.radius + 2, NODE_MIN_R + 3, NODE_MAX_R + 2); }); const columns = centers.length <= 2 ? centers.length : Math.ceil(Math.sqrt(centers.length * 1.35)); const rows = Math.ceil(centers.length / columns); const canvasWidth = Math.max( VIEW_W, GRAPH_PADDING * 2 + columns * CLUSTER_CELL_W, ); const canvasHeight = Math.max( VIEW_H, GRAPH_PADDING * 2 + rows * CLUSTER_CELL_H, ); const centerX = canvasWidth / 2; const centerY = canvasHeight / 2; const width = canvasWidth - GRAPH_PADDING * 2; const height = canvasHeight - GRAPH_PADDING * 2; const cellW = width / Math.max(columns, 1); const cellH = height / Math.max(rows, 1); const anchors = new Map(); centers.forEach((node, index) => { const row = Math.floor(index / columns); const col = index % columns; node.x = GRAPH_PADDING + cellW * (col + 0.5); node.y = GRAPH_PADDING + cellH * (row + 0.5); if (rows === 1) { node.y = centerY; } anchors.set(node.id, { x: node.x, y: node.y, strength: 0.55 }); }); centers.forEach((center) => { const childEdges = (outgoing.get(center.id) ?? []) .filter((edge) => !centerIds.has(edge.target) && nodeById.has(edge.target)) .sort((a, b) => { const angleA = predicateAngle(a.predicate); const angleB = predicateAngle(b.predicate); return angleA - angleB || a.predicate.localeCompare(b.predicate); }); const groups = new Map(); childEdges.forEach((edge) => { groups.set(edge.predicate, [...(groups.get(edge.predicate) ?? []), edge]); }); Array.from(groups.entries()).forEach(([predicate, group]) => { const baseAngle = predicateAngle(predicate); const spread = Math.min(0.78, 0.18 * Math.max(group.length - 1, 0)); group.forEach((edge, index) => { const child = nodeById.get(edge.target); if (!child) return; const parentCount = (incoming.get(child.id) ?? []).filter((incomingEdge) => centerIds.has(incomingEdge.source), ).length; if (parentCount > 1) return; const offset = group.length === 1 ? 0 : -spread / 2 + (spread * index) / Math.max(group.length - 1, 1); const distance = 76 + Math.min(childEdges.length, 8) * 3 + hashToUnit(`${center.id}:${child.id}`) * 20; const x = center.x + Math.cos(baseAngle + offset) * distance; const y = center.y + Math.sin(baseAngle + offset) * distance; child.x = x; child.y = y; anchors.set(child.id, { x, y, strength: 0.4 }); }); }); }); nodes .filter((node) => !centerIds.has(node.id)) .forEach((node) => { if (anchors.has(node.id)) return; const parentEdges = (incoming.get(node.id) ?? []).filter((edge) => centerIds.has(edge.source), ); if (parentEdges.length) { const parents = parentEdges .map((edge) => nodeById.get(edge.source)) .filter((parent): parent is VisualNode => Boolean(parent)); const avgX = parents.reduce((sum, parent) => sum + parent.x, 0) / parents.length; const avgY = parents.reduce((sum, parent) => sum + parent.y, 0) / parents.length; const isSharedTarget = parentEdges.length > 1; const angle = predicateAngle(parentEdges[0].predicate); const x = isSharedTarget ? avgX : avgX + Math.cos(angle) * 58; const y = isSharedTarget ? avgY : avgY + Math.sin(angle) * 58; node.x = x; node.y = y; anchors.set(node.id, { x, y, strength: isSharedTarget ? 0.46 : 0.32 }); return; } const relatedEdges = edges.filter( (edge) => edge.source === node.id || edge.target === node.id, ); const related = relatedEdges .map((edge) => edge.source === node.id ? nodeById.get(edge.target) : nodeById.get(edge.source), ) .filter((relatedNode): relatedNode is VisualNode => Boolean(relatedNode)); if (related.length) { const avgX = related.reduce((sum, relatedNode) => sum + relatedNode.x, 0) / related.length; const avgY = related.reduce((sum, relatedNode) => sum + relatedNode.y, 0) / related.length; const angle = hashToUnit(node.id) * Math.PI * 2; node.x = avgX + Math.cos(angle) * 64; node.y = avgY + Math.sin(angle) * 64; } else { const angle = hashToUnit(node.id) * Math.PI * 2; node.x = centerX + Math.cos(angle) * 120; node.y = centerY + Math.sin(angle) * 120; } anchors.set(node.id, { x: node.x, y: node.y, strength: 0.24 }); }); for (let tick = 0; tick < 80; tick += 1) { nodes.forEach((node) => { const anchor = anchors.get(node.id); if (!anchor) return; node.x += (anchor.x - node.x) * anchor.strength * 0.12; node.y += (anchor.y - node.y) * anchor.strength * 0.12; }); for (let i = 0; i < count; i += 1) { const a = nodes[i]; for (let j = i + 1; j < count; j += 1) { const b = nodes[j]; let dx = a.x - b.x; let dy = a.y - b.y; let distance = Math.sqrt(dx * dx + dy * dy); if (distance < 0.01) { dx = hashToUnit(`${a.id}:${b.id}:x`) - 0.5; dy = hashToUnit(`${a.id}:${b.id}:y`) - 0.5; distance = Math.sqrt(dx * dx + dy * dy); } const minDistance = a.radius + b.radius + 22; if (distance >= minDistance) continue; const force = ((minDistance - distance) / distance) * 0.32; const ax = dx * force; const ay = dy * force; const aAnchor = anchors.get(a.id)?.strength ?? 0.2; const bAnchor = anchors.get(b.id)?.strength ?? 0.2; a.x += ax * (1 - aAnchor); a.y += ay * (1 - aAnchor); b.x -= ax * (1 - bAnchor); b.y -= ay * (1 - bAnchor); } } nodes.forEach((node) => { node.x = clamp(node.x, GRAPH_PADDING, canvasWidth - GRAPH_PADDING); node.y = clamp(node.y, GRAPH_PADDING, canvasHeight - GRAPH_PADDING); }); } return { width: canvasWidth, height: canvasHeight }; } 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 degree = new Map(); filteredEdges.forEach((edge) => { degree.set(edge.source, (degree.get(edge.source) ?? 0) + 1); degree.set(edge.target, (degree.get(edge.target) ?? 0) + 1); }); visualNodes.forEach((node) => { const links = degree.get(node.id) ?? 0; const base = node.source === "literal" ? clamp(LITERAL_MIN_R + Math.sqrt(links) * 1.4, LITERAL_MIN_R, LITERAL_MAX_R) : clamp(NODE_MIN_R + Math.sqrt(links) * 1.8, NODE_MIN_R, NODE_MAX_R); node.radius = base; }); const canvas = layoutGraph(visualNodes, filteredEdges); return { nodes: visualNodes, edges: filteredEdges, types, canvas }; } 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 }; } function edgeLinePoints(source: VisualNode, target: VisualNode) { const dx = target.x - source.x; const dy = target.y - source.y; const distance = Math.max(Math.sqrt(dx * dx + dy * dy), 1); const nx = dx / distance; const ny = dy / distance; const x1 = source.x + nx * (source.radius + 2); const y1 = source.y + ny * (source.radius + 2); const x2 = target.x - nx * (target.radius + 7); const y2 = target.y - ny * (target.radius + 7); return { x1, y1, x2, y2, midX: (x1 + x2) / 2, midY: (y1 + y2) / 2, }; } 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 [legendCollapsed, setLegendCollapsed] = useState(true); const [showMinimap, setShowMinimap] = useState(true); const [shortcutsOpen, setShortcutsOpen] = useState(false); const canvasRef = useRef(null); const panRef = useRef({ active: false, x: 0, y: 0, scrollLeft: 0, scrollTop: 0, }); 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 showEdgeLabels = visibleEdges.length <= 70; const showNodeLabels = visibleNodes.length <= 180; useEffect(() => { const canvas = canvasRef.current; if (!canvas || graph.isLoading || visibleNodes.length === 0) return; window.requestAnimationFrame(() => { canvas.scrollLeft = Math.max(0, (canvas.scrollWidth - canvas.clientWidth) / 2); canvas.scrollTop = Math.max(0, (canvas.scrollHeight - canvas.clientHeight) / 2); }); }, [ graph.isLoading, projectName, visibleNodes.length, visual.canvas.height, visual.canvas.width, ]); const startCanvasPan = (event: ReactPointerEvent) => { if (event.button !== 0) return; const target = event.target as HTMLElement; if (target.closest("[data-graph-node], [data-graph-edge]")) return; panRef.current = { active: true, x: event.clientX, y: event.clientY, scrollLeft: event.currentTarget.scrollLeft, scrollTop: event.currentTarget.scrollTop, }; event.currentTarget.setPointerCapture(event.pointerId); }; const moveCanvasPan = (event: ReactPointerEvent) => { if (!panRef.current.active) return; event.currentTarget.scrollLeft = panRef.current.scrollLeft - (event.clientX - panRef.current.x); event.currentTarget.scrollTop = panRef.current.scrollTop - (event.clientY - panRef.current.y); event.preventDefault(); }; const endCanvasPan = (event: ReactPointerEvent) => { if (!panRef.current.active) return; panRef.current.active = false; if (event.currentTarget.hasPointerCapture(event.pointerId)) { event.currentTarget.releasePointerCapture(event.pointerId); } }; const jumpCanvasTo = (x: number, y: number) => { const canvas = canvasRef.current; if (!canvas) return; canvas.scrollTo({ left: clamp( x - canvas.clientWidth / 2, 0, Math.max(0, canvas.scrollWidth - canvas.clientWidth), ), top: clamp( y - canvas.clientHeight / 2, 0, Math.max(0, canvas.scrollHeight - canvas.clientHeight), ), behavior: "smooth", }); }; 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 { x1, y1, x2, y2, midX, midY } = edgeLinePoints( source, target, ); const isSelected = selected && "predicate" in selected && selected.id === edge.id; return ( selectNode(edge)} > {(showEdgeLabels || isSelected) && ( {edge.predicate.length > 18 ? `${edge.predicate.slice(0, 18)}...` : edge.predicate} )} ); })} {visibleNodes.map((node) => { const isSelected = selected && "id" in selected && selected.id === node.id; return ( selectNode(node)} > {(showNodeLabels || isSelected) && ( {node.name.length > 18 ? `${node.name.slice(0, 18)}...` : 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, collapsed, onCollapsedChange, }: { types: string[]; typeCounts: Map; hiddenTypes: Set; onToggle: (type: string) => void; collapsed: boolean; onCollapsedChange: (collapsed: boolean) => void; }) { return (
{!collapsed && (
    {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, onJump, }: { nodes: VisualNode[]; edges: VisualEdge[]; onJump: (x: number, y: number) => void; }) { 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; const jumpFromMinimap = (event: ReactMouseEvent) => { const rect = event.currentTarget.getBoundingClientRect(); const localX = event.clientX - rect.left; const localY = event.clientY - rect.top; const graphX = clamp((localX - offsetX) / scale + minX, minX, maxX); const graphY = clamp((localY - offsetY) / scale + minY, minY, maxY); onJump(graphX, graphY); }; return (
Minimap {nodes.length}·{edges.length}
{edges.map((e) => { const s = nodes.find((n) => n.id === e.source); const t = nodes.find((n) => n.id === e.target); if (!s || !t) return null; return ( ); })} {nodes.map((n) => ( ))}
); } 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}
); }