feat(agent-office): add overlay renderer with labels, badges, and speech bubbles

This commit is contained in:
2026-04-27 08:33:36 +09:00
parent f3de315272
commit 379ad41e32

View File

@@ -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;
const ts = tileSize * scale;
const cx = sprite.x * ts + offsetX + ts / 2;
const cy = sprite.y * ts + offsetY;
// 에이전트 이름 레이블
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();
constructor() {
this._bubbleAlpha = new Map(); // agentId → alpha (fade in/out)
}
// 알림 배지
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';
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;
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) {
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 ${Math.max(7, 7 * scale)}px monospace`;
ctx.font = `bold ${r}px sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(
sprite.notificationCount > 9 ? '9+' : String(sprite.notificationCount),
bx, by
);
ctx.restore();
}
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();
}
}