Fix office layout, performance, and crash issues

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.
This commit is contained in:
ZacharyZhang-NY committed 2026-06-30 22:45:16 -04:00
1 parent 2447bfba9c
commit 82e28111e9
2 files changed
+196 -249

No files matched your search

@@ -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<HTMLCanvasElement>(null);
const agentsRef = useRef<OfficeAgent[]>([]);
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 }>,
@@ -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<string, OfficeAgent[]> {
const m = new Map<string, OfficeAgent[]>();
for (const a of agents) {
if (!m.has(a.department)) m.set(a.department, []);
m.get(a.department)!.push(a);
}
return m;
}