271 lines
7.8 KiB
TypeScript
271 lines
7.8 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>,
|
|
focusEdgeIds?: ReadonlySet<string> | null,
|
|
hoveredEdgeId?: string | null,
|
|
selectedEdgeId?: string | null,
|
|
zoom = 1
|
|
): void {
|
|
const simplify = zoom < 0.18;
|
|
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);
|
|
const isSelected = selectedEdgeId === edge.id;
|
|
const isHovered = !isSelected && hoveredEdgeId === 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;
|
|
const focusAlpha = focusEdgeIds && !focusEdgeIds.has(edge.id) ? 0.1 : 1;
|
|
const interactionAlpha = isSelected ? 0.95 : isHovered ? 0.6 : 0;
|
|
const finalAlpha = Math.max(alpha * focusAlpha, interactionAlpha);
|
|
|
|
if (finalAlpha < MIN_VISIBLE_OPACITY) continue;
|
|
|
|
const cp = computeControlPoints(source.x, source.y, target.x, target.y);
|
|
|
|
ctx.save();
|
|
ctx.globalAlpha = finalAlpha;
|
|
|
|
// Subtle glow pass when edge has active particles
|
|
if (!simplify && (isActive || isSelected || isHovered)) {
|
|
ctx.shadowColor = edge.color ?? style.color;
|
|
ctx.shadowBlur = isSelected ? 16 : isHovered ? 10 : 12;
|
|
}
|
|
|
|
if (simplify) {
|
|
drawSimplifiedBezier(
|
|
ctx,
|
|
source.x,
|
|
source.y,
|
|
cp,
|
|
target.x,
|
|
target.y,
|
|
(style.startW + style.endW) * 0.5 * (isSelected ? 1.35 : isHovered ? 1.15 : 1),
|
|
edge.color ?? style.color,
|
|
style.dash
|
|
);
|
|
} else {
|
|
// 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 (!simplify && edge.type === 'blocking') {
|
|
drawArrowHead(ctx, cp, target.x, target.y, style.color, finalAlpha);
|
|
}
|
|
|
|
ctx.restore();
|
|
}
|
|
}
|
|
|
|
function drawSimplifiedBezier(
|
|
ctx: CanvasRenderingContext2D,
|
|
x1: number,
|
|
y1: number,
|
|
cp: ControlPoints,
|
|
x2: number,
|
|
y2: number,
|
|
width: number,
|
|
color: string,
|
|
dash?: number[]
|
|
): void {
|
|
ctx.beginPath();
|
|
ctx.moveTo(x1, y1);
|
|
ctx.bezierCurveTo(cp.cp1x, cp.cp1y, cp.cp2x, cp.cp2y, x2, y2);
|
|
ctx.strokeStyle = color;
|
|
ctx.lineWidth = width;
|
|
if (dash) ctx.setLineDash(dash);
|
|
ctx.stroke();
|
|
if (dash) ctx.setLineDash([]);
|
|
}
|
|
|
|
// ─── 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();
|
|
}
|