feat(graph): spawn/edge/task/comment animations

1. Member spawn animation: rotating dashed ring for 'spawning' status,
   pulsing hex outline for 'waiting' status
2. Edge flash: active edges pulse brighter (0.1-0.5 alpha) with glow
   shadow when particles travel along them
3. Smooth task positioning: tasks LERP to target position (0.15 factor)
   instead of teleporting when kanban column changes
4. Comment flight particles: when a member adds a comment to a task,
   a purple particle with speech bubble flies from member to task node
This commit is contained in:
iliya 2026-03-28 12:27:37 +02:00
parent 17e9be99dd
commit 6866f003dc
4 changed files with 108 additions and 8 deletions

View file

@ -44,8 +44,8 @@ export function drawAgents(
// Hexagonal body with interior fill
drawHexBody(ctx, x, y, r, color, node.state, time, isSelected, isHovered);
// Breathing animation
drawBreathing(ctx, x, y, r, node.state, time);
// Breathing animation + spawn/waiting effects
drawBreathing(ctx, x, y, r, node.state, time, node.spawnStatus);
// Name label
drawLabel(ctx, x, y, r, node.label, color);
@ -150,7 +150,34 @@ function drawBreathing(
r: number,
state: string,
time: number,
spawnStatus?: GraphNode['spawnStatus'],
): void {
// Spawning: rotating dashed ring (loading spinner)
if (spawnStatus === 'spawning') {
const ringR = r + AGENT_DRAW.orbitParticleOffset;
const rotation = time * ANIM.orbitSpeed * 2;
ctx.save();
ctx.beginPath();
ctx.arc(x, y, ringR, rotation, rotation + Math.PI * 1.4);
ctx.strokeStyle = COLORS.holoBase + alphaHex(0.5);
ctx.lineWidth = 2;
ctx.setLineDash([6, 4]);
ctx.stroke();
ctx.setLineDash([]);
ctx.restore();
return;
}
// Waiting: pulsing hex outline (breathing border)
if (spawnStatus === 'waiting') {
const pulse = 0.12 + 0.12 * Math.sin(time * AGENT_DRAW.waitingBreatheSpeed);
drawHexagon(ctx, x, y, r + AGENT_DRAW.outerRingOffset);
ctx.strokeStyle = COLORS.holoBase + alphaHex(pulse);
ctx.lineWidth = 1.5;
ctx.stroke();
return;
}
const isActive = state === 'active' || state === 'thinking' || state === 'tool_calling';
const speed = isActive ? ANIM.breathe.activeSpeed : ANIM.breathe.idleSpeed;
const amp = isActive ? ANIM.breathe.activeAmp : ANIM.breathe.idleAmp;

View file

@ -83,7 +83,10 @@ export function drawEdges(
const style = EDGE_STYLES[edge.type] ?? EDGE_STYLES['parent-child'];
const isActive = hasActiveParticles.has(edge.id);
const alpha = isActive ? BEAM.activeAlpha : BEAM.idleAlpha;
// Pulse alpha when particles are travelling: base 0.3 + 0.2 * sin wave
const alpha = isActive
? BEAM.activeAlpha + 0.2 * Math.sin(_time * 6)
: BEAM.idleAlpha;
if (alpha < MIN_VISIBLE_OPACITY) continue;
@ -92,6 +95,12 @@ export function drawEdges(
ctx.save();
ctx.globalAlpha = alpha;
// Subtle glow pass when edge has active particles
if (isActive) {
ctx.shadowColor = edge.color ?? style.color;
ctx.shadowBlur = 12;
}
// Draw tapered bezier
drawTaperedBezier(
ctx,

View file

@ -87,8 +87,11 @@ export class KanbanLayoutEngine {
task.fy = task.y;
continue;
}
task.x = baseX + colIdx * columnWidth;
task.y = baseY + rowIdx * rowHeight;
const targetX = baseX + colIdx * columnWidth;
const targetY = baseY + rowIdx * rowHeight;
// Smooth slide: LERP toward target; instant on first appearance
task.x = task.x != null ? task.x + (targetX - task.x) * 0.15 : targetX;
task.y = task.y != null ? task.y + (targetY - task.y) * 0.15 : targetY;
task.fx = task.x;
task.fy = task.y;
task.vx = 0;
@ -120,8 +123,10 @@ export class KanbanLayoutEngine {
static #layoutUnassigned(tasks: GraphNode[]): void {
const { columnWidth, rowHeight } = KANBAN_ZONE;
for (const [idx, task] of tasks.entries()) {
task.x = -400 + (idx % 3) * columnWidth;
task.y = 400 + Math.floor(idx / 3) * rowHeight;
const targetX = -400 + (idx % 3) * columnWidth;
const targetY = 400 + Math.floor(idx / 3) * rowHeight;
task.x = task.x != null ? task.x + (targetX - task.x) * 0.15 : targetX;
task.y = task.y != null ? task.y + (targetY - task.y) * 0.15 : targetY;
task.fx = task.x;
task.fy = task.y;
task.vx = 0;

View file

@ -27,6 +27,8 @@ export class TeamGraphAdapter {
readonly #seenRelated = new Set<string>();
readonly #seenMessageIds = new Set<string>();
#initialMessagesSeen = false;
readonly #seenCommentCounts = new Map<string, number>();
#initialCommentsSeen = false;
// ─── Static factory ──────────────────────────────────────────────────────
static create(): TeamGraphAdapter {
@ -54,7 +56,8 @@ export class TeamGraphAdapter {
}
// Simple hash for change detection (avoids full deep equality)
const hash = `${teamData.teamName}:${teamData.members.length}:${teamData.tasks.length}:${teamData.messages.length}:${teamData.isAlive}:${leadContext?.percent}`;
const totalComments = teamData.tasks.reduce((sum, t) => sum + (t.comments?.length ?? 0), 0);
const hash = `${teamData.teamName}:${teamData.members.length}:${teamData.tasks.length}:${teamData.messages.length}:${teamData.isAlive}:${leadContext?.percent}:${totalComments}`;
if (hash === this.#lastDataHash && teamName === this.#lastTeamName) {
return this.#cachedResult;
}
@ -63,6 +66,8 @@ export class TeamGraphAdapter {
if (teamName !== this.#lastTeamName) {
this.#seenMessageIds.clear();
this.#initialMessagesSeen = false;
this.#seenCommentCounts.clear();
this.#initialCommentsSeen = false;
}
this.#lastTeamName = teamName;
@ -80,6 +85,7 @@ export class TeamGraphAdapter {
this.#buildTaskNodes(nodes, edges, teamData, teamName);
this.#buildProcessNodes(nodes, edges, teamData, teamName);
this.#buildMessageParticles(particles, teamData.messages, teamName, leadId, edges);
this.#buildCommentParticles(particles, teamData, teamName, edges);
this.#cachedResult = {
nodes,
@ -100,6 +106,8 @@ export class TeamGraphAdapter {
this.#seenRelated.clear();
this.#seenMessageIds.clear();
this.#initialMessagesSeen = false;
this.#seenCommentCounts.clear();
this.#initialCommentsSeen = false;
this.#lastDataHash = '';
}
@ -299,6 +307,57 @@ export class TeamGraphAdapter {
}
}
#buildCommentParticles(
particles: GraphParticle[],
data: TeamData,
teamName: string,
edges: GraphEdge[]
): void {
// First call: record current comment counts without creating particles.
// This prevents pre-existing comments from spawning particles when the graph opens.
if (!this.#initialCommentsSeen) {
this.#initialCommentsSeen = true;
for (const task of data.tasks) {
this.#seenCommentCounts.set(task.id, task.comments?.length ?? 0);
}
return;
}
// Build a member color lookup for assigning particle colors
const memberColors = new Map<string, string>();
for (const member of data.members) {
if (member.color) memberColors.set(member.name, member.color);
}
for (const task of data.tasks) {
if (task.status === 'deleted') continue;
const prevCount = this.#seenCommentCounts.get(task.id) ?? 0;
const currentCount = task.comments?.length ?? 0;
if (currentCount > prevCount && prevCount > 0) {
// New comment(s) detected — create a particle from the author to the task
const newComment = task.comments![currentCount - 1];
const authorNodeId = `member:${teamName}:${newComment.author}`;
const taskNodeId = `task:${teamName}:${task.id}`;
const authorEdge = edges.find((e) => e.source === authorNodeId && e.target === taskNodeId);
if (authorEdge) {
particles.push({
id: `particle:comment:${task.id}:${currentCount}`,
edgeId: authorEdge.id,
progress: 0,
kind: 'message',
color: memberColors.get(newComment.author) ?? '#cc88ff',
label: '\u{1F4AC}',
});
}
}
this.#seenCommentCounts.set(task.id, currentCount);
}
}
// ─── Static mappers ──────────────────────────────────────────────────────
static #mapMemberStatus(status: string, spawnStatus?: string): GraphNodeState {