/** * 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, GraphOwnerSlotAssignment } from '../ports/types'; import type { StableRect } from '../layout/stableSlots'; import { GraphCanvas, type GraphCanvasHandle } from './GraphCanvas'; import { GraphControls, type GraphFilterState } from './GraphControls'; import { GraphOverlay } from './GraphOverlay'; import { GraphEdgeOverlay } from './GraphEdgeOverlay'; import { buildFocusState } from './buildFocusState'; import type { TransientHandoffCard } from './transientHandoffs'; 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, ANIM_SPEED } from '../constants/canvas-constants'; import { getLaunchAnchorScreenPlacement as buildLaunchAnchorScreenPlacement } from '../layout/launchAnchor'; export interface GraphViewProps { data: GraphDataPort; events?: GraphEventPort; config?: Partial; className?: string; suspendAnimation?: boolean; onRequestClose?: () => void; onRequestPinAsTab?: () => void; onRequestFullscreen?: () => void; isSurfaceActive?: boolean; onOpenTeamPage?: () => void; onCreateTask?: () => void; onToggleSidebar?: () => void; isSidebarVisible?: boolean; renderTopToolbarContent?: () => React.ReactNode; onOwnerSlotDrop?: (payload: { nodeId: string; assignment: GraphOwnerSlotAssignment; displacedNodeId?: string; displacedAssignment?: GraphOwnerSlotAssignment; }) => 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; getActivityWorldRect: (ownerNodeId: string) => StableRect | null; getTransientHandoffSnapshot: (options?: { focusNodeIds?: ReadonlySet | null; focusEdgeIds?: ReadonlySet | null; }) => { cards: TransientHandoffCard[]; time: number }; getCameraZoom: () => number; worldToScreen: (x: number, y: number) => { x: number; y: number }; getNodeWorldPosition: (nodeId: string) => { x: number; y: number } | null; getViewportSize: () => { width: number; height: number }; focusNodeIds: ReadonlySet | null; focusEdgeIds: ReadonlySet | null; }) => React.ReactNode; } export function GraphView({ data, events, config, className, suspendAnimation = false, onRequestClose, onRequestPinAsTab, onRequestFullscreen, isSurfaceActive = true, onOpenTeamPage, onCreateTask, onToggleSidebar, isSidebarVisible = true, renderTopToolbarContent, onOwnerSlotDrop, renderOverlay, renderEdgeOverlay, renderHud, }: GraphViewProps): React.JSX.Element { // ─── React state (user-facing only) ───────────────────────────────────── const [selectedNodeId, setSelectedNodeId] = useState(null); const [selectedEdgeId, setSelectedEdgeId] = useState(null); const [interactionLocked, setInteractionLocked] = useState(false); const [filters, setFilters] = useState({ 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(null); selectedNodeIdRef.current = selectedNodeId; const selectedEdgeIdRef = useRef(null); selectedEdgeIdRef.current = selectedEdgeId; const hoveredEdgeIdRef = useRef(null); const containerRef = useRef(null); const canvasHandle = useRef(null); const overlayRef = useRef(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()); const nodeMapNodesRef = useRef(null); const dragPreviewRef = useRef<{ nodeId: string; x: number; y: number; color?: string | null; } | null>(null); const selectionLockRef = useRef<{ userSelect: string; webkitUserSelect: string } | null>(null); const activePrimaryInteractionRef = useRef(false); // ─── Hooks ────────────────────────────────────────────────────────────── const simulation = useGraphSimulation(); const camera = useGraphCamera(); const interaction = useGraphInteraction( useCallback( (nodeId: string, x: number, y: number) => { simulation.setNodePosition(nodeId, x, y); }, [simulation] ) ); // 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 interactionRef = useRef(interaction); interactionRef.current = interaction; const processActivePointerMoveRef = useRef<((clientX: number, clientY: number) => boolean) | null>( null ); const completePointerInteractionRef = useRef<((clientX: number, clientY: number) => void) | null>( null ); const getVisibleNodes = useCallback( (nodes: GraphNode[]): GraphNode[] => nodes.filter((node) => { if (node.kind === 'task' && !filters.showTasks) return false; if (node.kind === 'process' && !filters.showProcesses) return false; return true; }), [filters.showProcesses, filters.showTasks] ); const getVisibleEdges = useCallback( (edges: GraphEdge[], visibleNodeIds: ReadonlySet): GraphEdge[] => edges.filter((edge) => { if (!filters.showEdges && edge.type !== 'parent-child') { return false; } return visibleNodeIds.has(edge.source) && visibleNodeIds.has(edge.target); }), [filters.showEdges] ); // ─── Sync data from adapter → simulation ──────────────────────────────── useEffect(() => { simulation.updateData(data.nodes, data.edges, data.particles, data.teamName, data.layout); }, [data, 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 => { 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 getCameraZoom = useCallback(() => cameraRef.current.transformRef.current.zoom, []); const getActivityWorldRect = useCallback( (ownerNodeId: string) => simulationRef.current.getActivityWorldRect(ownerNodeId), [] ); const getTransientHandoffSnapshot = useCallback( (options?: { focusNodeIds?: ReadonlySet | null; focusEdgeIds?: ReadonlySet | null; }) => canvasHandle.current?.getTransientHandoffSnapshot(options) ?? { cards: [], time: 0, }, [] ); const getNodeWorldPosition = useCallback((nodeId: string) => { const node = simulationRef.current.stateRef.current.nodes.find((candidate) => candidate.id === nodeId); if (node?.x == null || node?.y == null) { return null; } return { x: node.x, y: node.y }; }, []); const setInteractionSelectionDisabled = useCallback((disabled: boolean) => { if (typeof document === 'undefined') { return; } const bodyStyle = document.body.style; if (disabled) { if (!selectionLockRef.current) { selectionLockRef.current = { userSelect: bodyStyle.userSelect, webkitUserSelect: bodyStyle.webkitUserSelect, }; } bodyStyle.userSelect = 'none'; bodyStyle.webkitUserSelect = 'none'; return; } if (!selectionLockRef.current) { return; } bodyStyle.userSelect = selectionLockRef.current.userSelect; bodyStyle.webkitUserSelect = selectionLockRef.current.webkitUserSelect; selectionLockRef.current = null; }, []); const setInteractionGuards = useCallback( (active: boolean) => { activePrimaryInteractionRef.current = active; setInteractionLocked(active); setInteractionSelectionDisabled(active); }, [setInteractionSelectionDisabled] ); 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; const visibleNodes = getVisibleNodes(state.nodes); const visibleNodeIds = new Set(visibleNodes.map((node) => node.id)); const visibleEdges = getVisibleEdges(state.edges, visibleNodeIds); // 4. Draw canvas imperatively (NO React re-render) canvasHandle.current?.draw({ teamName: data.teamName, nodes: visibleNodes, edges: visibleEdges, 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, dragPreview: dragPreviewRef.current, }); rafRef.current = requestAnimationFrame(animate); }, [ data.teamName, focusState.focusEdgeIds, focusState.focusNodeIds, getVisibleEdges, getVisibleNodes, 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; }, []); useLayoutEffect(() => { if (isSurfaceActive) { return; } interactionRef.current.handleMouseUp(); simulationRef.current.clearTransientOwnerPositions(); dragPreviewRef.current = null; isPanningRef.current = false; edgeMouseDownRef.current = null; setInteractionGuards(false); }, [isSurfaceActive, setInteractionGuards]); 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; worldX: number; worldY: number; clientX: number; clientY: number; } | null>(null); const handleMouseDown = useCallback( (e: React.MouseEvent) => { if (e.button !== 0) return; // only left click e.preventDefault(); dragPreviewRef.current = null; setInteractionGuards(true); const canvas = canvasHandle.current?.getCanvas(); if (!canvas) { setInteractionGuards(false); return; } const rect = canvas.getBoundingClientRect(); const world = camera.screenToWorld(e.clientX - rect.left, e.clientY - rect.top); const nodes = getVisibleNodes(simulation.stateRef.current.nodes); const visibleNodeIds = new Set(nodes.map((node) => node.id)); const edges = getVisibleEdges(simulation.stateRef.current.edges, visibleNodeIds); 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, worldX: world.x, worldY: world.y, clientX: e.clientX, clientY: e.clientY, }; 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, getVisibleEdges, getVisibleNodes, interaction, markUserInteracted, setInteractionGuards, simulation.stateRef, ] ); const processActivePointerMove = useCallback( (clientX: number, clientY: number) => { if (isPanningRef.current) { if (typeof document !== 'undefined') { document.getSelection()?.removeAllRanges(); } camera.handlePanMove(clientX, clientY); return true; } const edgeMouseDown = edgeMouseDownRef.current; if ( edgeMouseDown && !interaction.dragNodeId.current && !interaction.isDragging.current ) { const dx = clientX - edgeMouseDown.clientX; const dy = clientY - edgeMouseDown.clientY; if (dx * dx + dy * dy > ANIM.dragThresholdPx * ANIM.dragThresholdPx) { if (typeof document !== 'undefined') { document.getSelection()?.removeAllRanges(); } hoveredEdgeIdRef.current = null; edgeMouseDownRef.current = null; isPanningRef.current = true; camera.handlePanStart(edgeMouseDown.clientX, edgeMouseDown.clientY); camera.handlePanMove(clientX, clientY); return true; } } if ( !activePrimaryInteractionRef.current && !interaction.dragNodeId.current && !interaction.isDragging.current ) { dragPreviewRef.current = null; return false; } const canvas = canvasHandle.current?.getCanvas(); if (!canvas) { dragPreviewRef.current = null; return false; } const rect = canvas.getBoundingClientRect(); const world = camera.screenToWorld(clientX - rect.left, clientY - rect.top); interaction.handleMouseMove(world.x, world.y, getVisibleNodes(simulation.stateRef.current.nodes)); const draggedNodeId = interaction.dragNodeId.current; if (interaction.isDragging.current && draggedNodeId) { if (typeof document !== 'undefined') { document.getSelection()?.removeAllRanges(); } const draggedNode = simulation.stateRef.current.nodes.find((node) => node.id === draggedNodeId); if (draggedNode?.kind === 'member') { const nearest = simulation.resolveNearestOwnerSlot(draggedNodeId, world.x, world.y); if (nearest) { dragPreviewRef.current = { nodeId: draggedNodeId, x: nearest.previewOwnerX, y: nearest.previewOwnerY, color: draggedNode.color, }; return true; } } } dragPreviewRef.current = null; return true; }, [camera, getVisibleNodes, interaction, simulation] ); const completePointerInteraction = useCallback( (clientX: number, clientY: number) => { const draggedNodeId = interaction.dragNodeId.current; const wasDragging = interaction.isDragging.current; if (isPanningRef.current) { camera.handlePanEnd(); isPanningRef.current = false; setInteractionGuards(false); dragPreviewRef.current = null; setSelectedNodeId(null); setSelectedEdgeId(null); edgeMouseDownRef.current = null; interaction.handleMouseUp(); return; } const clickedId = interaction.handleMouseUp(); if (wasDragging && draggedNodeId) { setInteractionGuards(false); const draggedNode = simulation.stateRef.current.nodes.find((node) => node.id === draggedNodeId); if (draggedNode?.kind === 'member' && draggedNode.x != null && draggedNode.y != null) { const nearest = simulation.resolveNearestOwnerSlot( draggedNodeId, draggedNode.x, draggedNode.y ); if (nearest) { onOwnerSlotDrop?.({ nodeId: draggedNodeId, assignment: nearest.assignment, displacedNodeId: nearest.displacedOwnerId, displacedAssignment: nearest.displacedAssignment, }); requestAnimationFrame(() => { simulation.clearNodePosition(draggedNodeId); }); dragPreviewRef.current = null; edgeMouseDownRef.current = null; return; } } simulation.clearNodePosition(draggedNodeId); dragPreviewRef.current = null; edgeMouseDownRef.current = null; return; } setInteractionGuards(false); 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(clientX - rect.left, clientY - rect.top); const dx = world.x - edgeMouseDownRef.current.worldX; const dy = world.y - edgeMouseDownRef.current.worldY; 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); setSelectedEdgeId(null); } if (!interaction.isDragging.current && !clickedEdgeId) { events?.onBackgroundClick?.(); } } dragPreviewRef.current = null; }, [camera, events, interaction, onOwnerSlotDrop, setInteractionGuards, simulation] ); processActivePointerMoveRef.current = processActivePointerMove; completePointerInteractionRef.current = completePointerInteraction; const handleMouseMove = useCallback( (e: React.MouseEvent) => { if (processActivePointerMove(e.clientX, e.clientY)) { return; } dragPreviewRef.current = null; 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 = getVisibleNodes(simulation.stateRef.current.nodes); const visibleNodeIds = new Set(nodes.map((node) => node.id)); const edges = getVisibleEdges(simulation.stateRef.current.edges, visibleNodeIds); 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, getVisibleEdges, getVisibleNodes, interaction, processActivePointerMove, simulation.stateRef, ] ); const handleMouseUp = useCallback( (e: React.MouseEvent) => { completePointerInteraction(e.clientX, e.clientY); }, [completePointerInteraction] ); useEffect(() => { const handleWindowMouseMove = (event: MouseEvent): void => { if ( !activePrimaryInteractionRef.current && !isPanningRef.current && !interactionRef.current.dragNodeId.current && !interactionRef.current.isDragging.current && !edgeMouseDownRef.current ) { return; } event.preventDefault(); processActivePointerMoveRef.current?.(event.clientX, event.clientY); }; const handleWindowMouseUp = (event: MouseEvent): void => { if ( !activePrimaryInteractionRef.current && !isPanningRef.current && !interactionRef.current.dragNodeId.current && !interactionRef.current.isDragging.current && !edgeMouseDownRef.current ) { setInteractionGuards(false); return; } completePointerInteractionRef.current?.(event.clientX, event.clientY); }; const clearInteraction = (): void => { if ( !activePrimaryInteractionRef.current && !isPanningRef.current && !interactionRef.current.isDragging.current ) { return; } interactionRef.current.handleMouseUp(); cameraRef.current.handlePanEnd(); isPanningRef.current = false; edgeMouseDownRef.current = null; dragPreviewRef.current = null; setInteractionGuards(false); }; window.addEventListener('mousemove', handleWindowMouseMove); window.addEventListener('mouseup', handleWindowMouseUp); window.addEventListener('blur', clearInteraction); window.addEventListener('dragstart', clearInteraction); return () => { window.removeEventListener('mousemove', handleWindowMouseMove); window.removeEventListener('mouseup', handleWindowMouseUp); window.removeEventListener('blur', clearInteraction); window.removeEventListener('dragstart', clearInteraction); }; }, [setInteractionGuards]); useEffect(() => { return () => { setInteractionGuards(false); }; }, [setInteractionGuards]); 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, getVisibleNodes(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, events, getVisibleNodes, interaction, simulation.stateRef] ); // ─── 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 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 => { 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 (
{ 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} onToggleSidebar={onToggleSidebar} isSidebarVisible={isSidebarVisible} teamName={data.teamName} teamColor={data.teamColor} isAlive={data.isAlive} topToolbarContent={renderTopToolbarContent?.()} interactionLocked={interactionLocked} /> {renderHud ? (
{renderHud({ getLaunchAnchorScreenPlacement, getActivityWorldRect, getTransientHandoffSnapshot, getCameraZoom, worldToScreen: camera.worldToScreen, getNodeWorldPosition, getViewportSize, focusNodeIds: focusState.focusNodeIds, focusEdgeIds: focusState.focusEdgeIds, })}
) : null} {(selectedNode || selectedEdge) && (
{selectedNode ? ( renderOverlay ? ( renderOverlay({ node: selectedNode, screenPos: camera.worldToScreen(selectedNode.x ?? 0, selectedNode.y ?? 0), onClose: () => setSelectedNodeId(null), }) ) : ( 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); }, }) ) : ( setSelectedEdgeId(null)} /> ) ) : null}
)}
); }