agent-ecosystem/packages/agent-graph/src/hooks/useGraphCamera.ts
Илия 11bb49c53e
feat(graph): force-directed agent graph visualization with kanban-zone task layout
Force-directed graph visualization for agent teams.

Package: @claude-teams/agent-graph (isolated workspace package)
- Space theme: bloom, particles, hex grid, depth stars
- Members as hexagonal nodes with breathing glow
- Tasks as pill cards in kanban columns (todo/wip/done/review/approved) per owner
- Message particles along edges (real-time only)
- Deterministic layout, Figma-style pan, scroll/pinch zoom
- Clean Architecture: ports/adapters/strategies, ES #private classes

Integration: features/agent-graph/ (adapter + overlay + tab)
- Full-screen overlay (Cmd+Shift+G) + Pin as Tab
- Graph button in Team section header
- Frustum culling, zero per-frame allocations, adaptive fps
- Performance overlay via ?perf query param

Also: CI runs on all PR branches, features/CLAUDE.md architecture guide
2026-03-28 12:03:42 +02:00

178 lines
5.5 KiB
TypeScript

/**
* 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<CameraTransform>;
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<CameraTransform>({ x: 0, y: 0, zoom: 1 }) as React.MutableRefObject<CameraTransform>;
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,
};
}