/** * Camera hook — pan, zoom, auto-fit. * Adapted from agent-flow's use-canvas-camera.ts (Apache 2.0). * All state in refs — no React re-renders. */ import { useRef, useCallback } from 'react'; import type { GraphNode } from '../ports/types'; import { CAMERA, ANIM, NODE, TASK_PILL } from '../constants/canvas-constants'; export interface CameraTransform { x: number; y: number; zoom: number; } export interface UseGraphCameraResult { transformRef: React.MutableRefObject; screenToWorld: (sx: number, sy: number) => { x: number; y: number }; worldToScreen: (wx: number, wy: number) => { x: number; y: number }; handleWheel: (e: WheelEvent) => void; handlePanStart: (sx: number, sy: number) => void; handlePanMove: (sx: number, sy: number) => void; handlePanEnd: () => void; zoomToFit: (nodes: GraphNode[], canvasW: number, canvasH: number) => void; zoomIn: () => void; zoomOut: () => void; updateInertia: () => void; } export function useGraphCamera(): UseGraphCameraResult { const transformRef = useRef({ x: 0, y: 0, zoom: 1 }) as React.MutableRefObject; const panStartRef = useRef<{ x: number; y: number; camX: number; camY: number } | null>(null); const velocityRef = useRef({ vx: 0, vy: 0 }); const screenToWorld = useCallback((sx: number, sy: number) => { const t = transformRef.current; return { x: (sx - t.x) / t.zoom, y: (sy - t.y) / t.zoom, }; }, []); const worldToScreen = useCallback((wx: number, wy: number) => { const t = transformRef.current; return { x: wx * t.zoom + t.x, y: wy * t.zoom + t.y, }; }, []); const handleWheel = useCallback((e: WheelEvent) => { const t = transformRef.current; // Trackpad pinch (ctrlKey=true) sends small deltaY values — use them directly. // Mouse wheel sends larger discrete deltaY — normalize to smaller steps. let zoomDelta: number; if (e.ctrlKey) { // Pinch-to-zoom: deltaY is typically -2..+2, dampen it zoomDelta = -e.deltaY * 0.008; } else { // Mouse wheel: deltaY is typically ±100-150, use discrete steps zoomDelta = e.deltaY < 0 ? 0.08 : -0.08; } const newZoom = Math.max(CAMERA.minZoom, Math.min(CAMERA.maxZoom, t.zoom * (1 + zoomDelta))); // Zoom toward cursor position const rect = (e.target as HTMLCanvasElement).getBoundingClientRect?.(); const cx = rect ? e.clientX - rect.left : e.offsetX; const cy = rect ? e.clientY - rect.top : e.offsetY; t.x = cx - (cx - t.x) * (newZoom / t.zoom); t.y = cy - (cy - t.y) * (newZoom / t.zoom); t.zoom = newZoom; }, []); const lastPanPos = useRef({ x: 0, y: 0 }); const handlePanStart = useCallback((sx: number, sy: number) => { const t = transformRef.current; panStartRef.current = { x: sx, y: sy, camX: t.x, camY: t.y }; lastPanPos.current = { x: sx, y: sy }; velocityRef.current = { vx: 0, vy: 0 }; }, []); const handlePanMove = useCallback((sx: number, sy: number) => { const start = panStartRef.current; if (!start) return; const t = transformRef.current; const dx = sx - start.x; const dy = sy - start.y; t.x = start.camX + dx; t.y = start.camY + dy; // Per-frame delta for inertia (not total drag distance) const frameDx = sx - lastPanPos.current.x; const frameDy = sy - lastPanPos.current.y; lastPanPos.current = { x: sx, y: sy }; velocityRef.current = { vx: frameDx * CAMERA.velocityScale, vy: frameDy * CAMERA.velocityScale }; }, []); const handlePanEnd = useCallback(() => { panStartRef.current = null; }, []); const updateInertia = useCallback(() => { const v = velocityRef.current; if (Math.abs(v.vx) < ANIM.inertiaThreshold && Math.abs(v.vy) < ANIM.inertiaThreshold) { v.vx = 0; v.vy = 0; return; } const t = transformRef.current; t.x += v.vx; t.y += v.vy; v.vx *= ANIM.inertiaDecay; v.vy *= ANIM.inertiaDecay; }, []); const zoomToFit = useCallback((nodes: GraphNode[], canvasW: number, canvasH: number) => { if (nodes.length === 0) return; let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; for (const n of nodes) { const x = n.x ?? 0; const y = n.y ?? 0; const pad = n.kind === 'task' ? TASK_PILL.width / 2 : n.kind === 'lead' ? NODE.radiusLead : NODE.radiusMember; minX = Math.min(minX, x - pad); minY = Math.min(minY, y - pad); maxX = Math.max(maxX, x + pad); maxY = Math.max(maxY, y + pad); } const padding = ANIM.viewportPadding; const contentW = maxX - minX + padding * 2; const contentH = maxY - minY + padding * 2; const centerX = (minX + maxX) / 2; const centerY = (minY + maxY) / 2; const zoom = Math.max( CAMERA.minZoom, Math.min(CAMERA.maxZoom, Math.min(canvasW / contentW, canvasH / contentH)), ); const t = transformRef.current; t.zoom = zoom; t.x = canvasW / 2 - centerX * zoom; t.y = canvasH / 2 - centerY * zoom; }, []); const zoomIn = useCallback(() => { const t = transformRef.current; t.zoom = Math.min(CAMERA.maxZoom, t.zoom * 1.2); }, []); const zoomOut = useCallback(() => { const t = transformRef.current; t.zoom = Math.max(CAMERA.minZoom, t.zoom / 1.2); }, []); return { transformRef, screenToWorld, worldToScreen, handleWheel, handlePanStart, handlePanMove, handlePanEnd, zoomToFit, zoomIn, zoomOut, updateInertia, }; }