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
154 lines
4.9 KiB
TypeScript
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();
|
|
}
|