1
0
This repository has been archived on 2026-08-25. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
tera/src/engine/officeMinimap.ts
T
karti 3e9b97ed8b Signing in puts people in the building
`member` and `anon` were told apart inside the office by the client and by
nothing else. `access.ts` picked an `officeDepth`, `createOfficeScene` built a
presence layer at full depth — and then nobody ever called `setPresence`, so
both tiers rendered the identical empty room. A tier that changes nothing you
can see is not a tier, and `routes/markers.ts` had already written down why one
drawn only in the browser is worse than none: it is a UI hiding a control over a
body the API hands to whoever asks.

So the refusal happens on the server now.

`GET /api/v1/offices/:id/presence` is the one route that always takes a session,
whatever else the deployment is configured for. `markers.ts` serves its feed to
anonymous callers when no feed is configured, on the grounds that there is
nothing there to protect; that reasoning does not transfer, and the difference is
the whole point — a marker is a company at an address and a presence is a person
at a desk.

The ordering inside the handler is the security property, not a detail. It
resolves the viewer *before* it looks at the id, so an anonymous caller gets an
identical 401 for a real office, a private one and one that was never created.
Check the office first and 404-for-unknown against 401-for-known tells them apart
perfectly, which is the enumeration oracle CONTRACT.md §6 forbids, wearing a
different status code. Three requests and one `deepEqual` hold that down.

`TERA_PRESENCE_DIR` is a second directory rather than a `people` field on the
pack, and that is the design. `types.ts` says a `Presence` binds to a `seatId`
and never to a coordinate so the geometry can be published while the people
cannot — which buys nothing if both live in one file, because an operator who
wants a public floorplan then has to strip the roster out by hand, and the first
time they forget the leak is permanent. Two directories makes the safe thing the
default thing. An office with no roster is 200 and empty, never 404: "no such
office" and "nobody has told me who is in this one" are different problems with
different fixes, and one 404 sends an operator after the wrong one.

On the client, occupancy arrives after the room is on screen rather than before —
the building is worth looking at while a second request is in flight. An API that
answers is believed, including when it answers with nobody; an office where
everyone has gone home is a real fact and overwriting it with invented people to
liven up the demo is the one thing this must never do. An API that does not
answer falls back to a fabricated roster, exactly as the markers do, because a
clone with no server is the flagship case and a member shown the same empty room
as a stranger has been told the tier means something when it does not.

Those twenty-five people are invented and the page says so. `sample.ts` says it
to a reader of the source; `#office-badge` now says "Sample occupancy — these
people are invented" to the person looking at the room, and it is not suppressed
when a real deployment's API merely happened to be down — that is exactly the
case where a member would otherwise read invented names as their colleagues.
Fabricated names at real desks look like a staff list, and a screenshot of one
must not be possible to take without the caption.

The floor plan marks the occupied desks, one colour for everybody where the
scene has four: at three device pixels a hue is a guess. The plan answers "is
anyone there" and the room answers "who, and what are they doing". Hovering a
desk names them, and the readout reads as an address getting more specific —
metres, then room, then person.

server: 127 tests pass, 11 of them new. Client typechecks and builds; the office
chunk absorbed the plan renderer and the entry chunk moved 2.3 kB for the sample
roster. Checked in the browser at office.lumbridgecorp.com: FULL VIEW, the badge,
figures at the benches, dots on the plan, and "3.7, 16.7 m · Alcatraz ·
Clementine Roux" under the pointer.
2026-08-06 03:46:01 -07:00

1240 lines
45 KiB
TypeScript

