1582 lines
62 KiB
TypeScript
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)})`;
|
|
}
|