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

1582 lines
62 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;
}
/**
* One robot walking about the building, as this widget needs it.
*
* Structural, and deliberately *not* `RobotView` imported from
* `interiors/robots.ts` — the same call `luminaires.ts` makes with its `Walker`,
* and made here for a stronger reason. This file is drawn from a `Plan` and
* nothing else; a type import from the robot layer would tie the widget's public
* contract to a module it otherwise has no business knowing exists, and the next
* thing that walks about a floor would have to be a robot to be drawable. Two
* fields is the whole of what a mark on a floor plan needs. A `RobotView`
* satisfies this as it stands and nothing has to be adapted.
*
* The robot's own `id` is read nowhere, on purpose. `drawOccupied` sets out why
* the plan answers "is anybody there" rather than "who" even for people, and a
* robot is further down that road again — `robots.ts` is explicit that a robot is
* nobody, so there is not even a name to decline to print.
*/
export interface PlanRobot {
/** Which storey it is on. It is drawn only while that storey is the one shown. */
levelId: string;
/**
* Office-world metres, at its feet. **Live**: whoever owns the robot mutates
* this vector in place every frame. This file reads it and never writes it.
*/
position: THREE.Vector3;
}
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 OfficeMinimapPlayer {
levelId: string;
x: number;
z: number;
/** Radians in office X/Z space; zero faces local north (-Z). */
headingRad: number;
kind: "humanoid" | "anonymous-dog";
}
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;
/**
* The robots walking about the building, so the plan shows them moving.
*
* Shaped like `setPresence` — the caller hands over the domain objects and the
* widget does its own resolving, rather than the caller pre-chewing them into
* pixels — with one difference that comes out of the data and not out of
* taste. Presence arrives from a poll every few seconds and each answer is a
* *snapshot*, so `setPresence` does its work when it is called. The robot layer
* publishes a stable array of vectors it mutates in place, so this is called
* **once**, with that array, and every frame afterwards is read straight out of
* it by `tick`. That is the same handshake `officeScene` already makes with
* `luminaires.setWalkers`, and it is what lets the plan show something moving
* at sixty hertz without anybody allocating anything.
*
* Calling it every frame is harmless — it costs one reference compare — so a
* caller that would rather push than be read is not punished for it. Handing
* over a *different* array drops the old one, and the new robots have no
* heading until they have taken a step.
*/
setRobots(robots: readonly PlanRobot[]): void;
setPlayer(player: OfficeMinimapPlayer | null): 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;
/**
* The empty robot list, shared and frozen by convention.
*
* Module-level so that an office with no robots — which is every pack that does
* not ask for them, and the default — never allocates for the feature at all.
* What it pays instead is one `length === 0` test per frame in three functions.
*/
const NO_ROBOTS: readonly PlanRobot[] = [];
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;
let player: OfficeMinimapPlayer | 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>();
/**
* The robots, live. The array belongs to whoever called `setRobots` and its
* contents change underneath this file between one draw and the next.
*/
let robotList: readonly PlanRobot[] = NO_ROBOTS;
/**
* Where each robot was as of the last draw — office metres, x then z — and the
* unit direction it was last seen travelling in, again x then z. Two flat
* arrays rather than an array of objects, for the reason every other buffer in
* this file is flat: the draw loop may not allocate and may not chase pointers.
*
* **The heading is derived here rather than published by the layer**, which
* looks like a gap and is not one. A `RobotView` carries a position and no yaw;
* the layer knows its yaw perfectly well and simply does not hand it out, and
* asking it to would be a change to a contract that three other callers read.
* Differencing two positions recovers the heading to better than a pixel: the
* layer advances a robot *exactly* along its own yaw — `x -= sin(yaw) · ds`,
* `z -= cos(yaw) · ds` — so the step between two draws **is** the yaw, one
* redraw stale, which at this widget's 30 Hz ceiling and the layer's 2.2 rad/s
* turn rate is under four degrees. Four degrees on a mark five pixels long is
* not visible.
*
* The one case where the derived heading and the rig's yaw genuinely part
* company is a robot rotating while barely moving — yielding to another robot,
* or pivoting into a doorway with its pace scaled to nearly nothing. Then this
* keeps pointing the way the machine last actually went, which is the better
* answer for a plan: a plan records what happened on the floor, not what a
* transform is doing this instant.
*/
let robotLast = new Float64Array(0);
let robotDir = new Float64Array(0);
// 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();
}
}
/**
* A robot, as a turned chassis with a bow on the front.
*
* **The shape carries this, not the colour.** `drawOccupied` has already
* established that a hue is a guess at three device pixels, and it is right; a
* robot drawn as a differently-tinted dot is a person to anybody who has not
* been told otherwise, and this widget has no legend to tell them with. So the
* marker is built out of the one channel that survives at five pixels —
* silhouette — and the plan's silhouettes are a small closed vocabulary:
*
* - a **circle** is somebody: an occupied desk, or a viewpoint pin;
* - an **axis-aligned rectangle** is the building or its furniture, drawn
* once into the raster and never moving again;
* - a **notched amber chevron** is the camera, and there is exactly one.
*
* A robot is therefore a *turned* rectangle with a point on the front. Hard
* corners, so it reads machined rather than grown. Wider across than it is
* deep, so the turn is visible at all and the thing has shoulders. Convex,
* unnotched, cool and about 60% of the linear size of the chevron, so it is
* never mistaken for the camera — which is still this widget's first job.
*
* A plain square was the first attempt and is useless twice over: four-fold
* symmetry means turning it conveys nothing, so the heading has to be a second
* mark stuck on the outside, and a square sitting unturned among the desks is a
* desk. A detached tick ahead of the body was the second attempt, and two
* pixels of ink with a gap in front of them reads as dirt on the screen rather
* than as a nose. Folding the point into the body path costs no extra ink, no
* extra fill, and cannot come adrift from the thing it belongs to.
*
* The colour is a mint green — the third hue on the drawing, after the
* people-blue and the camera-amber, and the last one this plan will get. Green
* is the furthest free hue from both of them; it is the brightest mark per unit
* of ink on a near-black ground, because luminance lives mostly in the green
* channel, which is what something moving among a hundred static grey
* rectangles wants; and it is already the colour a viewer reads as a machine
* that is running. Its riskiest confusion is with the camera's amber, since
* red-green colour blindness pulls both toward yellow — which is precisely the
* pair separated by silhouette and by size above, and is why the shape had to
* do the work first and the hue second.
*/
function drawRobots(ctx: Ctx) {
if (robotList.length === 0 || !level) return;
// Half the beam, the distance from the middle to the transom, and the point
// out in front of it. A touch smaller than the occupied dot on purpose: there
// are only ever a few of these, they are the only thing on the plan that
// moves, and a moving mark of a given size already shouts louder than a still
// one.
const half = 2.5 * dpr;
const rear = 1.7 * dpr;
const bow = 2.3 * dpr;
ctx.lineWidth = dpr;
ctx.fillStyle = theme.robot;
ctx.strokeStyle = theme.robotEdge;
for (let i = 0; i < robotList.length; i++) {
const robot = robotList[i];
// The level test is the whole of the storey handling, and it is per-draw
// rather than laid out like `occupiedPx` because a robot moves and a seat
// does not: there is nothing to cache that would still be true next frame.
if (!robot || robot.levelId !== level.id) continue;
const x = toPxX(robot.position.x);
const y = toPxY(robot.position.z);
// A direction in office metres is already a direction on the drawing —
// `toPxX` and `toPxY` are the same positive scale on both axes with no
// negation anywhere, which the header explains at length. `drawCamera`
// leans on the same fact and the two would break together if the plan were
// ever mirrored.
const fx = robotDir[i * 2] ?? 0;
const fy = robotDir[i * 2 + 1] ?? 0;
// Both zero only before a robot's first step: `recordRobots` writes a unit
// vector or nothing at all.
const known = fx !== 0 || fy !== 0;
const nx = known ? fx : 0;
const ny = known ? fy : 1;
// Starboard, from forward. Same derivation as the camera chevron's.
const sx = -ny;
const sy = nx;
// With no heading yet the body is drawn as a square and keeps its bow: a
// rectangle turned some arbitrary way is a claim about which way a machine
// is pointing, and this is the one state — a robot that has not moved since
// it was handed over — where there is honestly nothing to claim.
const back = known ? rear : half;
ctx.beginPath();
ctx.moveTo(x - nx * back - sx * half, y - ny * back - sy * half);
ctx.lineTo(x + nx * back - sx * half, y + ny * back - sy * half);
if (known) ctx.lineTo(x + nx * (back + bow), y + ny * (back + bow));
ctx.lineTo(x + nx * back + sx * half, y + ny * back + sy * half);
ctx.lineTo(x - nx * back + sx * half, y - ny * back + sy * half);
ctx.closePath();
ctx.fill();
// The ground colour, hairline, exactly as an occupied desk gets: a machine
// crossing a desk bank has to keep its outline against the furniture it is
// walking over, and the fill alone does not manage it.
ctx.stroke();
}
}
function drawPlayer(ctx: Ctx) {
if (!player || !level || player.levelId !== level.id) return;
const x = toPxX(player.x);
const y = toPxY(player.z);
const nx = -Math.sin(player.headingRad);
const ny = -Math.cos(player.headingRad);
const sx = -ny;
const sy = nx;
const r = (player.kind === "anonymous-dog" ? 3.6 : 4.2) * dpr;
ctx.beginPath();
ctx.arc(x, y, r + 2.5 * dpr, 0, Math.PI * 2);
ctx.strokeStyle = theme.viewpointActive;
ctx.lineWidth = 1.2 * dpr;
ctx.stroke();
ctx.beginPath();
ctx.moveTo(x + nx * r * 1.35, y + ny * r * 1.35);
ctx.lineTo(x - nx * r * 0.65 + sx * r * 0.65, y - ny * r * 0.65 + sy * r * 0.65);
ctx.lineTo(x - nx * r * 0.65 - sx * r * 0.65, y - ny * r * 0.65 - sy * r * 0.65);
ctx.closePath();
ctx.fillStyle = theme.viewpointActive;
ctx.fill();
}
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);
// Over the furniture, the desks and the viewpoint pins, and under the
// crosshair and the camera. A robot standing on a viewpoint is the thing you
// want to see; the camera is the thing you want to see over everything, and
// that has been the order here since the widget was one function.
drawRobots(ctx);
drawPlayer(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;
}
/**
* True when a robot on the storey being drawn has moved since the last draw.
*
* Split from `recordRobots` exactly as `cameraMoved` is split from
* `recordCamera`, and compared exactly rather than with an epsilon for the
* reason given there and one of its own: a robot eases into its destination
* over the last 0.9 m, so its final frames are fractions of a millimetre, and
* any tolerance worth having would strand the marker short of where the figure
* in the scene is standing.
*
* **Only the storey being drawn counts.** A robot pacing about a mezzanine
* nobody is looking at must not hold this widget open at thirty frames a second
* for the whole session, drawing nothing, which is exactly what it would do if
* this looked at all of them.
*/
function robotsMoved(): boolean {
if (robotList.length === 0 || !level) return false;
for (let i = 0; i < robotList.length; i++) {
const robot = robotList[i];
if (!robot || robot.levelId !== level.id) continue;
if (robot.position.x !== robotLast[i * 2]) return true;
if (robot.position.z !== robotLast[i * 2 + 1]) return true;
}
return false;
}
/**
* Take the positions this draw is about to use, and turn the step since the
* last one into a heading.
*
* Every robot and not only the visible ones, unlike `robotsMoved`. The
* alternative is that a robot on another storey keeps whatever position it had
* when that storey was last on screen, and the first frame after changing
* floors derives its heading from a stride several metres long taken minutes
* ago — a marker confidently pointing across the building. A handful of robots
* is a handful of subtractions; being clever here would cost more to explain
* than to skip.
*
* A zero step leaves the heading alone rather than clearing it. That is what
* lets a robot that has stopped keep facing the way it arrived instead of
* losing its nose every time it pauses for a few seconds, which is most of the
* time — and the figure in the scene does exactly the same thing, because the
* rig's yaw is not reset when it halts either.
*/
function recordRobots() {
for (let i = 0; i < robotList.length; i++) {
const robot = robotList[i];
if (!robot) continue;
const x = robot.position.x;
const z = robot.position.z;
// NaN on the first pass after `setRobots`, which is deliberate and is why
// `robotLast` is filled with it: `NaN > 1e-6` is false, so the first draw
// records a position and claims no heading from it.
const dx = x - (robotLast[i * 2] ?? NaN);
const dz = z - (robotLast[i * 2 + 1] ?? NaN);
const step = Math.hypot(dx, dz);
if (step > 1e-6) {
robotDir[i * 2] = dx / step;
robotDir[i * 2 + 1] = dz / step;
}
robotLast[i * 2] = x;
robotLast[i * 2 + 1] = z;
}
}
// ---- 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;
},
setRobots(next) {
// In the intended wiring this is the same array object every time, so the
// common path is a reference compare and a return. That is not a
// micro-optimisation: marking the widget dirty on every call would defeat
// the bail-out in `tick` outright and pin the panel at its full redraw rate
// in an office where nothing whatsoever is moving.
if (next === robotList) return;
robotList = next;
robotLast = new Float64Array(next.length * 2);
// NaN, not the zero a fresh `Float64Array` comes with. Zero is a perfectly
// ordinary coordinate — plenty of packs put the corner of a floor plate
// near the origin — so a zeroed previous position makes the first step look
// like a stride from the origin to wherever the robot actually is, and
// every robot spends its first frame pointing away from the middle of the
// building. NaN makes that first difference no difference at all, which is
// the truth: nothing is known yet about where this machine came from.
robotLast.fill(NaN);
robotDir = new Float64Array(next.length * 2);
dirty = true;
},
setPlayer(next) {
if (
player?.levelId === next?.levelId && player?.x === next?.x &&
player?.z === next?.z && player?.headingRad === next?.headingRad &&
player?.kind === next?.kind
) return;
player = next ? { ...next } : null;
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;
}
// `robotsMoved` last of the three, because it is the only one that walks a
// list, and an office with no robots settles it on a length compare.
if (!dirty && pinging === 0 && !cameraMoved() && !robotsMoved()) return;
lastDraw = now;
dirty = false;
recordCamera();
// Before `draw`, not after: the headings this frame's markers are turned by
// are derived from the step that has just been taken, so recording after
// drawing would render every robot one frame behind its own nose.
recordRobots();
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 = [];
// Back to the shared empty. The robot list is somebody else's live array
// and it is the one thing this widget holds that outlives it — a disposed
// panel keeping a reference to a disposed scene's robots is how a torn-down
// office stays reachable from a DOM node nobody can see any more.
robotList = NO_ROBOTS;
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;
robot: string;
robotEdge: 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),
// The only green on the plan, and the only mark on it that moves. The full
// argument for a hue of its own rather than a second blue is at `drawRobots`,
// and the short version is that the silhouette is what says "machine" and the
// colour only has to stay out of the way of the people and of the camera.
robot: rgba(rgbOf(0x5fd9a6), 0.95),
// The ground colour behind it, exactly as an occupied desk gets. Written out
// again rather than sharing `occupiedEdge`: the two are the same value today
// and they are not the same decision, and a plan that changed how it rims its
// people because somebody adjusted its robots would be a small mystery.
robotEdge: 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)})`;
}