This commit is contained in:
lasta
2026-05-22 00:22:03 +09:00
parent 8d77bc659f
commit d841fb823a
49 changed files with 2732 additions and 3763 deletions

View File

@@ -1,7 +1,16 @@
import { useMemo, useState } from "react";
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,
@@ -58,6 +67,18 @@ const COLORS = [
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;
@@ -68,6 +89,7 @@ interface VisualNode {
radius: number;
color: string;
source: "entity" | "literal";
isHub?: boolean;
raw?: GraphNode;
}
@@ -85,6 +107,243 @@ function nodeColor(type: string, types: string[]): string {
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[],
@@ -170,15 +429,21 @@ function buildGraph(
visualNodes = visualNodes.filter((node) => connectedIds.has(node.id));
}
const centerX = VIEW_W / 2;
const centerY = VIEW_H / 2;
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;
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);
});
return { nodes: visualNodes, edges: filteredEdges, types };
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[]) {
@@ -187,6 +452,26 @@ function edgeEndpoint(edge: VisualEdge, nodes: VisualNode[]) {
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 }>();
@@ -202,8 +487,17 @@ export default function GraphViewPage() {
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",
@@ -267,6 +561,72 @@ export default function GraphViewPage() {
? 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);
@@ -530,61 +890,79 @@ export default function GraphViewPage() {
/>
) : (
<>
<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"
<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="10"
markerHeight="10"
refX="10"
refY="3"
markerWidth="6"
markerHeight="6"
refX="5.5"
refY="2.5"
orient="auto"
markerUnits="strokeWidth"
>
<path d="M0,0 L0,6 L9,3 z" fill="currentColor" />
<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 midX = (source.x + target.x) / 2;
const midY = (source.y + target.y) / 2;
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={source.x}
y1={source.y}
x2={target.x}
y2={target.y}
x1={x1}
y1={y1}
x2={x2}
y2={y2}
stroke="currentColor"
strokeWidth={1 + edge.confidence * 3}
strokeOpacity={isSelected ? 1 : 0.55}
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" : ""}
/>
<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>
{(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>
);
})}
@@ -594,6 +972,7 @@ export default function GraphViewPage() {
return (
<g
key={node.id}
data-graph-node
className="cursor-pointer"
onClick={() => selectNode(node)}
>
@@ -602,31 +981,41 @@ export default function GraphViewPage() {
cy={node.y}
r={node.radius}
fill={node.color}
className={
stroke={
isSelected
? "stroke-foreground"
: "stroke-background"
? "hsl(var(--foreground))"
: node.isHub
? "hsl(var(--muted-foreground))"
: "hsl(var(--background))"
}
strokeWidth={isSelected ? 3 : 2}
strokeWidth={isSelected ? 2.5 : node.isHub ? 2 : 1.3}
/>
<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>
{(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>
</svg>
</div>
{/* Legend overlay (top-right) */}
{showLegend && (
@@ -635,12 +1024,18 @@ export default function GraphViewPage() {
typeCounts={typeCounts}
hiddenTypes={hiddenTypes}
onToggle={toggleType}
collapsed={legendCollapsed}
onCollapsedChange={setLegendCollapsed}
/>
)}
{/* Minimap overlay (bottom-right) */}
{showMinimap && visibleNodes.length > 0 && (
<Minimap nodes={visibleNodes} edges={visibleEdges} />
<Minimap
nodes={visibleNodes}
edges={visibleEdges}
onJump={jumpCanvasTo}
/>
)}
</>
)}
@@ -803,58 +1198,83 @@ function LegendPanel({
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="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"
}
<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}
>
{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>
<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>
);
}
@@ -865,9 +1285,11 @@ function LegendPanel({
function Minimap({
nodes,
edges,
onJump,
}: {
nodes: VisualNode[];
edges: VisualEdge[];
onJump: (x: number, y: number) => void;
}) {
const W = 180;
const H = 130;
@@ -886,6 +1308,14 @@ function Minimap({
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">
@@ -898,8 +1328,11 @@ function Minimap({
<svg
width={W}
height={H}
className="block text-muted-foreground"
aria-hidden="true"
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);