agent-ecosystem/packages/agent-graph/src/ui/GraphView.tsx

784 lines
28 KiB
TypeScript

/**
* GraphView — main orchestrator with UNIFIED RAF loop.
*
* ARCHITECTURE: One RAF loop that:
* 1. Ticks d3-force simulation (updates node positions in refs)
* 2. Updates particles and effects (in refs)
* 3. Calls canvasRef.draw() imperatively (no React re-renders)
*
* React useState ONLY for: selectedNodeId, filters (user-facing UI state).
* ALL animation state (positions, particles, effects, time) lives in refs.
*/
import { useState, useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react';
import { autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/dom';
import type { GraphDataPort } from '../ports/GraphDataPort';
import type { GraphEventPort } from '../ports/GraphEventPort';
import type { GraphConfigPort } from '../ports/GraphConfigPort';
import type { GraphEdge, GraphNode } from '../ports/types';
import { GraphCanvas, type GraphCanvasHandle, type GraphDrawState } from './GraphCanvas';
import { GraphControls, type GraphFilterState } from './GraphControls';
import { GraphOverlay } from './GraphOverlay';
import { GraphEdgeOverlay } from './GraphEdgeOverlay';
import { buildFocusState } from './buildFocusState';
import { useGraphSimulation } from '../hooks/useGraphSimulation';
import { useGraphCamera } from '../hooks/useGraphCamera';
import { useGraphInteraction } from '../hooks/useGraphInteraction';
import {
collectInteractiveEdgesInViewport,
findEdgeAt,
findNodeAt,
getEdgeMidpoint,
} from '../canvas/hit-detection';
import { ANIM_SPEED } from '../constants/canvas-constants';
import { getActivityAnchorScreenPlacement as buildActivityAnchorScreenPlacement } from '../layout/activityLane';
import { getLaunchAnchorScreenPlacement as buildLaunchAnchorScreenPlacement } from '../layout/launchAnchor';
export interface GraphViewProps {
data: GraphDataPort;
events?: GraphEventPort;
config?: Partial<GraphConfigPort>;
className?: string;
suspendAnimation?: boolean;
onRequestClose?: () => void;
onRequestPinAsTab?: () => void;
onRequestFullscreen?: () => void;
onOpenTeamPage?: () => void;
onCreateTask?: () => void;
/** Custom overlay renderer — replaces built-in GraphOverlay. Allows host app to reuse its own components. */
renderOverlay?: (props: {
node: GraphNode;
screenPos: { x: number; y: number };
onClose: () => void;
}) => React.ReactNode;
renderEdgeOverlay?: (props: {
edge: GraphEdge;
sourceNode: GraphNode | undefined;
targetNode: GraphNode | undefined;
onClose: () => void;
onSelectNode: (nodeId: string) => void;
}) => React.ReactNode;
renderHud?: (props: {
getLaunchAnchorScreenPlacement: (
leadNodeId: string,
) => { x: number; y: number; scale: number; visible: boolean } | null;
getActivityAnchorScreenPlacement: (
ownerNodeId: string,
) => { x: number; y: number; scale: number; visible: boolean } | null;
getActivityAnchorWorldPosition: (
ownerNodeId: string,
) => { x: number; y: number } | null;
getCameraZoom: () => number;
worldToScreen: (x: number, y: number) => { x: number; y: number };
getNodeWorldPosition: (nodeId: string) => { x: number; y: number } | null;
getViewportSize: () => { width: number; height: number };
getNodeScreenPosition: (
nodeId: string,
) => { x: number; y: number; visible: boolean } | null;
focusNodeIds: ReadonlySet<string> | null;
}) => React.ReactNode;
}
export function GraphView({
data,
events,
config,
className,
suspendAnimation = false,
onRequestClose,
onRequestPinAsTab,
onRequestFullscreen,
onOpenTeamPage,
onCreateTask,
renderOverlay,
renderEdgeOverlay,
renderHud,
}: GraphViewProps): React.JSX.Element {
// ─── React state (user-facing only) ─────────────────────────────────────
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
const [filters, setFilters] = useState<GraphFilterState>({
showTasks: config?.showTasks ?? true,
showProcesses: config?.showProcesses ?? true,
showEdges: true,
paused: !(config?.animationEnabled ?? true),
});
const effectivePaused = filters.paused || suspendAnimation;
// Ref mirror of selectedNodeId — read by RAF loop to avoid recreating animate on selection change
const selectedNodeIdRef = useRef<string | null>(null);
selectedNodeIdRef.current = selectedNodeId;
const selectedEdgeIdRef = useRef<string | null>(null);
selectedEdgeIdRef.current = selectedEdgeId;
const hoveredEdgeIdRef = useRef<string | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const canvasHandle = useRef<GraphCanvasHandle>(null);
const overlayRef = useRef<HTMLDivElement>(null);
const rafRef = useRef(0);
const lastTimeRef = useRef(0);
const runningRef = useRef(false);
const hasAutoFit = useRef(false);
const allowAutoFitRef = useRef(true);
const nodeMapRef = useRef(new Map<string, GraphNode>());
const nodeMapNodesRef = useRef<GraphNode[] | null>(null);
// ─── Hooks ──────────────────────────────────────────────────────────────
const simulation = useGraphSimulation();
const camera = useGraphCamera();
// Stable refs for RAF loop (avoid recreating animate on hook identity change)
const simulationRef = useRef(simulation);
simulationRef.current = simulation;
const cameraRef = useRef(camera);
cameraRef.current = camera;
const interaction = useGraphInteraction(
useCallback(
(nodeId: string, x: number, y: number) => {
simulation.setNodePosition(nodeId, x, y);
},
[simulation]
)
);
// ─── Sync data from adapter → simulation ────────────────────────────────
useEffect(() => {
const filteredNodes = data.nodes.filter((n) => {
if (n.kind === 'task' && !filters.showTasks) return false;
if (n.kind === 'process' && !filters.showProcesses) return false;
return true;
});
const filteredEdges = filters.showEdges
? data.edges
: data.edges.filter((e) => e.type === 'parent-child');
simulation.updateData(filteredNodes, filteredEdges, data.particles);
}, [data, filters.showTasks, filters.showProcesses, filters.showEdges, simulation]);
// ─── UNIFIED RAF LOOP: tick simulation + draw canvas ────────────────────
const focusState = useMemo(
() => buildFocusState(selectedNodeId, selectedEdgeId, data.nodes, data.edges),
[selectedEdgeId, selectedNodeId, data.edges, data.nodes]
);
const getNodeMap = useCallback((nodes: GraphNode[]): Map<string, GraphNode> => {
if (nodeMapNodesRef.current === nodes) {
return nodeMapRef.current;
}
const nodeMap = nodeMapRef.current;
nodeMap.clear();
for (const node of nodes) {
nodeMap.set(node.id, node);
}
nodeMapNodesRef.current = nodes;
return nodeMap;
}, []);
const getInteractiveEdges = useCallback(
(canvas: HTMLCanvasElement, nodes: GraphNode[], edges: GraphEdge[]): GraphEdge[] => {
const nodeMap = getNodeMap(nodes);
const rect = canvas.getBoundingClientRect();
const transform = camera.transformRef.current;
const bounds = {
left: -transform.x / transform.zoom,
top: -transform.y / transform.zoom,
right: (rect.width - transform.x) / transform.zoom,
bottom: (rect.height - transform.y) / transform.zoom,
};
return collectInteractiveEdgesInViewport(edges, nodeMap, bounds);
},
[camera.transformRef, getNodeMap]
);
const getViewportSize = useCallback(() => {
const container = containerRef.current;
return {
width: container?.clientWidth ?? 0,
height: container?.clientHeight ?? 0,
};
}, []);
const getLaunchAnchorScreenPlacement = useCallback((leadNodeId: string) => {
const anchor = simulationRef.current.getLaunchAnchorWorldPosition(leadNodeId);
if (!anchor) {
return null;
}
const viewport = getViewportSize();
if (viewport.width <= 0 || viewport.height <= 0) {
return null;
}
const transform = cameraRef.current.transformRef.current;
return buildLaunchAnchorScreenPlacement({
anchorX: anchor.x,
anchorY: anchor.y,
cameraX: transform.x,
cameraY: transform.y,
zoom: transform.zoom,
viewportWidth: viewport.width,
viewportHeight: viewport.height,
});
}, [getViewportSize]);
const getActivityAnchorScreenPlacement = useCallback((ownerNodeId: string) => {
const anchor = simulationRef.current.getActivityAnchorWorldPosition(ownerNodeId);
if (!anchor) {
return null;
}
const viewport = getViewportSize();
if (viewport.width <= 0 || viewport.height <= 0) {
return null;
}
const transform = cameraRef.current.transformRef.current;
return buildActivityAnchorScreenPlacement({
anchorX: anchor.x,
anchorY: anchor.y,
cameraX: transform.x,
cameraY: transform.y,
zoom: transform.zoom,
viewportWidth: viewport.width,
viewportHeight: viewport.height,
});
}, [getViewportSize]);
const getActivityAnchorWorldPosition = useCallback(
(ownerNodeId: string) => simulationRef.current.getActivityAnchorWorldPosition(ownerNodeId),
[],
);
const getCameraZoom = useCallback(() => cameraRef.current.transformRef.current.zoom, []);
const getNodeScreenPosition = useCallback((nodeId: string) => {
const viewport = getViewportSize();
if (viewport.width <= 0 || viewport.height <= 0) {
return null;
}
const node = simulationRef.current.stateRef.current.nodes.find((candidate) => candidate.id === nodeId);
if (!node || node.x == null || node.y == null) {
return null;
}
const transform = cameraRef.current.transformRef.current;
const x = node.x * transform.zoom + transform.x;
const y = node.y * transform.zoom + transform.y;
return {
x,
y,
visible: x > -80 && x < viewport.width + 80 && y > -80 && y < viewport.height + 80,
};
}, [getViewportSize]);
const getNodeWorldPosition = useCallback((nodeId: string) => {
const node = simulationRef.current.stateRef.current.nodes.find((candidate) => candidate.id === nodeId);
if (!node || node.x == null || node.y == null) {
return null;
}
return { x: node.x, y: node.y };
}, []);
const animate = useCallback(() => {
if (!runningRef.current) return;
const now = performance.now() / 1000;
const dt = Math.min(
lastTimeRef.current > 0 ? now - lastTimeRef.current : ANIM_SPEED.defaultDeltaTime,
ANIM_SPEED.maxDeltaTime
);
lastTimeRef.current = now;
// 1. Tick simulation
simulationRef.current.tick(dt);
// 2. Update camera inertia
cameraRef.current.updateInertia();
// 3. Draw every frame: background stars and shooting stars need continuous motion.
const state = simulationRef.current.stateRef.current;
// 4. Draw canvas imperatively (NO React re-render)
canvasHandle.current?.draw({
teamName: data.teamName,
nodes: state.nodes,
edges: state.edges,
particles: state.particles,
effects: state.effects,
time: state.time,
camera: cameraRef.current.transformRef.current,
selectedNodeId: selectedNodeIdRef.current,
hoveredNodeId: interaction.hoveredNodeId.current,
selectedEdgeId: selectedEdgeIdRef.current,
hoveredEdgeId: hoveredEdgeIdRef.current,
focusNodeIds: focusState.focusNodeIds,
focusEdgeIds: focusState.focusEdgeIds,
});
rafRef.current = requestAnimationFrame(animate);
// eslint-disable-next-line react-hooks/exhaustive-deps -- all data read from .current refs
}, [focusState.focusEdgeIds, focusState.focusNodeIds, interaction.hoveredNodeId]);
// Start/stop RAF
useEffect(() => {
if (!effectivePaused) {
runningRef.current = true;
lastTimeRef.current = 0;
rafRef.current = requestAnimationFrame(animate);
} else {
runningRef.current = false;
cancelAnimationFrame(rafRef.current);
}
return () => {
runningRef.current = false;
cancelAnimationFrame(rafRef.current);
};
}, [effectivePaused, animate]);
const fitGraphToViewport = useCallback(() => {
const el = containerRef.current;
if (!el || data.nodes.length === 0) return;
camera.zoomToFit(
simulation.stateRef.current.nodes,
el.clientWidth,
el.clientHeight,
simulation.getExtraWorldBounds()
);
}, [camera, data.nodes.length, simulation]);
// ─── Auto-fit: until first user interaction, also react to container resizes ─────
useEffect(() => {
if (data.nodes.length === 0) {
hasAutoFit.current = false;
allowAutoFitRef.current = true;
return;
}
if (!hasAutoFit.current) {
hasAutoFit.current = true;
fitGraphToViewport();
const raf1 = requestAnimationFrame(() => {
fitGraphToViewport();
requestAnimationFrame(() => {
fitGraphToViewport();
});
});
return () => cancelAnimationFrame(raf1);
}
}, [data.nodes.length, fitGraphToViewport]);
useEffect(() => {
const el = containerRef.current;
if (!el || data.nodes.length === 0) return;
let frame = 0;
const observer = new ResizeObserver(() => {
if (!allowAutoFitRef.current) return;
cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => {
fitGraphToViewport();
});
});
observer.observe(el);
return () => {
observer.disconnect();
cancelAnimationFrame(frame);
};
}, [data.nodes.length, fitGraphToViewport]);
const markUserInteracted = useCallback(() => {
allowAutoFitRef.current = false;
}, []);
const handleWheel = useCallback(
(e: WheelEvent) => {
markUserInteracted();
camera.handleWheel(e);
},
[camera, markUserInteracted]
);
// ─── Mouse handlers (Figma-style: drag empty space = pan, drag node = move) ─
const isPanningRef = useRef(false);
const edgeMouseDownRef = useRef<{ id: string; x: number; y: number } | null>(null);
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
if (e.button !== 0) return; // only left click
const canvas = canvasHandle.current?.getCanvas();
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const world = camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top);
const nodes = simulation.stateRef.current.nodes;
const edges = simulation.stateRef.current.edges;
const nodeMap = getNodeMap(nodes);
const interactiveEdges = getInteractiveEdges(canvas, nodes, edges);
// Check if we hit a node
interaction.handleMouseDown(world.x, world.y, nodes);
// Hit a node (draggable or clickable) → don't pan
const hitNode = findNodeAt(world.x, world.y, nodes);
if (hitNode) {
markUserInteracted();
isPanningRef.current = false;
edgeMouseDownRef.current = null;
hoveredEdgeIdRef.current = null;
} else {
const hitEdge = findEdgeAt(world.x, world.y, interactiveEdges, nodeMap);
if (hitEdge) {
markUserInteracted();
isPanningRef.current = false;
edgeMouseDownRef.current = { id: hitEdge, x: world.x, y: world.y };
hoveredEdgeIdRef.current = hitEdge;
} else {
// Hit empty space → pan
markUserInteracted();
isPanningRef.current = true;
edgeMouseDownRef.current = null;
hoveredEdgeIdRef.current = null;
camera.handlePanStart(e.clientX, e.clientY);
}
}
},
[camera, getInteractiveEdges, getNodeMap, interaction, markUserInteracted, simulation.stateRef]
);
const handleMouseMove = useCallback(
(e: React.MouseEvent) => {
// Dragging with left button held
if (e.buttons & 1) {
if (isPanningRef.current) {
camera.handlePanMove(e.clientX, e.clientY);
return;
}
const canvas = canvasHandle.current?.getCanvas();
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const world = camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top);
interaction.handleMouseMove(world.x, world.y, simulation.stateRef.current.nodes);
return;
}
// No button held — hover detection + cursor update
const canvas = canvasHandle.current?.getCanvas();
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const world = camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top);
const nodes = simulation.stateRef.current.nodes;
const edges = simulation.stateRef.current.edges;
const hoveredNodeId = findNodeAt(world.x, world.y, nodes);
interaction.hoveredNodeId.current = hoveredNodeId;
if (hoveredNodeId) {
hoveredEdgeIdRef.current = null;
canvas.style.cursor = 'pointer';
return;
}
const nodeMap = getNodeMap(nodes);
const interactiveEdges = getInteractiveEdges(canvas, nodes, edges);
hoveredEdgeIdRef.current = findEdgeAt(world.x, world.y, interactiveEdges, nodeMap);
canvas.style.cursor = hoveredEdgeIdRef.current ? 'pointer' : 'grab';
},
[camera, getInteractiveEdges, getNodeMap, interaction, simulation.stateRef]
);
const handleMouseUp = useCallback(
(e: React.MouseEvent) => {
if (isPanningRef.current) {
camera.handlePanEnd();
isPanningRef.current = false;
setSelectedNodeId(null); // hide popover after pan
setSelectedEdgeId(null);
edgeMouseDownRef.current = null;
return;
}
const clickedId = interaction.handleMouseUp();
if (clickedId) {
setSelectedNodeId(clickedId);
setSelectedEdgeId(null);
const node = simulation.stateRef.current.nodes.find((n) => n.id === clickedId);
if (node) events?.onNodeClick?.(node.domainRef);
} else {
const canvas = canvasHandle.current?.getCanvas();
let clickedEdgeId: string | null = null;
if (canvas && edgeMouseDownRef.current && !interaction.isDragging.current) {
const rect = canvas.getBoundingClientRect();
const world = camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top);
const dx = world.x - edgeMouseDownRef.current.x;
const dy = world.y - edgeMouseDownRef.current.y;
if (dx * dx + dy * dy <= 25) {
clickedEdgeId = edgeMouseDownRef.current.id;
}
}
edgeMouseDownRef.current = null;
if (clickedEdgeId) {
setSelectedNodeId(null);
setSelectedEdgeId(clickedEdgeId);
const edge = simulation.stateRef.current.edges.find(
(candidate) => candidate.id === clickedEdgeId
);
if (edge) {
events?.onEdgeClick?.(edge);
}
} else {
setSelectedNodeId(null); // click on empty space — hide popover
setSelectedEdgeId(null);
}
if (!interaction.isDragging.current && !clickedEdgeId) {
events?.onBackgroundClick?.();
}
}
},
[interaction, simulation.stateRef, events, camera, data.teamName]
);
const handleDoubleClick = useCallback(
(e: React.MouseEvent) => {
const canvas = canvasHandle.current?.getCanvas();
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const world = camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top);
const nodeId = interaction.handleDoubleClick(
world.x,
world.y,
simulation.stateRef.current.nodes
);
if (nodeId) {
setSelectedEdgeId(null);
const node = simulation.stateRef.current.nodes.find((n) => n.id === nodeId);
if (node) {
// Unpin if pinned (toggle)
if (node.fx != null) {
node.fx = null;
node.fy = null;
}
events?.onNodeDoubleClick?.(node.domainRef);
}
}
},
[camera, interaction, simulation.stateRef, events]
);
// ─── Keyboard ───────────────────────────────────────────────────────────
useEffect(() => {
const handler = (e: KeyboardEvent) => {
// Don't capture from inputs
const target = e.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)
return;
if (e.key === 'Escape') {
if (selectedNodeId || selectedEdgeId) {
setSelectedNodeId(null);
setSelectedEdgeId(null);
} else {
onRequestClose?.();
}
}
if (e.key === 'f' || e.key === 'F') {
const el = containerRef.current;
if (el)
camera.zoomToFit(
simulation.stateRef.current.nodes,
el.clientWidth,
el.clientHeight,
simulation.getExtraWorldBounds()
);
}
if (e.key === ' ') {
e.preventDefault();
setFilters((f) => ({ ...f, paused: !f.paused }));
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, [selectedEdgeId, selectedNodeId, onRequestClose, camera, simulation.stateRef]);
// ─── Selected node for overlay ──────────────────────────────────────────
const selectedNode: GraphNode | null = selectedNodeId
? (simulation.stateRef.current.nodes.find((n) => n.id === selectedNodeId) ?? null)
: null;
const selectedEdge: GraphEdge | null = selectedEdgeId
? (simulation.stateRef.current.edges.find((edge) => edge.id === selectedEdgeId) ?? null)
: null;
const hasBlockingEdges = useMemo(
() => data.edges.some((edge) => edge.type === 'blocking'),
[data.edges]
);
const selectedEdgeNodeMap = useMemo(
() => getNodeMap(simulation.stateRef.current.nodes),
[data.nodes, getNodeMap, selectedEdgeId, simulation.stateRef]
);
useLayoutEffect(() => {
if ((!selectedNode && !selectedEdgeId) || !containerRef.current || !overlayRef.current) {
return;
}
const container = containerRef.current;
const floating = overlayRef.current;
const reference = {
getBoundingClientRect(): DOMRect {
const containerRect = container.getBoundingClientRect();
const screenPos = (() => {
if (selectedNode) {
return camera.worldToScreen(selectedNode.x ?? 0, selectedNode.y ?? 0);
}
if (selectedEdgeId) {
const currentNodes = simulation.stateRef.current.nodes;
const currentEdge = simulation.stateRef.current.edges.find(
(edge) => edge.id === selectedEdgeId
);
if (currentEdge) {
const nodeMap = getNodeMap(currentNodes);
const midpoint = getEdgeMidpoint(currentEdge, nodeMap);
if (midpoint) {
return camera.worldToScreen(midpoint.x, midpoint.y);
}
}
}
return camera.worldToScreen(0, 0);
})();
return DOMRect.fromRect({
x: containerRect.left + screenPos.x,
y: containerRect.top + screenPos.y,
width: 0,
height: 0,
});
},
};
const updatePosition = async (): Promise<void> => {
const { x, y } = await computePosition(reference, floating, {
strategy: 'fixed',
placement: 'right-start',
middleware: [
offset(16),
flip({
boundary: container,
padding: 12,
fallbackPlacements: ['left-start', 'bottom-start', 'top-start'],
}),
shift({
boundary: container,
padding: 12,
}),
],
});
floating.style.left = `${x}px`;
floating.style.top = `${y}px`;
};
const cleanup = autoUpdate(reference, floating, updatePosition, {
animationFrame: true,
});
void updatePosition();
return cleanup;
}, [camera, getNodeMap, selectedEdgeId, selectedNode, simulation.stateRef]);
// ─── Render ─────────────────────────────────────────────────────────────
return (
<div ref={containerRef} className={`relative h-full w-full overflow-hidden ${className ?? ''}`}>
<GraphCanvas
ref={canvasHandle}
showHexGrid={config?.showHexGrid ?? true}
showStarField={config?.showStarField ?? true}
bloomIntensity={config?.bloomIntensity ?? 0.6}
onWheel={handleWheel}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onDoubleClick={handleDoubleClick}
/>
<GraphControls
filters={filters}
onFiltersChange={setFilters}
onZoomIn={() => {
markUserInteracted();
camera.zoomIn();
}}
onZoomOut={() => {
markUserInteracted();
camera.zoomOut();
}}
onZoomToFit={() => {
markUserInteracted();
const el = containerRef.current;
if (el)
camera.zoomToFit(
simulation.stateRef.current.nodes,
el.clientWidth,
el.clientHeight,
simulation.getExtraWorldBounds()
);
}}
onRequestClose={onRequestClose}
onRequestPinAsTab={onRequestPinAsTab}
onRequestFullscreen={onRequestFullscreen}
onOpenTeamPage={onOpenTeamPage}
onCreateTask={onCreateTask}
teamName={data.teamName}
teamColor={data.teamColor}
isAlive={data.isAlive}
/>
{renderHud ? (
<div className="pointer-events-none absolute inset-0 z-[5] overflow-hidden">
{renderHud({
getLaunchAnchorScreenPlacement,
getActivityAnchorScreenPlacement,
getActivityAnchorWorldPosition,
getCameraZoom,
worldToScreen: camera.worldToScreen,
getNodeWorldPosition,
getViewportSize,
getNodeScreenPosition,
focusNodeIds: focusState.focusNodeIds,
})}
</div>
) : null}
{(selectedNode || selectedEdge) && (
<div ref={overlayRef} className="pointer-events-auto fixed z-20">
{selectedNode ? (
renderOverlay ? (
renderOverlay({
node: selectedNode,
screenPos: camera.worldToScreen(selectedNode.x ?? 0, selectedNode.y ?? 0),
onClose: () => setSelectedNodeId(null),
})
) : (
<GraphOverlay
selectedNode={selectedNode}
events={events}
onDeselect={() => setSelectedNodeId(null)}
/>
)
) : selectedEdge ? (
renderEdgeOverlay ? (
renderEdgeOverlay({
edge: selectedEdge,
sourceNode: selectedEdgeNodeMap.get(selectedEdge.source),
targetNode: selectedEdgeNodeMap.get(selectedEdge.target),
onClose: () => setSelectedEdgeId(null),
onSelectNode: (nodeId: string) => {
setSelectedEdgeId(null);
setSelectedNodeId(nodeId);
},
})
) : (
<GraphEdgeOverlay
edge={selectedEdge}
sourceNode={selectedEdgeNodeMap.get(selectedEdge.source)}
targetNode={selectedEdgeNodeMap.get(selectedEdge.target)}
onClose={() => setSelectedEdgeId(null)}
/>
)
) : null}
</div>
)}
</div>
);
}