Files
AI/ontology_platform/web/frontend/src/pages/GraphViewPage.tsx
2026-05-22 00:22:03 +09:00

1391 lines
46 KiB
TypeScript

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<string, VisualEdge[]>();
const incoming = new Map<string, VisualEdge[]>();
const degree = new Map<string, number>();
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<string, { x: number; y: number; strength: number }>();
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<string, VisualEdge[]>();
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<string, VisualNode>();
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<string>();
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<string, number>();
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<VisualNode | VisualEdge | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [hiddenTypes, setHiddenTypes] = useState<Set<string>>(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<HTMLDivElement | null>(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<string, number>();
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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 (
<>
<PageHeader
icon={Network}
breadcrumbs={[
{ label: "Dashboard", to: "/" },
{ label: projectName, to: `/sources/${projectName}` },
{ label: "Graph View" },
]}
title="Graph View"
description={`${project?.name ?? projectName} — relation graph, candidates, and edge evidence.`}
actions={
<Tooltip content="Refresh graph data" shortcut="R">
<Button variant="outline" onClick={() => graph.refetch()}>
<RefreshCw className="h-4 w-4" />
Refresh
</Button>
</Tooltip>
}
/>
<div className="mx-auto max-w-7xl px-6 py-6">
{graph.isError && (
<div className="mb-4 flex items-center gap-2 rounded-md border border-danger-border bg-danger-subtle px-4 py-3 text-sm text-danger">
<AlertCircle className="h-4 w-4" />
{(graph.error as Error).message}
</div>
)}
{/* Filter bar — unified FilterPanel */}
{(() => {
const statusLabels: Record<string, string> = {
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 (
<FilterPanel
className="mb-4"
chips={chips.length > 0 ? <FilterChips chips={chips} /> : undefined}
onClearAll={chips.length > 0 ? clearAll : undefined}
>
<FilterSearch
value={search}
onChange={setSearch}
placeholder="Search node or type…"
/>
<FilterSelect
label="Type"
value={entityType}
onChange={setEntityType}
placeholder="All types"
options={visual.types.map((t) => ({
value: t,
label: t,
hint: typeCounts.get(t),
}))}
/>
<FilterSelect
label="Predicate"
value={predicate}
onChange={setPredicate}
placeholder="All predicates"
options={predicates.map((p) => ({ value: p, label: p }))}
/>
<FilterSelect
label="Status"
value={statusScope === "validated_claim" ? "" : statusScope}
onChange={(v) => setStatusScope(v || "validated_claim")}
placeholder="Validated"
allowEmpty
options={Object.entries(statusLabels)
.filter(([k]) => k !== "validated_claim")
.map(([k, v]) => ({ value: k, label: v }))}
/>
<FilterRange
label="Min confidence"
value={minConfidence}
onChange={setMinConfidence}
format={(v) => `${Math.round(v * 100)}%`}
/>
<FilterToggle
label="Blend candidates"
checked={includeCandidates}
onChange={setIncludeCandidates}
disabled={statusScope !== "validated_claim"}
/>
</FilterPanel>
);
})()}
{/* Canvas */}
<div className="relative mb-6 overflow-hidden rounded-lg border border-border bg-surface">
<div className="flex items-center justify-between border-b border-border px-4 py-2.5">
<div className="text-sm">
<span className="font-medium text-foreground">
Ontology Network
</span>
<span className="ml-2 text-muted-foreground">
{visibleNodes.length} nodes / {visibleEdges.length} edges shown
</span>
</div>
<div className="flex items-center gap-1">
<Tooltip
content={showLegend ? "Hide legend" : "Show legend"}
shortcut="L"
>
<Button
variant="ghost"
size="icon"
aria-label="Toggle legend"
onClick={() => setShowLegend((v) => !v)}
>
<Layers className="h-4 w-4" />
</Button>
</Tooltip>
<Tooltip
content={showMinimap ? "Hide minimap" : "Show minimap"}
shortcut="M"
>
<Button
variant="ghost"
size="icon"
aria-label="Toggle minimap"
onClick={() => setShowMinimap((v) => !v)}
>
<MapIcon className="h-4 w-4" />
</Button>
</Tooltip>
<Tooltip content="Show keyboard shortcuts" shortcut="?">
<Button
variant="ghost"
size="icon"
aria-label="Keyboard shortcuts"
onClick={() => setShortcutsOpen(true)}
>
<Keyboard className="h-4 w-4" />
</Button>
</Tooltip>
</div>
</div>
<div className="relative">
{graph.isLoading ? (
<Skeleton className="h-[640px]" />
) : visibleNodes.length === 0 ? (
<EmptyState
icon={GitBranch}
title="No graph to show"
description="No nodes match the current filters."
variant="muted"
compact
className="h-[500px]"
/>
) : (
<>
<div
ref={canvasRef}
className="relative h-[640px] cursor-grab overflow-auto bg-background-subtle active:cursor-grabbing"
onPointerDown={startCanvasPan}
onPointerMove={moveCanvasPan}
onPointerUp={endCanvasPan}
onPointerCancel={endCanvasPan}
>
<svg
viewBox={`0 0 ${visual.canvas.width} ${visual.canvas.height}`}
width={visual.canvas.width}
height={visual.canvas.height}
className="block min-h-full min-w-full text-muted-foreground"
role="img"
aria-label="Ontology graph"
>
<defs>
<marker
id="arrow"
markerWidth="6"
markerHeight="6"
refX="5.5"
refY="2.5"
orient="auto"
markerUnits="strokeWidth"
>
<path d="M0,0 L0,5 L5.5,2.5 z" fill="currentColor" />
</marker>
</defs>
{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 (
<g
key={edge.id}
data-graph-edge
className="cursor-pointer"
onClick={() => selectNode(edge)}
>
<line
x1={x1}
y1={y1}
x2={x2}
y2={y2}
stroke="currentColor"
strokeWidth={isSelected ? 1.7 : 0.65 + edge.confidence * 0.75}
strokeOpacity={isSelected ? 0.95 : 0.42}
markerEnd="url(#arrow)"
className={isSelected ? "text-brand-600 dark:text-brand-300" : ""}
/>
{(showEdgeLabels || isSelected) && (
<text
x={midX}
y={midY - 2}
textAnchor="middle"
className="fill-muted-foreground text-[7px]"
opacity={isSelected ? 0.95 : 0.62}
paintOrder="stroke"
stroke="hsl(var(--background))"
strokeWidth="2"
strokeLinejoin="round"
>
{edge.predicate.length > 18
? `${edge.predicate.slice(0, 18)}...`
: edge.predicate}
</text>
)}
</g>
);
})}
{visibleNodes.map((node) => {
const isSelected =
selected && "id" in selected && selected.id === node.id;
return (
<g
key={node.id}
data-graph-node
className="cursor-pointer"
onClick={() => selectNode(node)}
>
<circle
cx={node.x}
cy={node.y}
r={node.radius}
fill={node.color}
stroke={
isSelected
? "hsl(var(--foreground))"
: node.isHub
? "hsl(var(--muted-foreground))"
: "hsl(var(--background))"
}
strokeWidth={isSelected ? 2.5 : node.isHub ? 2 : 1.3}
/>
{(showNodeLabels || isSelected) && (
<text
x={node.x}
y={node.y + node.radius + 10}
textAnchor="middle"
className={
node.isHub
? "fill-foreground text-[8px] font-semibold"
: "fill-foreground text-[8px] font-medium"
}
opacity={node.source === "literal" ? 0.74 : 0.86}
paintOrder="stroke"
stroke="hsl(var(--background))"
strokeWidth="2"
strokeLinejoin="round"
>
{node.name.length > 18
? `${node.name.slice(0, 18)}...`
: node.name}
</text>
)}
</g>
);
})}
</svg>
</div>
{/* Legend overlay (top-right) */}
{showLegend && (
<LegendPanel
types={visual.types}
typeCounts={typeCounts}
hiddenTypes={hiddenTypes}
onToggle={toggleType}
collapsed={legendCollapsed}
onCollapsedChange={setLegendCollapsed}
/>
)}
{/* Minimap overlay (bottom-right) */}
{showMinimap && visibleNodes.length > 0 && (
<Minimap
nodes={visibleNodes}
edges={visibleEdges}
onJump={jumpCanvasTo}
/>
)}
</>
)}
</div>
</div>
{/* Bottom stats row */}
<div className="grid gap-4 lg:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="text-sm">Graph Health</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<HealthRow label="Visible nodes" value={visibleNodes.length} />
<HealthRow label="Visible edges" value={visibleEdges.length} />
<HealthRow
label="Avg confidence"
value={
visibleEdges.length ? formatPercent(averageConfidence) : "—"
}
/>
<Progress value={averageConfidence * 100} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm">
<CircleDot className="h-4 w-4" />
Predicate Counts
</CardTitle>
</CardHeader>
<CardContent>
<ul className="grid gap-1.5 sm:grid-cols-2">
{predicates.map((name) => {
const count = (graph.data?.edges ?? []).filter(
(edge) => edge.predicate === name,
).length;
return (
<li
key={name}
className="flex items-center justify-between rounded-md border border-border px-3 py-2 text-sm"
>
<span className="truncate">{name}</span>
<span className="font-medium tabular-nums">{count}</span>
</li>
);
})}
{predicates.length === 0 && (
<li className="col-span-full py-4 text-center text-sm text-muted-foreground">
No predicates found.
</li>
)}
</ul>
</CardContent>
</Card>
</div>
</div>
{/* Node / Edge detail drawer */}
<Drawer
open={drawerOpen && !!selected}
onOpenChange={(open) => {
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 && (
<Badge variant={"predicate" in selected ? "secondary" : "outline"}>
{"predicate" in selected ? selected.predicate : selected.type}
</Badge>
)
}
footer={
<Button
variant="ghost"
onClick={() => {
setDrawerOpen(false);
setSelected(null);
}}
>
Close
</Button>
}
>
{selected && "predicate" in selected && (
<div className="space-y-3">
<Detail label="Claim" value={`#${selected.id}`} />
<Detail label="Status" value={selected.raw.status} />
<Detail
label="Confidence"
value={formatPercent(selected.confidence)}
/>
<Detail
label="Object"
value={selected.raw.object?.name ?? selected.raw.target_value}
/>
<Detail
label="Last seen"
value={formatDateTime(selected.raw.last_seen_at)}
/>
<Detail
label="Metadata"
value={humanizeValue(selected.raw.metadata)}
/>
</div>
)}
{selected && !("predicate" in selected) && (
<div className="space-y-3">
<Detail label="Name" value={selected.name} />
<Detail label="Source" value={selected.source} />
<Detail
label="Canonical"
value={selected.raw?.canonical_name}
/>
<Detail
label="Metadata"
value={humanizeValue(selected.raw?.metadata)}
/>
<Button
variant="outline"
size="sm"
className="w-full"
onClick={() => navigate(`/editor/${projectName}`)}
>
Open in Entity Manager
</Button>
</div>
)}
</Drawer>
<ShortcutsOverlay
open={shortcutsOpen}
onOpenChange={setShortcutsOpen}
groups={pageShortcutGroups}
title="Graph View shortcuts"
/>
</>
);
}
/* ============================================================
* Legend Panel — overlay, toggle visibility per type
* ========================================================== */
function LegendPanel({
types,
typeCounts,
hiddenTypes,
onToggle,
collapsed,
onCollapsedChange,
}: {
types: string[];
typeCounts: Map<string, number>;
hiddenTypes: Set<string>;
onToggle: (type: string) => void;
collapsed: boolean;
onCollapsedChange: (collapsed: boolean) => void;
}) {
return (
<div
className={
collapsed
? "absolute right-3 top-3 w-32 rounded-md border border-border bg-popover/95 shadow-md backdrop-blur-sm"
: "absolute right-3 top-3 w-52 rounded-md border border-border bg-popover/95 shadow-md backdrop-blur-sm"
}
>
<button
type="button"
onClick={() => onCollapsedChange(!collapsed)}
className="flex w-full items-center justify-between gap-2 px-3 py-1.5 text-left text-2xs font-semibold uppercase tracking-wider text-muted-foreground transition-colors hover:bg-accent/60"
aria-expanded={!collapsed}
>
<span>Legend</span>
<span className="flex items-center gap-1 font-mono normal-case">
{collapsed && types.length}
{collapsed ? (
<ChevronRight className="h-3.5 w-3.5" />
) : (
<ChevronDown className="h-3.5 w-3.5" />
)}
</span>
</button>
{!collapsed && (
<ul className="max-h-64 overflow-y-auto border-t border-border py-1">
{types.map((type) => {
const hidden = hiddenTypes.has(type);
const count = typeCounts.get(type) ?? 0;
return (
<li key={type}>
<button
type="button"
onClick={() => onToggle(type)}
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs transition-colors hover:bg-accent/60"
aria-pressed={!hidden}
>
<span
className="h-2.5 w-2.5 flex-shrink-0 rounded-full"
style={{
backgroundColor: nodeColor(type, types),
opacity: hidden ? 0.3 : 1,
}}
/>
<span
className={
hidden
? "flex-1 truncate text-muted-foreground/60 line-through"
: "flex-1 truncate text-foreground"
}
>
{type}
</span>
<span className="text-2xs text-muted-foreground tabular-nums">
{count}
</span>
{hidden ? (
<EyeOff className="h-3 w-3 text-muted-foreground/60" />
) : (
<Eye className="h-3 w-3 text-muted-foreground" />
)}
</button>
</li>
);
})}
</ul>
)}
</div>
);
}
/* ============================================================
* 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<SVGSVGElement>) => {
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 (
<div className="absolute bottom-3 right-3 rounded-md border border-border bg-popover/95 shadow-md backdrop-blur-sm">
<div className="flex items-center justify-between border-b border-border px-2.5 py-1 text-2xs font-semibold uppercase tracking-wider text-muted-foreground">
<span>Minimap</span>
<span className="font-mono normal-case">
{nodes.length}·{edges.length}
</span>
</div>
<svg
width={W}
height={H}
className="block cursor-crosshair text-muted-foreground"
role="button"
aria-label="Jump to minimap position"
tabIndex={0}
onClick={jumpFromMinimap}
>
{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 (
<line
key={e.id}
x1={tx(s.x)}
y1={ty(s.y)}
x2={tx(t.x)}
y2={ty(t.y)}
stroke="currentColor"
strokeOpacity="0.35"
strokeWidth="0.5"
/>
);
})}
{nodes.map((n) => (
<circle
key={n.id}
cx={tx(n.x)}
cy={ty(n.y)}
r={1.8}
fill={n.color}
/>
))}
</svg>
</div>
);
}
function Detail({ label, value }: { label: string; value: unknown }) {
return (
<div className="rounded-md border border-border bg-background-subtle px-3 py-2 text-sm">
<div className="text-2xs uppercase tracking-wider text-muted-foreground">
{label}
</div>
<div className="mt-1 break-words font-medium text-foreground">
{humanizeValue(value)}
</div>
</div>
);
}
function HealthRow({ label, value }: { label: string; value: number | string }) {
return (
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">{label}</span>
<span className="font-medium tabular-nums">
{typeof value === "number" ? value.toLocaleString() : value}
</span>
</div>
);
}