Rebuild office with rich pixel-art rendering inspired by claude-office

Characters now have round heads, headphones, eyes, shirts, pants, and
shoes — proper pixel-art people like claude-office. Desks are detailed
wooden tables with monitors showing task text, keyboards, and coffee
cups. The wall has a city skyline window, analog clock, whiteboard with
TODO items, and an employee-of-the-month painting. Active agents bob
and show speech bubbles with their current task. Floor has tile grid.
This commit is contained in:
ZacharyZhang-NY committed 2026-06-30 22:34:58 -04:00
1 parent f60ef60da1
commit 2447bfba9c
2 files changed
+369 -192

No files matched your search

@@ -1,30 +1,10 @@
import { useEffect, useRef, useMemo } from "react";
import { useEffect, useRef, useMemo, useCallback } from "react";
import type { PageProps } from "./types";
import { activeOrgId } from "./types";
import { drawScene, type OfficeAgent } from "./office/office-renderer";
type Status = "active" | "thinking" | "busy" | "idle";
interface OfficeAgent {
id: string;
name: string;
department: string;
status: Status;
task: string | null;
x: number;
y: number;
targetX: number;
targetY: number;
skin: string;
shirt: string;
frame: number;
facing: "left" | "right";
}
const SKIN = ["#f4c28b", "#e8b472", "#c68e5b", "#a56c3e", "#8b5232"];
const SHIRT = ["#5b8dd9", "#d95b7a", "#5bbf6f", "#d9a65b", "#9b6fd9", "#5bc4c4", "#d97b5b", "#5b9bd9"];
const DESK_COLOR = "#c8b89a";
const FLOOR_COLOR = "#f0ebe3";
const WALL_COLOR = "#e8e3db";
const FACE_COLORS = ["#4a90d9", "#50b866", "#9b59b6", "#e67e22", "#e74c3c", "#3498db", "#1abc9c", "#f39c12"];
const SHIRT_COLORS = ["#5b8dd9", "#d95b7a", "#5bbf6f", "#d9a65b", "#9b6fd9", "#5bc4c4", "#d97b5b", "#7fd95b"];
function hash(s: string): number {
let h = 0;
@@ -32,24 +12,16 @@ function hash(s: string): number {
return Math.abs(h);
}
function stableColor(palette: string[], name: string): string {
return palette[hash(name) % palette.length];
}
function deriveStatus(presence: Array<{ principalType: string; principalId: string; status: string }>, runs: Array<{ agentId: string | null; state: string }>, agentId: string): Status {
if (runs.some((r) => r.agentId === agentId && r.state === "running")) return "active";
const p = presence.find((r) => r.principalType === "agent" && r.principalId === agentId);
if (p?.status === "thinking") return "thinking";
if (p?.status === "waiting_approval" || p?.status === "busy") return "busy";
if (p?.status === "active" || p?.status === "online") return "active";
return "idle";
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<number>(0);
const animRef = useRef(0);
const startRef = useRef(Date.now());
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]);
@@ -58,194 +30,98 @@ export function OfficePage({ snapshot }: PageProps) {
const existing = agentsRef.current;
agentsRef.current = orgAgents.map((a, i) => {
const prev = existing.find((e) => e.id === a.id);
const status = deriveStatus(snapshot.chatPresence, snapshot.taskRuns, a.id);
const status = resolveStatus(snapshot.chatPresence, snapshot.taskRuns, a.id);
const task = orgTasks.find((t) => t.assigneeAgentId === a.id && t.status === "in_progress")?.title ?? null;
const deskX = 80 + (i % 6) * 120;
const deskY = 100 + Math.floor(i / 6) * 140;
return {
id: a.id,
name: a.name,
role: a.role,
department: a.department,
status,
task,
x: prev?.x ?? deskX,
y: prev?.y ?? deskY,
targetX: status === "idle" ? deskX + (hash(a.id + String(Date.now()).slice(0, -4)) % 60) - 30 : deskX,
targetY: status === "idle" ? deskY + (hash(a.name + String(Date.now()).slice(0, -4)) % 40) - 20 : deskY,
skin: stableColor(SKIN, a.name),
shirt: stableColor(SHIRT, a.name + a.role),
frame: prev?.frame ?? 0,
facing: prev?.facing ?? "right",
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,
};
});
}, [orgAgents, orgTasks, snapshot.chatPresence, snapshot.taskRuns]);
useEffect(() => {
const render = useCallback(() => {
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);
let running = true;
const tick = () => {
if (!running) return;
const w = canvas.clientWidth * 2;
const h = canvas.clientHeight * 2;
canvas.width = w;
canvas.height = h;
ctx.scale(2, 2);
const time = Date.now() - startRef.current;
const agents = agentsRef.current;
drawOffice(ctx, canvas.clientWidth, canvas.clientHeight, agentsRef.current);
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) {
const speed = 0.6;
agent.x += (dx / dist) * speed;
agent.y += (dy / dist) * speed;
agent.frame += 0.08;
agent.facing = dx > 0 ? "right" : "left";
}
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;
}
}
animRef.current = requestAnimationFrame(tick);
};
animRef.current = requestAnimationFrame(tick);
return () => { running = false; cancelAnimationFrame(animRef.current); };
drawScene(ctx, w, h, agents, time);
animRef.current = requestAnimationFrame(render);
}, []);
useEffect(() => {
const interval = setInterval(() => {
animRef.current = requestAnimationFrame(render);
return () => cancelAnimationFrame(animRef.current);
}, [render]);
useEffect(() => {
const wander = setInterval(() => {
for (const agent of agentsRef.current) {
if (agent.status === "idle") {
const baseX = 80 + (agentsRef.current.indexOf(agent) % 6) * 120;
const baseY = 100 + Math.floor(agentsRef.current.indexOf(agent) / 6) * 140;
agent.targetX = baseX + (Math.random() * 80 - 40);
agent.targetY = baseY + (Math.random() * 60 - 30);
agent.targetX = agent.deskX + DESK_CENTER_OFFSET + (Math.random() * 100 - 50);
agent.targetY = agent.deskY + 56 + (Math.random() * 40 - 20);
} else {
agent.targetX = agent.deskX + DESK_CENTER_OFFSET;
agent.targetY = agent.deskY + 56;
}
}
}, 3000);
return () => clearInterval(interval);
}, 4000);
return () => clearInterval(wander);
}, []);
return (
<div style={{ flex: 1, overflow: "hidden", background: FLOOR_COLOR }}>
<canvas
ref={canvasRef}
style={{ width: "100%", height: "100%", display: "block", imageRendering: "pixelated" }}
/>
<div style={{ flex: 1, overflow: "hidden" }}>
<canvas ref={canvasRef} style={{ width: "100%", height: "100%", display: "block" }} />
</div>
);
}
function drawOffice(ctx: CanvasRenderingContext2D, w: number, h: number, agents: OfficeAgent[]) {
ctx.fillStyle = FLOOR_COLOR;
ctx.fillRect(0, 0, w, h);
const DESK_CENTER_OFFSET = 50;
ctx.fillStyle = WALL_COLOR;
ctx.fillRect(0, 0, w, 50);
for (let x = 20; x < w; x += 160) {
drawWindow(ctx, x, 8, 60, 36);
}
const deptGroups = new Map<string, OfficeAgent[]>();
for (const a of agents) {
if (!deptGroups.has(a.department)) deptGroups.set(a.department, []);
deptGroups.get(a.department)!.push(a);
}
let groupIdx = 0;
for (const [dept, group] of deptGroups) {
const gx = 40 + (groupIdx % 3) * ((w - 60) / 3);
const gy = 60 + Math.floor(groupIdx / 3) * 150;
ctx.fillStyle = "rgba(0,0,0,0.04)";
ctx.fillRect(gx, gy, (w - 100) / 3, 130);
ctx.strokeStyle = "rgba(0,0,0,0.06)";
ctx.strokeRect(gx, gy, (w - 100) / 3, 130);
ctx.fillStyle = "#999";
ctx.font = "9px monospace";
ctx.fillText(dept.toUpperCase(), gx + 6, gy + 12);
for (let i = 0; i < group.length; i++) {
const dx = gx + 30 + i * 70;
const dy = gy + 50;
drawDesk(ctx, dx, dy);
}
groupIdx++;
}
const sorted = [...agents].sort((a, b) => a.y - b.y);
for (const agent of sorted) {
drawPixelPerson(ctx, agent);
}
}
function drawWindow(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number) {
ctx.fillStyle = "#b8d4e8";
ctx.fillRect(x, y, w, h);
ctx.strokeStyle = "#d0c8c0";
ctx.lineWidth = 1;
ctx.strokeRect(x, y, w, h);
ctx.beginPath();
ctx.moveTo(x + w / 2, y);
ctx.lineTo(x + w / 2, y + h);
ctx.moveTo(x, y + h / 2);
ctx.lineTo(x + w, y + h / 2);
ctx.stroke();
}
function drawDesk(ctx: CanvasRenderingContext2D, x: number, y: number) {
ctx.fillStyle = DESK_COLOR;
ctx.fillRect(x - 16, y, 32, 4);
ctx.fillStyle = "#a89880";
ctx.fillRect(x - 4, y + 4, 2, 12);
ctx.fillRect(x + 2, y + 4, 2, 12);
ctx.fillStyle = "#444";
ctx.fillRect(x - 8, y - 12, 16, 10);
ctx.fillStyle = "#6af";
ctx.fillRect(x - 7, y - 11, 14, 8);
}
function drawPixelPerson(ctx: CanvasRenderingContext2D, agent: OfficeAgent) {
const x = Math.round(agent.x);
const y = Math.round(agent.y);
const bobble = agent.status === "active" ? Math.sin(agent.frame * 3) * 1.5 : 0;
const legOffset = Math.abs(Math.sin(agent.frame * 2)) > 0.5 ? 1 : 0;
ctx.fillStyle = agent.skin;
ctx.fillRect(x - 3, y - 18 + bobble, 6, 6);
ctx.fillStyle = "#333";
const eyeX = agent.facing === "right" ? 1 : -2;
ctx.fillRect(x + eyeX, y - 16 + bobble, 1, 1);
ctx.fillRect(x + eyeX + 2, y - 16 + bobble, 1, 1);
ctx.fillStyle = agent.shirt;
ctx.fillRect(x - 4, y - 12 + bobble, 8, 8);
ctx.fillStyle = "#555";
ctx.fillRect(x - 3, y - 4 + bobble, 3, 5 + legOffset);
ctx.fillRect(x, y - 4 + bobble, 3, 5 + (1 - legOffset));
if (agent.status === "active") {
ctx.fillStyle = "#34d399";
ctx.beginPath();
ctx.arc(x + 5, y - 19 + bobble, 2, 0, Math.PI * 2);
ctx.fill();
} else if (agent.status === "thinking") {
ctx.fillStyle = "#818cf8";
ctx.fillText("?", x + 4, y - 18 + bobble);
}
ctx.fillStyle = "#777";
ctx.font = "8px sans-serif";
ctx.textAlign = "center";
ctx.fillText(agent.name.split(" ")[0], x, y + 8);
ctx.textAlign = "start";
function resolveStatus(
presence: Array<{ principalType: string; principalId: string; status: string }>,
runs: Array<{ agentId: string | null; state: string }>,
agentId: string,
): OfficeAgent["status"] {
if (runs.some((r) => r.agentId === agentId && r.state === "running")) return "active";
const p = presence.find((r) => r.principalType === "agent" && r.principalId === agentId);
if (p?.status === "thinking") return "thinking";
if (p?.status === "waiting_approval" || p?.status === "busy") return "busy";
if (p?.status === "active" || p?.status === "online") return "active";
return "idle";
}
@@ -0,0 +1,301 @@
export interface OfficeAgent {
id: string;
name: string;
role: string;
department: string;
status: "active" | "thinking" | "busy" | "idle";
task: string | null;
x: number;
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 = "#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";
export function drawScene(ctx: CanvasRenderingContext2D, w: number, h: number, agents: OfficeAgent[], time: number) {
drawFloor(ctx, w, h);
drawWall(ctx, w);
drawWallDecorations(ctx, w, time);
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; }
}
for (const [, group] of groups) {
for (const agent of group) drawDeskUnit(ctx, agent, time);
}
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(); }
}
function drawWall(ctx: CanvasRenderingContext2D, w: number) {
ctx.fillStyle = WALL;
ctx.fillRect(0, 0, w, WALL_H);
ctx.strokeStyle = WALL_TILE;
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(); }
ctx.fillStyle = "#333";
ctx.fillRect(0, WALL_H - 4, w, 4);
}
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 drawCityWindow(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number) {
ctx.fillStyle = "#1a1a3a";
ctx.fillRect(x, y, w, h);
ctx.strokeStyle = "#666";
ctx.lineWidth = 2;
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);
}
}
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();
}
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.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();
}
function drawWhiteboard(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.strokeRect(x, y, w, h);
ctx.fillStyle = "#333";
ctx.font = "bold 7px monospace";
ctx.fillText("TODO", x + 6, 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));
}
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) {
ctx.fillStyle = "#4f8";
ctx.font = "5px monospace";
const taskText = agent.task.slice(0, 18);
ctx.fillText(taskText, mx + 4, my + 12);
}
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);
if (agent.status !== "idle") {
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);
}
}
function drawCharacter(ctx: CanvasRenderingContext2D, agent: OfficeAgent, time: number) {
const x = Math.round(agent.x);
const y = Math.round(agent.y);
const active = agent.status === "active" || agent.status === "thinking";
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.fillStyle = agent.faceColor;
ctx.beginPath(); ctx.arc(x, y - CHAR_H + 10 + bob, 10, 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.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.fillStyle = agent.shirtColor;
roundRect(ctx, x - 10, y - CHAR_H + 20 + bob, 20, 16, 3);
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.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.fillStyle = "#333";
ctx.fillRect(x - 7, y - CHAR_H + 43 + bob, 6, 3);
ctx.fillRect(x + 1, y - CHAR_H + 43 + bob, 6, 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();
}
if (agent.task && active) drawSpeechBubble(ctx, x, y - CHAR_H - 10, agent.task.slice(0, 24));
ctx.fillStyle = "#ccc";
ctx.font = "9px sans-serif";
ctx.textAlign = "center";
ctx.fillText(agent.name.split(" ")[0], x, y + 6);
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;
}