/** * Task pill-shaped node rendering. * NEW — not from agent-flow. Custom renderer for our task nodes. */ import type { GraphNode } from '../ports/types'; import { COLORS, getTaskStatusColor, getReviewStateColor } from '../constants/colors'; import { TASK_PILL, MIN_VISIBLE_OPACITY, ANIM } from '../constants/canvas-constants'; import { truncateText, wrapTextLines } from './draw-misc'; import { drawPillShell, drawPillStackLayer } from './draw-pill-shell'; import { hexWithAlpha } from './render-cache'; import type { KanbanZoneInfo } from '../layout/kanbanLayout'; const KANBAN_HEADER_FONT = '600 10px monospace'; const KANBAN_HEADER_ALPHA = 0.92; const KANBAN_HEADER_LETTER_SPACING = 2; /** * Draw all task nodes as pill-shaped cards. */ export function drawTasks( ctx: CanvasRenderingContext2D, nodes: GraphNode[], time: number, selectedId: string | null, hoveredId: string | null, focusNodeIds?: ReadonlySet | null, zoom = 1 ): void { const simplify = zoom < 0.2; for (const node of nodes) { if (node.kind !== 'task') continue; const opacity = getTaskOpacity(node, focusNodeIds); if (opacity < MIN_VISIBLE_OPACITY) continue; const x = node.x ?? 0; const y = node.y ?? 0; const isSelected = node.id === selectedId; const isHovered = node.id === hoveredId; ctx.save(); ctx.globalAlpha = opacity; if (simplify) { drawTaskPillLod(ctx, x, y, node, time, isSelected, isHovered); } else { drawTaskPill(ctx, x, y, node, time, isSelected, isHovered); } ctx.restore(); } } // ─── Private ──────────────────────────────────────────────────────────────── function getTaskOpacity(node: GraphNode, focusNodeIds?: ReadonlySet | null): number { if (node.taskStatus === 'deleted') return 0; if (focusNodeIds && !focusNodeIds.has(node.id)) return 0.25; return 1; } function drawTaskPill( ctx: CanvasRenderingContext2D, x: number, y: number, node: GraphNode, time: number, isSelected: boolean, isHovered: boolean ): void { const w = TASK_PILL.width; const h = TASK_PILL.height; const r = TASK_PILL.borderRadius; const halfW = w / 2; const halfH = h / 2; const statusColor = getTaskStatusColor(node.taskStatus); const reviewColor = getReviewStateColor(node.reviewState); ctx.save(); ctx.translate(x, y); if (node.isOverflowStack) { drawOverflowStack(ctx, halfW, halfH, r, node, isSelected, isHovered); ctx.restore(); return; } // Pulse only for active work — completed + approved = static const needsAttention = (node.taskStatus === 'in_progress' && node.reviewState !== 'approved') || node.reviewState === 'review' || node.reviewState === 'needsFix' || node.needsClarification != null; const isFinished = node.taskStatus === 'completed' || node.reviewState === 'approved'; const breathe = needsAttention && !isFinished ? 1 + ANIM.breathe.activeAmp * Math.sin(time * ANIM.breathe.activeSpeed) : 1; const scale = breathe; ctx.scale(scale, scale); // Shadow — stronger for attention tasks, red for blocked ctx.shadowColor = node.isBlocked ? hexWithAlpha(COLORS.edgeBlocking, 0.3) : hexWithAlpha(statusColor, 0.25); ctx.shadowBlur = needsAttention || node.isBlocked ? 12 : 4; // Background fill drawPillShell(ctx, { width: w, height: h, radius: r, fillStyle: isSelected ? COLORS.cardBgSelected : isHovered ? 'rgba(15, 20, 40, 0.7)' : COLORS.cardBg, borderColor: node.isBlocked ? hexWithAlpha(COLORS.edgeBlocking, isSelected ? 0.9 : 0.7) : hexWithAlpha(statusColor, isSelected ? 0.8 : 0.5), borderWidth: node.isBlocked ? (isSelected ? 2.5 : 1.8) : isSelected ? 2 : 1, shadowColor: node.isBlocked ? hexWithAlpha(COLORS.edgeBlocking, 0.3) : hexWithAlpha(statusColor, 0.25), shadowBlur: needsAttention || node.isBlocked ? 12 : 4, accentColor: node.isBlocked ? hexWithAlpha(COLORS.edgeBlocking, 0.6) : undefined, }); // Review state overlay border — pulsing for review/needsFix, STATIC for approved if (reviewColor !== 'transparent') { ctx.beginPath(); ctx.roundRect(-halfW - 1, -halfH - 1, w + 2, h + 2, r + 1); const reviewAlpha = node.reviewState === 'approved' ? 0.6 : 0.5 + 0.3 * Math.sin(time * 3); ctx.strokeStyle = hexWithAlpha(reviewColor, reviewAlpha); ctx.lineWidth = 1.5; ctx.stroke(); } // Clarification warning indicator if (node.needsClarification) { const pulseAlpha = 0.4 + 0.4 * Math.sin(time * 4); ctx.beginPath(); ctx.roundRect(-halfW - 2, -halfH - 2, w + 4, h + 4, r + 2); ctx.strokeStyle = hexWithAlpha(COLORS.error, pulseAlpha); ctx.lineWidth = 1; ctx.stroke(); } if (node.hasLiveTaskLogs) { drawLiveTaskLogIndicator(ctx, -halfW + 8, -halfH + 8, time); } // Subject (main title - up to two lines) let subjectLineCount = 0; if (node.sublabel) { ctx.font = `bold ${TASK_PILL.idFontSize}px sans-serif`; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; ctx.fillStyle = COLORS.textPrimary; const textX = -halfW + 10; const hasReviewChip = node.reviewState !== 'approved' && (node.reviewMode === 'manual' || (node.reviewMode === 'assigned' && !!node.reviewerName)); const maxW = hasReviewChip ? w - 88 : w - 24; const subjectLines = wrapTextLines(ctx, node.sublabel, maxW, ctx.font, 2); subjectLineCount = subjectLines.length; const titleStartY = subjectLines.length > 1 ? -16 : -12; const titleLineHeight = TASK_PILL.idFontSize + 1.5; subjectLines.forEach((line, index) => { ctx.fillText(line, textX, titleStartY + index * titleLineHeight); }); } // Display ID (secondary — small) const displayId = node.displayId ?? node.label; ctx.font = `${TASK_PILL.subjectFontSize}px monospace`; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; ctx.fillStyle = COLORS.textDim; ctx.fillText(displayId, -halfW + 10, subjectLineCount > 1 ? 23 : 12); // Approved badge: checkmark at right side if (node.reviewState === 'approved') { ctx.font = 'bold 13px sans-serif'; ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; ctx.fillStyle = COLORS.reviewApproved; ctx.fillText('\u2713', halfW - 8, 0); // ✓ } if ( node.reviewState !== 'approved' && (node.reviewMode === 'manual' || (node.reviewMode === 'assigned' && node.reviewerName)) ) { drawReviewChip(ctx, halfW, -halfH, node); } // Comment count badge — on the bottom-right border edge, 1.5x bigger if (node.totalCommentCount && node.totalCommentCount > 0) { const badgeX = halfW - 6; const badgeY = halfH; // Speech bubble background const bw = 20; const bh = 15; ctx.fillStyle = hexWithAlpha('#aaeeff', 0.85); ctx.beginPath(); ctx.roundRect(badgeX - bw / 2, badgeY - bh / 2, bw, bh, 3); ctx.fill(); // Tail pointing up-left ctx.beginPath(); ctx.moveTo(badgeX - 5, badgeY + bh / 2); ctx.lineTo(badgeX - 9, badgeY + bh / 2 + 5); ctx.lineTo(badgeX - 1, badgeY + bh / 2); ctx.closePath(); ctx.fill(); // Total count inside bubble ctx.font = 'bold 10px monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = '#0a0f1e'; ctx.fillText(String(node.totalCommentCount), badgeX, badgeY + 0.5); // Unread count badge (blue circle, top-right of bubble) if (node.unreadCommentCount && node.unreadCommentCount > 0) { const dotX = badgeX + bw / 2 + 1; const dotY = badgeY - bh / 2 - 1; ctx.fillStyle = '#3b82f6'; ctx.beginPath(); ctx.arc(dotX, dotY, 7, 0, Math.PI * 2); ctx.fill(); ctx.font = 'bold 8px monospace'; ctx.fillStyle = '#ffffff'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(String(node.unreadCommentCount), dotX, dotY + 0.5); } } ctx.restore(); } function drawTaskPillLod( ctx: CanvasRenderingContext2D, x: number, y: number, node: GraphNode, time: number, isSelected: boolean, isHovered: boolean ): void { const w = TASK_PILL.width; const h = TASK_PILL.height; const r = TASK_PILL.borderRadius; const halfW = w / 2; const halfH = h / 2; const statusColor = getTaskStatusColor(node.taskStatus); ctx.save(); ctx.translate(x, y); if (node.isOverflowStack) { drawOverflowStack(ctx, halfW, halfH, r, node, isSelected, isHovered); ctx.restore(); return; } ctx.beginPath(); ctx.roundRect(-halfW, -halfH, w, h, r); ctx.fillStyle = isSelected ? COLORS.cardBgSelected : isHovered ? 'rgba(15, 20, 40, 0.78)' : COLORS.cardBg; ctx.fill(); ctx.strokeStyle = node.isBlocked ? hexWithAlpha(COLORS.edgeBlocking, isSelected ? 0.85 : 0.65) : hexWithAlpha(statusColor, isSelected ? 0.8 : 0.55); ctx.lineWidth = node.isBlocked ? (isSelected ? 2.2 : 1.5) : isSelected ? 2 : 1; ctx.stroke(); if (node.isBlocked) { ctx.fillStyle = hexWithAlpha(COLORS.edgeBlocking, 0.6); ctx.beginPath(); ctx.roundRect(-halfW, -halfH, 4, h, [r, 0, 0, r]); ctx.fill(); } if (node.hasLiveTaskLogs) { drawLiveTaskLogIndicator(ctx, -halfW + 8, -halfH + 8, time, true); } ctx.restore(); } function drawLiveTaskLogIndicator( ctx: CanvasRenderingContext2D, x: number, y: number, time: number, compact = false ): void { const coreRadius = compact ? 2.5 : 3.4; const glowRadius = compact ? 7 : 10; const pulse = 0.55 + 0.25 * Math.sin(time * 6); const color = COLORS.reviewApproved; const glow = ctx.createRadialGradient(x, y, 0, x, y, glowRadius); glow.addColorStop(0, hexWithAlpha(color, 0.35 + pulse * 0.28)); glow.addColorStop(1, hexWithAlpha(color, 0)); ctx.save(); ctx.fillStyle = glow; ctx.beginPath(); ctx.arc(x, y, glowRadius, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = hexWithAlpha(color, 0.95); ctx.beginPath(); ctx.arc(x, y, coreRadius, 0, Math.PI * 2); ctx.fill(); ctx.strokeStyle = hexWithAlpha(color, pulse); ctx.lineWidth = compact ? 0.8 : 1; ctx.beginPath(); ctx.arc(x, y, coreRadius + (compact ? 1.2 : 1.8), 0, Math.PI * 2); ctx.stroke(); ctx.restore(); } function drawOverflowStack( ctx: CanvasRenderingContext2D, halfW: number, halfH: number, r: number, node: GraphNode, isSelected: boolean, isHovered: boolean ): void { for (const [offset, alpha] of [ [6, 0.18], [3, 0.28], ] as const) { drawPillStackLayer(ctx, { width: TASK_PILL.width, height: TASK_PILL.height, radius: r, offsetX: offset, offsetY: -offset, fillColor: '#334155', fillAlpha: alpha, }); } drawPillShell(ctx, { width: TASK_PILL.width, height: TASK_PILL.height, radius: r, fillStyle: isSelected ? COLORS.cardBgSelected : isHovered ? 'rgba(15, 20, 40, 0.78)' : COLORS.cardBg, borderColor: node.isBlocked ? hexWithAlpha(COLORS.edgeBlocking, isSelected ? 0.85 : 0.65) : hexWithAlpha(COLORS.taskPending, isSelected ? 0.85 : 0.55), borderWidth: node.isBlocked ? (isSelected ? 2.4 : 1.5) : isSelected ? 2 : 1, accentColor: node.isBlocked ? hexWithAlpha(COLORS.edgeBlocking, 0.6) : undefined, }); ctx.font = 'bold 12px sans-serif'; ctx.textAlign = 'left'; ctx.textBaseline = 'middle'; ctx.fillStyle = COLORS.textPrimary; ctx.fillText(node.label, -halfW + 14, -8); ctx.font = '10px monospace'; ctx.fillStyle = COLORS.textDim; ctx.fillText('more tasks', -halfW + 14, 12); } function drawReviewChip( ctx: CanvasRenderingContext2D, halfW: number, halfH: number, node: GraphNode ): void { const chipText = node.reviewMode === 'manual' ? 'REV' : (node.reviewerName ?? 'REV'); const chipColor = node.reviewMode === 'manual' ? '#8b5cf6' : (node.reviewerColor ?? '#38bdf8'); const chipX = halfW - 44; const chipY = halfH + 10; const chipW = 34; const chipH = 12; ctx.beginPath(); ctx.roundRect(chipX, chipY, chipW, chipH, 6); ctx.fillStyle = hexWithAlpha(chipColor, 0.2); ctx.fill(); ctx.strokeStyle = hexWithAlpha(chipColor, 0.55); ctx.lineWidth = 1; ctx.stroke(); ctx.font = 'bold 7px monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = hexWithAlpha(chipColor, 0.95); ctx.fillText( chipText.length > 8 ? `${chipText.slice(0, 7)}…` : chipText, chipX + chipW / 2, chipY + chipH / 2 + 0.5 ); if (node.changePresence === 'has_changes') { ctx.beginPath(); ctx.arc(chipX + chipW + 4, chipY + chipH / 2, 2.5, 0, Math.PI * 2); ctx.fillStyle = '#38bdf8'; ctx.fill(); } } function measureSpacedText( ctx: CanvasRenderingContext2D, text: string, letterSpacing: number ): number { const chars = Array.from(text); const glyphWidth = chars.reduce((width, char) => width + ctx.measureText(char).width, 0); return glyphWidth + Math.max(0, chars.length - 1) * letterSpacing; } function drawCenteredSpacedText( ctx: CanvasRenderingContext2D, text: string, x: number, y: number, letterSpacing: number ): void { const chars = Array.from(text); const previousAlign = ctx.textAlign; ctx.textAlign = 'left'; let cursorX = x - measureSpacedText(ctx, text, letterSpacing) / 2; for (const char of chars) { ctx.fillText(char, cursorX, y); cursorX += ctx.measureText(char).width + letterSpacing; } ctx.textAlign = previousAlign; } /** * Draw kanban column headers above task columns. */ export function drawColumnHeaders( ctx: CanvasRenderingContext2D, zones: KanbanZoneInfo[], zoom = 1 ): void { if (zoom < 0.22) return; for (const zone of zones) { // Section header for unassigned tasks — larger, centered above all columns if (zone.ownerId === '__unassigned__') { ctx.font = KANBAN_HEADER_FONT; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = hexWithAlpha(COLORS.taskPending, KANBAN_HEADER_ALPHA); const labelY = (zone.headers[0]?.y ?? zone.ownerY + 60) + 10; drawCenteredSpacedText(ctx, 'Unassigned', zone.ownerX, labelY, KANBAN_HEADER_LETTER_SPACING); // Overflow badge for (const header of zone.headers) { if (header.overflowCount > 0) { ctx.font = '7px monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; ctx.fillStyle = hexWithAlpha(header.color, 0.45); ctx.fillText(`+${header.overflowCount} more`, header.x, header.overflowY + 4); } } continue; } for (const header of zone.headers) { ctx.font = KANBAN_HEADER_FONT; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = hexWithAlpha(header.color, KANBAN_HEADER_ALPHA); drawCenteredSpacedText( ctx, header.label, header.x, header.y + 10, KANBAN_HEADER_LETTER_SPACING ); // Overflow badge: "+N more" if (header.overflowCount > 0) { const badgeText = `+${header.overflowCount} more`; ctx.font = '10px monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; ctx.fillStyle = hexWithAlpha(header.color, 0.45); ctx.fillText(badgeText, header.x, header.overflowY + 4); } } } }