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 df534c3530 On the office door, the map is of the office
office.lumbridgecorp.com and tera.lumbridgecorp.com are one bundle behind two
names, and the office door had two ways of forgetting which one you came in
through.

The plan panel was the loud one. `createMinimap` is built per city, from
`city.world`, and nothing swapped it when you stepped inside — so the one piece
of chrome whose entire job is to answer "where am I" went on drawing the Bay
Area while the scene in front of it was a thirty-four-metre floor plate. Not
merely unhelpful: pointing at another county.

So there is a second widget for the other place. `officeMinimap.ts` reads the
`Plan` the scene was already built from — no second resolution pass, no geometry
re-derived — and draws it: room slabs, the walls as their `solid` runs only,
which leaves a gap at every door and arch because `Plan` had already split them
for the collider; a glazing hairline across each window, without which the
reference office's north edge simply is not there; furniture at its real
footprint from the asset registry, turned by its own yaw, so four benches of
twelve read as four benches of twelve; room names over a halo, because a room's
centroid is usually the middle of its own desks. Same corner, same `M`, same
camera footprint, same click-to-seek, same keyboard aim. The footprint is cut
against `level.floorY` rather than y=0, which is the same number on a one-storey
pack and a whole storey out on any other.

It is a second module rather than a mode inside the first because 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 and lives under a fixed interior rig where the sun does not
reach. One file would have been an `if (city)` at the top of every function.

It arrives in the office chunk, not the entry chunk. It reads prop footprints
off the asset registry, so it is downstream of the furniture catalogue that the
`await import()` in `loadOffice` exists to hold back; a value import in main.ts
would have pulled all of it forward and silently undone the split. So main.ts
takes the constructor as an argument and names the module type-only, and the
build agrees: officeMinimap is its own 10.3 kB chunk and the entry is unmoved.

The quiet one was the URL. Leaving the office swapped the scene and left the
address bar saying `office.` — which is the thing you copy, bookmark and send to
someone, and it took them somewhere other than what you were looking at. From
the `office.` host the exit is now a real navigation to the same hostname with
its first label swapped: the same string comparison that decided this was the
office door decides where the city is, so a deployment serving one name and not
the other gets an honest 404 on a name it chose not to serve rather than a
silent lie. `?city=` carries the metro across so stepping out into SoCal lands
in SoCal, and is read unconditionally because a deep link to a city is a
reasonable thing to want on its own. On `?view=office` there is no other host to
go to, so the scene swap stands and the query is dropped on the way out.

Checked against the production build with both real hostnames resolved at a
local static server: office.lumbridgecorp.com boots into the office with the
floor plan in the panel, the hover readout gives office metres and the room
under the pointer ("9.8, 16.7 m · Bernal"), `O` lands on tera.lumbridgecorp.com
showing the Bay Area, and the console is clean.
2026-08-06 03:45:53 -07:00

1115 lines
40 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";
/** 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;
/** 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;
/** 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;
// 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();
}
/**
* 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();
}
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();
}
}
}
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);
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 });
}
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,
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;
},
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;
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),
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)})`;
}