/** * 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; 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 | 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(null); const [selectedEdgeId, setSelectedEdgeId] = useState(null); 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); // ─── 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 => { 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 => { 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} teamName={data.teamName} teamColor={data.teamColor} isAlive={data.isAlive} /> {renderHud ? (
{renderHud({ getLaunchAnchorScreenPlacement, getActivityAnchorScreenPlacement, getActivityAnchorWorldPosition, getCameraZoom, worldToScreen: camera.worldToScreen, getNodeWorldPosition, getViewportSize, getNodeScreenPosition, focusNodeIds: focusState.focusNodeIds, })}
) : 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}
)}
); }