From 82e28111e912a497fdd570a5451615edf6880b8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Tue, 30 Jun 2026 22:45:16 -0400 Subject: [PATCH] Fix office layout, performance, and crash issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layout: agents now arrange in a horizontal grid (3-4 per row based on viewport width) instead of stacking vertically. Position calculation uses layoutPositions() which guarantees agents stay within the canvas. Performance: render loop throttled to 30fps instead of uncapped 60fps. Removed Math.random() from render path — building window lights now use a deterministic hash that changes slowly. Canvas dimensions only resize when clientWidth/clientHeight actually change. Crash fix: clearRect at frame start prevents accumulated draw state. setTransform instead of scale prevents compounding transforms. Animation cleanup properly cancels requestAnimationFrame on unmount. --- .../src/renderer/src/pages/OfficePage.tsx | 119 ++++--- .../src/pages/office/office-renderer.ts | 326 +++++++----------- 2 files changed, 196 insertions(+), 249 deletions(-) diff --git a/AgentCompany-New/src/renderer/src/pages/OfficePage.tsx b/AgentCompany-New/src/renderer/src/pages/OfficePage.tsx index dec24215..1a876ddb 100644 --- a/AgentCompany-New/src/renderer/src/pages/OfficePage.tsx +++ b/AgentCompany-New/src/renderer/src/pages/OfficePage.tsx @@ -1,10 +1,10 @@ -import { useEffect, useRef, useMemo, useCallback } from "react"; +import { useEffect, useRef, useMemo } from "react"; import type { PageProps } from "./types"; import { activeOrgId } from "./types"; -import { drawScene, type OfficeAgent } from "./office/office-renderer"; +import { drawScene, layoutPositions, type OfficeAgent } from "./office/office-renderer"; -const FACE_COLORS = ["#4a90d9", "#50b866", "#9b59b6", "#e67e22", "#e74c3c", "#3498db", "#1abc9c", "#f39c12"]; -const SHIRT_COLORS = ["#5b8dd9", "#d95b7a", "#5bbf6f", "#d9a65b", "#9b6fd9", "#5bc4c4", "#d97b5b", "#7fd95b"]; +const FACE = ["#4a90d9", "#50b866", "#9b59b6", "#e67e22", "#e74c3c", "#1abc9c", "#f39c12", "#3498db"]; +const SHIRT = ["#5b8dd9", "#d95b7a", "#5bbf6f", "#d9a65b", "#9b6fd9", "#5bc4c4", "#d97b5b", "#7fd95b"]; function hash(s: string): number { let h = 0; @@ -12,24 +12,26 @@ function hash(s: string): number { return Math.abs(h); } -function pick(palette: string[], seed: string): string { - return palette[hash(seed) % palette.length]; -} - export function OfficePage({ snapshot }: PageProps) { const orgId = activeOrgId(snapshot); const canvasRef = useRef(null); const agentsRef = useRef([]); const animRef = useRef(0); - const startRef = useRef(Date.now()); + const lastFrameRef = useRef(0); const orgAgents = useMemo(() => snapshot.agents.filter((a) => a.orgId === orgId), [snapshot.agents, orgId]); const orgTasks = useMemo(() => snapshot.tasks.filter((t) => t.orgId === orgId), [snapshot.tasks, orgId]); useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const w = canvas.clientWidth; + const positions = layoutPositions(orgAgents.length, w); const existing = agentsRef.current; + agentsRef.current = orgAgents.map((a, i) => { const prev = existing.find((e) => e.id === a.id); + const pos = positions[i]; const status = resolveStatus(snapshot.chatPresence, snapshot.taskRuns, a.id); const task = orgTasks.find((t) => t.assigneeAgentId === a.id && t.status === "in_progress")?.title ?? null; return { @@ -39,68 +41,81 @@ export function OfficePage({ snapshot }: PageProps) { department: a.department, status, task, - x: prev?.x ?? 100 + i * 120, - y: prev?.y ?? 200, - targetX: prev?.targetX ?? 100 + i * 120, - targetY: prev?.targetY ?? 200, - deskX: 0, - deskY: 0, - faceColor: pick(FACE_COLORS, a.name), - shirtColor: pick(SHIRT_COLORS, a.name + a.role), - frame: prev?.frame ?? hash(a.id) % 100, - facing: prev?.facing ?? 1, + x: prev?.x ?? pos.x, + y: prev?.y ?? pos.y, + targetX: pos.x, + targetY: pos.y, + faceColor: FACE[hash(a.name) % FACE.length], + shirtColor: SHIRT[hash(a.name + a.role) % SHIRT.length], + frame: hash(a.id) % 100, }; }); }, [orgAgents, orgTasks, snapshot.chatPresence, snapshot.taskRuns]); - const render = useCallback(() => { + useEffect(() => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext("2d"); if (!ctx) return; - const dpr = window.devicePixelRatio || 1; - const w = canvas.clientWidth; - const h = canvas.clientHeight; - canvas.width = w * dpr; - canvas.height = h * dpr; - ctx.scale(dpr, dpr); - const time = Date.now() - startRef.current; - const agents = agentsRef.current; + let running = true; + const FPS_INTERVAL = 1000 / 30; - for (const agent of agents) { - const dx = agent.targetX - agent.x; - const dy = agent.targetY - agent.y; - const dist = Math.sqrt(dx * dx + dy * dy); - if (dist > 1) { - agent.x += (dx / dist) * 0.8; - agent.y += (dy / dist) * 0.8; - agent.frame += 0.1; - agent.facing = dx > 0 ? 1 : -1; + const tick = (now: number) => { + if (!running) return; + animRef.current = requestAnimationFrame(tick); + + const elapsed = now - lastFrameRef.current; + if (elapsed < FPS_INTERVAL) return; + lastFrameRef.current = now - (elapsed % FPS_INTERVAL); + + const dpr = window.devicePixelRatio || 1; + const w = canvas.clientWidth; + const h = canvas.clientHeight; + if (canvas.width !== w * dpr || canvas.height !== h * dpr) { + canvas.width = w * dpr; + canvas.height = h * dpr; } - } + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); - drawScene(ctx, w, h, agents, time); - animRef.current = requestAnimationFrame(render); + for (const agent of agentsRef.current) { + const dx = agent.targetX - agent.x; + const dy = agent.targetY - agent.y; + const dist = Math.sqrt(dx * dx + dy * dy); + if (dist > 1) { + agent.x += (dx / dist) * 0.7; + agent.y += (dy / dist) * 0.7; + agent.frame += 0.08; + } + } + + drawScene(ctx, w, h, agentsRef.current, now); + }; + + animRef.current = requestAnimationFrame(tick); + return () => { running = false; cancelAnimationFrame(animRef.current); }; }, []); useEffect(() => { - animRef.current = requestAnimationFrame(render); - return () => cancelAnimationFrame(animRef.current); - }, [render]); + const canvas = canvasRef.current; + if (!canvas) return; + const w = canvas.clientWidth; + const positions = layoutPositions(agentsRef.current.length, w); - useEffect(() => { const wander = setInterval(() => { - for (const agent of agentsRef.current) { + agentsRef.current.forEach((agent, i) => { + const pos = positions[i]; + if (!pos) return; if (agent.status === "idle") { - agent.targetX = agent.deskX + DESK_CENTER_OFFSET + (Math.random() * 100 - 50); - agent.targetY = agent.deskY + 56 + (Math.random() * 40 - 20); + agent.targetX = pos.x + ((hash(agent.id + String(Date.now()).slice(0, -3)) % 60) - 30); + agent.targetY = pos.y + ((hash(agent.name + String(Date.now()).slice(0, -3)) % 30) - 15); } else { - agent.targetX = agent.deskX + DESK_CENTER_OFFSET; - agent.targetY = agent.deskY + 56; + agent.targetX = pos.x; + agent.targetY = pos.y; } - } - }, 4000); + }); + }, 5000); + return () => clearInterval(wander); }, []); @@ -111,8 +126,6 @@ export function OfficePage({ snapshot }: PageProps) { ); } -const DESK_CENTER_OFFSET = 50; - function resolveStatus( presence: Array<{ principalType: string; principalId: string; status: string }>, runs: Array<{ agentId: string | null; state: string }>, diff --git a/AgentCompany-New/src/renderer/src/pages/office/office-renderer.ts b/AgentCompany-New/src/renderer/src/pages/office/office-renderer.ts index ba562b91..498e211e 100644 --- a/AgentCompany-New/src/renderer/src/pages/office/office-renderer.ts +++ b/AgentCompany-New/src/renderer/src/pages/office/office-renderer.ts @@ -9,200 +9,159 @@ export interface OfficeAgent { y: number; targetX: number; targetY: number; - deskX: number; - deskY: number; faceColor: string; shirtColor: string; frame: number; - facing: number; } -const WALL_H = 120; -const TILE = 24; -const DESK_W = 100; -const DESK_H = 48; -const CHAR_H = 44; +const WALL_H = 110; +const DESK_W = 90; +const DESK_SPACING = 140; +const ROW_H = 160; const WALL = "#3a3a4a"; -const WALL_TILE = "#424252"; -const FLOOR = "#4a4a5a"; -const FLOOR_TILE = "#52526a"; -const DESK_TOP = "#8b7355"; -const DESK_LEG = "#6b5335"; -const MONITOR_BODY = "#555"; -const MONITOR_SCREEN = "#222"; -const MONITOR_ACTIVE = "#1a3a2a"; +const FLOOR = "#484858"; export function drawScene(ctx: CanvasRenderingContext2D, w: number, h: number, agents: OfficeAgent[], time: number) { - drawFloor(ctx, w, h); + ctx.clearRect(0, 0, w, h); + + ctx.fillStyle = FLOOR; + ctx.fillRect(0, 0, w, h); + drawWall(ctx, w); - drawWallDecorations(ctx, w, time); + drawWallDecor(ctx, w, time); + drawFloorGrid(ctx, w, h); - const groups = groupByDepartment(agents); - let col = 0; - let row = 0; - const cols = Math.max(1, Math.floor((w - 40) / (DESK_W + 60))); - for (const [, group] of groups) { - for (const agent of group) { - agent.deskX = 60 + (col % cols) * (DESK_W + 60); - agent.deskY = WALL_H + 40 + row * (DESK_H + 90); - if (agent.status !== "idle") { - agent.targetX = agent.deskX + DESK_W / 2; - agent.targetY = agent.deskY + DESK_H + 8; - } - col++; - if (col % cols === 0) row++; - } - if (col % cols !== 0) { row++; col = Math.ceil(col / cols) * cols; } - } + const cols = Math.max(2, Math.floor((w - 40) / DESK_SPACING)); - for (const [, group] of groups) { - for (const agent of group) drawDeskUnit(ctx, agent, time); + for (let i = 0; i < agents.length; i++) { + const col = i % cols; + const row = Math.floor(i / cols); + const dx = 50 + col * DESK_SPACING; + const dy = WALL_H + 30 + row * ROW_H; + drawDesk(ctx, dx, dy, agents[i]); } const sorted = [...agents].sort((a, b) => a.y - b.y); for (const agent of sorted) drawCharacter(ctx, agent, time); } -function drawFloor(ctx: CanvasRenderingContext2D, w: number, h: number) { - ctx.fillStyle = FLOOR; - ctx.fillRect(0, 0, w, h); - ctx.strokeStyle = FLOOR_TILE; - ctx.lineWidth = 0.5; - for (let x = 0; x < w; x += TILE) { ctx.beginPath(); ctx.moveTo(x, WALL_H); ctx.lineTo(x, h); ctx.stroke(); } - for (let y = WALL_H; y < h; y += TILE) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke(); } +export function layoutPositions(count: number, viewW: number): Array<{ x: number; y: number }> { + const cols = Math.max(2, Math.floor((viewW - 40) / DESK_SPACING)); + return Array.from({ length: count }, (_, i) => ({ + x: 50 + (i % cols) * DESK_SPACING + DESK_W / 2, + y: WALL_H + 30 + Math.floor(i / cols) * ROW_H + 70, + })); } function drawWall(ctx: CanvasRenderingContext2D, w: number) { ctx.fillStyle = WALL; ctx.fillRect(0, 0, w, WALL_H); - ctx.strokeStyle = WALL_TILE; + ctx.strokeStyle = "#42425a"; ctx.lineWidth = 0.5; - for (let x = 0; x < w; x += TILE) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, WALL_H); ctx.stroke(); } - for (let y = 0; y < WALL_H; y += TILE / 2) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke(); } + for (let x = 0; x < w; x += 24) { + ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, WALL_H); ctx.stroke(); + } + for (let y = 0; y < WALL_H; y += 12) { + ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke(); + } ctx.fillStyle = "#333"; - ctx.fillRect(0, WALL_H - 4, w, 4); + ctx.fillRect(0, WALL_H - 3, w, 3); } -function drawWallDecorations(ctx: CanvasRenderingContext2D, w: number, time: number) { - drawCityWindow(ctx, 30, 12, 90, 70); - if (w > 400) drawCityWindow(ctx, w - 130, 12, 90, 70); - drawClock(ctx, w / 2, 50, 28, time); - if (w > 600) drawWhiteboard(ctx, w / 2 + 80, 14, 120, 76); - if (w > 300) drawPainting(ctx, 160, 16, 60, 50); +function drawFloorGrid(ctx: CanvasRenderingContext2D, w: number, h: number) { + ctx.strokeStyle = "rgba(255,255,255,0.03)"; + ctx.lineWidth = 0.5; + for (let x = 0; x < w; x += 24) { ctx.beginPath(); ctx.moveTo(x, WALL_H); ctx.lineTo(x, h); ctx.stroke(); } + for (let y = WALL_H; y < h; y += 24) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke(); } } -function drawCityWindow(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number) { +function drawWallDecor(ctx: CanvasRenderingContext2D, w: number, time: number) { + drawWindow(ctx, 20, 10, 80, 60, time); + if (w > 500) drawWindow(ctx, w - 110, 10, 80, 60, time); + drawClock(ctx, Math.min(w / 2, 300), 45, 22, time); + if (w > 400) drawBoard(ctx, Math.min(w / 2 + 60, w - 160), 12, 100, 70); +} + +function drawWindow(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, time: number) { ctx.fillStyle = "#1a1a3a"; ctx.fillRect(x, y, w, h); ctx.strokeStyle = "#666"; - ctx.lineWidth = 2; + ctx.lineWidth = 1.5; ctx.strokeRect(x, y, w, h); - ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(x + w / 2, y); ctx.lineTo(x + w / 2, y + h); ctx.stroke(); - for (let bx = x + 8; bx < x + w - 8; bx += 14) { - for (let by = y + h - 50; by < y + h - 6; by += 8) { - ctx.fillStyle = Math.random() > 0.4 ? "#ff9" : "#553"; - ctx.fillRect(bx, by, 4, 4); + const seed = Math.floor(time * 0.0001); + for (let bx = x + 6; bx < x + w - 6; bx += 10) { + for (let by = y + h - 40; by < y + h - 4; by += 7) { + const on = ((bx * 7 + by * 13 + seed) % 5) > 1; + ctx.fillStyle = on ? "#ffa" : "#334"; + ctx.fillRect(bx, by, 3, 3); } } - ctx.fillStyle = "#446"; - ctx.fillRect(x + 2, y + 2, 8, 8); ctx.fillStyle = "#ff8"; - ctx.beginPath(); ctx.arc(x + 6, y + 6, 4, 0, Math.PI * 2); ctx.fill(); + ctx.beginPath(); ctx.arc(x + w * 0.75, y + 12, 5, 0, Math.PI * 2); ctx.fill(); } function drawClock(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number, time: number) { ctx.fillStyle = "#eee"; ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI * 2); ctx.fill(); - ctx.strokeStyle = "#333"; - ctx.lineWidth = 2; - ctx.stroke(); - for (let i = 0; i < 12; i++) { - const a = (i / 12) * Math.PI * 2 - Math.PI / 2; - ctx.fillStyle = "#333"; - ctx.fillRect(cx + Math.cos(a) * (r - 5) - 1, cy + Math.sin(a) * (r - 5) - 1, 2, 2); - } - const min = (time * 0.001) % (Math.PI * 2); - const hr = min / 12; - ctx.strokeStyle = "#333"; - ctx.lineWidth = 2; - ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(cx + Math.cos(hr - Math.PI / 2) * (r * 0.5), cy + Math.sin(hr - Math.PI / 2) * (r * 0.5)); ctx.stroke(); + ctx.strokeStyle = "#444"; ctx.lineWidth = 1.5; - ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(cx + Math.cos(min - Math.PI / 2) * (r * 0.7), cy + Math.sin(min - Math.PI / 2) * (r * 0.7)); ctx.stroke(); + ctx.stroke(); + const min = (time * 0.0005) % (Math.PI * 2); + ctx.strokeStyle = "#333"; + ctx.lineWidth = 1.5; + ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(cx + Math.cos(min / 12 - Math.PI / 2) * r * 0.5, cy + Math.sin(min / 12 - Math.PI / 2) * r * 0.5); ctx.stroke(); + ctx.lineWidth = 1; + ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(cx + Math.cos(min - Math.PI / 2) * r * 0.7, cy + Math.sin(min - Math.PI / 2) * r * 0.7); ctx.stroke(); } -function drawWhiteboard(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number) { +function drawBoard(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number) { ctx.fillStyle = "#f5f5f0"; ctx.fillRect(x, y, w, h); ctx.strokeStyle = "#888"; - ctx.lineWidth = 2; + ctx.lineWidth = 1.5; ctx.strokeRect(x, y, w, h); ctx.fillStyle = "#333"; ctx.font = "bold 7px monospace"; - ctx.fillText("TODO", x + 6, y + 12); + ctx.fillText("TODO", x + 5, y + 12); ctx.font = "6px monospace"; ctx.fillStyle = "#555"; - const items = ["Review PRD", "Ship v1.0", "Fix bug #42", "Deploy staging"]; - items.forEach((item, i) => ctx.fillText(`• ${item}`, x + 6, y + 22 + i * 10)); + const items = ["Review PRD", "Ship v1.0", "Fix bug #42", "Deploy"]; + for (let i = 0; i < items.length; i++) ctx.fillText(`• ${items[i]}`, x + 5, y + 22 + i * 10); } -function drawPainting(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number) { - ctx.fillStyle = "#654"; - ctx.fillRect(x - 3, y - 3, w + 6, h + 6); - ctx.fillStyle = "#1a2a3a"; - ctx.fillRect(x, y, w, h); - ctx.fillStyle = "#2a4a3a"; - ctx.fillRect(x, y + h * 0.6, w, h * 0.4); - ctx.fillStyle = "#ff8"; - ctx.beginPath(); ctx.arc(x + w * 0.7, y + h * 0.25, 6, 0, Math.PI * 2); ctx.fill(); - ctx.fillStyle = "#888"; - ctx.font = "6px sans-serif"; - ctx.textAlign = "center"; - ctx.fillText("EMPLOYEE", x + w / 2, y + h + 10); - ctx.fillText("OF THE MONTH", x + w / 2, y + h + 18); - ctx.textAlign = "start"; -} - -function drawDeskUnit(ctx: CanvasRenderingContext2D, agent: OfficeAgent, _time: number) { - const dx = agent.deskX; - const dy = agent.deskY; - ctx.fillStyle = DESK_TOP; - ctx.fillRect(dx, dy, DESK_W, 6); - ctx.fillStyle = DESK_LEG; - ctx.fillRect(dx + 4, dy + 6, 4, DESK_H - 6); - ctx.fillRect(dx + DESK_W - 8, dy + 6, 4, DESK_H - 6); - ctx.fillRect(dx + DESK_W / 2 - 2, dy + 6, 4, DESK_H - 10); - - const mx = dx + DESK_W / 2 - 16; - const my = dy - 28; - ctx.fillStyle = MONITOR_BODY; - ctx.fillRect(mx, my, 32, 24); - ctx.fillStyle = agent.status === "active" ? MONITOR_ACTIVE : MONITOR_SCREEN; - ctx.fillRect(mx + 2, my + 2, 28, 18); - ctx.fillStyle = MONITOR_BODY; - ctx.fillRect(mx + 13, my + 24, 6, 4); - ctx.fillRect(mx + 8, my + 28, 16, 2); - - if (agent.status === "active" && agent.task) { +function drawDesk(ctx: CanvasRenderingContext2D, x: number, y: number, agent: OfficeAgent) { + const active = agent.status === "active" || agent.status === "thinking"; + const mx = x + DESK_W / 2 - 14; + const my = y - 26; + ctx.fillStyle = "#555"; + ctx.fillRect(mx, my, 28, 20); + ctx.fillStyle = active ? "#1a3a2a" : "#222"; + ctx.fillRect(mx + 2, my + 2, 24, 14); + if (active && agent.task) { ctx.fillStyle = "#4f8"; ctx.font = "5px monospace"; - const taskText = agent.task.slice(0, 18); - ctx.fillText(taskText, mx + 4, my + 12); + ctx.fillText(agent.task.slice(0, 16), mx + 3, my + 10); } + ctx.fillStyle = "#555"; + ctx.fillRect(mx + 11, my + 20, 6, 3); + ctx.fillRect(mx + 6, my + 23, 16, 2); + + ctx.fillStyle = "#8b7355"; + ctx.fillRect(x, y, DESK_W, 5); + ctx.fillStyle = "#6b5335"; + ctx.fillRect(x + 4, y + 5, 3, 30); + ctx.fillRect(x + DESK_W - 7, y + 5, 3, 30); ctx.fillStyle = "#666"; - ctx.fillRect(dx + DESK_W / 2 - 12, dy - 2, 24, 3); - ctx.fillRect(dx + DESK_W / 2 - 14, dy - 3, 2, 4); - ctx.fillRect(dx + DESK_W / 2 + 12, dy - 3, 2, 4); + ctx.fillRect(x + DESK_W / 2 - 10, y - 2, 20, 3); - if (agent.status !== "idle") { + if (active) { ctx.fillStyle = "#86654a"; - ctx.fillRect(dx + DESK_W - 20, dy - 8, 8, 8); - ctx.fillStyle = "#f5f5f0"; - ctx.fillRect(dx + DESK_W - 19, dy - 7, 6, 4); - ctx.fillStyle = "#86654a"; - ctx.fillRect(dx + DESK_W - 17, dy - 2, 2, 2); + ctx.fillRect(x + DESK_W - 16, y - 7, 7, 7); + ctx.fillStyle = "#f5f0e8"; + ctx.fillRect(x + DESK_W - 15, y - 6, 5, 3); } } @@ -213,89 +172,64 @@ function drawCharacter(ctx: CanvasRenderingContext2D, agent: OfficeAgent, time: const bob = active ? Math.sin(time * 0.003 + agent.frame) * 1.5 : 0; ctx.fillStyle = "#222"; - ctx.beginPath(); - ctx.arc(x - 10, y - CHAR_H + 6 + bob, 12, Math.PI, 0); - ctx.lineTo(x + 10, y - CHAR_H + 6 + bob); - ctx.arc(x + 10, y - CHAR_H + 6 + bob, 12, 0, Math.PI, true); - ctx.fill(); + ctx.beginPath(); ctx.arc(x - 9, y - 36 + bob, 10, Math.PI, 0); ctx.fill(); + ctx.beginPath(); ctx.arc(x + 9, y - 36 + bob, 10, Math.PI, 0, true); ctx.fill(); + ctx.fillRect(x - 9, y - 36 + bob, 18, 5); + ctx.beginPath(); ctx.arc(x - 12, y - 34 + bob, 4, 0, Math.PI * 2); ctx.fill(); + ctx.beginPath(); ctx.arc(x + 12, y - 34 + bob, 4, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = agent.faceColor; - ctx.beginPath(); ctx.arc(x, y - CHAR_H + 10 + bob, 10, 0, Math.PI * 2); ctx.fill(); + ctx.beginPath(); ctx.arc(x, y - 30 + bob, 9, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = "#fff"; - ctx.fillRect(x - 4, y - CHAR_H + 8 + bob, 3, 3); - ctx.fillRect(x + 1, y - CHAR_H + 8 + bob, 3, 3); + ctx.fillRect(x - 4, y - 32 + bob, 3, 3); + ctx.fillRect(x + 1, y - 32 + bob, 3, 3); ctx.fillStyle = "#111"; - ctx.fillRect(x - 3, y - CHAR_H + 9 + bob, 2, 2); - ctx.fillRect(x + 2, y - CHAR_H + 9 + bob, 2, 2); - - ctx.fillStyle = "#222"; - ctx.fillRect(x - 14, y - CHAR_H + 4 + bob, 4, 10); - ctx.fillRect(x + 10, y - CHAR_H + 4 + bob, 4, 10); - ctx.beginPath(); ctx.arc(x - 12, y - CHAR_H + 4 + bob, 5, 0, Math.PI * 2); ctx.fill(); - ctx.beginPath(); ctx.arc(x + 12, y - CHAR_H + 4 + bob, 5, 0, Math.PI * 2); ctx.fill(); + ctx.fillRect(x - 3, y - 31 + bob, 2, 2); + ctx.fillRect(x + 2, y - 31 + bob, 2, 2); ctx.fillStyle = agent.shirtColor; - roundRect(ctx, x - 10, y - CHAR_H + 20 + bob, 20, 16, 3); + ctx.fillRect(x - 9, y - 20 + bob, 18, 14); ctx.fillStyle = agent.faceColor; - ctx.fillRect(x - 12, y - CHAR_H + 22 + bob, 4, 10); - ctx.fillRect(x + 8, y - CHAR_H + 22 + bob, 4, 10); + ctx.fillRect(x - 11, y - 18 + bob, 3, 8); + ctx.fillRect(x + 8, y - 18 + bob, 3, 8); ctx.fillStyle = "#444"; - ctx.fillRect(x - 6, y - CHAR_H + 36 + bob, 5, 8); - ctx.fillRect(x + 1, y - CHAR_H + 36 + bob, 5, 8); + ctx.fillRect(x - 5, y - 6 + bob, 4, 7); + ctx.fillRect(x + 1, y - 6 + bob, 4, 7); ctx.fillStyle = "#333"; - ctx.fillRect(x - 7, y - CHAR_H + 43 + bob, 6, 3); - ctx.fillRect(x + 1, y - CHAR_H + 43 + bob, 6, 3); + ctx.fillRect(x - 6, y + 1 + bob, 5, 3); + ctx.fillRect(x + 1, y + 1 + bob, 5, 3); if (active) { ctx.fillStyle = agent.status === "active" ? "#34d399" : "#818cf8"; - ctx.beginPath(); ctx.arc(x + 12, y - CHAR_H + 2 + bob, 3, 0, Math.PI * 2); ctx.fill(); + ctx.beginPath(); ctx.arc(x + 11, y - 40 + bob, 3, 0, Math.PI * 2); ctx.fill(); } - if (agent.task && active) drawSpeechBubble(ctx, x, y - CHAR_H - 10, agent.task.slice(0, 24)); + if (agent.task && active) { + const text = agent.task.slice(0, 20); + ctx.font = "7px sans-serif"; + const tw = ctx.measureText(text).width + 10; + const bx = x - tw / 2; + const by = y - 50 + bob; + ctx.fillStyle = "#fff"; + ctx.beginPath(); + ctx.moveTo(bx + 3, by); ctx.lineTo(bx + tw - 3, by); ctx.arcTo(bx + tw, by, bx + tw, by + 3, 3); + ctx.lineTo(bx + tw, by + 13); ctx.arcTo(bx + tw, by + 16, bx + tw - 3, by + 16, 3); + ctx.lineTo(bx + 3, by + 16); ctx.arcTo(bx, by + 16, bx, by + 13, 3); + ctx.lineTo(bx, by + 3); ctx.arcTo(bx, by, bx + 3, by, 3); + ctx.fill(); + ctx.beginPath(); ctx.moveTo(x - 3, by + 16); ctx.lineTo(x, by + 20); ctx.lineTo(x + 3, by + 16); ctx.fill(); + ctx.fillStyle = "#333"; + ctx.textAlign = "center"; + ctx.fillText(text, x, by + 11); + ctx.textAlign = "start"; + } - ctx.fillStyle = "#ccc"; + ctx.fillStyle = "#bbb"; ctx.font = "9px sans-serif"; ctx.textAlign = "center"; - ctx.fillText(agent.name.split(" ")[0], x, y + 6); + ctx.fillText(agent.name.split(" ")[0], x, y + 10); ctx.textAlign = "start"; } - -function drawSpeechBubble(ctx: CanvasRenderingContext2D, x: number, y: number, text: string) { - ctx.font = "7px monospace"; - const tw = ctx.measureText(text).width + 12; - const bx = x - tw / 2; - ctx.fillStyle = "#fff"; - roundRect(ctx, bx, y - 14, tw, 16, 4); - ctx.fillStyle = "#fff"; - ctx.beginPath(); - ctx.moveTo(x - 3, y + 2); - ctx.lineTo(x, y + 7); - ctx.lineTo(x + 3, y + 2); - ctx.fill(); - ctx.fillStyle = "#333"; - ctx.textAlign = "center"; - ctx.fillText(text, x, y - 3); - ctx.textAlign = "start"; -} - -function roundRect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) { - ctx.beginPath(); - ctx.moveTo(x + r, y); - ctx.arcTo(x + w, y, x + w, y + h, r); - ctx.arcTo(x + w, y + h, x, y + h, r); - ctx.arcTo(x, y + h, x, y, r); - ctx.arcTo(x, y, x + w, y, r); - ctx.fill(); -} - -function groupByDepartment(agents: OfficeAgent[]): Map { - const m = new Map(); - for (const a of agents) { - if (!m.has(a.department)) m.set(a.department, []); - m.get(a.department)!.push(a); - } - return m; -}