/** * GraphCanvas — Canvas 2D rendering component with imperative RAF draw loop. * * ARCHITECTURE: The canvas draws imperatively via drawRef, NOT via React re-renders. * GraphView calls `drawRef.current()` from the unified RAF loop. * React only manages: mount/unmount, resize, mouse events. */ import { useRef, useEffect, useImperativeHandle, forwardRef } from 'react'; import type { GraphNode, GraphEdge, GraphParticle } from '../ports/types'; import { drawBackground, createDepthParticles, createShootingStarField, updateDepthParticles, updateShootingStarField, type DepthParticle, type ShootingStarField, } from '../canvas/background-layer'; import { drawEdges } from '../canvas/draw-edges'; import { drawHandoffCards } from '../canvas/draw-handoff-cards'; import { drawParticles } from '../canvas/draw-particles'; import { drawAgents, drawCrossTeamNodes } from '../canvas/draw-agents'; import { drawTasks, drawColumnHeaders } from '../canvas/draw-tasks'; import { drawProcesses } from '../canvas/draw-processes'; import { drawEffects, type VisualEffect } from '../canvas/draw-effects'; import { drawHexagon } from '../canvas/draw-misc'; import { BloomRenderer } from '../canvas/bloom-renderer'; import { KanbanLayoutEngine } from '../layout/kanbanLayout'; import { computeAdaptiveParticleBudget, selectRenderableParticles, } from './selectRenderableParticles'; import { createTransientHandoffState, selectRenderableTransientHandoffCards, type TransientHandoffCard, updateTransientHandoffState, } from './transientHandoffs'; import type { CameraTransform } from '../hooks/useGraphCamera'; import { NODE } from '../constants/canvas-constants'; // ─── Draw State (passed by ref, not by props — no React re-renders) ───────── export interface GraphDrawState { teamName: string; nodes: GraphNode[]; edges: GraphEdge[]; particles: GraphParticle[]; effects: VisualEffect[]; time: number; camera: CameraTransform; selectedNodeId: string | null; hoveredNodeId: string | null; selectedEdgeId: string | null; hoveredEdgeId: string | null; focusNodeIds: ReadonlySet | null; focusEdgeIds: ReadonlySet | null; dragPreview: | { nodeId: string; x: number; y: number; color?: string | null; } | null; } export interface GraphCanvasHandle { /** Call this from RAF to draw one frame */ draw: (state: GraphDrawState) => void; /** Get the canvas element for coordinate transforms */ getCanvas: () => HTMLCanvasElement | null; /** Read current transient handoff cards for DOM HUD rendering */ getTransientHandoffSnapshot: (options?: { focusNodeIds?: ReadonlySet | null; focusEdgeIds?: ReadonlySet | null; }) => { cards: TransientHandoffCard[]; time: number; }; } export interface GraphCanvasProps { showHexGrid?: boolean; showStarField?: boolean; bloomIntensity?: number; onWheel?: (e: WheelEvent) => void; onMouseDown?: (e: React.MouseEvent) => void; onMouseMove?: (e: React.MouseEvent) => void; onMouseUp?: (e: React.MouseEvent) => void; onDoubleClick?: (e: React.MouseEvent) => void; onContextMenu?: (e: React.MouseEvent) => void; className?: string; } export const GraphCanvas = forwardRef(function GraphCanvas( { showHexGrid = true, showStarField = true, bloomIntensity = 0.6, onWheel, onMouseDown, onMouseMove, onMouseUp, onDoubleClick, onContextMenu, className, }, ref ) { const canvasRef = useRef(null); const containerRef = useRef(null); const bloomRef = useRef(new BloomRenderer(bloomIntensity)); const starsRef = useRef([]); const shootingStarsRef = useRef(createShootingStarField()); const sizeRef = useRef({ w: 0, h: 0 }); const lastBackgroundTimeRef = useRef(null); // Performance tracking const perfRef = useRef({ frames: 0, fps: 0, frameTimeMs: 0, lastFpsUpdate: 0, frameTimes: [] as number[], }); // Rate-limited error logging (prevent console flood at 60fps) const lastDrawErrorRef = useRef(0); // Update bloom intensity without recreating useEffect(() => { bloomRef.current.setIntensity(bloomIntensity); }, [bloomIntensity]); // Handle resize useEffect(() => { const container = containerRef.current; if (!container) return; const observer = new ResizeObserver((entries) => { for (const entry of entries) { const { width, height } = entry.contentRect; const dpr = window.devicePixelRatio || 1; const canvas = canvasRef.current; if (!canvas) continue; canvas.width = width * dpr; canvas.height = height * dpr; canvas.style.width = `${width}px`; canvas.style.height = `${height}px`; sizeRef.current = { w: width, h: height }; bloomRef.current.resize(width * dpr, height * dpr); starsRef.current = createDepthParticles(width, height); shootingStarsRef.current = createShootingStarField(); lastBackgroundTimeRef.current = null; } }); observer.observe(container); return () => observer.disconnect(); }, []); // Persistent per-frame collections (reused, never GC'd) const nodeMapCache = useRef(new Map()); const edgeMapCache = useRef(new Map()); const visibleNodesCache = useRef([]); const visibleEdgesCache = useRef([]); const visibleNodeIdsCache = useRef(new Set()); const visibleEdgeIdsCache = useRef(new Set()); const activeParticleEdgesCache = useRef(new Set()); const handoffStateRef = useRef(createTransientHandoffState()); const lastTeamNameRef = useRef(null); const lastDrawTimeRef = useRef(0); // Imperative draw function — called from RAF, NOT from React render useImperativeHandle( ref, () => ({ draw: (state: GraphDrawState) => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); if (!ctx) return; const frameStart = performance.now(); const dpr = window.devicePixelRatio || 1; const { w, h } = sizeRef.current; if (w === 0 || h === 0) return; try { lastDrawTimeRef.current = state.time; if (lastTeamNameRef.current !== state.teamName) { handoffStateRef.current = createTransientHandoffState(); lastTeamNameRef.current = state.teamName; } const cam = state.camera; const zoom = cam.zoom; // ─── Frustum culling: compute visible world-space bounds ────────── const viewLeft = -cam.x / zoom; const viewTop = -cam.y / zoom; const viewRight = (w - cam.x) / zoom; const viewBottom = (h - cam.y) / zoom; const pad = 200; // overdraw padding for glow/labels // ─── Reuse cached maps (avoid per-frame allocation) ─────────────── const nodeMap = nodeMapCache.current; nodeMap.clear(); for (const n of state.nodes) nodeMap.set(n.id, n); const edgeMap = edgeMapCache.current; edgeMap.clear(); for (const e of state.edges) edgeMap.set(e.id, e); // ─── Filter visible nodes (frustum cull) — reuse array ──────────── const visibleNodes = visibleNodesCache.current; visibleNodes.length = 0; for (const n of state.nodes) { const x = n.x ?? 0; const y = n.y ?? 0; if ( x > viewLeft - pad && x < viewRight + pad && y > viewTop - pad && y < viewBottom + pad ) { visibleNodes.push(n); } } // ─── Active particle edges — reuse Set ─────────────────────────── const activeParticleEdges = activeParticleEdgesCache.current; activeParticleEdges.clear(); for (const p of state.particles) activeParticleEdges.add(p.edgeId); // ─── Draw ───────────────────────────────────────────────────────── ctx.save(); ctx.scale(dpr, dpr); ctx.clearRect(0, 0, w, h); // 1. Background (screen space) const backgroundDt = Math.min( Math.max( lastBackgroundTimeRef.current == null ? 0 : state.time - lastBackgroundTimeRef.current, 0 ), 0.1 ); lastBackgroundTimeRef.current = state.time; updateDepthParticles(starsRef.current, w, h, backgroundDt); updateShootingStarField(shootingStarsRef.current, w, h, backgroundDt); drawBackground(ctx, w, h, starsRef.current, shootingStarsRef.current, cam, state.time, { showHexGrid, showStarField, }); // 2. World-space content ctx.save(); ctx.translate(cam.x, cam.y); ctx.scale(zoom, zoom); // 2a. Edges (only those connecting visible nodes) — reuse collections const visibleNodeIds = visibleNodeIdsCache.current; visibleNodeIds.clear(); for (const n of visibleNodes) visibleNodeIds.add(n.id); const visibleEdges = visibleEdgesCache.current; visibleEdges.length = 0; const visibleEdgeIds = visibleEdgeIdsCache.current; visibleEdgeIds.clear(); for (const e of state.edges) { if (visibleNodeIds.has(e.source) || visibleNodeIds.has(e.target)) { visibleEdges.push(e); visibleEdgeIds.add(e.id); } } const prioritizedEdgeIds = state.focusEdgeIds ?? (state.selectedEdgeId ? new Set([state.selectedEdgeId]) : null); drawEdges( ctx, visibleEdges, nodeMap, state.time, activeParticleEdges, prioritizedEdgeIds, state.hoveredEdgeId, state.selectedEdgeId, zoom ); // 2b. Particles - adaptive degradation keeps one visible particle per active edge const particleBudget = computeAdaptiveParticleBudget({ visibleNodeCount: visibleNodes.length, visibleEdgeCount: visibleEdges.length, frameTimeMs: perfRef.current.frameTimeMs, hasFocusedEdges: (prioritizedEdgeIds?.size ?? 0) > 0, zoom, }); const renderableParticles = selectRenderableParticles({ particles: state.particles, visibleEdgeIds, focusEdgeIds: prioritizedEdgeIds, budget: particleBudget, }); updateTransientHandoffState(handoffStateRef.current, { particles: state.particles, edgeMap, nodeMap, time: state.time, }); const renderableHandoffCards = selectRenderableTransientHandoffCards( handoffStateRef.current, { focusNodeIds: state.focusNodeIds, focusEdgeIds: prioritizedEdgeIds ?? state.focusEdgeIds, } ).filter((card) => card.anchorKind !== 'lead' && card.anchorKind !== 'member'); drawParticles(ctx, renderableParticles, edgeMap, nodeMap, state.time, prioritizedEdgeIds); // 2c. Visible nodes only (back to front: process → task → member/lead) drawProcesses( ctx, visibleNodes, state.time, state.selectedNodeId, state.hoveredNodeId, state.focusNodeIds, zoom ); drawCrossTeamNodes( ctx, visibleNodes, state.time, state.selectedNodeId, state.hoveredNodeId, state.focusNodeIds ); drawColumnHeaders(ctx, KanbanLayoutEngine.zones, zoom); drawTasks( ctx, visibleNodes, state.time, state.selectedNodeId, state.hoveredNodeId, state.focusNodeIds, zoom ); drawAgents( ctx, visibleNodes, state.time, state.selectedNodeId, state.hoveredNodeId, state.focusNodeIds, zoom ); if (state.dragPreview) { drawOwnerSlotPreview(ctx, state.dragPreview, state.time); } // 2d. Effects drawEffects(ctx, state.effects); ctx.restore(); // world space ctx.restore(); // DPR scale // 3. Bloom post-processing — always active for space aesthetic if (bloomIntensity > 0) { bloomRef.current.apply(canvas, ctx); } if (renderableHandoffCards.length > 0) { ctx.save(); ctx.scale(dpr, dpr); drawHandoffCards(ctx, { cards: renderableHandoffCards, nodeMap, time: state.time, camera: cam, viewport: { width: w, height: h }, }); ctx.restore(); } // 4. Performance overlay (enabled via ?perf in URL) const perf = perfRef.current; const frameMs = performance.now() - frameStart; perf.frameTimes.push(frameMs); perf.frames++; if (perf.frameTimes.length > 120) perf.frameTimes.shift(); const now = performance.now(); if (now - perf.lastFpsUpdate > 1000) { perf.fps = perf.frames; perf.frames = 0; perf.lastFpsUpdate = now; const sorted = [...perf.frameTimes].sort((a, b) => a - b); perf.frameTimeMs = sorted[Math.floor(sorted.length * 0.95)] ?? 0; } if (typeof window !== 'undefined' && window.location?.search?.includes('perf')) { ctx.save(); ctx.scale(dpr, dpr); ctx.fillStyle = 'rgba(0, 0, 0, 0.7)'; ctx.fillRect(w - 130, 4, 126, 48); ctx.font = '10px monospace'; ctx.fillStyle = perf.fps >= 50 ? '#66ffaa' : perf.fps >= 30 ? '#ffbb44' : '#ff5566'; ctx.textAlign = 'right'; ctx.fillText(`${perf.fps} fps`, w - 10, 18); ctx.fillStyle = '#aaeeff'; ctx.fillText(`p95: ${perf.frameTimeMs.toFixed(1)}ms`, w - 10, 32); ctx.fillText(`${state.nodes.length} nodes ${state.edges.length} edges`, w - 10, 46); ctx.restore(); } } catch (err) { // Rate-limited error logging — max once per 5 seconds const now = performance.now(); if (now - lastDrawErrorRef.current > 5000) { lastDrawErrorRef.current = now; console.error('[AgentGraph] Draw error:', err); } } }, getCanvas: () => canvasRef.current, getTransientHandoffSnapshot: (options) => ({ cards: selectRenderableTransientHandoffCards(handoffStateRef.current, options), time: lastDrawTimeRef.current, }), }), [showHexGrid, showStarField, bloomIntensity] ); // Wheel handler (passive: false required for preventDefault) useEffect(() => { const canvas = canvasRef.current; if (!canvas || !onWheel) return; const handler = (e: WheelEvent) => { e.preventDefault(); onWheel(e); }; canvas.addEventListener('wheel', handler, { passive: false }); return () => canvas.removeEventListener('wheel', handler); }, [onWheel]); return (
); }); function drawOwnerSlotPreview( ctx: CanvasRenderingContext2D, preview: NonNullable, time: number ): void { const radius = NODE.radiusMember; const outerRadius = radius + 18; const innerRadius = radius + 8; const glowRadius = radius + 34; const color = preview.color ?? '#8bd3ff'; const pulse = 0.35 + 0.15 * Math.sin(time * 6); ctx.save(); ctx.globalAlpha = 0.7 + pulse; ctx.setLineDash([8, 6]); ctx.lineDashOffset = -time * 48; ctx.lineWidth = 2.5; drawHexagon(ctx, preview.x, preview.y, outerRadius); ctx.strokeStyle = color; ctx.stroke(); ctx.setLineDash([]); drawHexagon(ctx, preview.x, preview.y, innerRadius); ctx.fillStyle = 'rgba(120, 190, 255, 0.08)'; ctx.fill(); const glow = ctx.createRadialGradient( preview.x, preview.y, radius * 0.45, preview.x, preview.y, glowRadius ); glow.addColorStop(0, 'rgba(120, 190, 255, 0.12)'); glow.addColorStop(1, 'rgba(120, 190, 255, 0)'); ctx.beginPath(); ctx.arc(preview.x, preview.y, glowRadius, 0, Math.PI * 2); ctx.fillStyle = glow; ctx.fill(); ctx.restore(); }