agent-ecosystem/packages/agent-graph/src/canvas/draw-handoff-cards.ts

260 lines
7.7 KiB
TypeScript

import { COLORS } from '../constants/colors';
import { HANDOFF_CARD, NODE, TASK_PILL, MIN_VISIBLE_OPACITY } from '../constants/canvas-constants';
import type { CameraTransform } from '../hooks/useGraphCamera';
import { getHandoffAnchorTarget } from '../layout/launchAnchor';
import type { GraphNode } from '../ports/types';
import {
getTransientHandoffCardAlpha,
type TransientHandoffCard,
} from '../ui/transientHandoffs';
import { truncateText } from './draw-misc';
import { hexWithAlpha, measureTextCached } from './render-cache';
export function drawHandoffCards(
ctx: CanvasRenderingContext2D,
params: {
cards: TransientHandoffCard[];
nodeMap: Map<string, GraphNode>;
time: number;
camera: CameraTransform;
viewport: { width: number; height: number };
}
): void {
const { cards, nodeMap, time, camera, viewport } = params;
if (cards.length === 0) return;
const stackIndexByAnchor = new Map<string, number>();
let drawnCount = 0;
for (const card of cards) {
if (drawnCount >= HANDOFF_CARD.maxVisible) break;
const anchorNode = nodeMap.get(card.anchorNodeId);
if (!anchorNode || anchorNode.x == null || anchorNode.y == null) continue;
const alpha = getTransientHandoffCardAlpha(card, time);
if (alpha <= MIN_VISIBLE_OPACITY) continue;
const previewLines = buildPreviewLines(ctx, card.preview);
const height = HANDOFF_CARD.baseHeight + previewLines.length * HANDOFF_CARD.previewLineHeight;
const stackIndex = stackIndexByAnchor.get(card.anchorNodeId) ?? 0;
stackIndexByAnchor.set(card.anchorNodeId, stackIndex + 1);
const position = getCardPosition({
node: anchorNode,
camera,
viewport,
height,
stackIndex,
});
if (!position) continue;
drawCard({
ctx,
card,
previewLines,
alpha,
x: position.x,
y: position.y,
width: HANDOFF_CARD.width,
height,
});
drawnCount += 1;
}
}
function getCardPosition(params: {
node: GraphNode;
camera: CameraTransform;
viewport: { width: number; height: number };
height: number;
stackIndex: number;
}): { x: number; y: number } | null {
const { node, camera, viewport, height, stackIndex } = params;
const anchor = getCardAnchorPosition(node, camera.zoom);
const screenX = anchor.x * camera.zoom + camera.x;
const screenY = anchor.y * camera.zoom + camera.y;
const visibleMargin = 80;
if (
screenX < -visibleMargin ||
screenX > viewport.width + visibleMargin ||
screenY < -visibleMargin ||
screenY > viewport.height + visibleMargin
) {
return null;
}
const stackOffset = stackIndex * (height + HANDOFF_CARD.stackGap);
let x = screenX - HANDOFF_CARD.width / 2;
let y = screenY - height / 2 - stackOffset;
if (x + HANDOFF_CARD.width > viewport.width - HANDOFF_CARD.viewportPadding) {
x = viewport.width - HANDOFF_CARD.width - HANDOFF_CARD.viewportPadding;
}
if (x < HANDOFF_CARD.viewportPadding) {
x = HANDOFF_CARD.viewportPadding;
}
if (y < HANDOFF_CARD.viewportPadding) {
y = HANDOFF_CARD.viewportPadding;
}
if (y + height > viewport.height - HANDOFF_CARD.viewportPadding) {
y = Math.max(HANDOFF_CARD.viewportPadding, viewport.height - height - HANDOFF_CARD.viewportPadding);
}
return { x, y };
}
function getCardAnchorPosition(node: GraphNode, zoom: number): { x: number; y: number } {
switch (node.kind) {
case 'lead':
case 'member':
return getHandoffAnchorTarget({
nodeX: node.x ?? 0,
nodeY: node.y ?? 0,
nodeKind: node.kind,
});
case 'task':
return {
x: (node.x ?? 0) + TASK_PILL.width * 0.5 + HANDOFF_CARD.anchorGap / zoom,
y: (node.y ?? 0) - (TASK_PILL.height * 0.5 + HANDOFF_CARD.anchorGap / zoom),
};
case 'process':
return {
x: (node.x ?? 0) + NODE.radiusProcess + HANDOFF_CARD.anchorGap / zoom,
y: (node.y ?? 0) - (NODE.radiusProcess + HANDOFF_CARD.anchorGap / zoom),
};
case 'crossteam':
return {
x: (node.x ?? 0) + NODE.radiusCrossTeam + HANDOFF_CARD.anchorGap / zoom,
y: (node.y ?? 0) - (NODE.radiusCrossTeam + HANDOFF_CARD.anchorGap / zoom),
};
}
}
function drawCard(params: {
ctx: CanvasRenderingContext2D;
card: TransientHandoffCard;
previewLines: string[];
alpha: number;
x: number;
y: number;
width: number;
height: number;
}): void {
const { ctx, card, previewLines, alpha, x, y, width, height } = params;
const accent = card.color || COLORS.particleInboxMessage;
const radius = 10;
ctx.save();
ctx.globalAlpha = alpha;
ctx.shadowColor = hexWithAlpha(accent, 0.22 * alpha);
ctx.shadowBlur = 12;
ctx.fillStyle = hexWithAlpha('#08111f', 0.92);
ctx.strokeStyle = hexWithAlpha(accent, 0.38);
ctx.lineWidth = 1;
ctx.beginPath();
ctx.roundRect(x, y, width, height, radius);
ctx.fill();
ctx.stroke();
ctx.shadowBlur = 0;
ctx.fillStyle = hexWithAlpha(accent, 0.14);
ctx.beginPath();
ctx.roundRect(x + 8, y + 8, 54, 16, 6);
ctx.fill();
ctx.fillStyle = hexWithAlpha(accent, 0.92);
ctx.font = 'bold 8px monospace';
ctx.textAlign = 'left';
ctx.fillText(getKindLabel(card.kind), x + 16, y + 19);
if (card.count > 1) {
const countText = `+${card.count - 1}`;
ctx.font = 'bold 8px monospace';
const countWidth = measureTextCached(ctx, ctx.font, countText) + 14;
ctx.fillStyle = hexWithAlpha(COLORS.holoBright, 0.16);
ctx.beginPath();
ctx.roundRect(x + width - countWidth - 10, y + 8, countWidth, 16, 6);
ctx.fill();
ctx.fillStyle = COLORS.holoBright;
ctx.textAlign = 'center';
ctx.fillText(countText, x + width - countWidth / 2 - 10, y + 19);
}
ctx.textAlign = 'left';
ctx.font = 'bold 10px monospace';
ctx.fillStyle = COLORS.textPrimary;
const route = truncateText(
ctx,
`${card.sourceLabel} -> ${card.destinationLabel}`,
width - 20,
ctx.font
);
ctx.fillText(route, x + 10, y + 36);
if (previewLines.length > 0) {
ctx.font = '8px monospace';
ctx.fillStyle = hexWithAlpha(COLORS.holoBright, 0.86);
for (let index = 0; index < previewLines.length; index += 1) {
ctx.fillText(
previewLines[index],
x + 10,
y + 50 + index * HANDOFF_CARD.previewLineHeight
);
}
}
ctx.restore();
}
function buildPreviewLines(ctx: CanvasRenderingContext2D, preview: string | undefined): string[] {
if (!preview) return [];
ctx.font = '8px monospace';
let remaining = preview.replace(/\s+/g, ' ').trim();
if (remaining.length === 0) return [];
const lines: string[] = [];
for (let index = 0; index < HANDOFF_CARD.previewMaxLines && remaining.length > 0; index += 1) {
if (index === HANDOFF_CARD.previewMaxLines - 1) {
lines.push(truncateText(ctx, remaining, HANDOFF_CARD.previewMaxWidth, ctx.font));
break;
}
const words = remaining.split(' ');
let line = '';
let consumedWords = 0;
for (const word of words) {
const candidate = line.length > 0 ? `${line} ${word}` : word;
if (measureTextCached(ctx, ctx.font, candidate) <= HANDOFF_CARD.previewMaxWidth) {
line = candidate;
consumedWords += 1;
continue;
}
break;
}
if (consumedWords === 0) {
lines.push(truncateText(ctx, words[0] ?? remaining, HANDOFF_CARD.previewMaxWidth, ctx.font));
break;
}
lines.push(line);
remaining = words.slice(consumedWords).join(' ').trim();
}
return lines;
}
function getKindLabel(kind: TransientHandoffCard['kind']): string {
switch (kind) {
case 'task_comment':
return 'COMMENT';
case 'task_assign':
return 'TASK';
case 'review_request':
return 'REVIEW';
case 'review_response':
return 'REPLY';
case 'inbox_message':
return 'MESSAGE';
}
}