/**
* The office, seen from straight above, in the same corner of the screen the
* city plan lives in.
*
* `minimap.ts` answers "where am I on this board" and it answers it about a
* board ninety-four kilometres across. Inside the building that widget is not
* merely unhelpful, it is *wrong*: it goes on drawing the Bay Area while the
* scene in front of it is a thirty-four-metre floor plate, so the one piece of
* chrome whose entire job is to say where you are is pointing at another county.
* This is the same widget for the other place — same corner, same `M` key, same
* camera footprint, same click-to-seek — reading `Plan` instead of `World`.
*
* It is deliberately a second module rather than a mode inside the first. The
* two share their *shape* and almost none of their content: one projects
* lat/lng through a `World`, rasterises a coastline and a hillshade and follows
* the sun; this one is already in metres, has walls instead of a shoreline, and
* lives under a fixed interior rig where the sun does not reach. Threading both
* through one file would mean a `if (city)` at the top of every function and a
* theme that is two themes. What they genuinely share is copied, and the
* comments say where the original is.
*
* The rules it keeps from `minimap.ts`, because they were earned there:
*
* - **Canvas 2D, permanently.** A second WebGL context to draw a few hundred
* filled rectangles would double the driver-side cost of the page.
* - **Nothing here knows how big an office is.** Every coordinate comes from
* `plan.bounds`. A 12 m studio and a 60 m floor plate both fit.
* - **`tick()` runs inside the stage's frame loop**, allocates nothing in the
* steady state, and bails when neither the camera nor the data has moved.
* The plan itself is rasterised once into an offscreen surface and blitted.
*
* This module owns exactly one DOM node: the canvas it hands back.
*/
import * as THREE from "three";
import type { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { kit, type AssetRegistry } from "../assets/kit.ts";
import type { LevelPlan, Plan, ResolvedRoom } from "../interiors/plan.ts";
import type { Presence } from "../interiors/types.ts";
/** What the pointer is over, for a readout line the caller owns. */
export interface OfficePlanHoverInfo {
/** Office-world metres. The same numbers a pack is authored in. */
x: number;
z: number;
/** The room under the pointer, by name, or `null` out in the circulation. */
room: string | null;
/**
* Who is at the desk under the pointer, by label, or `null`.
*
* A label and never an id. The plan is drawn from the pack, which knows seat
* `eng-04` and nothing else; the name arrives separately over an authenticated
* request, and handing back the id when there is nobody there would leak the
* seating chart into a widget that is otherwise pure geometry.
*/
person: string | null;
/** The level being drawn, by name. Printed only when there is more than one. */
level: string;
}
export interface OfficeMinimapOptions {
/** The resolved office. The same `Plan` the scene was built from, or the drawing lies. */
plan: Plan;
/** The live office camera. Read every frame, written only by the wheel dolly. */
camera: THREE.PerspectiveCamera;
/** The live orbit controls. `controls.target` is the crosshair. */
controls: OrbitControls;
/**
* Where prop footprints come from. Defaults to the shared `kit`, which is what
* `officeScene` defaults to as well — pass the same registry you passed the
* scene, or a prop it knows and this does not comes out as a 0.6 m square.
*/
registry?: AssetRegistry;
/** Fires when the user clicks, drags or commits a keyboard seek. Office metres. */
onSeek?(x: number, z: number): void;
/** Fires on hover, and once with `null` when the pointer leaves. */
onHover?(info: OfficePlanHoverInfo | null): void;
/** Device-pixel-ratio ceiling. Matches `stage.ts`: above 2 the gain is not real. */
maxPixelRatio?: number;
}
export interface OfficeMinimap {
/** The widget. The caller inserts it into its own container and sizes it in CSS. */
canvas: HTMLCanvasElement;
/**
* Which viewpoint the legend is showing as current, so the plan can ring the
* same one. `null` rings nothing, which is the state between a `flyTo` being
* asked for and arriving.
*/
setActiveView(id: string | null): void;
/**
* Who is in, so the plan can mark their desks.
*
* Takes `Presence[]` rather than a set of seat ids so the hover readout can
* name somebody without a second lookup by the caller. A presence whose seat
* is not on this plan is dropped, exactly as `presence.ts` drops it in the
* scene and for the same reason: there is nowhere to put it, and inventing a
* spot would turn a private id into a public coordinate.
*/
setPresence(people: readonly Presence[]): void;
/** Call from the stage tick. Cheap by construction — see the file header. */
tick(): void;
/** Re-do the backing store at the current size and re-rasterise the plan. */
resize(): void;
dispose(): void;
}
/** See `minimap.ts`: the 2D context union will not resolve overloads. One cast, one type. */
type Ctx = CanvasRenderingContext2D;
type Surface = HTMLCanvasElement | OffscreenCanvas;
/** Redraw ceiling, in ms. The stage runs at 60; the footprint does not need to. */
const FRAME_MS = 33;
/** How long the seek confirmation ring lives, in ms. Suppressed for reduced motion. */
const PING_MS = 420;
/** The interface's accent, as `index.html` sets it. Camera, footprint, active viewpoint. */
const ACCENT = 0xf2b134;
/**
* A prop smaller than this on the longer axis is not drawn.
*
* Half the props in the reference pack are mugs, monitors, plants and desk
* tidies. At the widget's scale — about seven device pixels to the metre on a
* 14 rem frame over a 34 m floor — a 0.25 m object is under two pixels, so it
* contributes no shape, only a speckle over the desks that reads as noise on
* the one drawing whose job is legibility. The threshold is in metres rather
* than in pixels on purpose: a plan that gains and loses its furniture as the
* panel is resized is worse than one that draws a stable subset.
*/
const MIN_PROP_M = 0.35;
/** Props standing above head height are fittings, not furniture. See `drawProps`. */
const MAX_PROP_ELEVATION_M = 1.6;
export function createOfficeMinimap(options: OfficeMinimapOptions): OfficeMinimap {
const { plan, camera, controls } = options;
const registry = options.registry ?? kit;
const maxPixelRatio = options.maxPixelRatio ?? 2;
const canvas = document.createElement("canvas");
canvas.className = "minimap-canvas";
canvas.tabIndex = 0;
canvas.setAttribute("role", "application");
canvas.setAttribute(
"aria-label",
`Floor plan of ${plan.office.name}. Click or drag to move the view, scroll to zoom, ` +
`arrow keys to aim and Enter to go.`,
);
// Without this a drag on a touch screen scrolls the page out from under the
// pointer capture and the seek stops mid-gesture.
canvas.style.touchAction = "none";
// The widget takes its size from its container, and it has to — see the long
// note in `minimap.ts` about the backing store / layout feedback loop this
// breaks. The container must have a real height.
canvas.style.display = "block";
canvas.style.width = "100%";
canvas.style.height = "100%";
const viewCtx = canvas.getContext("2d") as Ctx | null;
/** The plan is rasterised once into its own surface and blitted under the overlay. */
const staticSurface: Surface =
typeof OffscreenCanvas === "function"
? new OffscreenCanvas(1, 1)
: document.createElement("canvas");
const staticCtx = staticSurface.getContext("2d") as Ctx | null;
// ---- The board ------------------------------------------------------------
/**
* The extent, from the office's own bounds, in office-world metres.
*
* Scene +x is right and +z is *down* the drawing, which is the office pack's
* own convention — `Yaw` zero faces -Z, so -Z is the top of the plan — and it
* happens to match the city widget's north-up orientation exactly. Nothing is
* negated anywhere in this file, and that is why.
*
* A small margin, because a building whose outer wall is exactly on the board
* edge loses half that wall's thickness to the clip.
*/
const margin = Math.max(0.4, Math.max(plan.bounds.width, plan.bounds.depth) * 0.02);
const westX = plan.bounds.minX - margin;
const northZ = plan.bounds.minZ - margin;
const boardW = Math.max(1e-3, plan.bounds.width + margin * 2);
const boardH = Math.max(1e-3, plan.bounds.depth + margin * 2);
// Layout, in device pixels. Everything is recomputed by `layout()`.
let dpr = 1;
let pxW = 0;
let pxH = 0;
/** Device pixels per office metre. */
let scale = 0;
let boardX = 0;
let boardY = 0;
let boardPxW = 0;
let boardPxH = 0;
let ready = false;
const toPxX = (x: number): number => boardX + (x - westX) * scale;
const toPxY = (z: number): number => boardY + (z - northZ) * scale;
const fromPxX = (px: number): number => westX + (px - boardX) / scale;
const fromPxZ = (py: number): number => northZ + (py - boardY) / scale;
// ---- State ----------------------------------------------------------------
/**
* The storey being drawn.
*
* A one-level pack — which the reference office is, and which nearly every
* pack will be — never changes this. A stacked pack does, and it is chosen by
* where the camera is *looking* rather than where it is standing: on a
* mezzanine the camera is routinely a storey above the floor it is showing
* you, so `camera.position.y` would draw the wrong plan for the whole of a
* viewpoint that is framed correctly.
*/
let level: LevelPlan | null = plan.levels[0] ?? null;
let activeViewId: string | null = null;
/** Occupied seats on this storey: x, y device pixels per person, laid out once. */
let occupiedPx = new Float64Array(0);
/** Seat id -> label, for the hover readout. Every seat in the building, not just this storey. */
let peopleBySeat = new Map<string, string>();
// Laid-out geometry. Flat arrays and paths of device pixels, rebuilt on resize
// and on a change of storey, so the draw loop reads numbers and never projects.
let roomPaths: { path: Path2D; open: boolean }[] = [];
let zonePath = new Path2D();
let wallPath = new Path2D();
let glazingPath = new Path2D();
let propPath = new Path2D();
let labels: { text: string; x: number; y: number }[] = [];
/** Viewpoint pins on this storey: x, y device pixels, then the index into `viewpoints`. */
let viewpointPx = new Float64Array(0);
let viewpointIds: string[] = [];
// ---- Interaction state ----------------------------------------------------
let dirty = true;
let lastDraw = 0;
let hoverX = -1;
let hoverY = -1;
let hoverRoom: string | null = null;
let dragging = false;
/** The keyboard's aim point, in device pixels. `-1` until an arrow key is pressed. */
let pendingX = -1;
let pendingY = -1;
/** When the seek confirmation ring started, in `performance.now()` ms. 0 = not running. */
let pinging = 0;
let pingX = 0;
let pingZ = 0;
const motionQuery =
typeof window.matchMedia === "function"
? window.matchMedia("(prefers-reduced-motion: reduce)")
: null;
let reducedMotion = motionQuery?.matches ?? false;
// Camera state as of the last draw, for the bail-out. Compared exactly rather
// than with an epsilon, for the reason `minimap.ts` sets out: OrbitControls'
// damping asymptotes, and a footprint frozen a few frames early on a
// still-drifting map is the kind of small wrongness that reads as a fault.
let lastCamX = NaN;
let lastCamY = NaN;
let lastCamZ = NaN;
let lastTgtX = NaN;
let lastTgtY = NaN;
let lastTgtZ = NaN;
let lastFov = NaN;
let lastAspect = NaN;
// Scratch for the frustum corners. Allocated once; the draw loop may not
// allocate.
const corners = [
new THREE.Vector3(),
new THREE.Vector3(),
new THREE.Vector3(),
new THREE.Vector3(),
];
/** NDC corners, bottom-left first, so the quad comes out wound consistently. */
const NDC_X = [-1, 1, 1, -1];
const NDC_Y = [-1, -1, 1, 1];
const theme = buildTheme();
// ---- Layout ---------------------------------------------------------------
/**
* Fit the floor plate inside the widget, letterboxed, never stretched.
*
* The reference office is 34 x 18 metres — very nearly two to one — and
* squeezing that into a square frame is instantly wrong to anyone who has
* stood in the room. Whatever is left over stays transparent so the panel's
* own card background shows through, exactly as the city widget does.
*/
function layout() {
const pad = Math.round(2 * dpr);
const availW = Math.max(1, pxW - pad * 2);
const availH = Math.max(1, pxH - pad * 2);
scale = Math.min(availW / boardW, availH / boardH);
boardPxW = boardW * scale;
boardPxH = boardH * scale;
boardX = pad + (availW - boardPxW) / 2;
boardY = pad + (availH - boardPxH) / 2;
}
/**
* Which storey the camera is looking at.
*
* Nearest floor at or below the orbit target, falling back to the nearest
* floor outright, so a target that has drifted under the slab still resolves
* to the ground floor rather than to nothing.
*/
function levelForCamera(): LevelPlan | null {
if (plan.levels.length <= 1) return plan.levels[0] ?? null;
const y = controls.target.y;
let best: LevelPlan | null = null;
let bestGap = Infinity;
for (const candidate of plan.levels) {
const gap = Math.abs(candidate.floorY - y);
const below = candidate.floorY <= y + 0.01;
// A storey you are standing on beats one you are under, at any distance:
// `below` is preferred outright and distance only breaks the tie.
const score = below ? gap : gap + 1e6;
if (score < bestGap) {
bestGap = score;
best = candidate;
}
}
return best ?? plan.levels[0] ?? null;
}
// ---- Building the drawing --------------------------------------------------
function outlinePath(outline: readonly { x: number; z: number }[]): Path2D {
const path = new Path2D();
outline.forEach((point, i) => {
const x = toPxX(point.x);
const y = toPxY(point.z);
if (i === 0) path.moveTo(x, y);
else path.lineTo(x, y);
});
path.closePath();
return path;
}
/**
* A rectangle standing in the office, as a path in device pixels.
*
* `w` runs along the object's local +X and `d` along its local +Z. For yaw φ
* those axes point at `(cos φ, -sin φ)` and `(sin φ, cos φ)` — three.js's
* rotation about +Y, which is what `Yaw` promises and what `plan.ts` computes
* its wall yaws with. Getting this sign wrong mirrors every desk in the
* building about its own centre, which is invisible on a square and obvious on
* a wall run.
*/
function boxPath(path: Path2D, cx: number, cz: number, w: number, d: number, yaw: number) {
const c = Math.cos(yaw);
const s = Math.sin(yaw);
const hw = w / 2;
const hd = d / 2;
for (let i = 0; i < 4; i++) {
const u = i === 0 || i === 3 ? -hw : hw;
const v = i < 2 ? -hd : hd;
const x = cx + u * c + v * s;
const z = cz - u * s + v * c;
if (i === 0) path.moveTo(toPxX(x), toPxY(z));
else path.lineTo(toPxX(x), toPxY(z));
}
path.closePath();
}
function buildGeometry() {
roomPaths = [];
zonePath = new Path2D();
wallPath = new Path2D();
glazingPath = new Path2D();
propPath = new Path2D();
labels = [];
viewpointPx = new Float64Array(0);
viewpointIds = [];
if (!level || scale <= 0) return;
// Rooms, in pack order, because that is the order they are drawn in the
// scene: the open floor is laid down first and the meeting rooms sit on top.
for (const room of level.rooms) {
roomPaths.push({ path: outlinePath(room.outline), open: room.ceiling === null });
}
for (const zone of level.zones) {
const path = outlinePath(zone.outline);
// One path for every zone rather than one per zone: they are drawn in a
// single flat tint, so the only thing separate paths would buy is the
// ability to tint them differently, which needs a palette this widget
// deliberately does not have. Overlapping zones double the tint; the pack
// that does that is describing overlapping zones.
zonePath.addPath(path);
}
/**
* Walls, as the solid runs only.
*
* `Plan` has already split every wall around its openings, so taking the
* `solid` runs and ignoring the lintels and aprons leaves a gap at every
* door, window and arch — which is exactly how a floor plan is drawn, and
* it costs nothing because the decomposition was done for the collider
* anyway. Nothing here re-derives where a hole is.
*/
for (const run of level.runs) {
if (run.role !== "solid") continue;
boxPath(wallPath, run.center.x, run.center.z, run.length, run.thickness, run.yaw);
}
/**
* Glazing, as a thin line across the hole it fills.
*
* Only windows. A door and an arch are gaps you walk through and the gap is
* the drawing; a window is a gap you cannot, and leaving it blank breaks the
* building's outline into disconnected stubs — the reference office is
* glazed along its whole north edge, so without this the top wall simply is
* not there.
*/
for (const opening of level.openings) {
if (opening.kind !== "window") continue;
boxPath(
glazingPath,
opening.center.x,
opening.center.z,
opening.width,
// A hairline in metres, so it stays a hairline at every widget size
// rather than swelling into a second wall on a wide panel.
Math.min(opening.thickness, 0.06),
opening.yaw,
);
}
/**
* Furniture, at its real footprint.
*
* The registry already knows how big every asset is — it has to, to build
* them — so a desk on this plan is the desk's own width and depth turned by
* its own yaw, not a generic dot. That is the difference between a diagram
* of a floor and a picture of a floor: four benches of twelve read as four
* benches of twelve, and the circulation between them is the space that is
* actually there.
*
* Props are drawn without their `params`, because a `PropPlacement` does not
* carry any — the pack's props are placed by id and take the asset's
* defaults. An asset whose footprint depends on parameters it was never
* given comes out at its default size, which is the same size the scene
* builds it at.
*/
for (const prop of level.props) {
// Wall-mounted screens, ceiling fittings and anything else off the floor.
// They are above where a plan is cut, and drawing them puts a solid
// rectangle over the room they hang in.
if (prop.position.y - level.floorY > MAX_PROP_ELEVATION_M) continue;
const footprint = registry.footprintOf(prop.kind);
const w = footprint.width * prop.scale[0];
const d = footprint.depth * prop.scale[2];
if (Math.max(w, d) < MIN_PROP_M) continue;
boxPath(propPath, prop.position.x, prop.position.z, w, d, prop.rotation);
}
layoutLabels();
layoutViewpoints();
layoutOccupied();
}
/**
* Room names, where they fit.
*
* Measured against the room's own bounds and dropped when they do not fit,
* rather than shrunk or ellipsised. A plan with six names on it is read; a
* plan with fifteen names on it, four of them clipped and two overlapping, is
* looked at and then ignored. Which six survive is decided by the geometry and
* therefore changes with the panel width, which is correct: a wider panel has
* room for more of them.
*/
function layoutLabels() {
if (!staticCtx || !level) return;
const ctx = staticCtx;
ctx.save();
ctx.font = labelFont(dpr);
for (const room of level.rooms) {
const w = ctx.measureText(room.name).width;
const boxW = (room.bounds.maxX - room.bounds.minX) * scale;
const boxH = (room.bounds.maxZ - room.bounds.minZ) * scale;
if (w > boxW * 0.88 || boxH < 11 * dpr) continue;
labels.push({
text: room.name,
x: toPxX(room.centroid.x),
y: toPxY(room.centroid.z),
});
}
ctx.restore();
}
/**
* Where the occupied desks are, in device pixels.
*
* Resolved through `plan.seat()` rather than through the caller, because the
* seat's position is the plan's fact and a second copy of it would be a second
* thing to get wrong. Seats on another storey resolve fine and are skipped
* here — they are drawn when that storey is.
*/
function layoutOccupied() {
if (!level || scale <= 0) return;
const points: number[] = [];
for (const seatId of peopleBySeat.keys()) {
const seat = plan.seat(seatId);
if (!seat || seat.levelId !== level.id) continue;
points.push(toPxX(seat.position.x), toPxY(seat.position.z));
}
occupiedPx = new Float64Array(points);
}
function layoutViewpoints() {
if (!level) return;
const here = plan.viewpoints.filter((v) => v.levelId === level?.id);
viewpointPx = new Float64Array(here.length * 2);
viewpointIds = here.map((v) => v.id);
here.forEach((v, i) => {
viewpointPx[i * 2] = toPxX(v.focus.at.x);
viewpointPx[i * 2 + 1] = toPxY(v.focus.at.z);
});
}
// ---- The static plan --------------------------------------------------------
function renderStatic() {
if (!staticCtx || !ready) return;
const ctx = staticCtx;
ctx.clearRect(0, 0, pxW, pxH);
ctx.save();
ctx.beginPath();
ctx.rect(boardX, boardY, boardPxW, boardPxH);
ctx.clip();
ctx.fillStyle = theme.ground;
ctx.fillRect(boardX, boardY, boardPxW, boardPxH);
for (const room of roomPaths) {
// An atrium — a room the pack said explicitly has no ceiling — is drawn
// lighter, because from above it is the one part of the floor you can
// actually see into. It is the only room distinction this widget makes,
// and it is made from a fact in the data rather than from a name.
ctx.fillStyle = room.open ? theme.atrium : theme.floor;
ctx.fill(room.path);
}
ctx.fillStyle = theme.zone;
ctx.fill(zonePath);
ctx.strokeStyle = theme.roomEdge;
ctx.lineWidth = dpr;
for (const room of roomPaths) ctx.stroke(room.path);
ctx.fillStyle = theme.prop;
ctx.fill(propPath);
ctx.strokeStyle = theme.propEdge;
ctx.lineWidth = dpr * 0.6;
ctx.stroke(propPath);
ctx.fillStyle = theme.glazing;
ctx.fill(glazingPath);
// Walls last, over everything. A desk pushed against a partition should be
// clipped by it rather than drawn across it, and the wall is the line the
// eye uses to find the room.
ctx.fillStyle = theme.wall;
ctx.fill(wallPath);
/**
* Names last, over a halo.
*
* The halo is not decoration. A room's centroid is very often the middle of
* its furniture — "The Floor" centres on a desk bank, which is the whole
* point of a desk bank — so a name drawn flat lands on top of the one part
* of the drawing with the most edges in it and becomes unreadable exactly
* where it is most needed. Stroking the ground colour behind the glyphs
* buys the contrast back without moving the label somewhere it does not
* belong, which is the alternative and is worse: a name floating in the
* corridor beside its room is a name attached to the wrong room.
*/
ctx.font = labelFont(dpr);
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.lineWidth = 3 * dpr;
ctx.lineJoin = "round";
ctx.strokeStyle = theme.labelHalo;
ctx.fillStyle = theme.label;
for (const label of labels) {
ctx.strokeText(label.text, label.x, label.y);
ctx.fillText(label.text, label.x, label.y);
}
ctx.restore();
ctx.strokeStyle = theme.frame;
ctx.lineWidth = dpr;
ctx.strokeRect(
boardX + dpr / 2,
boardY + dpr / 2,
Math.max(0, boardPxW - dpr),
Math.max(0, boardPxH - dpr),
);
}
// ---- The overlay ------------------------------------------------------------
/**
* The footprint: where the camera's frustum meets *this storey's floor*.
*
* The city widget intersects with y = 0 because a city's ground is y = 0. An
* office's is `level.floorY`, and on a stacked pack the difference is a whole
* storey — a footprint drawn against the wrong plane is offset by the camera's
* height over the storey gap, which is a large and confidently-wrong number.
*
* The clamp on rays that are not heading downward is `minimap.ts`'s and is
* kept for the same reason: solving `t = -h / dir.y` for an upward ray gives a
* negative `t`, which puts the corner behind the camera and turns the
* trapezoid into a bow-tie that flickers across the plan every time you tilt
* up. Indoors this happens constantly, because a viewpoint two metres off the
* floor looking across the room has most of its frustum above the floor plane.
*/
function drawFootprint(ctx: Ctx) {
if (!level) return;
const height = camera.position.y - level.floorY;
if (!(height > 0.01)) return;
camera.updateMatrixWorld();
const maxRay = Math.max(boardW, boardH) * 4;
ctx.beginPath();
for (let i = 0; i < 4; i++) {
const v = corners[i];
if (!v) return;
v.set(NDC_X[i] ?? 0, NDC_Y[i] ?? 0, 0.5).unproject(camera).sub(camera.position);
const length = v.length();
if (!(length > 1e-6)) return;
v.multiplyScalar(1 / length);
const t = v.y < -1e-4 ? Math.min(-height / v.y, maxRay) : maxRay;
const px = toPxX(camera.position.x + v.x * t);
const py = toPxY(camera.position.z + v.z * t);
if (i === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
}
ctx.closePath();
ctx.fillStyle = theme.footprintFill;
ctx.fill();
ctx.strokeStyle = theme.footprintStroke;
ctx.lineWidth = 1.25 * dpr;
ctx.lineJoin = "round";
ctx.stroke();
}
/** A chevron at the camera, pointing the way it is looking. */
function drawCamera(ctx: Ctx) {
const x = toPxX(camera.position.x);
const y = toPxY(camera.position.z);
const dx = controls.target.x - camera.position.x;
const dz = controls.target.z - camera.position.z;
const len = Math.hypot(dx, dz);
if (!(len > 1e-6)) return;
const nx = dx / len;
const ny = dz / len;
const sx = -ny;
const sy = nx;
const s = 4.6 * dpr;
ctx.beginPath();
ctx.moveTo(x + nx * s * 1.5, y + ny * s * 1.5);
ctx.lineTo(x - nx * s * 0.7 + sx * s, y - ny * s * 0.7 + sy * s);
ctx.lineTo(x - nx * s * 0.2, y - ny * s * 0.2);
ctx.lineTo(x - nx * s * 0.7 - sx * s, y - ny * s * 0.7 - sy * s);
ctx.closePath();
ctx.fillStyle = theme.camera;
ctx.fill();
ctx.strokeStyle = theme.cameraEdge;
ctx.lineWidth = dpr;
ctx.stroke();
}
function crosshair(ctx: Ctx, x: number, y: number, color: string, r: number) {
ctx.strokeStyle = color;
ctx.lineWidth = dpr;
ctx.beginPath();
ctx.moveTo(x - r, y);
ctx.lineTo(x - r * 0.35, y);
ctx.moveTo(x + r * 0.35, y);
ctx.lineTo(x + r, y);
ctx.moveTo(x, y - r);
ctx.lineTo(x, y - r * 0.35);
ctx.moveTo(x, y + r * 0.35);
ctx.lineTo(x, y + r);
ctx.stroke();
ctx.beginPath();
ctx.arc(x, y, r * 0.32, 0, Math.PI * 2);
ctx.stroke();
}
/** The pack's viewpoints, as the same dots the city widget gives its chapters. */
function drawViewpoints(ctx: Ctx) {
ctx.lineWidth = 1.2 * dpr;
for (let i = 0; i < viewpointIds.length; i++) {
const x = viewpointPx[i * 2] ?? 0;
const y = viewpointPx[i * 2 + 1] ?? 0;
ctx.beginPath();
ctx.arc(x, y, 1.7 * dpr, 0, Math.PI * 2);
ctx.fillStyle = theme.viewpoint;
ctx.fill();
if (viewpointIds[i] === activeViewId) {
ctx.beginPath();
ctx.arc(x, y, 4.6 * dpr, 0, Math.PI * 2);
ctx.strokeStyle = theme.viewpointActive;
ctx.stroke();
}
}
}
/**
* An occupied desk, as a filled dot with a dark rim.
*
* Drawn in the overlay rather than into the static raster, because occupancy
* is the one thing on this plan that changes without the building changing —
* re-rasterising fifteen rooms and two hundred props to move one dot would be
* the wrong trade by three orders of magnitude.
*
* One colour for everybody, deliberately, where the scene has four. The scene
* has the room to distinguish heads-down from in-a-meeting and this does not:
* at three device pixels a hue is a guess, and four guesses on one plan is a
* legend nobody asked for. The plan answers "is anyone there", the room
* answers "who, and what are they doing".
*/
function drawOccupied(ctx: Ctx) {
if (occupiedPx.length === 0) return;
const r = 2.4 * dpr;
ctx.lineWidth = dpr;
ctx.fillStyle = theme.occupied;
ctx.strokeStyle = theme.occupiedEdge;
for (let i = 0; i < occupiedPx.length; i += 2) {
ctx.beginPath();
ctx.arc(occupiedPx[i] ?? 0, occupiedPx[i + 1] ?? 0, r, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
}
}
function drawPing(ctx: Ctx, now: number) {
if (pinging === 0) return;
const t = (now - pinging) / PING_MS;
if (t >= 1) {
pinging = 0;
return;
}
ctx.beginPath();
ctx.arc(toPxX(pingX), toPxY(pingZ), (3 + 13 * t) * dpr, 0, Math.PI * 2);
ctx.strokeStyle = rgba(theme.accentRgb, 0.75 * (1 - t));
ctx.lineWidth = 1.4 * dpr;
ctx.stroke();
}
function draw(now: number) {
if (!viewCtx) return;
const ctx = viewCtx;
ctx.clearRect(0, 0, pxW, pxH);
ctx.drawImage(staticSurface as CanvasImageSource, 0, 0);
ctx.save();
ctx.beginPath();
ctx.rect(boardX, boardY, boardPxW, boardPxH);
ctx.clip();
drawFootprint(ctx);
drawOccupied(ctx);
drawViewpoints(ctx);
crosshair(ctx, toPxX(controls.target.x), toPxY(controls.target.z), theme.target, 5 * dpr);
drawCamera(ctx);
if (pendingX >= 0) crosshair(ctx, pendingX, pendingY, theme.pending, 7 * dpr);
if (hoverX >= 0) crosshair(ctx, hoverX, hoverY, theme.hover, 6 * dpr);
drawPing(ctx, now);
ctx.restore();
}
/** True when anything the overlay draws from the camera has changed. */
function cameraMoved(): boolean {
return (
camera.position.x !== lastCamX ||
camera.position.y !== lastCamY ||
camera.position.z !== lastCamZ ||
controls.target.x !== lastTgtX ||
controls.target.y !== lastTgtY ||
controls.target.z !== lastTgtZ ||
camera.fov !== lastFov ||
camera.aspect !== lastAspect
);
}
function recordCamera() {
lastCamX = camera.position.x;
lastCamY = camera.position.y;
lastCamZ = camera.position.z;
lastTgtX = controls.target.x;
lastTgtY = controls.target.y;
lastTgtZ = controls.target.z;
lastFov = camera.fov;
lastAspect = camera.aspect;
}
// ---- Interaction ------------------------------------------------------------
/**
* Pointer client coordinates to device pixels on the backing store, via the
* bounding rect's own ratio rather than `dpr` — see `minimap.ts`. The two
* differ under a CSS transform or browser page zoom, and in a 34 m room a
* seek a few metres from where you clicked lands in the wrong room.
*/
function eventToPx(event: PointerEvent | WheelEvent): [number, number] {
const rect = canvas.getBoundingClientRect();
const kx = rect.width > 0 ? pxW / rect.width : dpr;
const ky = rect.height > 0 ? pxH / rect.height : dpr;
return [(event.clientX - rect.left) * kx, (event.clientY - rect.top) * ky];
}
const clampX = (px: number): number => Math.min(boardX + boardPxW, Math.max(boardX, px));
const clampY = (py: number): number => Math.min(boardY + boardPxH, Math.max(boardY, py));
function roomAt(x: number, z: number): ResolvedRoom | null {
if (!level) return null;
return plan.roomAt(level.id, { x, z });
}
/**
* Whoever is sitting within a desk's width of the pointer, or nobody.
*
* A radius rather than a hit test on the seat itself, because a seat is a
* point and a pointer on a 14 rem widget is worth about fifteen centimetres of
* office. Three quarters of a metre is close enough to be unambiguous — desks
* in a bench are 1.7 m apart — and forgiving enough to be usable.
*
* Linear over the occupied seats, which is the right algorithm at this size: a
* full floor is a few dozen people, this runs on pointer moves already
* throttled by the browser, and a spatial index would be more code than the
* thing it indexes.
*/
function personNear(x: number, z: number): string | null {
if (!level) return null;
const reach = 0.75;
let best: string | null = null;
let bestGap = reach * reach;
for (const [seatId, label] of peopleBySeat) {
const seat = plan.seat(seatId);
if (!seat || seat.levelId !== level.id) continue;
const dx = seat.position.x - x;
const dz = seat.position.z - z;
const gap = dx * dx + dz * dz;
if (gap < bestGap) {
bestGap = gap;
best = label;
}
}
return best;
}
function seekTo(px: number, py: number) {
if (!ready) return;
const x = clampX(px);
const y = clampY(py);
pingX = fromPxX(x);
pingZ = fromPxZ(y);
// The only animation in the widget, and the only thing reduced motion turns
// off. The seek itself has never been eased.
pinging = reducedMotion ? 0 : performance.now();
dirty = true;
options.onSeek?.(pingX, pingZ);
}
function onPointerDown(event: PointerEvent) {
if (!ready || event.button !== 0) return;
const [px, py] = eventToPx(event);
dragging = true;
canvas.setPointerCapture(event.pointerId);
canvas.focus({ preventScroll: true });
pendingX = -1;
seekTo(px, py);
event.preventDefault();
}
function onPointerMove(event: PointerEvent) {
if (!ready) return;
const [px, py] = eventToPx(event);
const x = clampX(px);
const y = clampY(py);
if (x !== hoverX || y !== hoverY) {
hoverX = x;
hoverY = y;
dirty = true;
const wx = fromPxX(x);
const wz = fromPxZ(y);
const room = roomAt(wx, wz);
hoverRoom = room?.name ?? null;
options.onHover?.({
x: wx,
z: wz,
room: hoverRoom,
person: personNear(wx, wz),
level: level?.name ?? "",
});
}
if (dragging) seekTo(px, py);
}
function endDrag(event: PointerEvent) {
if (!dragging) return;
dragging = false;
if (canvas.hasPointerCapture(event.pointerId)) canvas.releasePointerCapture(event.pointerId);
}
function onPointerLeave() {
// Pointer capture makes the boundary events fire at capture release rather
// than at the real edge, so a drag that runs off the widget would otherwise
// drop the readout while it is still seeking.
if (dragging) return;
if (hoverX < 0 && hoverRoom === null) return;
hoverX = -1;
hoverY = -1;
hoverRoom = null;
dirty = true;
options.onHover?.(null);
}
/**
* The wheel dollies the real camera along its own view vector, written
* straight into `camera.position` — safe because `OrbitControls.update`
* re-derives its spherical coordinates from the camera every frame, and
* bounded by the controls' own limits, so the plan cannot put the camera
* anywhere dragging the scene could not.
*/
function onWheel(event: WheelEvent) {
if (!ready) return;
event.preventDefault();
// `deltaMode` 1 is lines, not pixels — Firefox reports a handful of lines
// where everyone else reports a hundred-odd pixels.
const raw = event.deltaMode === 1 ? event.deltaY * 16 : event.deltaY;
const step = Math.exp(Math.max(-160, Math.min(160, raw)) * 0.0022);
const dx = camera.position.x - controls.target.x;
const dy = camera.position.y - controls.target.y;
const dz = camera.position.z - controls.target.z;
const distance = Math.hypot(dx, dy, dz);
if (!(distance > 1e-6)) return;
const next = Math.min(controls.maxDistance, Math.max(controls.minDistance, distance * step));
const k = next / distance;
camera.position.set(
controls.target.x + dx * k,
controls.target.y + dy * k,
controls.target.z + dz * k,
);
dirty = true;
}
/** Keyboard aiming. Arrows move a pending crosshair, Enter commits it. */
function onKeyDown(event: KeyboardEvent) {
if (!ready) return;
const step = (event.shiftKey ? 0.06 : 0.015) * Math.max(boardPxW, boardPxH);
let dx = 0;
let dy = 0;
switch (event.key) {
case "ArrowLeft":
dx = -step;
break;
case "ArrowRight":
dx = step;
break;
case "ArrowUp":
dy = -step;
break;
case "ArrowDown":
dy = step;
break;
case "Enter":
case " ":
if (pendingX >= 0) {
seekTo(pendingX, pendingY);
event.preventDefault();
}
return;
case "Escape":
if (pendingX >= 0) {
pendingX = -1;
dirty = true;
}
return;
default:
return;
}
if (pendingX < 0) {
pendingX = clampX(toPxX(controls.target.x));
pendingY = clampY(toPxY(controls.target.z));
}
pendingX = clampX(pendingX + dx);
pendingY = clampY(pendingY + dy);
dirty = true;
event.preventDefault();
}
function onBlur() {
if (pendingX < 0) return;
pendingX = -1;
dirty = true;
}
function onMotionChange(event: MediaQueryListEvent) {
reducedMotion = event.matches;
if (reducedMotion) pinging = 0;
}
canvas.addEventListener("pointerdown", onPointerDown);
canvas.addEventListener("pointermove", onPointerMove);
canvas.addEventListener("pointerup", endDrag);
canvas.addEventListener("pointercancel", endDrag);
canvas.addEventListener("pointerleave", onPointerLeave);
canvas.addEventListener("wheel", onWheel, { passive: false });
canvas.addEventListener("keydown", onKeyDown);
canvas.addEventListener("blur", onBlur);
motionQuery?.addEventListener("change", onMotionChange);
// The widget is sized by the caller's CSS, so it watches its own box: it is
// inserted into a panel that may be closed, and a container that animates open
// would otherwise leave a plan rasterised at the wrong size.
const observer =
typeof ResizeObserver === "function" ? new ResizeObserver(() => resize()) : null;
observer?.observe(canvas);
// ---- Lifecycle --------------------------------------------------------------
function resize() {
const cssW = canvas.clientWidth;
const cssH = canvas.clientHeight;
if (cssW === 0 || cssH === 0) {
ready = false;
return;
}
const nextDpr = Math.min(window.devicePixelRatio || 1, maxPixelRatio);
const w = Math.max(1, Math.round(cssW * nextDpr));
const h = Math.max(1, Math.round(cssH * nextDpr));
if (ready && w === pxW && h === pxH) return;
dpr = nextDpr;
pxW = w;
pxH = h;
canvas.width = w;
canvas.height = h;
staticSurface.width = w;
staticSurface.height = h;
ready = true;
layout();
buildGeometry();
renderStatic();
dirty = true;
}
resize();
return {
canvas,
setActiveView(id) {
if (id === activeViewId) return;
activeViewId = id;
dirty = true;
},
setPresence(people) {
peopleBySeat = new Map();
for (const person of people) {
// Last writer wins on a duplicated seat, which matches what the scene
// does with two meshes at one position: you see one person. A roster
// that seats two people at one desk is wrong in the roster.
peopleBySeat.set(person.seatId, person.label);
}
layoutOccupied();
dirty = true;
},
tick() {
if (!ready || !viewCtx) return;
const now = performance.now();
if (now - lastDraw < FRAME_MS) return;
// A storey change is the one thing that invalidates the raster, and it is
// checked here rather than watched, because the only thing that can cause
// it is the camera moving and this is the function the camera's movement
// already runs through. On a single-level pack it is one identity compare.
const next = levelForCamera();
if (next !== level) {
level = next;
buildGeometry();
renderStatic();
dirty = true;
}
if (!dirty && pinging === 0 && !cameraMoved()) return;
lastDraw = now;
dirty = false;
recordCamera();
draw(now);
},
resize,
dispose() {
observer?.disconnect();
canvas.removeEventListener("pointerdown", onPointerDown);
canvas.removeEventListener("pointermove", onPointerMove);
canvas.removeEventListener("pointerup", endDrag);
canvas.removeEventListener("pointercancel", endDrag);
canvas.removeEventListener("pointerleave", onPointerLeave);
canvas.removeEventListener("wheel", onWheel);
canvas.removeEventListener("keydown", onKeyDown);
canvas.removeEventListener("blur", onBlur);
motionQuery?.removeEventListener("change", onMotionChange);
ready = false;
roomPaths = [];
labels = [];
canvas.remove();
},
};
}
/** The label face, sized in device pixels so it is the same physical size everywhere. */
function labelFont(dpr: number): string {
return `${Math.round(9 * dpr)}px ui-monospace, SFMono-Regular, Menlo, monospace`;
}
// ---- Palette ---------------------------------------------------------------
interface Rgb {
r: number;
g: number;
b: number;
}
interface Theme {
ground: string;
floor: string;
atrium: string;
zone: string;
roomEdge: string;
wall: string;
glazing: string;
prop: string;
propEdge: string;
label: string;
labelHalo: string;
occupied: string;
occupiedEdge: string;
frame: string;
footprintFill: string;
footprintStroke: string;
camera: string;
cameraEdge: string;
target: string;
hover: string;
pending: string;
viewpoint: string;
viewpointActive: string;
accentRgb: Rgb;
}
/**
* One palette, and no day-night pair.
*
* The city plan authors two and crossfades them because the map it is drawing is
* lit by a sun that swings through 360° over a day. An office is not: the
* interior rig in `officeScene` is fixed, deliberately, because a floor plate
* under a rotating sun is a room where you cannot find the meeting room at 2
* a.m. The scene does not change with the hour, so neither does this, and a
* `setSolarElevation` here would be a method that had to exist and do nothing.
*
* The values are the panel's own — `index.html`'s ink ramp over the office
* background — so the widget reads as part of the card it sits in rather than as
* a photograph pasted into it. Contrast runs floor → furniture → wall, in that
* order and with real gaps between them, because that is the order the eye needs
* them: the slab is context, the desks are content, and the walls are the lines
* you navigate by.
*/
function buildTheme(): Theme {
const accent = rgbOf(ACCENT);
return {
// Outside the building. Near-black, so the floor plate reads as a lit object
// on a dark ground rather than as a hole in a light one.
ground: rgba(rgbOf(0x0a0d11), 1),
floor: rgba(rgbOf(0x1c232b), 1),
// An atrium is drawn lighter because from above it is the part of the floor
// you can actually see into.
atrium: rgba(rgbOf(0x252e38), 1),
zone: rgba(rgbOf(0xffffff), 0.035),
roomEdge: rgba(rgbOf(0xffffff), 0.07),
// The strongest thing on the drawing, and the only near-white. Everything
// else is a step down from this.
wall: rgba(rgbOf(0xd6dee6), 0.92),
// Glazing is a wall you can see through, and it is drawn as one: same hue,
// half the presence.
glazing: rgba(rgbOf(0x9fc4d8), 0.62),
prop: rgba(rgbOf(0x8f9aa6), 0.5),
propEdge: rgba(rgbOf(0xc3ccd6), 0.32),
label: rgba(rgbOf(0xffffff), 0.58),
// The ground colour, near-opaque, so a name over a desk bank sits in its own
// small clearing rather than in the middle of the desks.
labelHalo: rgba(rgbOf(0x0a0d11), 0.82),
// Brighter than the furniture it sits on and cooler than the amber the
// camera owns, so a busy floor never competes with "where am I looking",
// which is still this widget's first job.
occupied: rgba(rgbOf(0x8ec3e8), 0.95),
occupiedEdge: rgba(rgbOf(0x0a0d11), 0.7),
frame: rgba(rgbOf(0x9fb4c6), 0.3),
// Faint, for the reason the city widget's is faint: on the whole-floor view
// the footprint covers most of the widget, and a fill that is a hint over
// one room is a colour cast over the building. The outline carries the
// shape; the fill only says which side of it you are on.
footprintFill: rgba(accent, 0.1),
footprintStroke: rgba(accent, 0.8),
camera: rgba(accent, 0.95),
cameraEdge: rgba(rgbOf(0x0a0d11), 0.55),
target: rgba(rgbOf(0xe8f1f8), 0.8),
hover: rgba(rgbOf(0xe8f1f8), 0.45),
pending: rgba(accent, 0.75),
viewpoint: rgba(rgbOf(0xdfe9f1), 0.55),
viewpointActive: rgba(accent, 0.9),
accentRgb: accent,
};
}
function rgbOf(hex: number): Rgb {
return { r: (hex >> 16) & 255, g: (hex >> 8) & 255, b: hex & 255 };
}
/** Legacy comma syntax, not `rgb(r g b / a)`: canvas parsing, not CSS, is the floor here. */
function rgba(c: Rgb, alpha: number): string {
return `rgba(${Math.round(c.r)}, ${Math.round(c.g)}, ${Math.round(c.b)}, ${alpha.toFixed(3)})`;
}