S3.9: the org chart draws everyone as their office pixel person, with one shared CEO rule
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ccd698602e
commit
e5a4e87300
7 files changed
+60
-7
No files matched your search
@@ -12,6 +12,7 @@ import { dayPart, season } from "../shared/palette-clock";
|
||||
import { pixelScale } from "../shared/pixel-scale";
|
||||
import type { Camera, OfficeAgentInput } from "./office/office-types";
|
||||
import { officeStatus } from "./office/agent-state";
|
||||
import { officeRoster } from "./office/roster";
|
||||
import { t } from "../i18n/i18n";
|
||||
|
||||
const FPS_INTERVAL = 1000 / 24;
|
||||
@@ -42,9 +43,7 @@ export function OfficePage({ snapshot }: PageProps) {
|
||||
if (simRef.current === null) simRef.current = new OfficeSimulation();
|
||||
|
||||
const inputs = useMemo<OfficeAgentInput[]>(() => {
|
||||
const orgAgents = snapshot.agents
|
||||
.filter((agent) => agent.orgId === orgId && agent.lifecycle === "active")
|
||||
.sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
|
||||
const orgAgents = officeRoster(snapshot.agents.filter((agent) => agent.orgId === orgId));
|
||||
const allTasks = snapshot.tasks.filter((task) => task.orgId === orgId);
|
||||
const orgTasks = allTasks.filter(isBusinessTask);
|
||||
return orgAgents.map((agent) => ({
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { appearanceFor, drawPerson } from "./sprites";
|
||||
|
||||
const W = 24;
|
||||
const H = 24;
|
||||
/** Each sprite pixel shows as a 2×2 block. */
|
||||
const ZOOM = 2;
|
||||
|
||||
/** The person as the office draws them, from the shoulders up, so a face on the org chart is the same face at the desk. */
|
||||
export function PixelAvatar({ id, isCeo }: { id: string; isCeo: boolean }) {
|
||||
const ref = useRef<HTMLCanvasElement>(null);
|
||||
useEffect(() => {
|
||||
const canvas = ref.current!;
|
||||
const scale = ZOOM * Math.max(1, Math.ceil(window.devicePixelRatio));
|
||||
canvas.width = W * scale;
|
||||
canvas.height = H * scale;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) throw new Error("The avatar canvas has no 2D context.");
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
ctx.setTransform(scale, 0, 0, scale, 0, 0);
|
||||
drawPerson(ctx, W / 2, H + 10, appearanceFor(id, isCeo), { facing: "south", walkPhase: 0, working: false, carrying: null, timeMs: 0, animSeed: 0 }, isCeo);
|
||||
}, [id, isCeo]);
|
||||
return <canvas ref={ref} className="pixel-avatar" data-person={id} aria-hidden="true" style={{ width: W * ZOOM, height: H * ZOOM }} />;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/** Agents who come to the office, in hire order: the office and the org chart read the same list. */
|
||||
export function officeRoster<T extends { id: string; lifecycle: string; createdAt: string }>(agents: T[]): T[] {
|
||||
return agents
|
||||
.filter((agent) => agent.lifecycle === "active")
|
||||
.sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
|
||||
}
|
||||
|
||||
/** The first c-suite agent on the roster holds the CEO office and wears the CEO's look. */
|
||||
export function ceoIdOf(roster: Array<{ id: string; roleLevel: string }>): string | null {
|
||||
return roster.find((agent) => agent.roleLevel === "c_suite")?.id ?? null;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { buildFloorPlan, findPath } from "./floor-plan";
|
||||
import type { AmenitySpot, Camera, FloorPlan, OfficeAgentInput, Rect, Vec } from "./office-types";
|
||||
import { appearanceFor, type Carrying, type Facing, type SpriteAppearance } from "./sprites";
|
||||
import { ceoIdOf } from "./roster";
|
||||
|
||||
export type AgentBehavior =
|
||||
| { kind: "seated" }
|
||||
@@ -278,7 +279,7 @@ export class OfficeSimulation {
|
||||
* else takes open-floor desks in order.
|
||||
*/
|
||||
sync(inputs: OfficeAgentInput[]): void {
|
||||
const ceoId = inputs.find((input) => input.roleLevel === "c_suite")?.id ?? null;
|
||||
const ceoId = ceoIdOf(inputs);
|
||||
const openCount = inputs.filter((input) => input.id !== ceoId).length;
|
||||
const planRebuilt = openCount !== this.openDeskDemand;
|
||||
if (planRebuilt) {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Avatar } from "../../shared/ui";
|
||||
import { PixelAvatar } from "../office/PixelAvatar";
|
||||
import { ceoIdOf, officeRoster } from "../office/roster";
|
||||
import type { AgentRecord, EmployeeRecord } from "../../../../shared/domain";
|
||||
import { t } from "../../i18n/i18n";
|
||||
|
||||
const NODE_W = 208;
|
||||
const NODE_W = 232;
|
||||
const NODE_H = 64;
|
||||
const H_GAP = 24;
|
||||
const V_GAP = 44;
|
||||
@@ -47,6 +48,7 @@ export function OrgChartTree({
|
||||
onSelectAgent: (agentId: string) => void;
|
||||
}) {
|
||||
const layout = useMemo(() => buildLayout(boardChair, agents), [boardChair, agents]);
|
||||
const ceoId = useMemo(() => ceoIdOf(officeRoster(agents)), [agents]);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const scaleRef = useRef(1);
|
||||
const pendingCenter = useRef<{ cx: number; cy: number } | null>(null);
|
||||
@@ -126,7 +128,7 @@ export function OrgChartTree({
|
||||
onClick={() => { if (node.agentId) onSelectAgent(node.agentId); }}
|
||||
disabled={!node.agentId}
|
||||
>
|
||||
<Avatar name={node.name} bot={!node.human} size="sm" />
|
||||
{node.id !== "__org__" && <PixelAvatar id={node.id} isCeo={node.id === ceoId} />}
|
||||
<span className="org-node-text">
|
||||
<span className="org-node-name">{node.name}</span>
|
||||
<span className="org-node-role">{node.role}</span>
|
||||
|
||||
@@ -635,6 +635,7 @@ a { color: inherit; }
|
||||
}
|
||||
.org-node-card:hover:not(:disabled) { border-color: var(--border-strong); box-shadow: var(--shadow-md); }
|
||||
.org-node-card:disabled { cursor: default; }
|
||||
.pixel-avatar { flex-shrink: 0; image-rendering: pixelated; }
|
||||
.org-node-card.human { border-color: var(--amber); background: var(--amber-soft); }
|
||||
.org-node-text { min-width: 0; display: flex; flex-direction: column; gap: 2px; }
|
||||
.org-node-name {
|
||||
|
||||
@@ -113,6 +113,21 @@ test("the office floor opens agent details on click", async () => {
|
||||
// Hired agents take open-floor desks in hire order and are clickable too.
|
||||
await hireAgent(page, "Ava Patel", "Designer", "Design");
|
||||
await hireAgent(page, "Ken Ito", "Engineer", "Engineering");
|
||||
|
||||
// The org chart draws everyone as the office's pixel people: the chair, the CEO and both hires.
|
||||
await page.getByRole("button", { name: "Team", exact: true }).click();
|
||||
const avatars = page.locator(".org-node-card canvas.pixel-avatar");
|
||||
await expect(avatars).toHaveCount(4);
|
||||
const painted = await avatars.evaluateAll((canvases) => canvases.map((canvas) => {
|
||||
const { data } = (canvas as HTMLCanvasElement).getContext("2d")!.getImageData(0, 0, (canvas as HTMLCanvasElement).width, (canvas as HTMLCanvasElement).height);
|
||||
let opaque = 0;
|
||||
for (let i = 3; i < data.length; i += 4) if (data[i] === 255) opaque++;
|
||||
return opaque;
|
||||
}));
|
||||
for (const count of painted) expect(count).toBeGreaterThan(100);
|
||||
// Different people look different.
|
||||
const looks = await avatars.evaluateAll((canvases) => canvases.map((canvas) => (canvas as HTMLCanvasElement).toDataURL()));
|
||||
expect(new Set(looks).size).toBe(4);
|
||||
await page.getByRole("button", { name: "Office", exact: true }).click();
|
||||
await expect(canvas).toBeVisible();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
Reference in new issue
Block a user