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; 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(); 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'; } }