From df534c353043a2e9163ec9661f7309390d90f09d Mon Sep 17 00:00:00 2001 From: Kartios Date: Thu, 6 Aug 2026 01:36:25 -0700 Subject: [PATCH] On the office door, the map is of the office MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit office.lumbridgecorp.com and tera.lumbridgecorp.com are one bundle behind two names, and the office door had two ways of forgetting which one you came in through. The plan panel was the loud one. `createMinimap` is built per city, from `city.world`, and nothing swapped it when you stepped inside — so the one piece of chrome whose entire job is to answer "where am I" went on drawing the Bay Area while the scene in front of it was a thirty-four-metre floor plate. Not merely unhelpful: pointing at another county. So there is a second widget for the other place. `officeMinimap.ts` reads the `Plan` the scene was already built from — no second resolution pass, no geometry re-derived — and draws it: room slabs, the walls as their `solid` runs only, which leaves a gap at every door and arch because `Plan` had already split them for the collider; a glazing hairline across each window, without which the reference office's north edge simply is not there; furniture at its real footprint from the asset registry, turned by its own yaw, so four benches of twelve read as four benches of twelve; room names over a halo, because a room's centroid is usually the middle of its own desks. Same corner, same `M`, same camera footprint, same click-to-seek, same keyboard aim. The footprint is cut against `level.floorY` rather than y=0, which is the same number on a one-storey pack and a whole storey out on any other. It is a second module rather than a mode inside the first because the two share their shape and almost none of their content: one projects lat/lng through a `World`, rasterises a coastline and a hillshade and follows the sun; this one is already in metres and lives under a fixed interior rig where the sun does not reach. One file would have been an `if (city)` at the top of every function. It arrives in the office chunk, not the entry chunk. It reads prop footprints off the asset registry, so it is downstream of the furniture catalogue that the `await import()` in `loadOffice` exists to hold back; a value import in main.ts would have pulled all of it forward and silently undone the split. So main.ts takes the constructor as an argument and names the module type-only, and the build agrees: officeMinimap is its own 10.3 kB chunk and the entry is unmoved. The quiet one was the URL. Leaving the office swapped the scene and left the address bar saying `office.` — which is the thing you copy, bookmark and send to someone, and it took them somewhere other than what you were looking at. From the `office.` host the exit is now a real navigation to the same hostname with its first label swapped: the same string comparison that decided this was the office door decides where the city is, so a deployment serving one name and not the other gets an honest 404 on a name it chose not to serve rather than a silent lie. `?city=` carries the metro across so stepping out into SoCal lands in SoCal, and is read unconditionally because a deep link to a city is a reasonable thing to want on its own. On `?view=office` there is no other host to go to, so the scene swap stands and the query is dropped on the way out. Checked against the production build with both real hostnames resolved at a local static server: office.lumbridgecorp.com boots into the office with the floor plan in the panel, the hover readout gives office metres and the room under the pointer ("9.8, 16.7 m · Bernal"), `O` lands on tera.lumbridgecorp.com showing the Bay Area, and the console is clean. --- src/engine/officeMinimap.ts | 1114 +++++++++++++++++++++++++++++++++++ src/main.ts | 222 ++++++- 2 files changed, 1328 insertions(+), 8 deletions(-) create mode 100644 src/engine/officeMinimap.ts 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