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
|
// Hexagonal body with interior fill
|
||||||
drawHexBody(ctx, x, y, r, color, node.state, time, isSelected, isHovered);
|
drawHexBody(ctx, x, y, r, color, node.state, time, isSelected, isHovered);
|
||||||
|
|
||||||
// Breathing animation
|
// Breathing animation + spawn/waiting effects
|
||||||
drawBreathing(ctx, x, y, r, node.state, time);
|
drawBreathing(ctx, x, y, r, node.state, time, node.spawnStatus);
|
||||||
|
|
||||||
// Name label
|
// Name label
|
||||||
drawLabel(ctx, x, y, r, node.label, color);
|
drawLabel(ctx, x, y, r, node.label, color);
|
||||||
|
|
@ -150,7 +150,34 @@ function drawBreathing(
|
||||||
r: number,
|
r: number,
|
||||||
state: string,
|
state: string,
|
||||||
time: number,
|
time: number,
|
||||||
|
spawnStatus?: GraphNode['spawnStatus'],
|
||||||
): void {
|
): 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 isActive = state === 'active' || state === 'thinking' || state === 'tool_calling';
|
||||||
const speed = isActive ? ANIM.breathe.activeSpeed : ANIM.breathe.idleSpeed;
|
const speed = isActive ? ANIM.breathe.activeSpeed : ANIM.breathe.idleSpeed;
|
||||||
const amp = isActive ? ANIM.breathe.activeAmp : ANIM.breathe.idleAmp;
|
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 style = EDGE_STYLES[edge.type] ?? EDGE_STYLES['parent-child'];
|
||||||
const isActive = hasActiveParticles.has(edge.id);
|
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;
|
if (alpha < MIN_VISIBLE_OPACITY) continue;
|
||||||
|
|
||||||
|
|
@ -92,6 +95,12 @@ export function drawEdges(
|
||||||
ctx.save();
|
ctx.save();
|
||||||
ctx.globalAlpha = alpha;
|
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
|
// Draw tapered bezier
|
||||||
drawTaperedBezier(
|
drawTaperedBezier(
|
||||||
ctx,
|
ctx,
|
||||||
|
|
|
||||||
|
|
@ -87,8 +87,11 @@ export class KanbanLayoutEngine {
|
||||||
task.fy = task.y;
|
task.fy = task.y;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
task.x = baseX + colIdx * columnWidth;
|
const targetX = baseX + colIdx * columnWidth;
|
||||||
task.y = baseY + rowIdx * rowHeight;
|
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.fx = task.x;
|
||||||
task.fy = task.y;
|
task.fy = task.y;
|
||||||
task.vx = 0;
|
task.vx = 0;
|
||||||
|
|
@ -120,8 +123,10 @@ export class KanbanLayoutEngine {
|
||||||
static #layoutUnassigned(tasks: GraphNode[]): void {
|
static #layoutUnassigned(tasks: GraphNode[]): void {
|
||||||
const { columnWidth, rowHeight } = KANBAN_ZONE;
|
const { columnWidth, rowHeight } = KANBAN_ZONE;
|
||||||
for (const [idx, task] of tasks.entries()) {
|
for (const [idx, task] of tasks.entries()) {
|
||||||
task.x = -400 + (idx % 3) * columnWidth;
|
const targetX = -400 + (idx % 3) * columnWidth;
|
||||||
task.y = 400 + Math.floor(idx / 3) * rowHeight;
|
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.fx = task.x;
|
||||||
task.fy = task.y;
|
task.fy = task.y;
|
||||||
task.vx = 0;
|
task.vx = 0;
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,8 @@ export class TeamGraphAdapter {
|
||||||
readonly #seenRelated = new Set<string>();
|
readonly #seenRelated = new Set<string>();
|
||||||
readonly #seenMessageIds = new Set<string>();
|
readonly #seenMessageIds = new Set<string>();
|
||||||
#initialMessagesSeen = false;
|
#initialMessagesSeen = false;
|
||||||
|
readonly #seenCommentCounts = new Map<string, number>();
|
||||||
|
#initialCommentsSeen = false;
|
||||||
|
|
||||||
// ─── Static factory ──────────────────────────────────────────────────────
|
// ─── Static factory ──────────────────────────────────────────────────────
|
||||||
static create(): TeamGraphAdapter {
|
static create(): TeamGraphAdapter {
|
||||||
|
|
@ -54,7 +56,8 @@ export class TeamGraphAdapter {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Simple hash for change detection (avoids full deep equality)
|
// 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) {
|
if (hash === this.#lastDataHash && teamName === this.#lastTeamName) {
|
||||||
return this.#cachedResult;
|
return this.#cachedResult;
|
||||||
}
|
}
|
||||||
|
|
@ -63,6 +66,8 @@ export class TeamGraphAdapter {
|
||||||
if (teamName !== this.#lastTeamName) {
|
if (teamName !== this.#lastTeamName) {
|
||||||
this.#seenMessageIds.clear();
|
this.#seenMessageIds.clear();
|
||||||
this.#initialMessagesSeen = false;
|
this.#initialMessagesSeen = false;
|
||||||
|
this.#seenCommentCounts.clear();
|
||||||
|
this.#initialCommentsSeen = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.#lastTeamName = teamName;
|
this.#lastTeamName = teamName;
|
||||||
|
|
@ -80,6 +85,7 @@ export class TeamGraphAdapter {
|
||||||
this.#buildTaskNodes(nodes, edges, teamData, teamName);
|
this.#buildTaskNodes(nodes, edges, teamData, teamName);
|
||||||
this.#buildProcessNodes(nodes, edges, teamData, teamName);
|
this.#buildProcessNodes(nodes, edges, teamData, teamName);
|
||||||
this.#buildMessageParticles(particles, teamData.messages, teamName, leadId, edges);
|
this.#buildMessageParticles(particles, teamData.messages, teamName, leadId, edges);
|
||||||
|
this.#buildCommentParticles(particles, teamData, teamName, edges);
|
||||||
|
|
||||||
this.#cachedResult = {
|
this.#cachedResult = {
|
||||||
nodes,
|
nodes,
|
||||||
|
|
@ -100,6 +106,8 @@ export class TeamGraphAdapter {
|
||||||
this.#seenRelated.clear();
|
this.#seenRelated.clear();
|
||||||
this.#seenMessageIds.clear();
|
this.#seenMessageIds.clear();
|
||||||
this.#initialMessagesSeen = false;
|
this.#initialMessagesSeen = false;
|
||||||
|
this.#seenCommentCounts.clear();
|
||||||
|
this.#initialCommentsSeen = false;
|
||||||
this.#lastDataHash = '';
|
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 mappers ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
static #mapMemberStatus(status: string, spawnStatus?: string): GraphNodeState {
|
static #mapMemberStatus(status: string, spawnStatus?: string): GraphNodeState {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue