Files
AI/ontology_platform/web/frontend/src/pages/GraphViewPage.tsx

958 lines
30 KiB
TypeScript
Raw Normal View History

2026-05-19 20:31:52 +09:00
import { useMemo, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import {
AlertCircle,
CircleDot,
2026-05-20 18:43:08 +09:00
Eye,
EyeOff,
2026-05-19 20:31:52 +09:00
GitBranch,
2026-05-20 18:43:08 +09:00
Keyboard,
Layers,
Map as MapIcon,
2026-05-19 20:31:52 +09:00
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";
2026-05-20 18:43:08 +09:00
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";
2026-05-19 20:31:52 +09:00
import { useGraphNeighborhood } from "@/hooks/usePlatform";
2026-05-20 18:43:08 +09:00
import { useKeyboardShortcuts } from "@/hooks/useKeyboardShortcuts";
2026-05-19 20:31:52 +09:00
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",
];
2026-05-20 18:43:08 +09:00
const VIEW_W = 860;
const VIEW_H = 600;
2026-05-19 20:31:52 +09:00
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<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));
}
2026-05-20 18:43:08 +09:00
const centerX = VIEW_W / 2;
const centerY = VIEW_H / 2;
2026-05-19 20:31:52 +09:00
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<VisualNode | VisualEdge | null>(null);
2026-05-20 18:43:08 +09:00
const [drawerOpen, setDrawerOpen] = useState(false);
const [hiddenTypes, setHiddenTypes] = useState<Set<string>>(new Set());
const [showLegend, setShowLegend] = useState(true);
const [showMinimap, setShowMinimap] = useState(true);
const [shortcutsOpen, setShortcutsOpen] = useState(false);
2026-05-19 20:31:52 +09:00
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,
],
);
2026-05-20 18:43:08 +09:00
// 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
2026-05-19 20:31:52 +09:00
: 0;
2026-05-20 18:43:08 +09:00
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" },
],
},
];
2026-05-19 20:31:52 +09:00
return (
2026-05-20 18:43:08 +09:00
<>
<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>
}
/>
2026-05-19 20:31:52 +09:00
2026-05-20 18:43:08 +09:00
<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">
2026-05-19 20:31:52 +09:00
<AlertCircle className="h-4 w-4" />
{(graph.error as Error).message}
2026-05-20 18:43:08 +09:00
</div>
)}
2026-05-19 20:31:52 +09:00
2026-05-20 18:43:08 +09:00
{/* 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>
);
})()}
2026-05-19 20:31:52 +09:00
2026-05-20 18:43:08 +09:00
{/* 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>
2026-05-19 20:31:52 +09:00
</div>
2026-05-20 18:43:08 +09:00
</div>
<div className="relative">
2026-05-19 20:31:52 +09:00
{graph.isLoading ? (
<Skeleton className="h-[640px]" />
2026-05-20 18:43:08 +09:00
) : visibleNodes.length === 0 ? (
<EmptyState
icon={GitBranch}
title="No graph to show"
description="No nodes match the current filters."
variant="muted"
compact
className="h-[500px]"
/>
2026-05-19 20:31:52 +09:00
) : (
2026-05-20 18:43:08 +09:00
<>
<svg
viewBox={`0 0 ${VIEW_W} ${VIEW_H}`}
className="block h-[640px] w-full bg-background-subtle text-muted-foreground"
role="img"
aria-label="Ontology graph"
>
<defs>
<marker
id="arrow"
markerWidth="10"
markerHeight="10"
refX="10"
refY="3"
orient="auto"
markerUnits="strokeWidth"
2026-05-19 20:31:52 +09:00
>
2026-05-20 18:43:08 +09:00
<path d="M0,0 L0,6 L9,3 z" fill="currentColor" />
</marker>
</defs>
{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 (
<g
key={edge.id}
className="cursor-pointer"
onClick={() => selectNode(edge)}
2026-05-19 20:31:52 +09:00
>
2026-05-20 18:43:08 +09:00
<line
x1={source.x}
y1={source.y}
x2={target.x}
y2={target.y}
stroke="currentColor"
strokeWidth={1 + edge.confidence * 3}
strokeOpacity={isSelected ? 1 : 0.55}
markerEnd="url(#arrow)"
className={isSelected ? "text-brand-600 dark:text-brand-300" : ""}
/>
<text
x={midX}
y={midY}
textAnchor="middle"
className="fill-muted-foreground text-[10px]"
paintOrder="stroke"
stroke="hsl(var(--background))"
strokeWidth="3"
strokeLinejoin="round"
>
{edge.predicate}
</text>
</g>
);
})}
{visibleNodes.map((node) => {
const isSelected =
selected && "id" in selected && selected.id === node.id;
return (
<g
key={node.id}
className="cursor-pointer"
onClick={() => selectNode(node)}
>
<circle
cx={node.x}
cy={node.y}
r={node.radius}
fill={node.color}
className={
isSelected
? "stroke-foreground"
: "stroke-background"
}
strokeWidth={isSelected ? 3 : 2}
/>
<text
x={node.x}
y={node.y + node.radius + 14}
textAnchor="middle"
className="fill-foreground text-[11px] font-medium"
paintOrder="stroke"
stroke="hsl(var(--background))"
strokeWidth="3"
strokeLinejoin="round"
>
{node.name.length > 24
? `${node.name.slice(0, 24)}...`
: node.name}
</text>
</g>
);
})}
</svg>
2026-05-19 20:31:52 +09:00
2026-05-20 18:43:08 +09:00
{/* Legend overlay (top-right) */}
{showLegend && (
<LegendPanel
types={visual.types}
typeCounts={typeCounts}
hiddenTypes={hiddenTypes}
onToggle={toggleType}
2026-05-19 20:31:52 +09:00
/>
2026-05-20 18:43:08 +09:00
)}
{/* Minimap overlay (bottom-right) */}
{showMinimap && visibleNodes.length > 0 && (
<Minimap nodes={visibleNodes} edges={visibleEdges} />
)}
</>
)}
</div>
</div>
2026-05-19 20:31:52 +09:00
2026-05-20 18:43:08 +09:00
{/* Bottom stats row */}
<div className="grid gap-4 lg:grid-cols-2">
2026-05-19 20:31:52 +09:00
<Card>
<CardHeader>
2026-05-20 18:43:08 +09:00
<CardTitle className="text-sm">Graph Health</CardTitle>
2026-05-19 20:31:52 +09:00
</CardHeader>
<CardContent className="space-y-3">
2026-05-20 18:43:08 +09:00
<HealthRow label="Visible nodes" value={visibleNodes.length} />
<HealthRow label="Visible edges" value={visibleEdges.length} />
2026-05-19 20:31:52 +09:00
<HealthRow
label="Avg confidence"
value={
2026-05-20 18:43:08 +09:00
visibleEdges.length ? formatPercent(averageConfidence) : "—"
2026-05-19 20:31:52 +09:00
}
/>
<Progress value={averageConfidence * 100} />
</CardContent>
</Card>
<Card>
<CardHeader>
2026-05-20 18:43:08 +09:00
<CardTitle className="flex items-center gap-2 text-sm">
<CircleDot className="h-4 w-4" />
2026-05-19 20:31:52 +09:00
Predicate Counts
</CardTitle>
</CardHeader>
<CardContent>
2026-05-20 18:43:08 +09:00
<ul className="grid gap-1.5 sm:grid-cols-2">
2026-05-19 20:31:52 +09:00
{predicates.map((name) => {
const count = (graph.data?.edges ?? []).filter(
(edge) => edge.predicate === name,
).length;
return (
<li
key={name}
2026-05-20 18:43:08 +09:00
className="flex items-center justify-between rounded-md border border-border px-3 py-2 text-sm"
2026-05-19 20:31:52 +09:00
>
2026-05-20 18:43:08 +09:00
<span className="truncate">{name}</span>
<span className="font-medium tabular-nums">{count}</span>
2026-05-19 20:31:52 +09:00
</li>
);
})}
{predicates.length === 0 && (
2026-05-20 18:43:08 +09:00
<li className="col-span-full py-4 text-center text-sm text-muted-foreground">
2026-05-19 20:31:52 +09:00
No predicates found.
</li>
)}
</ul>
</CardContent>
</Card>
2026-05-20 18:43:08 +09:00
</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,
}: {
types: string[];
typeCounts: Map<string, number>;
hiddenTypes: Set<string>;
onToggle: (type: string) => void;
}) {
return (
<div className="absolute right-3 top-3 w-52 rounded-md border border-border bg-popover/95 shadow-md backdrop-blur-sm">
<div className="border-b border-border px-3 py-1.5 text-2xs font-semibold uppercase tracking-wider text-muted-foreground">
Legend
</div>
<ul className="max-h-64 overflow-y-auto 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,
}: {
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 (
<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>
2026-05-19 20:31:52 +09:00
</div>
2026-05-20 18:43:08 +09:00
<svg
width={W}
height={H}
className="block text-muted-foreground"
aria-hidden="true"
>
{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>
2026-05-19 20:31:52 +09:00
</div>
);
}
function Detail({ label, value }: { label: string; value: unknown }) {
return (
2026-05-20 18:43:08 +09:00
<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>
2026-05-19 20:31:52 +09:00
</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>
2026-05-20 18:43:08 +09:00
<span className="font-medium tabular-nums">
2026-05-19 20:31:52 +09:00
{typeof value === "number" ? value.toLocaleString() : value}
</span>
</div>
);
}