Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions content/posts/city-of-agents/components/activity-chart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"use client";

import { ACTIVITIES } from "./sim";
import { FILL } from "./use-city";

/** Columns per day: one every ten minutes. */
export const SLOTS = 144;
const H = 60;
/** Walking and doing nothing are drawn lighter, so the four activities carry the picture. */
const WEIGHT: Record<(typeof ACTIVITIES)[number], number> = { sleep: 1, work: 1, eat: 1, social: 1, walking: 0.55, idle: 0.25 };

export type ActivityLayer = { columns: readonly (ArrayLike<number> | null)[]; opacity?: number };

/**
* A day as stacked columns, one every ten minutes: what share of people were doing what, sleep at the bottom. One path
* per activity and layer, not a rectangle per cell, so a live chart redrawn a few times a second stays cheap. Later
* layers are drawn over earlier ones; `now` marks a slot with a line.
*/
export function ActivityColumns({ layers, now, label, className = "h-28" }: { layers: ActivityLayer[]; now?: number; label: string; className?: string }) {
return (
<svg viewBox={`0 0 ${SLOTS} ${H}`} preserveAspectRatio="none" className={`w-full rounded-sm border border-border ${className}`} role="img" aria-label={label}>
{layers.map((layer, l) => ACTIVITIES.map((kind, k) => {
let d = "";
layer.columns.forEach((column, x) => {
if (!column) return;
let y = H;
for (let j = 0; j < k; j++) y -= column[j] * H;
const h = column[k] * H;
if (h > 0.05) d += `M${x} ${(y - h).toFixed(2)}h1.02v${h.toFixed(2)}h-1.02z`;
});
return d ? <path key={`${l}-${kind}`} d={d} className={FILL[kind]} opacity={WEIGHT[kind] * (layer.opacity ?? 1)} /> : null;
}))}
{now !== undefined && <line x1={now} x2={now} y1={0} y2={H} className="stroke-foreground" strokeWidth={1.5} vectorEffect="non-scaling-stroke" />}
</svg>
);
}

export function HourTicks() {
return <div className="label flex justify-between" aria-hidden><span>00</span><span>06</span><span>12</span><span>18</span><span>24</span></div>;
}
37 changes: 36 additions & 1 deletion content/posts/city-of-agents/components/city-lab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import { Pause, Play } from "lucide-react";
import { useRef, useState } from "react";
import { Readout } from "@/components/lab/readout";
import { Button } from "@/components/ui/button";
import { ActivityColumns, HourTicks } from "./activity-chart";
import { personTask } from "./describe";
import { personName, useLabels } from "./labels";
import type { PanelState } from "./session";
import { formatTime, type Mode, PARAMS } from "./sim";
import { SWATCH, useCity } from "./use-city";

Expand All @@ -24,6 +27,37 @@ export function Legend() {
);
}

/**
* Who the camera follows and what they are up to right now, in the Overseer's words. Only the name is announced: the
* task changes every few seconds at speed, and a screen reader would never stop talking.
*/
export function FollowBadge({ panel }: { panel: PanelState | null }) {
const t = useLabels(), id = panel?.follow ?? -1, p = panel && id >= 0 ? panel.people[id] : undefined;
if (!panel || !p) return null;
return (
<div className="label absolute top-2 left-2 flex max-w-[calc(100%-1rem)] items-center gap-1.5 rounded-sm bg-background/80 px-2 py-1 text-foreground">
<span role="status" className="shrink-0">{t.following} · {personName(t, id)}</span>
<span aria-hidden className={`size-2 shrink-0 rounded-full border border-border ${SWATCH[p.action ?? "idle"]}`} />
<span className="truncate" data-testid="city-follow-task">{personTask(t, panel.city, p)}</span>
</div>
);
}

/**
* The day so far under the picture: people go indoors, so the scene alone no longer shows how many are at work or
* asleep. Yesterday shows, faint, only where today has not got to yet.
*/
export function DayStrip({ panel }: { panel: PanelState | null }) {
const t = useLabels();
if (!panel) return null;
return (
<div className="grid gap-1" data-testid="city-day">
<ActivityColumns layers={[{ columns: panel.yesterday.map((c, k) => (panel.today[k] ? null : c)), opacity: 0.3 }, { columns: panel.today }]} now={(panel.t % 1440) / 10} label={t.today} className="h-12" />
<HourTicks />
</div>
);
}

/**
* The city and the people in it, with as few controls as the point needs: play, speed, follow someone. With `modes`,
* also who decides and whether duty exists — the same city and the same legs under a different head.
Expand All @@ -41,8 +75,9 @@ export function CityLab({ seed = 1, n = 8, agents = 300, modes = false }: { seed
<canvas ref={canvas} role="img" aria-label={t.scene} className="aspect-[4/5] w-full cursor-pointer rounded-sm bg-[#070918] sm:aspect-[16/9]" data-testid="city-canvas"
onClick={(e) => act(() => s?.setFollow(s.pick(e.clientX, e.clientY)))} />
{!ready && <p className="label absolute inset-0 grid place-items-center text-muted-foreground">{t.loading}</p>}
{follow >= 0 && <p className="label absolute top-2 left-2 rounded-sm bg-background/80 px-2 py-1 text-foreground" role="status">{t.following} · {personName(t, follow)}</p>}
<FollowBadge panel={panel} />
</div>
<DayStrip panel={panel} />
<Legend />
{modes && (
<div className="flex flex-wrap items-center gap-x-6 gap-y-2">
Expand Down
Loading
Loading