/** * 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(); /** * 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)})`; }