agent-ecosystem/packages/agent-graph/src/canvas/draw-particles.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

154 lines
4.9 KiB
TypeScript

/**
* Particle animation along edges.
* Adapted from agent-flow's draw-particles.ts (Apache 2.0).
*/
import type { GraphNode, GraphEdge, GraphParticle } from '../ports/types';
import { COLORS } from '../constants/colors';
import { PARTICLE_DRAW, BEAM } from '../constants/canvas-constants';
import { bezierPoint, computeControlPoints, type ControlPoints } from './draw-edges';
import { getGlowSprite, hexWithAlpha } from './render-cache';
/**
* Build a lookup from edge.id → edge for fast particle→edge resolution.
*/
export function buildEdgeMap(edges: GraphEdge[]): Map<string, GraphEdge> {
const map = new Map<string, GraphEdge>();
for (const e of edges) map.set(e.id, e);
return map;
}
/**
* Draw all active particles along their edges.
*/
export function drawParticles(
ctx: CanvasRenderingContext2D,
particles: GraphParticle[],
edgeMap: Map<string, GraphEdge>,
nodeMap: Map<string, GraphNode>,
time: number,
): void {
for (const p of particles) {
const edge = edgeMap.get(p.edgeId);
if (!edge) continue;
const source = nodeMap.get(edge.source);
const target = nodeMap.get(edge.target);
if (!source || !target) continue;
if (source.x == null || source.y == null || target.x == null || target.y == null) continue;
const cp = computeControlPoints(source.x, source.y, target.x, target.y);
const color = p.color || COLORS.message;
const baseSize = (p.size ?? 1) * 3;
// Differentiate visual by particle kind
const size = p.kind === 'spawn' ? baseSize * 1.5
: p.kind === 'review_request' || p.kind === 'review_response' ? baseSize * 1.2
: baseSize;
// Wobble offset for organic look
const phaseOffset = p.id.charCodeAt(Math.min(5, p.id.length - 1)) * 0.1;
const wobbleAmp = BEAM.wobble.amp;
drawParticleTrail(ctx, source, target, cp, p.progress, color, size, wobbleAmp, phaseOffset, time);
drawParticleCore(ctx, source, target, cp, p.progress, color, size, wobbleAmp, phaseOffset, time);
// Label
if (p.label && p.progress > PARTICLE_DRAW.labelMinT && p.progress < PARTICLE_DRAW.labelMaxT) {
const pos = getWobbledPosition(source, target, cp, p.progress, wobbleAmp, phaseOffset, time);
ctx.font = `${PARTICLE_DRAW.labelFontSize}px monospace`;
ctx.textAlign = 'center';
ctx.fillStyle = hexWithAlpha(color, 0.56);
ctx.fillText(p.label, pos.x, pos.y + PARTICLE_DRAW.labelYOffset);
}
}
}
// ─── Private Helpers ────────────────────────────────────────────────────────
function getWobbledPosition(
source: GraphNode,
target: GraphNode,
cp: ControlPoints,
t: number,
wobbleAmp: number,
phaseOffset: number,
time: number,
): { x: number; y: number } {
const pos = bezierPoint(source.x!, source.y!, cp, target.x!, target.y!, t);
// Perpendicular wobble
const dt = 0.01;
const tNext = Math.min(1, t + dt);
const posNext = bezierPoint(source.x!, source.y!, cp, target.x!, target.y!, tNext);
const dx = posNext.x - pos.x;
const dy = posNext.y - pos.y;
const len = Math.sqrt(dx * dx + dy * dy) || 1;
const nx = -dy / len;
const ny = dx / len;
const wobble = Math.sin(t * BEAM.wobble.freq + time * BEAM.wobble.timeFreq + phaseOffset) * wobbleAmp;
return {
x: pos.x + nx * wobble,
y: pos.y + ny * wobble,
};
}
function drawParticleTrail(
ctx: CanvasRenderingContext2D,
source: GraphNode,
target: GraphNode,
cp: ControlPoints,
progress: number,
color: string,
size: number,
wobbleAmp: number,
phaseOffset: number,
time: number,
): void {
const trailSegments = 6;
const trailStep = BEAM.wobble.trailOffset / trailSegments;
for (let i = trailSegments; i >= 1; i--) {
const t = Math.max(0, progress - trailStep * i);
const pos = getWobbledPosition(source, target, cp, t, wobbleAmp, phaseOffset, time);
const alpha = (1 - i / trailSegments) * 0.3;
const trailSize = size * (1 - i / trailSegments) * 0.5;
ctx.fillStyle = hexWithAlpha(color, alpha);
ctx.beginPath();
ctx.arc(pos.x, pos.y, trailSize, 0, Math.PI * 2);
ctx.fill();
}
}
function drawParticleCore(
ctx: CanvasRenderingContext2D,
source: GraphNode,
target: GraphNode,
cp: ControlPoints,
progress: number,
color: string,
size: number,
wobbleAmp: number,
phaseOffset: number,
time: number,
): void {
const pos = getWobbledPosition(source, target, cp, progress, wobbleAmp, phaseOffset, time);
// Glow sprite
const glowR = PARTICLE_DRAW.glowRadius;
const sprite = getGlowSprite(color, glowR, 0.4, 0);
ctx.drawImage(sprite, pos.x - glowR, pos.y - glowR);
// Core dot
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(pos.x, pos.y, size, 0, Math.PI * 2);
ctx.fill();
// Highlight
ctx.fillStyle = '#ffffff';
ctx.beginPath();
ctx.arc(pos.x, pos.y, size * PARTICLE_DRAW.coreHighlightScale, 0, Math.PI * 2);
ctx.fill();
}