agent-ecosystem/packages/agent-graph/src/canvas/draw-edges.ts
iliya 6866f003dc 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
2026-03-28 12:27:37 +02:00

219 lines
6.5 KiB
TypeScript

/**
* Edge drawing with tapered bezier curves and gradients.
* Adapted from agent-flow's draw-edges.ts (Apache 2.0).
*/
import type { GraphNode, GraphEdge, GraphEdgeType } from '../ports/types';
import { COLORS } from '../constants/colors';
import { BEAM, MIN_VISIBLE_OPACITY } from '../constants/canvas-constants';
// ─── Edge Type → Color/Width Mapping ────────────────────────────────────────
const EDGE_STYLES: Record<GraphEdgeType, { color: string; startW: number; endW: number; dash?: number[] }> = {
'parent-child': { color: COLORS.edgeParentChild, ...BEAM.parentChild },
ownership: { color: COLORS.edgeOwnership, ...BEAM.ownership },
blocking: { color: COLORS.edgeBlocking, ...BEAM.blocking, dash: [8, 4] },
related: { color: COLORS.edgeRelated, ...BEAM.related, dash: [4, 4] },
message: { color: COLORS.edgeMessage, ...BEAM.message },
};
// ─── Bezier Utilities ───────────────────────────────────────────────────────
export interface ControlPoints {
cp1x: number;
cp1y: number;
cp2x: number;
cp2y: number;
}
export function computeControlPoints(
x1: number,
y1: number,
x2: number,
y2: number,
): ControlPoints {
const dx = x2 - x1;
const dy = y2 - y1;
const nx = -dy * BEAM.curvature;
const ny = dx * BEAM.curvature;
return {
cp1x: x1 + dx * BEAM.cp1 + nx,
cp1y: y1 + dy * BEAM.cp1 + ny,
cp2x: x1 + dx * BEAM.cp2 + nx,
cp2y: y1 + dy * BEAM.cp2 + ny,
};
}
/**
* Evaluate a cubic bezier at parameter t.
*/
export function bezierPoint(
x1: number,
y1: number,
cp: ControlPoints,
x2: number,
y2: number,
t: number,
): { x: number; y: number } {
const u = 1 - t;
const uu = u * u;
const uuu = uu * u;
const tt = t * t;
const ttt = tt * t;
return {
x: uuu * x1 + 3 * uu * t * cp.cp1x + 3 * u * tt * cp.cp2x + ttt * x2,
y: uuu * y1 + 3 * uu * t * cp.cp1y + 3 * u * tt * cp.cp2y + ttt * y2,
};
}
// ─── Draw All Edges ─────────────────────────────────────────────────────────
export function drawEdges(
ctx: CanvasRenderingContext2D,
edges: GraphEdge[],
nodeMap: Map<string, GraphNode>,
_time: number,
hasActiveParticles: Set<string>,
): void {
for (const edge of edges) {
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 style = EDGE_STYLES[edge.type] ?? EDGE_STYLES['parent-child'];
const isActive = hasActiveParticles.has(edge.id);
// 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;
const cp = computeControlPoints(source.x, source.y, target.x, target.y);
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,
source.x,
source.y,
cp,
target.x,
target.y,
style.startW,
style.endW,
edge.color ?? style.color,
style.dash,
);
// Arrow for blocking edges
if (edge.type === 'blocking') {
drawArrowHead(ctx, cp, target.x, target.y, style.color, alpha);
}
ctx.restore();
}
}
// ─── Tapered Bezier ─────────────────────────────────────────────────────────
function drawTaperedBezier(
ctx: CanvasRenderingContext2D,
x1: number,
y1: number,
cp: ControlPoints,
x2: number,
y2: number,
startW: number,
endW: number,
color: string,
dash?: number[],
): void {
if (dash) {
// Dashed edges use stroke, not fill polygon
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.bezierCurveTo(cp.cp1x, cp.cp1y, cp.cp2x, cp.cp2y, x2, y2);
ctx.strokeStyle = color;
ctx.lineWidth = (startW + endW) / 2;
ctx.setLineDash(dash);
ctx.stroke();
ctx.setLineDash([]);
return;
}
// Build polygon outline for tapered width
const segments = BEAM.segments;
const leftPoints: { x: number; y: number }[] = [];
const rightPoints: { x: number; y: number }[] = [];
for (let i = 0; i <= segments; i++) {
const t = i / segments;
const pos = bezierPoint(x1, y1, cp, x2, y2, t);
const w = startW + (endW - startW) * t;
// Normal perpendicular
const dt = 0.01;
const tNext = Math.min(1, t + dt);
const posNext = bezierPoint(x1, y1, cp, x2, y2, 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;
leftPoints.push({ x: pos.x + nx * w * 0.5, y: pos.y + ny * w * 0.5 });
rightPoints.push({ x: pos.x - nx * w * 0.5, y: pos.y - ny * w * 0.5 });
}
ctx.beginPath();
ctx.moveTo(leftPoints[0].x, leftPoints[0].y);
for (let i = 1; i < leftPoints.length; i++) {
ctx.lineTo(leftPoints[i].x, leftPoints[i].y);
}
for (let i = rightPoints.length - 1; i >= 0; i--) {
ctx.lineTo(rightPoints[i].x, rightPoints[i].y);
}
ctx.closePath();
ctx.fillStyle = color;
ctx.fill();
}
// ─── Arrow Head ─────────────────────────────────────────────────────────────
function drawArrowHead(
ctx: CanvasRenderingContext2D,
cp: ControlPoints,
x2: number,
y2: number,
color: string,
alpha: number,
): void {
// Compute direction at t=1
const dx = x2 - cp.cp2x;
const dy = y2 - cp.cp2y;
const len = Math.sqrt(dx * dx + dy * dy) || 1;
const ux = dx / len;
const uy = dy / len;
const arrowSize = 8;
ctx.save();
ctx.globalAlpha = alpha;
ctx.fillStyle = color;
ctx.beginPath();
ctx.moveTo(x2, y2);
ctx.lineTo(x2 - ux * arrowSize - uy * arrowSize * 0.5, y2 - uy * arrowSize + ux * arrowSize * 0.5);
ctx.lineTo(x2 - ux * arrowSize + uy * arrowSize * 0.5, y2 - uy * arrowSize - ux * arrowSize * 0.5);
ctx.closePath();
ctx.fill();
ctx.restore();
}