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:
parent
17e9be99dd
commit
6866f003dc
4 changed files with 108 additions and 8 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue