From 379ad41e32e6216158282d4d2559ccecf6d689c1 Mon Sep 17 00:00:00 2001 From: gahusb Date: Mon, 27 Apr 2026 08:33:36 +0900 Subject: [PATCH] feat(agent-office): add overlay renderer with labels, badges, and speech bubbles --- .../agent-office/canvas/OverlayRenderer.js | 155 ++++++++++++------ 1 file changed, 109 insertions(+), 46 deletions(-) diff --git a/src/pages/agent-office/canvas/OverlayRenderer.js b/src/pages/agent-office/canvas/OverlayRenderer.js index 58d9a0d..dc92675 100644 --- a/src/pages/agent-office/canvas/OverlayRenderer.js +++ b/src/pages/agent-office/canvas/OverlayRenderer.js @@ -1,59 +1,122 @@ // src/pages/agent-office/canvas/OverlayRenderer.js -// Phase 3 stub — full implementation in Phase 3 /** - * 오버레이 렌더러 (Phase 3에서 구현) - * 에이전트 위의 상태 아이콘, 이름 레이블, 알림 배지, 말풍선 등 + * 캔버스 위 오버레이 렌더링: + * - 이름 라벨 (항상) + * - 상태 배지 (항상) + * - 말풍선 (waiting 상태에서만) + * - 알림 배지 (notification > 0 일 때) */ + +const STATE_BADGE = { + idle: { text: 'idle', bg: '#374151', fg: '#9ca3af' }, + working: { text: 'working', bg: '#1e3a5f', fg: '#60a5fa' }, + waiting: { text: 'waiting', bg: '#92400e', fg: '#fbbf24' }, + reporting: { text: 'reporting', bg: '#1e3a5f', fg: '#60a5fa' }, + break: { text: 'break', bg: '#065f46', fg: '#34d399' } +}; + export class OverlayRenderer { - /** - * 에이전트 오버레이 렌더링 - * @param {CanvasRenderingContext2D} ctx - * @param {object} sprite - AgentSprite 인스턴스 - * @param {object} theme - 테마 객체 - * @param {number} scale - 줌 레벨 - * @param {number} offsetX - 패닝 X - * @param {number} offsetY - 패닝 Y - * @param {number} tileSize - 타일 크기 - */ - draw(ctx, sprite, theme, scale, offsetX, offsetY, tileSize) { - // Phase 3에서 구현 예정 - // 현재는 기본 레이블만 표시 - if (!sprite || sprite.x == null) return; + constructor() { + this._bubbleAlpha = new Map(); // agentId → alpha (fade in/out) + } - const ts = tileSize * scale; - const cx = sprite.x * ts + offsetX + ts / 2; - const cy = sprite.y * ts + offsetY; + draw(ctx, sprite, theme, zoom, panX, panY, tileSize) { + const ts = tileSize * zoom; + const centerX = sprite.x * ts + panX + ts / 2; + const topY = sprite.y * ts + panY - ts * 0.3; - // 에이전트 이름 레이블 - if (sprite.meta && sprite.meta.displayName) { - ctx.save(); - ctx.font = `${Math.max(8, 9 * scale)}px monospace`; - ctx.textAlign = 'center'; - ctx.fillStyle = theme.text.secondary; - ctx.fillText(sprite.meta.displayName, cx, cy - 4 * scale); - ctx.restore(); + const fontSize = Math.max(10, 11 * zoom / 2); + const smallFontSize = Math.max(8, 9 * zoom / 2); + + // 1. 이름 라벨 + ctx.font = `bold ${fontSize}px 'Courier New', monospace`; + ctx.textAlign = 'center'; + ctx.fillStyle = sprite.meta.color; + ctx.fillText(sprite.meta.displayName, centerX, topY + ts * 1.85); + + // 2. 상태 배지 + const badge = STATE_BADGE[sprite.state] || STATE_BADGE.idle; + const badgeText = badge.text; + ctx.font = `${smallFontSize}px 'Courier New', monospace`; + const badgeW = ctx.measureText(badgeText).width + 8; + const badgeH = smallFontSize + 4; + const badgeX = centerX - badgeW / 2; + const badgeY = topY + ts * 1.9; + + ctx.fillStyle = badge.bg; + this._roundRect(ctx, badgeX, badgeY, badgeW, badgeH, 3); + ctx.fill(); + ctx.fillStyle = badge.fg; + ctx.textAlign = 'center'; + ctx.fillText(badgeText, centerX, badgeY + badgeH - 3); + + // 3. 말풍선 (waiting 상태에서만) + if (sprite.state === 'waiting') { + this._drawBubble(ctx, sprite, centerX, topY - ts * 0.2, zoom); } - // 알림 배지 + // 4. 알림 배지 if (sprite.notificationCount > 0) { - const bx = cx + ts * 0.3; - const by = cy - ts * 0.5; - const r = Math.max(5, 6 * scale); - ctx.save(); - ctx.beginPath(); - ctx.arc(bx, by, r, 0, Math.PI * 2); - ctx.fillStyle = '#ef4444'; - ctx.fill(); - ctx.fillStyle = '#ffffff'; - ctx.font = `bold ${Math.max(7, 7 * scale)}px monospace`; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.fillText( - sprite.notificationCount > 9 ? '9+' : String(sprite.notificationCount), - bx, by - ); - ctx.restore(); + this._drawNotificationBadge(ctx, centerX + ts * 0.5, topY + ts * 0.2, sprite.notificationCount, zoom); } } + + _drawBubble(ctx, sprite, x, y, zoom) { + const text = '승인 대기!'; + const fontSize = Math.max(10, 11 * zoom / 2); + ctx.font = `bold ${fontSize}px 'Courier New', monospace`; + const tw = ctx.measureText(text).width; + const pw = tw + 16; + const ph = fontSize + 12; + const px = x - pw / 2; + const py = y - ph; + + // 말풍선 배경 + ctx.fillStyle = '#fbbf24'; + this._roundRect(ctx, px, py, pw, ph, 6); + ctx.fill(); + + // 꼬리 삼각형 + ctx.beginPath(); + ctx.moveTo(x - 5, py + ph); + ctx.lineTo(x + 5, py + ph); + ctx.lineTo(x, py + ph + 6); + ctx.closePath(); + ctx.fill(); + + // 텍스트 + ctx.fillStyle = '#000000'; + ctx.textAlign = 'center'; + ctx.fillText(text, x, py + ph - 5); + } + + _drawNotificationBadge(ctx, x, y, count, zoom) { + const r = Math.max(7, 8 * zoom / 2); + ctx.fillStyle = '#ef4444'; + ctx.beginPath(); + ctx.arc(x, y, r, 0, Math.PI * 2); + ctx.fill(); + + ctx.fillStyle = '#ffffff'; + ctx.font = `bold ${r}px sans-serif`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(count > 9 ? '9+' : String(count), x, y); + ctx.textBaseline = 'alphabetic'; + } + + _roundRect(ctx, x, y, w, h, r) { + ctx.beginPath(); + ctx.moveTo(x + r, y); + ctx.lineTo(x + w - r, y); + ctx.quadraticCurveTo(x + w, y, x + w, y + r); + ctx.lineTo(x + w, y + h - r); + ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h); + ctx.lineTo(x + r, y + h); + ctx.quadraticCurveTo(x, y + h, x, y + h - r); + ctx.lineTo(x, y + r); + ctx.quadraticCurveTo(x, y, x + r, y); + ctx.closePath(); + } }