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
178 lines
5.5 KiB
TypeScript
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,
|
|
};
|
|
}
|