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