Rebuild the pixel office as a real floor plan with rooms and lanes

Replace the viewport-derived desk grid with a fixed-world office: a north
band of rooms (CEO office, meeting room, snack bar, restroom) with door
signage opening onto a corridor, and an open desk field that grows by rows
so any head-count fits the world instead of being cut off. Geometry lives in
a pure floor-plan module that also builds an axis-aligned lane graph with
BFS routing, unit-tested for reachability and collision invariants. A camera
transform centers or clamps the view, the first c_suite agent takes the CEO
office, name labels render the full name, and the mock TODO-board text is
gone. Office screenshots join the visual-review capture set.
This commit is contained in:
ZacharyZhang-NY committed 2026-07-06 21:39:16 -04:00
1 parent f61046b7a4
commit cab4e4ba33
8 files changed
+1098 -307

No files matched your search

@@ -1,56 +1,38 @@
import { useEffect, useRef, useMemo } from "react";
import { useEffect, useMemo, useRef } from "react";
import type { PageProps } from "./types";
import { activeOrgId } from "./types";
import { drawScene, layoutPositions, type OfficeAgent } from "./office/office-renderer";
import { hashString, renderOffice, type RenderPerson } from "./office/office-renderer";
import { OfficeSimulation, cameraFor } from "./office/simulation";
import type { AgentActivity, OfficeAgentInput } from "./office/office-types";
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;
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0;
return Math.abs(h);
}
const FPS_INTERVAL = 1000 / 24;
export function OfficePage({ snapshot }: PageProps) {
const orgId = activeOrgId(snapshot);
const canvasRef = useRef<HTMLCanvasElement>(null);
const agentsRef = useRef<OfficeAgent[]>([]);
const animRef = useRef(0);
const lastFrameRef = useRef(0);
const simRef = useRef<OfficeSimulation | null>(null);
if (simRef.current === null) simRef.current = new OfficeSimulation();
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]);
const inputs = useMemo<OfficeAgentInput[]>(() => {
const orgAgents = snapshot.agents
.filter((agent) => agent.orgId === orgId)
.sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
const orgTasks = snapshot.tasks.filter((task) => task.orgId === orgId);
return orgAgents.map((agent) => ({
id: agent.id,
name: agent.name,
role: agent.role,
title: agent.title,
department: agent.department,
roleLevel: agent.roleLevel,
activity: resolveActivity(snapshot.chatPresence, snapshot.taskRuns, agent.id),
taskTitle: orgTasks.find((task) => task.assigneeAgentId === agent.id && task.status === "in_progress")?.title ?? null,
}));
}, [snapshot.agents, snapshot.tasks, snapshot.chatPresence, snapshot.taskRuns, 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 {
id: a.id,
name: a.name,
role: a.role,
department: a.department,
status,
task,
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]);
simRef.current!.sync(inputs);
}, [inputs]);
useEffect(() => {
const canvas = canvasRef.current;
@@ -59,67 +41,50 @@ export function OfficePage({ snapshot }: PageProps) {
if (!ctx) return;
let running = true;
const FPS_INTERVAL = 1000 / 24;
let raf = 0;
let lastFrame = 0;
const tick = (now: number) => {
if (!running) return;
raf = requestAnimationFrame(tick);
if (now - lastFrame < FPS_INTERVAL) return;
lastFrame = now;
const elapsed = now - lastFrameRef.current;
if (elapsed < FPS_INTERVAL) {
animRef.current = requestAnimationFrame(tick);
return;
}
lastFrameRef.current = now;
const viewW = canvas.clientWidth;
const viewH = canvas.clientHeight;
if (viewW === 0 || viewH === 0) return;
const dpr = window.devicePixelRatio || 1;
const w = canvas.clientWidth;
const h = canvas.clientHeight;
if (w === 0 || h === 0) { animRef.current = requestAnimationFrame(tick); return; }
const pw = Math.round(w * dpr);
const ph = Math.round(h * dpr);
if (canvas.width !== pw || canvas.height !== ph) { canvas.width = pw; canvas.height = ph; }
const backingW = Math.round(viewW * dpr);
const backingH = Math.round(viewH * dpr);
if (canvas.width !== backingW || canvas.height !== backingH) {
canvas.width = backingW;
canvas.height = backingH;
}
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
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);
const sim = simRef.current!;
const camera = cameraFor(sim.plan.world, viewW, viewH, {
x: sim.plan.world.w / 2,
y: viewH / 2,
});
const people: RenderPerson[] = sim.agents.map((agent) => ({
id: agent.input.id,
name: agent.input.name,
activity: agent.input.activity,
x: agent.x,
y: agent.y,
desk: agent.isCeo ? sim.plan.ceoDesk : sim.plan.desks[agent.deskIndex!].bounds,
isCeo: agent.isCeo,
animSeed: hashString(agent.input.id) % 100,
}));
renderOffice(ctx, viewW, viewH, sim.plan, camera, people, now);
};
animRef.current = requestAnimationFrame(tick);
return () => { running = false; cancelAnimationFrame(animRef.current); };
}, []);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const w = canvas.clientWidth;
const positions = layoutPositions(agentsRef.current.length, w);
const wander = setInterval(() => {
agentsRef.current.forEach((agent, i) => {
const pos = positions[i];
if (!pos) return;
if (agent.status === "idle") {
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 = pos.x;
agent.targetY = pos.y;
}
});
}, 5000);
return () => clearInterval(wander);
raf = requestAnimationFrame(tick);
return () => {
running = false;
cancelAnimationFrame(raf);
};
}, []);
return (
@@ -129,15 +94,15 @@ export function OfficePage({ snapshot }: PageProps) {
);
}
function resolveStatus(
function resolveActivity(
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";
): AgentActivity {
if (runs.some((run) => run.agentId === agentId && run.state === "running")) return "active";
const record = presence.find((row) => row.principalType === "agent" && row.principalId === agentId);
if (record?.status === "thinking") return "thinking";
if (record?.status === "waiting_approval" || record?.status === "busy") return "busy";
if (record?.status === "active" || record?.status === "online") return "active";
return "idle";
}
@@ -0,0 +1,310 @@
import type { AmenitySpot, DeskSlot, FloorPlan, LaneGraph, Rect, RoomSpec, Vec } from "./office-types";
/**
* Fixed-size world geometry (logical px). The office is a north band of rooms
* (CEO office, meeting room, snack bar, restroom) opening onto an east-west
* corridor, with the open desk field growing downward row by row. All
* movement rides an axis-aligned lane graph built from the same constants,
* so walkable space is correct by construction.
*/
export const WORLD_W = 1216;
export const WALL = 16;
const ROOM_TOP = WALL;
const ROOM_BOTTOM = 272;
const ROOM_WALL_BOTTOM = ROOM_BOTTOM + WALL;
export const CORRIDOR_Y = 320;
const CORRIDOR_MIN_X = 48;
const CORRIDOR_MAX_X = 1176;
const INTERIOR_LANE_Y = 232;
const DOOR_HALF = 24;
const DESK_W = 80;
const DESK_H = 40;
const DESK_TOP0 = 384;
const ROW_PITCH = 128;
const DESK_COLS = [144, 368, 592, 816, 1040];
const AISLE_XS = [104, 296, 520, 744, 968, 1160];
const SEAT_DROP = 54;
const ROW_LANE_DROP = 88;
const MIN_DESK_ROWS = 2;
const CEO_DESK: Rect = { x: 100, y: 120, w: 120, h: 40 };
const CEO_SEAT: Vec = { x: 160, y: 182 };
const CEO_SOFA: Rect = { x: 32, y: 96, w: 24, h: 72 };
const CEO_PLANT: Rect = { x: 268, y: 24, w: 24, h: 24 };
const MEETING_TABLE: Rect = { x: 392, y: 120, w: 160, h: 64 };
const SNACK_COUNTER: Rect = { x: 656, y: 40, w: 224, h: 28 };
const SNACK_TABLE: Rect = { x: 748, y: 170, w: 40, h: 40 };
const RESTROOM_STALLS: Rect = { x: 1064, y: WALL, w: 120, h: 72 };
const RESTROOM_SINKS: Rect = { x: 928, y: 32, w: 96, h: 32 };
const CORRIDOR_PLANTS: Rect[] = [
{ x: 302, y: 290, w: 20, h: 20 },
{ x: 622, y: 290, w: 20, h: 20 },
{ x: 894, y: 290, w: 20, h: 20 },
];
const ROOMS: RoomSpec[] = [
{ kind: "ceo", label: "CEO OFFICE", bounds: { x: 16, y: ROOM_TOP, w: 288, h: 256 }, doorCx: 160, interiorLaneY: INTERIOR_LANE_Y },
{ kind: "meeting", label: "MEETING ROOM", bounds: { x: 320, y: ROOM_TOP, w: 304, h: 256 }, doorCx: 472, interiorLaneY: INTERIOR_LANE_Y },
{ kind: "snack", label: "SNACK BAR", bounds: { x: 640, y: ROOM_TOP, w: 256, h: 256 }, doorCx: 768, interiorLaneY: INTERIOR_LANE_Y },
{ kind: "restroom", label: "RESTROOM", bounds: { x: 912, y: ROOM_TOP, w: 288, h: 256 }, doorCx: 1056, interiorLaneY: INTERIOR_LANE_Y },
];
const AMENITIES: AmenitySpot[] = [
{ kind: "coffee", room: "snack", stand: { x: 704, y: 92 } },
{ kind: "snack", room: "snack", stand: { x: 840, y: 92 } },
{ kind: "restroom", room: "restroom", stand: { x: 1084, y: 104 } },
{ kind: "restroom", room: "restroom", stand: { x: 1124, y: 104 } },
{ kind: "restroom", room: "restroom", stand: { x: 1164, y: 104 } },
];
interface Segment {
a: Vec;
b: Vec;
}
export function buildFloorPlan(openDeskCount: number): FloorPlan {
const rows = Math.max(MIN_DESK_ROWS, Math.ceil(Math.max(openDeskCount, 0) / DESK_COLS.length));
const desks = buildDesks(rows);
const lastRowLane = rowLaneY(rows - 1);
const worldH = lastRowLane + 64;
const colliders: Rect[] = [
{ x: 0, y: 0, w: WORLD_W, h: WALL },
{ x: 0, y: WALL, w: WALL, h: 280 },
{ x: 0, y: 344, w: WALL, h: worldH - 344 - WALL },
{ x: WORLD_W - WALL, y: WALL, w: WALL, h: worldH - 2 * WALL },
{ x: 0, y: worldH - WALL, w: WORLD_W, h: WALL },
{ x: 304, y: ROOM_TOP, w: WALL, h: ROOM_WALL_BOTTOM - ROOM_TOP },
{ x: 624, y: ROOM_TOP, w: WALL, h: ROOM_WALL_BOTTOM - ROOM_TOP },
{ x: 896, y: ROOM_TOP, w: WALL, h: ROOM_WALL_BOTTOM - ROOM_TOP },
...southWallsWithDoors(),
CEO_DESK,
CEO_SOFA,
CEO_PLANT,
MEETING_TABLE,
SNACK_COUNTER,
SNACK_TABLE,
RESTROOM_STALLS,
RESTROOM_SINKS,
...CORRIDOR_PLANTS,
...desks.map((desk) => desk.bounds),
];
return {
world: { w: WORLD_W, h: worldH },
rooms: ROOMS,
ceoDesk: CEO_DESK,
ceoSeat: CEO_SEAT,
desks,
amenities: AMENITIES,
colliders,
lanes: buildLaneGraph(rows, desks),
entrance: { x: CORRIDOR_MIN_X, y: CORRIDOR_Y },
};
}
function rowLaneY(row: number): number {
return DESK_TOP0 + row * ROW_PITCH + ROW_LANE_DROP;
}
function buildDesks(rows: number): DeskSlot[] {
const desks: DeskSlot[] = [];
for (let row = 0; row < rows; row++) {
for (let col = 0; col < DESK_COLS.length; col++) {
const x = DESK_COLS[col];
const y = DESK_TOP0 + row * ROW_PITCH;
desks.push({
index: desks.length,
bounds: { x, y, w: DESK_W, h: DESK_H },
seat: { x: x + DESK_W / 2, y: y + SEAT_DROP },
});
}
}
return desks;
}
function southWallsWithDoors(): Rect[] {
const walls: Rect[] = [];
for (const room of ROOMS) {
const left = room.bounds.x;
const right = room.bounds.x + room.bounds.w;
const gapLeft = room.doorCx - DOOR_HALF;
const gapRight = room.doorCx + DOOR_HALF;
walls.push({ x: left, y: ROOM_BOTTOM, w: gapLeft - left, h: WALL });
walls.push({ x: gapRight, y: ROOM_BOTTOM, w: right - gapRight, h: WALL });
}
return walls;
}
function buildSegments(rows: number, desks: DeskSlot[]): Segment[] {
const segments: Segment[] = [
{ a: { x: CORRIDOR_MIN_X, y: CORRIDOR_Y }, b: { x: CORRIDOR_MAX_X, y: CORRIDOR_Y } },
];
for (let row = 0; row < rows; row++) {
const y = rowLaneY(row);
segments.push({ a: { x: AISLE_XS[0], y }, b: { x: AISLE_XS[AISLE_XS.length - 1], y } });
}
const lastLane = rowLaneY(rows - 1);
for (const x of AISLE_XS) {
segments.push({ a: { x, y: CORRIDOR_Y }, b: { x, y: lastLane } });
}
for (const room of ROOMS) {
const laneMinX = room.bounds.x + 24;
const laneMaxX = room.bounds.x + room.bounds.w - 24;
segments.push({ a: { x: laneMinX, y: room.interiorLaneY }, b: { x: laneMaxX, y: room.interiorLaneY } });
segments.push({ a: { x: room.doorCx, y: room.interiorLaneY }, b: { x: room.doorCx, y: CORRIDOR_Y } });
}
segments.push({ a: { x: CEO_SEAT.x, y: CEO_SEAT.y }, b: { x: CEO_SEAT.x, y: INTERIOR_LANE_Y } });
for (const spot of AMENITIES) {
segments.push({ a: { x: spot.stand.x, y: spot.stand.y }, b: { x: spot.stand.x, y: INTERIOR_LANE_Y } });
}
for (const desk of desks) {
const lane = desk.bounds.y + ROW_LANE_DROP;
segments.push({ a: { x: desk.seat.x, y: desk.seat.y }, b: { x: desk.seat.x, y: lane } });
}
return segments;
}
function buildLaneGraph(rows: number, desks: DeskSlot[]): LaneGraph {
const segments = buildSegments(rows, desks);
const nodes: Vec[] = [];
const nodeIndex = new Map<string, number>();
const edges = new Set<string>();
const keyOf = (p: Vec) => `${p.x},${p.y}`;
const addNode = (p: Vec): number => {
const key = keyOf(p);
const existing = nodeIndex.get(key);
if (existing !== undefined) return existing;
nodes.push({ x: p.x, y: p.y });
nodeIndex.set(key, nodes.length - 1);
return nodes.length - 1;
};
for (const segment of segments) {
const points = pointsOnSegment(segment, segments);
for (let i = 0; i + 1 < points.length; i++) {
const a = addNode(points[i]);
const b = addNode(points[i + 1]);
if (a === b) continue;
edges.add(a < b ? `${a}|${b}` : `${b}|${a}`);
}
}
return {
nodes,
edges: [...edges].map((pair) => {
const [a, b] = pair.split("|");
return [Number(a), Number(b)] as [number, number];
}),
};
}
function pointsOnSegment(segment: Segment, all: Segment[]): Vec[] {
const points: Vec[] = [segment.a, segment.b];
for (const other of all) {
if (other === segment) continue;
const hit = intersection(segment, other);
if (hit) points.push(hit);
}
const horizontal = segment.a.y === segment.b.y;
points.sort((p, q) => (horizontal ? p.x - q.x : p.y - q.y));
return points.filter((p, i) => i === 0 || p.x !== points[i - 1].x || p.y !== points[i - 1].y);
}
function intersection(s1: Segment, s2: Segment): Vec | null {
const h1 = s1.a.y === s1.b.y;
const h2 = s2.a.y === s2.b.y;
if (h1 === h2) return null;
const horizontal = h1 ? s1 : s2;
const vertical = h1 ? s2 : s1;
const [hx1, hx2] = [Math.min(horizontal.a.x, horizontal.b.x), Math.max(horizontal.a.x, horizontal.b.x)];
const [vy1, vy2] = [Math.min(vertical.a.y, vertical.b.y), Math.max(vertical.a.y, vertical.b.y)];
const x = vertical.a.x;
const y = horizontal.a.y;
if (x < hx1 || x > hx2 || y < vy1 || y > vy2) return null;
return { x, y };
}
/**
* Shortest lane path between two world points. Both endpoints snap to their
* nearest lane node, so a walker interrupted mid-lane re-routes correctly.
* Returns waypoints including `from` and `to`, with collinear points removed.
*/
export function findPath(plan: FloorPlan, from: Vec, to: Vec): Vec[] {
const start = nearestNode(plan.lanes, from);
const goal = nearestNode(plan.lanes, to);
const nodePath = bfs(plan.lanes, start, goal);
const waypoints = [from, ...nodePath.map((i) => plan.lanes.nodes[i]), to];
return simplify(waypoints);
}
function nearestNode(lanes: LaneGraph, p: Vec): number {
let best = 0;
let bestDist = Infinity;
for (let i = 0; i < lanes.nodes.length; i++) {
const node = lanes.nodes[i];
const dist = Math.abs(node.x - p.x) + Math.abs(node.y - p.y);
if (dist < bestDist) {
bestDist = dist;
best = i;
}
}
return best;
}
function bfs(lanes: LaneGraph, start: number, goal: number): number[] {
if (start === goal) return [start];
const adjacency = new Map<number, number[]>();
for (const [a, b] of lanes.edges) {
(adjacency.get(a) ?? adjacency.set(a, []).get(a)!).push(b);
(adjacency.get(b) ?? adjacency.set(b, []).get(b)!).push(a);
}
const previous = new Map<number, number>();
const queue = [start];
previous.set(start, start);
while (queue.length > 0) {
const current = queue.shift()!;
if (current === goal) break;
for (const next of adjacency.get(current) ?? []) {
if (previous.has(next)) continue;
previous.set(next, current);
queue.push(next);
}
}
if (!previous.has(goal)) {
throw new Error(`Office lane graph is disconnected between nodes ${start} and ${goal}`);
}
const path: number[] = [];
for (let node = goal; node !== start; node = previous.get(node)!) path.unshift(node);
path.unshift(start);
return path;
}
function simplify(points: Vec[]): Vec[] {
const result: Vec[] = [];
for (const point of points) {
const last = result[result.length - 1];
if (last && last.x === point.x && last.y === point.y) continue;
const beforeLast = result[result.length - 2];
if (
last && beforeLast &&
((last.x === point.x && beforeLast.x === point.x) || (last.y === point.y && beforeLast.y === point.y))
) {
result[result.length - 1] = point;
continue;
}
result.push(point);
}
return result;
}
export function rectContains(rect: Rect, p: Vec): boolean {
return p.x >= rect.x && p.x <= rect.x + rect.w && p.y >= rect.y && p.y <= rect.y + rect.h;
}
export function rectsOverlap(a: Rect, b: Rect): boolean {
return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;
}
@@ -1,235 +1,136 @@
export interface OfficeAgent {
import type { AgentActivity, Camera, FloorPlan, Rect } from "./office-types";
import { deskScreenRect, drawWorld } from "./scene-renderer";
export interface RenderPerson {
id: string;
name: string;
role: string;
department: string;
status: "active" | "thinking" | "busy" | "idle";
task: string | null;
activity: AgentActivity;
x: number;
y: number;
targetX: number;
targetY: number;
faceColor: string;
shirtColor: string;
frame: number;
desk: Rect | null;
isCeo: boolean;
animSeed: number;
}
const WALL_H = 110;
const DESK_W = 90;
const DESK_SPACING = 140;
const ROW_H = 160;
const ACTIVITY_DOT: Partial<Record<AgentActivity, string>> = {
active: "#34d399",
thinking: "#818cf8",
busy: "#f59e0b",
};
const WALL = "#3a3a4a";
const FLOOR = "#484858";
const FACE_TONES = ["#f3c99f", "#e8b184", "#d29b6e", "#b57e4f", "#95603a", "#7c4d2f"];
const SHIRT_COLORS = ["#5b8dd9", "#d95b7a", "#5bbf6f", "#d9a65b", "#9b6fd9", "#5bc4c4", "#d97b5b", "#7fd95b"];
export function drawScene(ctx: CanvasRenderingContext2D, w: number, h: number, agents: OfficeAgent[], time: number) {
ctx.clearRect(0, 0, w, h);
ctx.fillStyle = FLOOR;
ctx.fillRect(0, 0, w, h);
drawWall(ctx, w);
drawWallDecor(ctx, w, time);
drawFloorGrid(ctx, w, h);
const cols = Math.max(2, Math.floor((w - 40) / DESK_SPACING));
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);
export function hashString(value: string): number {
let hash = 0;
for (let i = 0; i < value.length; i++) hash = (hash * 31 + value.charCodeAt(i)) | 0;
return Math.abs(hash);
}
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,
}));
}
/**
* Composes one frame: static world under the camera transform, then desks'
* activity glow, then people (painter's order by y) with full-name labels.
*/
export function renderOffice(
ctx: CanvasRenderingContext2D,
viewW: number,
viewH: number,
plan: FloorPlan,
camera: Camera,
people: RenderPerson[],
timeMs: number,
): void {
ctx.fillStyle = "#14161c";
ctx.fillRect(0, 0, viewW, viewH);
ctx.save();
ctx.translate(-camera.x, -camera.y);
function drawWall(ctx: CanvasRenderingContext2D, w: number) {
ctx.fillStyle = WALL;
ctx.fillRect(0, 0, w, WALL_H);
ctx.strokeStyle = "#42425a";
ctx.lineWidth = 0.5;
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 - 3, w, 3);
}
drawWorld(ctx, plan, { x: camera.x, y: camera.y, w: viewW, h: viewH });
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 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 = 1.5;
ctx.strokeRect(x, y, w, h);
ctx.beginPath(); ctx.moveTo(x + w / 2, y); ctx.lineTo(x + w / 2, y + h); ctx.stroke();
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);
for (const person of people) {
if (!person.desk) continue;
if (person.activity !== "active" && person.activity !== "thinking") continue;
const screen = deskScreenRect(person.desk);
ctx.fillStyle = "#1c3a2c";
ctx.fillRect(screen.x, screen.y, screen.w, screen.h);
ctx.fillStyle = "#37d38a";
const lines = 3 + (hashString(person.id) % 3);
for (let i = 0; i < lines; i++) {
const width = 6 + ((hashString(person.id) >> (i * 3)) % 14);
ctx.fillRect(screen.x + 3, screen.y + 3 + i * 3, width, 1);
}
}
ctx.fillStyle = "#ff8";
ctx.beginPath(); ctx.arc(x + w * 0.75, y + 12, 5, 0, Math.PI * 2); ctx.fill();
const ordered = [...people].sort((a, b) => a.y - b.y);
for (const person of ordered) drawPerson(ctx, person, timeMs);
for (const person of ordered) drawNameLabel(ctx, person);
ctx.restore();
}
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 = "#444";
ctx.lineWidth = 1.5;
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 drawPerson(ctx: CanvasRenderingContext2D, person: RenderPerson, timeMs: number): void {
const x = Math.round(person.x);
const working = person.activity === "active" || person.activity === "thinking";
const bob = working ? Math.sin(timeMs * 0.003 + person.animSeed) * 1.2 : 0;
const y = Math.round(person.y + bob);
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 = 1.5;
ctx.strokeRect(x, y, w, h);
ctx.fillStyle = "#333";
ctx.font = "bold 7px monospace";
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"];
for (let i = 0; i < items.length; i++) ctx.fillText(`• ${items[i]}`, x + 5, y + 22 + i * 10);
}
const face = FACE_TONES[hashString(person.id) % FACE_TONES.length];
const shirt = person.isCeo ? "#37415c" : SHIRT_COLORS[hashString(person.id + person.name) % SHIRT_COLORS.length];
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";
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 = "rgba(20,16,12,0.25)";
ctx.fillRect(x - 8, y + 2, 16, 4);
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 = "#3a3f47";
ctx.fillRect(x - 5, y - 7, 4, 8);
ctx.fillRect(x + 1, y - 7, 4, 8);
ctx.fillStyle = "#666";
ctx.fillRect(x + DESK_W / 2 - 10, y - 2, 20, 3);
ctx.fillStyle = shirt;
ctx.fillRect(x - 8, y - 20, 16, 13);
ctx.fillStyle = face;
ctx.fillRect(x - 10, y - 18, 3, 7);
ctx.fillRect(x + 7, y - 18, 3, 7);
if (active) {
ctx.fillStyle = "#86654a";
ctx.fillRect(x + DESK_W - 16, y - 7, 7, 7);
ctx.fillStyle = "#f5f0e8";
ctx.fillRect(x + DESK_W - 15, y - 6, 5, 3);
}
}
ctx.fillStyle = face;
ctx.fillRect(x - 7, y - 32, 14, 12);
ctx.fillStyle = "#2c2320";
ctx.fillRect(x - 8, y - 34, 16, 5);
ctx.fillRect(x - 8, y - 31, 3, 4);
ctx.fillRect(x + 5, y - 31, 3, 4);
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 = "#1d1d22";
ctx.fillRect(x - 4, y - 27, 2, 2);
ctx.fillRect(x + 2, y - 27, 2, 2);
ctx.fillStyle = "#222";
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 - 30 + bob, 9, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = "#fff";
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 - 31 + bob, 2, 2);
ctx.fillRect(x + 2, y - 31 + bob, 2, 2);
ctx.fillStyle = agent.shirtColor;
ctx.fillRect(x - 9, y - 20 + bob, 18, 14);
ctx.fillStyle = agent.faceColor;
ctx.fillRect(x - 11, y - 18 + bob, 3, 8);
ctx.fillRect(x + 8, y - 18 + bob, 3, 8);
ctx.fillStyle = "#444";
ctx.fillRect(x - 5, y - 6 + bob, 4, 7);
ctx.fillRect(x + 1, y - 6 + bob, 4, 7);
ctx.fillStyle = "#333";
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 + 11, y - 40 + bob, 3, 0, Math.PI * 2); ctx.fill();
}
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";
const dot = ACTIVITY_DOT[person.activity];
if (dot) {
ctx.fillStyle = dot;
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.arc(x + 10, y - 36, 3, 0, Math.PI * 2);
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 = "#bbb";
ctx.font = "9px sans-serif";
function drawNameLabel(ctx: CanvasRenderingContext2D, person: RenderPerson): void {
ctx.font = "600 9px -apple-system, 'Segoe UI', sans-serif";
const width = Math.ceil(ctx.measureText(person.name).width) + 10;
const x = Math.round(person.x - width / 2);
const y = Math.round(person.y + 7);
ctx.fillStyle = "rgba(22,22,28,0.78)";
drawRoundedRect(ctx, x, y, width, 13, 4);
ctx.fillStyle = "#f2f2f5";
ctx.textAlign = "center";
ctx.fillText(agent.name.split(" ")[0], x, y + 10);
ctx.textBaseline = "middle";
ctx.fillText(person.name, person.x, y + 7);
ctx.textAlign = "start";
ctx.textBaseline = "alphabetic";
}
function drawRoundedRect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number): void {
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();
}
@@ -0,0 +1,81 @@
export interface Vec {
x: number;
y: number;
}
export interface Rect {
x: number;
y: number;
w: number;
h: number;
}
export type RoomKind = "ceo" | "meeting" | "snack" | "restroom";
export interface RoomSpec {
kind: RoomKind;
label: string;
/** Interior area, excluding surrounding walls. */
bounds: Rect;
/** Center x of the door opening on the corridor-facing south wall. */
doorCx: number;
/** Horizontal walking lane inside the room. */
interiorLaneY: number;
}
export interface DeskSlot {
index: number;
bounds: Rect;
/** Where the assigned person stands/sits, south of the desk. */
seat: Vec;
}
export type AmenityKind = "coffee" | "snack" | "restroom";
export interface AmenitySpot {
kind: AmenityKind;
room: RoomKind;
/** Where a visiting person stands while using the amenity. */
stand: Vec;
}
export interface LaneGraph {
nodes: Vec[];
/** Adjacency as index pairs into `nodes`; every edge is axis-aligned. */
edges: Array<[number, number]>;
}
export interface FloorPlan {
world: { w: number; h: number };
rooms: RoomSpec[];
/** The CEO's dedicated desk inside the CEO office. */
ceoDesk: Rect;
ceoSeat: Vec;
/** Open-floor desks, assigned to non-CEO agents in stable order. */
desks: DeskSlot[];
amenities: AmenitySpot[];
/** Solid obstacles for player collision: walls and furniture. */
colliders: Rect[];
lanes: LaneGraph;
/** Player spawn point, just inside the entrance. */
entrance: Vec;
}
export type AgentActivity = "active" | "thinking" | "busy" | "idle";
/** Snapshot-derived facts the office simulation needs about one agent. */
export interface OfficeAgentInput {
id: string;
name: string;
role: string;
title: string;
department: string;
roleLevel: string;
activity: AgentActivity;
taskTitle: string | null;
}
export interface Camera {
x: number;
y: number;
}
@@ -0,0 +1,374 @@
import type { FloorPlan, Rect, RoomSpec } from "./office-types";
import { WALL } from "./floor-plan";
/**
* Draws the static office world in a 3/4 top-down pixel style: wood floor,
* room interiors, wall faces with windows, furniture, and door signage.
* Everything here is a pure function of the floor plan and the visible rect.
*/
/** Height of the visible north-wall face inside each room. */
export const WALL_FACE_H = 48;
const PALETTE = {
wood: "#b08a62",
woodSeam: "#9c7852",
corridor: "#a8815c",
wallFill: "#4c4239",
wallEdge: "#3b332c",
wallFace: "#6b5f52",
wallFaceShadow: "#5d5246",
baseboard: "#4f463c",
ceoCarpet: "#5c6a94",
ceoCarpetEdge: "#505d85",
meetingCarpet: "#6c8f78",
meetingCarpetEdge: "#5e8069",
snackTileA: "#d8c6a6",
snackTileB: "#cbb891",
restTileA: "#adbfd2",
restTileB: "#9db0c5",
deskWood: "#8a6d4c",
deskWoodDark: "#75593b",
deskLeg: "#5f4832",
screenOff: "#20242c",
monitorShell: "#4b5058",
chair: "#3d4148",
chairDark: "#33373d",
glassSky: "#8fc3e8",
glassSkyLow: "#c3e0f2",
frame: "#3f3831",
signPlate: "#2f2a25",
signText: "#d8cfc2",
} as const;
export function drawWorld(ctx: CanvasRenderingContext2D, plan: FloorPlan, view: Rect): void {
drawFloor(ctx, plan, view);
for (const room of plan.rooms) drawRoomFloor(ctx, room);
drawStructure(ctx, plan);
for (const room of plan.rooms) drawRoomFurniture(ctx, plan, room);
drawCorridorPlants(ctx, plan);
for (const desk of plan.desks) {
if (rectsTouch(view, expand(desk.bounds, 40))) drawDesk(ctx, desk.bounds, false);
}
for (const room of plan.rooms) drawDoorSign(ctx, room);
drawEntrance(ctx, plan);
}
/** The monitor screen glass for a desk; the activity glow overlay reuses it. */
export function deskScreenRect(desk: Rect): Rect {
return { x: desk.x + desk.w / 2 - 13, y: desk.y - 15, w: 26, h: 15 };
}
function drawFloor(ctx: CanvasRenderingContext2D, plan: FloorPlan, view: Rect): void {
const { w, h } = plan.world;
ctx.fillStyle = PALETTE.wood;
ctx.fillRect(0, 0, w, h);
ctx.fillStyle = PALETTE.woodSeam;
const top = Math.max(0, Math.floor(view.y / 16) * 16);
const bottom = Math.min(h, view.y + view.h);
for (let y = top; y < bottom; y += 16) {
ctx.fillRect(0, y, w, 1);
const offset = (y / 16) % 2 === 0 ? 0 : 48;
for (let x = offset; x < w; x += 96) ctx.fillRect(x, y, 1, 16);
}
ctx.fillStyle = PALETTE.corridor;
ctx.fillRect(WALL, 288, plan.world.w - 2 * WALL, 64);
}
function drawRoomFloor(ctx: CanvasRenderingContext2D, room: RoomSpec): void {
const b = room.bounds;
if (room.kind === "ceo" || room.kind === "meeting") {
ctx.fillStyle = room.kind === "ceo" ? PALETTE.ceoCarpet : PALETTE.meetingCarpet;
ctx.fillRect(b.x, b.y, b.w, b.h);
ctx.strokeStyle = room.kind === "ceo" ? PALETTE.ceoCarpetEdge : PALETTE.meetingCarpetEdge;
ctx.lineWidth = 2;
ctx.strokeRect(b.x + 6, b.y + 6, b.w - 12, b.h - 12);
return;
}
const [tileA, tileB] = room.kind === "snack"
? [PALETTE.snackTileA, PALETTE.snackTileB]
: [PALETTE.restTileA, PALETTE.restTileB];
for (let ty = 0; ty < b.h; ty += 16) {
for (let tx = 0; tx < b.w; tx += 16) {
ctx.fillStyle = ((tx + ty) / 16) % 2 === 0 ? tileA : tileB;
ctx.fillRect(b.x + tx, b.y + ty, Math.min(16, b.w - tx), Math.min(16, b.h - ty));
}
}
}
function drawStructure(ctx: CanvasRenderingContext2D, plan: FloorPlan): void {
const { w, h } = plan.world;
ctx.fillStyle = PALETTE.wallFill;
ctx.fillRect(0, 0, w, WALL);
ctx.fillRect(0, h - WALL, w, WALL);
ctx.fillRect(0, WALL, WALL, 280);
ctx.fillRect(0, 344, WALL, h - 344 - WALL);
ctx.fillRect(w - WALL, WALL, WALL, h - 2 * WALL);
for (const room of plan.rooms) {
const b = room.bounds;
ctx.fillStyle = PALETTE.wallFace;
ctx.fillRect(b.x, b.y, b.w, WALL_FACE_H);
ctx.fillStyle = PALETTE.wallFaceShadow;
ctx.fillRect(b.x, b.y + WALL_FACE_H - 6, b.w, 6);
ctx.fillStyle = PALETTE.baseboard;
ctx.fillRect(b.x, b.y + WALL_FACE_H, b.w, 2);
drawRoomWindows(ctx, room);
}
ctx.fillStyle = PALETTE.wallFill;
ctx.fillRect(304, WALL, WALL, 272);
ctx.fillRect(624, WALL, WALL, 272);
ctx.fillRect(896, WALL, WALL, 272);
for (const room of plan.rooms) drawSouthWall(ctx, room);
ctx.fillStyle = PALETTE.wallEdge;
ctx.fillRect(0, 0, w, 2);
}
function drawSouthWall(ctx: CanvasRenderingContext2D, room: RoomSpec): void {
const b = room.bounds;
const y = b.y + b.h;
const gapL = room.doorCx - 24;
const gapR = room.doorCx + 24;
ctx.fillStyle = PALETTE.wallFill;
ctx.fillRect(b.x - WALL, y, gapL - b.x + WALL, WALL);
ctx.fillRect(gapR, y, b.x + b.w - gapR + WALL, WALL);
ctx.fillStyle = PALETTE.wallEdge;
ctx.fillRect(b.x - WALL, y + WALL - 2, gapL - b.x + WALL, 2);
ctx.fillRect(gapR, y + WALL - 2, b.x + b.w - gapR + WALL, 2);
ctx.fillStyle = PALETTE.frame;
ctx.fillRect(gapL - 3, y, 3, WALL);
ctx.fillRect(gapR, y, 3, WALL);
}
function drawRoomWindows(ctx: CanvasRenderingContext2D, room: RoomSpec): void {
for (const rect of windowRects(room)) drawWindowFrame(ctx, rect);
}
/** Window glass rects on each room's north wall face (world coordinates). */
export function windowRects(room: RoomSpec): Rect[] {
const b = room.bounds;
const y = b.y + 8;
const h = WALL_FACE_H - 20;
if (room.kind === "restroom") {
return [{ x: b.x + b.w - 190, y: y + 6, w: 44, h: h - 10 }];
}
if (room.kind === "snack") {
return [{ x: b.x + 24, y, w: 72, h }];
}
const w = 72;
return [
{ x: b.x + 28, y, w, h },
{ x: b.x + b.w - 28 - w, y, w, h },
];
}
function drawWindowFrame(ctx: CanvasRenderingContext2D, rect: Rect): void {
ctx.fillStyle = PALETTE.frame;
ctx.fillRect(rect.x - 3, rect.y - 3, rect.w + 6, rect.h + 6);
const grad = ctx.createLinearGradient(0, rect.y, 0, rect.y + rect.h);
grad.addColorStop(0, PALETTE.glassSky);
grad.addColorStop(1, PALETTE.glassSkyLow);
ctx.fillStyle = grad;
ctx.fillRect(rect.x, rect.y, rect.w, rect.h);
ctx.fillStyle = PALETTE.frame;
ctx.fillRect(rect.x + rect.w / 2 - 1, rect.y, 2, rect.h);
}
function drawRoomFurniture(ctx: CanvasRenderingContext2D, plan: FloorPlan, room: RoomSpec): void {
if (room.kind === "ceo") drawCeoFurniture(ctx, plan);
if (room.kind === "meeting") drawMeetingFurniture(ctx);
if (room.kind === "snack") drawSnackFurniture(ctx);
if (room.kind === "restroom") drawRestroomFurniture(ctx);
}
function drawCeoFurniture(ctx: CanvasRenderingContext2D, plan: FloorPlan): void {
const desk = plan.ceoDesk;
ctx.fillStyle = "#414b6e";
ctx.fillRect(desk.x - 16, desk.y - 8, desk.w + 32, desk.h + 40);
drawDesk(ctx, desk, true);
drawSofa(ctx, 32, 96);
drawPlant(ctx, 280, 48);
ctx.fillStyle = PALETTE.frame;
ctx.fillRect(190, 24, 34, 22);
ctx.fillStyle = "#7f96b8";
ctx.fillRect(192, 26, 30, 18);
ctx.fillStyle = "#5c7a9c";
ctx.fillRect(192, 36, 30, 8);
}
function drawMeetingFurniture(ctx: CanvasRenderingContext2D): void {
const table: Rect = { x: 392, y: 120, w: 160, h: 64 };
ctx.fillStyle = PALETTE.deskWoodDark;
ctx.fillRect(table.x, table.y, table.w, table.h);
ctx.fillStyle = PALETTE.deskWood;
ctx.fillRect(table.x + 3, table.y + 3, table.w - 6, table.h - 9);
for (let i = 0; i < 3; i++) {
drawChair(ctx, table.x + 22 + i * 52, table.y - 16);
drawChair(ctx, table.x + 22 + i * 52, table.y + table.h + 4);
}
ctx.fillStyle = "#f2efe8";
ctx.fillRect(452, 22, 96, 30);
ctx.fillStyle = PALETTE.frame;
ctx.strokeStyle = PALETTE.frame;
ctx.lineWidth = 2;
ctx.strokeRect(452, 22, 96, 30);
ctx.fillStyle = "#b3543f";
ctx.fillRect(460, 30, 26, 3);
ctx.fillStyle = "#4f7fae";
ctx.fillRect(460, 38, 40, 3);
ctx.fillStyle = "#6d9c6b";
ctx.fillRect(460, 44, 33, 3);
}
function drawSnackFurniture(ctx: CanvasRenderingContext2D): void {
ctx.fillStyle = "#8f8578";
ctx.fillRect(656, 40, 224, 28);
ctx.fillStyle = "#a99e8f";
ctx.fillRect(656, 40, 224, 8);
ctx.fillStyle = "#d5dade";
ctx.fillRect(660, 8, 30, 48);
ctx.fillStyle = "#b9c0c6";
ctx.fillRect(660, 30, 30, 3);
ctx.fillRect(682, 14, 4, 12);
drawCoffeeMachine(ctx, 694, 26);
ctx.fillStyle = "#c9a86a";
ctx.fillRect(806, 30, 14, 12);
ctx.fillStyle = "#b3543f";
ctx.fillRect(826, 28, 12, 14);
ctx.fillStyle = "#6d9c6b";
ctx.fillRect(844, 30, 12, 12);
ctx.fillStyle = "#94897b";
ctx.fillRect(748, 170, 40, 40);
ctx.fillStyle = "#a89d8e";
ctx.fillRect(751, 173, 34, 34);
drawStool(ctx, 738, 184);
drawStool(ctx, 792, 184);
}
function drawCoffeeMachine(ctx: CanvasRenderingContext2D, x: number, y: number): void {
ctx.fillStyle = "#2e3238";
ctx.fillRect(x, y, 22, 30);
ctx.fillStyle = "#454b52";
ctx.fillRect(x + 2, y + 2, 18, 8);
ctx.fillStyle = "#c9553e";
ctx.fillRect(x + 8, y + 12, 6, 4);
ctx.fillStyle = "#d8d3c8";
ctx.fillRect(x + 7, y + 20, 8, 7);
}
function drawRestroomFurniture(ctx: CanvasRenderingContext2D): void {
for (let i = 0; i < 3; i++) {
const x = 1064 + i * 40;
ctx.fillStyle = "#7d8a99";
ctx.fillRect(x, 16, 40, 72);
ctx.fillStyle = "#8f9dad";
ctx.fillRect(x + 4, 28, 32, 58);
ctx.fillStyle = "#6c7887";
ctx.fillRect(x + 30, 54, 4, 8);
}
ctx.fillStyle = "#c6d2de";
ctx.fillRect(928, 32, 96, 32);
ctx.fillStyle = "#e4ebf2";
for (let i = 0; i < 2; i++) ctx.fillRect(940 + i * 44, 38, 28, 14);
ctx.fillStyle = "#aab8c6";
ctx.fillRect(936, 18, 80, 10);
}
function drawCorridorPlants(ctx: CanvasRenderingContext2D, plan: FloorPlan): void {
void plan;
drawPlant(ctx, 312, 300);
drawPlant(ctx, 632, 300);
drawPlant(ctx, 904, 300);
}
function drawPlant(ctx: CanvasRenderingContext2D, cx: number, cy: number): void {
ctx.fillStyle = "#8a5a3b";
ctx.fillRect(cx - 7, cy, 14, 10);
ctx.fillStyle = "#4e7a46";
ctx.fillRect(cx - 9, cy - 12, 18, 12);
ctx.fillStyle = "#659a58";
ctx.fillRect(cx - 5, cy - 18, 10, 8);
}
function drawSofa(ctx: CanvasRenderingContext2D, x: number, y: number): void {
ctx.fillStyle = "#7a4a41";
ctx.fillRect(x, y, 24, 72);
ctx.fillStyle = "#94594e";
ctx.fillRect(x + 8, y + 4, 16, 64);
}
function drawChair(ctx: CanvasRenderingContext2D, x: number, y: number): void {
ctx.fillStyle = PALETTE.chairDark;
ctx.fillRect(x, y, 16, 12);
ctx.fillStyle = PALETTE.chair;
ctx.fillRect(x + 2, y + 2, 12, 8);
}
function drawStool(ctx: CanvasRenderingContext2D, x: number, y: number): void {
ctx.fillStyle = "#6d5334";
ctx.fillRect(x, y, 10, 10);
}
function drawDesk(ctx: CanvasRenderingContext2D, desk: Rect, executive: boolean): void {
ctx.fillStyle = PALETTE.deskLeg;
ctx.fillRect(desk.x + 4, desk.y + desk.h - 6, 5, 10);
ctx.fillRect(desk.x + desk.w - 9, desk.y + desk.h - 6, 5, 10);
ctx.fillStyle = executive ? "#5f4a33" : PALETTE.deskWoodDark;
ctx.fillRect(desk.x, desk.y, desk.w, desk.h);
ctx.fillStyle = executive ? "#75593b" : PALETTE.deskWood;
ctx.fillRect(desk.x + 2, desk.y + 2, desk.w - 4, desk.h - 7);
const screen = deskScreenRect(desk);
ctx.fillStyle = PALETTE.monitorShell;
ctx.fillRect(screen.x - 2, screen.y - 2, screen.w + 4, screen.h + 4);
ctx.fillStyle = PALETTE.screenOff;
ctx.fillRect(screen.x, screen.y, screen.w, screen.h);
ctx.fillStyle = PALETTE.monitorShell;
ctx.fillRect(screen.x + screen.w / 2 - 3, screen.y + screen.h + 2, 6, 3);
if (executive) {
ctx.fillStyle = "#d8d3c8";
ctx.fillRect(desk.x + desk.w - 22, desk.y + 8, 12, 8);
ctx.fillStyle = "#4e7a46";
ctx.fillRect(desk.x + 8, desk.y + 6, 8, 10);
}
ctx.fillStyle = "#23262b";
ctx.fillRect(desk.x + desk.w / 2 - 8, desk.y + desk.h - 12, 16, 5);
drawChair(ctx, desk.x + desk.w / 2 - 8, desk.y + desk.h + 4);
}
function drawDoorSign(ctx: CanvasRenderingContext2D, room: RoomSpec): void {
const label = room.label;
ctx.font = "bold 8px monospace";
const width = ctx.measureText(label).width + 10;
const x = room.doorCx - width / 2;
const y = room.bounds.y + room.bounds.h - 13;
ctx.fillStyle = PALETTE.signPlate;
ctx.fillRect(x, y, width, 12);
ctx.fillStyle = PALETTE.signText;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(label, room.doorCx, y + 7);
ctx.textAlign = "start";
ctx.textBaseline = "alphabetic";
}
function drawEntrance(ctx: CanvasRenderingContext2D, plan: FloorPlan): void {
void plan;
ctx.fillStyle = PALETTE.frame;
ctx.fillRect(0, 292, 4, 56);
ctx.fillStyle = "#7fb3d9";
ctx.fillRect(4, 296, 4, 48);
ctx.fillStyle = "#8d6a45";
ctx.fillRect(20, 300, 26, 40);
ctx.fillStyle = "#9c7852";
ctx.fillRect(22, 302, 22, 36);
}
function rectsTouch(a: Rect, b: Rect): boolean {
return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;
}
function expand(rect: Rect, margin: number): Rect {
return { x: rect.x - margin, y: rect.y - margin, w: rect.w + margin * 2, h: rect.h + margin * 2 };
}
@@ -0,0 +1,74 @@
import { buildFloorPlan } from "./floor-plan";
import type { Camera, FloorPlan, OfficeAgentInput, Vec } from "./office-types";
export interface AgentSim {
input: OfficeAgentInput;
x: number;
y: number;
seat: Vec;
deskIndex: number | null;
isCeo: boolean;
}
/**
* Holds the living state of the office: who sits where and where everyone
* currently stands. Snapshot changes flow in through `sync`, which keeps
* existing positions stable so people never teleport when data refreshes.
*/
export class OfficeSimulation {
plan: FloorPlan;
agents: AgentSim[] = [];
private openDeskDemand = -1;
constructor() {
this.plan = buildFloorPlan(0);
}
/**
* Reconciles simulation state with the latest snapshot. The first
* `c_suite` agent (stable input order) occupies the CEO office; everyone
* else takes open-floor desks in order.
*/
sync(inputs: OfficeAgentInput[]): void {
const ceoId = inputs.find((input) => input.roleLevel === "c_suite")?.id ?? null;
const openCount = inputs.filter((input) => input.id !== ceoId).length;
if (openCount !== this.openDeskDemand) {
this.openDeskDemand = openCount;
this.plan = buildFloorPlan(openCount);
}
const existing = new Map(this.agents.map((agent) => [agent.input.id, agent]));
let openIndex = 0;
this.agents = inputs.map((input) => {
const isCeo = input.id === ceoId;
const deskIndex = isCeo ? null : openIndex++;
const seat = isCeo ? this.plan.ceoSeat : this.plan.desks[deskIndex!].seat;
const prev = existing.get(input.id);
return {
input,
x: prev?.x ?? seat.x,
y: prev?.y ?? seat.y,
seat,
deskIndex,
isCeo,
};
});
}
}
/**
* Camera top-left for a viewport centered on `focus`, clamped to the world.
* When the world is smaller than the viewport on an axis, the world is
* centered on that axis instead.
*/
export function cameraFor(world: { w: number; h: number }, viewW: number, viewH: number, focus: Vec): Camera {
return {
x: axisCamera(world.w, viewW, focus.x),
y: axisCamera(world.h, viewH, focus.y),
};
}
function axisCamera(worldSize: number, viewSize: number, focus: number): number {
if (worldSize <= viewSize) return -(viewSize - worldSize) / 2;
return Math.min(Math.max(focus - viewSize / 2, 0), worldSize - viewSize);
}
@@ -48,6 +48,7 @@ test("captures every page for visual review", async () => {
await go(page, "Approvals", outDir, "15-approvals");
await go(page, "Social", outDir, "16-social");
await go(page, "Settings", outDir, "17-settings");
await go(page, "Office", outDir, "18-office");
for (const tab of ["Workspace", "Billing", "Skills", "Migration"]) {
await page.getByRole("button", { name: "Settings" }).click();
@@ -0,0 +1,85 @@
import { describe, expect, test } from "vitest";
import { buildFloorPlan, findPath } from "../../src/renderer/src/pages/office/floor-plan";
import type { FloorPlan, Rect, Vec } from "../../src/renderer/src/pages/office/office-types";
function segmentIntersectsRect(a: Vec, b: Vec, rect: Rect): boolean {
if (a.x === b.x) {
const [y1, y2] = [Math.min(a.y, b.y), Math.max(a.y, b.y)];
return a.x > rect.x && a.x < rect.x + rect.w && y2 > rect.y && y1 < rect.y + rect.h;
}
const [x1, x2] = [Math.min(a.x, b.x), Math.max(a.x, b.x)];
return a.y > rect.y && a.y < rect.y + rect.h && x2 > rect.x && x1 < rect.x + rect.w;
}
function expectWalkablePath(plan: FloorPlan, path: Vec[]): void {
expect(path.length).toBeGreaterThanOrEqual(2);
for (let i = 0; i + 1 < path.length; i++) {
const from = path[i];
const to = path[i + 1];
expect(from.x === to.x || from.y === to.y).toBe(true);
for (const collider of plan.colliders) {
expect(segmentIntersectsRect(from, to, collider)).toBe(false);
}
}
}
describe("office floor plan", () => {
test("provides the four rooms of a real office", () => {
const plan = buildFloorPlan(5);
expect(plan.rooms.map((room) => room.kind)).toEqual(["ceo", "meeting", "snack", "restroom"]);
expect(plan.amenities.filter((a) => a.kind === "restroom").length).toBeGreaterThanOrEqual(2);
expect(plan.amenities.some((a) => a.kind === "coffee")).toBe(true);
expect(plan.amenities.some((a) => a.kind === "snack")).toBe(true);
});
test("desk field grows by rows so no head-count is ever cut off", () => {
const small = buildFloorPlan(3);
const large = buildFloorPlan(63);
expect(small.desks.length).toBeGreaterThanOrEqual(3);
expect(large.desks.length).toBeGreaterThanOrEqual(63);
expect(large.world.h).toBeGreaterThan(small.world.h);
expect(large.world.w).toBe(small.world.w);
});
test("desk seats and amenity stands never sit inside a collider", () => {
const plan = buildFloorPlan(28);
const standing = (p: Vec): Rect => ({ x: p.x - 7, y: p.y - 7, w: 14, h: 14 });
const spots = [plan.ceoSeat, ...plan.desks.map((d) => d.seat), ...plan.amenities.map((a) => a.stand)];
for (const spot of spots) {
const body = standing(spot);
for (const collider of plan.colliders) {
const overlap =
body.x < collider.x + collider.w &&
body.x + body.w > collider.x &&
body.y < collider.y + collider.h &&
body.y + body.h > collider.y;
expect(overlap).toBe(false);
}
}
});
test("every desk, the CEO seat, and every amenity is reachable from the entrance", () => {
const plan = buildFloorPlan(17);
const targets = [plan.ceoSeat, ...plan.desks.map((d) => d.seat), ...plan.amenities.map((a) => a.stand)];
for (const target of targets) {
expectWalkablePath(plan, findPath(plan, plan.entrance, target));
}
});
test("routes between desks and amenities across the whole office", () => {
const plan = buildFloorPlan(20);
const lastDesk = plan.desks[plan.desks.length - 1];
const coffee = plan.amenities.find((a) => a.kind === "coffee")!;
const restroom = plan.amenities.find((a) => a.kind === "restroom")!;
expectWalkablePath(plan, findPath(plan, lastDesk.seat, coffee.stand));
expectWalkablePath(plan, findPath(plan, coffee.stand, restroom.stand));
expectWalkablePath(plan, findPath(plan, restroom.stand, plan.ceoSeat));
});
test("re-routes from an arbitrary mid-walk position", () => {
const plan = buildFloorPlan(10);
const midCorridor = { x: 500, y: 320 };
const firstDesk = plan.desks[0];
expectWalkablePath(plan, findPath(plan, midCorridor, firstDesk.seat));
});
});