2026-05-22 00:22:03 +09:00
|
|
|
import {
|
|
|
|
|
useEffect,
|
|
|
|
|
useMemo,
|
|
|
|
|
useRef,
|
|
|
|
|
useState,
|
|
|
|
|
type MouseEvent as ReactMouseEvent,
|
|
|
|
|
type PointerEvent as ReactPointerEvent,
|
|
|
|
|
} from "react";
|
2026-05-19 20:31:52 +09:00
|
|
|
import { useNavigate, useParams } from "react-router-dom";
|
|
|
|
|
import {
|
|
|
|
|
AlertCircle,
|
2026-05-22 00:22:03 +09:00
|
|
|
ChevronDown,
|
|
|
|
|
ChevronRight,
|
2026-05-19 20:31:52 +09:00
|
|
|
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-22 00:22:03 +09:00
|
|
|
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;
|
|
|
|
|
}
|
2026-05-20 18:43:08 +09:00
|
|
|
|
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";
|
2026-05-22 00:22:03 +09:00
|
|
|
isHub?: boolean;
|
2026-05-19 20:31:52 +09:00
|
|
|
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];
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-22 00:22:03 +09:00
|
|
|
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 };
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 20:31:52 +09:00
|
|
|
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-22 00:22:03 +09:00
|
|
|
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;
|
2026-05-19 20:31:52 +09:00
|
|
|
});
|
2026-05-22 00:22:03 +09:00
|
|
|
const canvas = layoutGraph(visualNodes, filteredEdges);
|
|
|
|
|
return { nodes: visualNodes, edges: filteredEdges, types, canvas };
|
2026-05-19 20:31:52 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 };
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-22 00:22:03 +09:00
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 20:31:52 +09:00
|
|
|
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);
|
2026-05-22 00:22:03 +09:00
|
|
|
const [legendCollapsed, setLegendCollapsed] = useState(true);
|
2026-05-20 18:43:08 +09:00
|
|
|
const [showMinimap, setShowMinimap] = useState(true);
|
|
|
|
|
const [shortcutsOpen, setShortcutsOpen] = useState(false);
|
2026-05-22 00:22:03 +09:00
|
|
|
const canvasRef = useRef<HTMLDivElement | null>(null);
|
|
|
|
|
const panRef = useRef({
|
|
|
|
|
active: false,
|
|
|
|
|
x: 0,
|
|
|
|
|
y: 0,
|
|
|
|
|
scrollLeft: 0,
|
|
|
|
|
scrollTop: 0,
|
|
|
|
|
});
|
2026-05-20 18:43:08 +09:00
|
|
|
|
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-22 00:22:03 +09:00
|
|
|
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",
|
|
|
|
|
});
|
|
|
|
|
};
|
2026-05-19 20:31:52 +09:00
|
|
|
|
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
|
|
|
<>
|
2026-05-22 00:22:03 +09:00
|
|
|
<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"
|
2026-05-20 18:43:08 +09:00
|
|
|
>
|
|
|
|
|
<defs>
|
|
|
|
|
<marker
|
|
|
|
|
id="arrow"
|
2026-05-22 00:22:03 +09:00
|
|
|
markerWidth="6"
|
|
|
|
|
markerHeight="6"
|
|
|
|
|
refX="5.5"
|
|
|
|
|
refY="2.5"
|
2026-05-20 18:43:08 +09:00
|
|
|
orient="auto"
|
|
|
|
|
markerUnits="strokeWidth"
|
2026-05-19 20:31:52 +09:00
|
|
|
>
|
2026-05-22 00:22:03 +09:00
|
|
|
<path d="M0,0 L0,5 L5.5,2.5 z" fill="currentColor" />
|
2026-05-20 18:43:08 +09:00
|
|
|
</marker>
|
|
|
|
|
</defs>
|
|
|
|
|
{visibleEdges.map((edge) => {
|
|
|
|
|
const { source, target } = edgeEndpoint(edge, visibleNodes);
|
|
|
|
|
if (!source || !target) return null;
|
2026-05-22 00:22:03 +09:00
|
|
|
const { x1, y1, x2, y2, midX, midY } = edgeLinePoints(
|
|
|
|
|
source,
|
|
|
|
|
target,
|
|
|
|
|
);
|
2026-05-20 18:43:08 +09:00
|
|
|
const isSelected =
|
|
|
|
|
selected && "predicate" in selected && selected.id === edge.id;
|
|
|
|
|
return (
|
|
|
|
|
<g
|
|
|
|
|
key={edge.id}
|
2026-05-22 00:22:03 +09:00
|
|
|
data-graph-edge
|
2026-05-20 18:43:08 +09:00
|
|
|
className="cursor-pointer"
|
|
|
|
|
onClick={() => selectNode(edge)}
|
2026-05-19 20:31:52 +09:00
|
|
|
>
|
2026-05-20 18:43:08 +09:00
|
|
|
<line
|
2026-05-22 00:22:03 +09:00
|
|
|
x1={x1}
|
|
|
|
|
y1={y1}
|
|
|
|
|
x2={x2}
|
|
|
|
|
y2={y2}
|
2026-05-20 18:43:08 +09:00
|
|
|
stroke="currentColor"
|
2026-05-22 00:22:03 +09:00
|
|
|
strokeWidth={isSelected ? 1.7 : 0.65 + edge.confidence * 0.75}
|
|
|
|
|
strokeOpacity={isSelected ? 0.95 : 0.42}
|
2026-05-20 18:43:08 +09:00
|
|
|
markerEnd="url(#arrow)"
|
|
|
|
|
className={isSelected ? "text-brand-600 dark:text-brand-300" : ""}
|
|
|
|
|
/>
|
2026-05-22 00:22:03 +09:00
|
|
|
{(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>
|
|
|
|
|
)}
|
2026-05-20 18:43:08 +09:00
|
|
|
</g>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
{visibleNodes.map((node) => {
|
|
|
|
|
const isSelected =
|
|
|
|
|
selected && "id" in selected && selected.id === node.id;
|
|
|
|
|
return (
|
|
|
|
|
<g
|
|
|
|
|
key={node.id}
|
2026-05-22 00:22:03 +09:00
|
|
|
data-graph-node
|
2026-05-20 18:43:08 +09:00
|
|
|
className="cursor-pointer"
|
|
|
|
|
onClick={() => selectNode(node)}
|
|
|
|
|
>
|
|
|
|
|
<circle
|
|
|
|
|
cx={node.x}
|
|
|
|
|
cy={node.y}
|
|
|
|
|
r={node.radius}
|
|
|
|
|
fill={node.color}
|
2026-05-22 00:22:03 +09:00
|
|
|
stroke={
|
2026-05-20 18:43:08 +09:00
|
|
|
isSelected
|
2026-05-22 00:22:03 +09:00
|
|
|
? "hsl(var(--foreground))"
|
|
|
|
|
: node.isHub
|
|
|
|
|
? "hsl(var(--muted-foreground))"
|
|
|
|
|
: "hsl(var(--background))"
|
2026-05-20 18:43:08 +09:00
|
|
|
}
|
2026-05-22 00:22:03 +09:00
|
|
|
strokeWidth={isSelected ? 2.5 : node.isHub ? 2 : 1.3}
|
2026-05-20 18:43:08 +09:00
|
|
|
/>
|
2026-05-22 00:22:03 +09:00
|
|
|
{(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>
|
|
|
|
|
)}
|
2026-05-20 18:43:08 +09:00
|
|
|
</g>
|
|
|
|
|
);
|
|
|
|
|
})}
|
2026-05-22 00:22:03 +09:00
|
|
|
</svg>
|
|
|
|
|
</div>
|
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-22 00:22:03 +09:00
|
|
|
collapsed={legendCollapsed}
|
|
|
|
|
onCollapsedChange={setLegendCollapsed}
|
2026-05-19 20:31:52 +09:00
|
|
|
/>
|
2026-05-20 18:43:08 +09:00
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Minimap overlay (bottom-right) */}
|
|
|
|
|
{showMinimap && visibleNodes.length > 0 && (
|
2026-05-22 00:22:03 +09:00
|
|
|
<Minimap
|
|
|
|
|
nodes={visibleNodes}
|
|
|
|
|
edges={visibleEdges}
|
|
|
|
|
onJump={jumpCanvasTo}
|
|
|
|
|
/>
|
2026-05-20 18:43:08 +09:00
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</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,
|
2026-05-22 00:22:03 +09:00
|
|
|
collapsed,
|
|
|
|
|
onCollapsedChange,
|
2026-05-20 18:43:08 +09:00
|
|
|
}: {
|
|
|
|
|
types: string[];
|
|
|
|
|
typeCounts: Map<string, number>;
|
|
|
|
|
hiddenTypes: Set<string>;
|
|
|
|
|
onToggle: (type: string) => void;
|
2026-05-22 00:22:03 +09:00
|
|
|
collapsed: boolean;
|
|
|
|
|
onCollapsedChange: (collapsed: boolean) => void;
|
2026-05-20 18:43:08 +09:00
|
|
|
}) {
|
|
|
|
|
return (
|
2026-05-22 00:22:03 +09:00
|
|
|
<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}
|
2026-05-20 18:43:08 +09:00
|
|
|
>
|
2026-05-22 00:22:03 +09:00
|
|
|
<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>
|
|
|
|
|
)}
|
2026-05-20 18:43:08 +09:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/* ============================================================
|
|
|
|
|
* Minimap — scaled overview of the whole graph
|
|
|
|
|
* ========================================================== */
|
|
|
|
|
function Minimap({
|
|
|
|
|
nodes,
|
|
|
|
|
edges,
|
2026-05-22 00:22:03 +09:00
|
|
|
onJump,
|
2026-05-20 18:43:08 +09:00
|
|
|
}: {
|
|
|
|
|
nodes: VisualNode[];
|
|
|
|
|
edges: VisualEdge[];
|
2026-05-22 00:22:03 +09:00
|
|
|
onJump: (x: number, y: number) => void;
|
2026-05-20 18:43:08 +09:00
|
|
|
}) {
|
|
|
|
|
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;
|
2026-05-22 00:22:03 +09:00
|
|
|
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);
|
|
|
|
|
};
|
2026-05-20 18:43:08 +09:00
|
|
|
|
|
|
|
|
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}
|
2026-05-22 00:22:03 +09:00
|
|
|
className="block cursor-crosshair text-muted-foreground"
|
|
|
|
|
role="button"
|
|
|
|
|
aria-label="Jump to minimap position"
|
|
|
|
|
tabIndex={0}
|
|
|
|
|
onClick={jumpFromMinimap}
|
2026-05-20 18:43:08 +09:00
|
|
|
>
|
|
|
|
|
{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>
|
|
|
|
|
);
|
|
|
|
|
}
|