/** * 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 { const map = new Map(); 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, nodeMap: Map, 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(); }