diff --git a/src/engine/officeMinimap.ts b/src/engine/officeMinimap.ts new file mode 100644 index 0000000..de5950f --- /dev/null +++ b/src/engine/officeMinimap.ts @@ -0,0 +1,1114 @@ +/** + * 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)})`; +} diff --git a/src/main.ts b/src/main.ts index d546818..f4a9fbb 100644 --- a/src/main.ts +++ b/src/main.ts @@ -51,6 +51,13 @@ import { createMinimap, type Minimap } from "./engine/minimap.ts"; import type { OfficeScene } from "./interiors/officeScene.ts"; import type { Office } from "./interiors/types.ts"; import type { MaterialRegistry } from "./assets/materials.ts"; +// Type-only for the reason above, and it matters more here than it looks: +// `officeMinimap.ts` imports the asset registry as a *value*, to read prop +// footprints, so a value import of it here would drag the furniture catalogue +// back into the entry chunk and quietly undo the split this block exists to +// protect. It arrives with the office, in `loadOffice()`, because it is only +// ever drawn once you are standing in one. +import type { OfficeMinimap } from "./engine/officeMinimap.ts"; import type { Godmode, GodmodePlace } from "./tools/index.ts"; import type { PoseEditor } from "./tools/poseEditor.ts"; @@ -165,6 +172,20 @@ let poseEditor: PoseEditor | null = null; */ let officePack: Office | null = null; let officeMaterials: MaterialRegistry | null = null; +/** + * The plan panel's other occupant. + * + * There are two of these because there are two places, and the panel shows the + * one you are standing in. Holding the city plan up while the scene in front of + * it is a floor plate was the bug this pair exists to fix: the single piece of + * chrome whose whole job is "where am I" was answering about another county. + * + * It is built with the office and dies with it, so the office's `Plan`, camera + * and controls — all three of which it holds by reference — cannot outlive the + * scene they came from. Which of the two is *mounted* is a separate question + * from which exist, and `showPlan()` is the only thing that answers it. + */ +let officePlan: OfficeMinimap | null = null; /** * `office.lumbridgecorp.com` and `tera.lumbridgecorp.com` are one bundle behind @@ -174,9 +195,50 @@ let officeMaterials: MaterialRegistry | null = null; * city" work from the office front door — so this is a policy about where boot * *stops*, not about what boot builds. */ +const OFFICE_HOST = location.hostname.split(".")[0] === "office"; + const OPENS_IN_OFFICE = - location.hostname.split(".")[0] === "office" || - new URLSearchParams(location.search).get("view") === "office"; + OFFICE_HOST || new URLSearchParams(location.search).get("view") === "office"; + +/** + * Where the city is, when this page is the office front door. + * + * `office.lumbridgecorp.com` and `tera.lumbridgecorp.com` are one bundle behind + * two names, and until now leaving the office from the office door swapped the + * scene and left the address bar saying `office.` — so the site's own URL + * disagreed with the site. That is not a cosmetic complaint: it is the thing you + * copy, bookmark and send to someone, and it took them somewhere other than what + * you were looking at. + * + * So the city door is this hostname with its first label swapped, and the guess + * is exactly as safe as the one directly above it: it is only ever consulted + * when that label is literally `office`, which is the same string comparison + * that made this the office door in the first place. Same registrable domain, + * same origin policy, same static root, same API — a deployment that serves one + * of these names and not the other has half-configured itself, and the honest + * failure for that is a 404 on a name it chose not to serve rather than a silent + * lie in the address bar. + * + * Anything else — a bare domain, `spaces.example.com`, `?view=office` on the + * city door — derives nothing and keeps the in-page swap, which is what the + * whole scene-swap design was for. + */ +function cityDoorUrl(cityWanted?: string): string | null { + if (!OFFICE_HOST) return null; + const url = new URL(location.href); + const labels = url.hostname.split("."); + labels[0] = "tera"; + url.hostname = labels.join("."); + url.search = ""; + url.hash = ""; + // Carried so that stepping out of the office into SoCal lands in SoCal. The + // page load is the cost of telling the truth about where you are; arriving in + // the wrong metro on top of it would not be. + if (cityWanted !== undefined && cityWanted !== CITIES[0]?.id) { + url.searchParams.set("city", cityWanted); + } + return url.href; +} // ---- Time ----------------------------------------------------------------- @@ -271,6 +333,8 @@ async function mountCity(id: string) { weatherWatch = null; poseEditor?.destroy(); poseEditor = null; + officePlan?.dispose(); + officePlan = null; office?.dispose(); office = null; inside = false; @@ -438,7 +502,7 @@ async function mountCity(id: string) { : ""; }, }); - minimapFrame?.replaceChildren(minimap.canvas); + showPlan(); minimap.setMarkers(id === "sf" ? markers : []); // The instruments, for the one visitor in a deployment who has them. The pose @@ -463,7 +527,12 @@ async function mountCity(id: string) { */ requestAnimationFrame(function pumpMinimap() { requestAnimationFrame(pumpMinimap); - minimap?.tick(); + // Only the mounted one. The other is still constructed and still holds a live + // camera, but its canvas is out of the document, so `clientWidth` is 0, every + // `resize()` puts it back to `ready = false`, and ticking it would be a + // function call that exists to do nothing sixty times a second. + if (inside) officePlan?.tick(); + else minimap?.tick(); // The pose editor is on the same pump for the same reasons, and is `null` for // everyone who is not god, so this is one property read per frame on a // public page. @@ -511,7 +580,7 @@ async function enterOffice() { // it did not arrive at all. Either way there is no room to walk into and // `loadOffice` has already said so on the button. if (!built || !city) return; - const { createOfficeScene, pack, materials } = built; + const { createOfficeScene, createOfficeMinimap, pack, materials } = built; const depth = access.can.officeDepth; office = createOfficeScene(pack, { dom: city.stage.renderer.domElement, @@ -526,18 +595,100 @@ async function enterOffice() { : { onPlacePick: (place) => showDetail(place ? place.label : null) }), }); office.onViewChange(() => renderLegend()); + officePlan = buildOfficePlan(createOfficeMinimap, office); } city.stage.setScene(office); inside = true; + showPlan(); showDetail(null); refreshGodmodePlace(); renderLegend(); } +/** + * The plan panel, for the office. + * + * Built once alongside the scene rather than on every entry, because it reads + * that scene's camera and controls by reference — a second one built against a + * disposed office would draw a footprint from a camera nobody is moving. + * + * The constructor is passed in rather than imported, for the reason written at + * the import block: naming the module here as a value would put the furniture + * catalogue back in the entry chunk. + */ +function buildOfficePlan( + create: typeof import("./engine/officeMinimap.ts").createOfficeMinimap, + scene: OfficeScene, +): OfficeMinimap { + return create({ + plan: scene.plan, + camera: scene.camera, + controls: scene.controls, + onSeek(x, z) { + /** + * The same move the city plan makes, and for the same reason: slide the + * orbit target and carry the camera with it, keeping the offset between + * them. A seek is "look over there", not "go to viewpoint three". + * + * The height is deliberately untouched. Indoors that matters more than it + * does outdoors — the difference between a viewpoint two metres off the + * floor and one fourteen metres up is the difference between standing in + * the room and looking down into it, and clicking the plan should not + * silently move you between those two. + */ + const { camera, controls } = scene; + const dx = camera.position.x - controls.target.x; + const dy = camera.position.y - controls.target.y; + const dz = camera.position.z - controls.target.z; + controls.target.set(x, controls.target.y, z); + camera.position.set(x + dx, controls.target.y + dy, z + dz); + }, + onHover(info) { + if (!minimapReadout) return; + if (!info) { + minimapReadout.textContent = ""; + return; + } + // Metres, not degrees. The city widget prints a latitude and a longitude + // because that is the address of a place on a map; the address of a place + // in a building is where it is on the floor, in the units the pack was + // authored in, so a self-hoster can read a number off this and type it + // into their own office file. + const where = `${info.x.toFixed(1)}, ${info.z.toFixed(1)} m`; + // The storey is named only when there is more than one to be on. On a + // single-level pack — which is the reference office and will be most + // packs — "Level 1" on every hover is a word that never changes and + // therefore never informs. + const level = scene.plan.levels.length > 1 && info.level ? ` · ${info.level}` : ""; + minimapReadout.textContent = `${where}${level}${info.room ? ` · ${info.room}` : ""}`; + }, + }); +} + +/** + * Put the plan for wherever you are into the panel. + * + * One function rather than a `replaceChildren` at each of the call sites, + * because the failure it prevents is silent: the panel keeps showing a widget + * that is still ticking and still correct *about somewhere else*, which looks + * exactly like a working map. + */ +function showPlan() { + const widget = inside ? officePlan : minimap; + if (!minimapFrame || !widget) return; + minimapFrame.replaceChildren(widget.canvas); + // The canvas has just re-entered the document with a real box for the first + // time, or the first time since it left it. Nothing else tells it that: the + // `ResizeObserver` fires on a change of size, and going from detached to + // attached at the same size is not one. + widget.resize(); +} + function leaveOffice() { if (!city) return; city.stage.setScene(city.stageScene); inside = false; + showPlan(); showDetail(null); refreshGodmodePlace(); renderLegend(); @@ -569,19 +720,27 @@ function leaveOffice() { */ async function loadOffice(): Promise<{ createOfficeScene: typeof import("./interiors/officeScene.ts").createOfficeScene; + createOfficeMinimap: typeof import("./engine/officeMinimap.ts").createOfficeMinimap; pack: Office; materials: MaterialRegistry; } | null> { try { - const [interiors, pack, assets] = await Promise.all([ + // A fourth import and still one arrival. The plan renderer reads prop + // footprints off the asset registry, so it is already downstream of the + // furniture catalogue this chunk exists to hold back — asking for it here + // costs nothing beyond the module itself, and asking for it anywhere else + // would cost the whole catalogue in the entry chunk. + const [interiors, pack, assets, plan] = await Promise.all([ import("./interiors/officeScene.ts"), import("./offices/lumbridge-hq.ts"), import("./assets/materials.ts"), + import("./engine/officeMinimap.ts"), ]); officePack ??= pack.default; officeMaterials ??= new assets.MaterialRegistry({ quality: "high" }); return { createOfficeScene: interiors.createOfficeScene, + createOfficeMinimap: plan.createOfficeMinimap, pack: officePack, materials: officeMaterials, }; @@ -687,7 +846,11 @@ function renderLegend() { : `Map of ${cityLabel}, seen from above. Drag to orbit, scroll to zoom.`, ); } + // Each plan rings the entry its own legend is showing as current. Both are + // updated whichever one is mounted, so the hidden one is already right when it + // comes back rather than correcting itself a frame after it appears. minimap?.setChapters(city.chapters, city.current()); + officePlan?.setActiveView(office?.current() ?? null); renderOfficeBadge(); } @@ -853,7 +1016,35 @@ function flyToIndex(index: number) { else city?.flyTo(view.id); } +/** + * Step out of the office to the city, by whichever route keeps the URL honest. + * + * Returns true when it has started a navigation and the caller should stop — the + * page is on its way out, and anything after this runs against a document that + * is about to be replaced. + * + * The `?view=office` case is the cheap half of the same fix. There is no other + * host to go to, so the scene swap stands, but the query that put us in the + * office is dropped from the address bar on the way out. `replaceState` rather + * than `pushState`: the office and the city are one page, and a back button that + * walks a history of scene swaps is a back button that does not leave. + */ +function leaveToCity(cityWanted?: string): boolean { + const url = cityDoorUrl(cityWanted); + if (url !== null) { + location.assign(url); + return true; + } + const here = new URL(location.href); + if (here.searchParams.get("view") === "office") { + here.searchParams.delete("view"); + history.replaceState(null, "", `${here.pathname}${here.search}${here.hash}`); + } + return false; +} + function switchCity(id: string) { + if (inside && leaveToCity(id)) return; if (inside) leaveOffice(); if (id === wantedCity) return; wantedCity = id; @@ -880,6 +1071,7 @@ let entering = false; async function toggleOffice() { if (inside) { + if (leaveToCity()) return; leaveOffice(); return; } @@ -1030,7 +1222,9 @@ window.addEventListener("keydown", (event) => { if (event.key === "Escape") { if (shortcutsCard && !shortcutsCard.hidden) closeShortcuts(); - else if (inside) leaveOffice(); + else if (inside) { + if (!leaveToCity()) leaveOffice(); + } else showDetail(null); return; } @@ -1450,7 +1644,19 @@ async function boot() { } } - const first = CITIES[0]; + /** + * Which metro to build, from the URL if it names one this build has. + * + * The parameter exists to make leaving the office lossless — `office.` sends + * you to `tera.?city=socal` rather than dropping you in the Bay Area — but it + * is read unconditionally, because a deep link to a city is a reasonable thing + * to want on its own and a parameter that only works when it arrives from one + * particular page is a trap. An unknown id falls back rather than failing: + * `?city=paris` on a build with two cities in it should show a city, not a + * black screen. + */ + const wanted = new URLSearchParams(location.search).get("city"); + const first = CITIES.find((c) => c.id === wanted) ?? CITIES[0]; await building(`Building ${first?.label ?? "the city"}…`, () => mountCity(first?.id ?? "sf")); // The instruments, after the first board, because the panel reads a live