/**
 * The demo: two cities under a real sun and moon, and one office you can step
 * into.
 *
 * It ships **no real company data**. The markers are fabricated — see
 * `src/adapters/sample.ts`, which says so loudly — because real positions are
 * geocoded and real pipeline status is private, and neither belongs in this
 * repo. When a Tera API is present the same markers arrive from it instead, and
 * the UI says which of the two it is showing.
 *
 * It also runs with no server at all: the sun and moon are computed locally,
 * the traffic is simulated, the office is a data file. Clone it and it works.
 */

import {
  createAtmosphere,
  observe,
  PACIFIC_MARINE_LAYER,
  type Atmosphere,
  type WeatherObservation,
} from "./engine/atmosphere.ts";
import { officeDaylight, withHouseLights } from "./interiors/daylight.ts";
import { createScene, type SceneHandle } from "./engine/scene.ts";
import {
  regionOf,
  SimulatedFlights,
  withTrafficDial,
  type TrafficDial,
} from "./engine/flights.ts";
import { SatelliteCatalogue, type SatelliteElements } from "./engine/satellites.ts";
import type { Pose } from "./engine/scenekit.ts";
import { createStage, deviceProfile } from "./engine/stage.ts";
import { daylightPhase } from "./engine/solar.ts";
import type { City, Marker, MarkerPalette, View } from "./engine/types.ts";
import CALIFORNIA from "./cities/california.ts";
import SAN_FRANCISCO from "./cities/sf.ts";
import SOCAL from "./cities/socal.ts";
import CALIFORNIA_TRANSPORT from "./transport/california.ts";
import {
  mergeVehicleActions,
  sampleStandardGamepad,
  type GamepadButtonState,
} from "./input/vehicle.ts";
import type { VehicleActionSnapshot } from "./transport/vehicleController.ts";
import {
  createTeraClient,
  describeLiveness,
  type PresenceWatch,
  type TrafficSource,
  type WeatherWatch,
} from "./adapters/http.ts";
import {
  SAMPLE_MARKERS,
  SAMPLE_PALETTE,
  SAMPLE_PRESENCE_PALETTE,
  samplePresenceAt,
  sampleRoutesFor,
} from "./adapters/sample.ts";
import { OFFICE_SITES } from "./offices/sites.ts";
import { authFetch } from "./session.ts";
import { capabilitiesFor, resolveAccess, type Access } from "./access.ts";
import { createMinimap, type Minimap } from "./engine/minimap.ts";
/**
 * Three type-only imports and not one value among them, which is what keeps the
 * office and the instruments out of the entry chunk.
 *
 * `import type` is erased before Rollup ever sees it, so none of these three
 * files is an edge in the module graph and none of them lands in the 780 kB
 * everybody downloads. The office arrives through the `await import()` in
 * `loadOffice()`, the tools through the one in `boot()`, and the rule that says
 * so for `src/tools/` is written out at the top of `tools/index.ts`. Turning any
 * of these into a value import silently undoes the split, and nothing fails —
 * the bundle just gets big again.
 */
import type { OfficeScene } from "./interiors/officeScene.ts";
import type { Office, Presence } 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, GodmodeHouseLights, GodmodePlace } from "./tools/index.ts";
import type { PoseEditor } from "./tools/poseEditor.ts";

const CITIES: { id: string; label: string; city: City }[] = [
  { id: "california", label: "California", city: CALIFORNIA },
  { id: "sf", label: "Bay Area", city: SAN_FRANCISCO },
  { id: "socal", label: "SoCal", city: SOCAL },
];

/**
 * The buildings this page can walk into.
 *
 * Two of them, and the second one is why this is a table rather than the single
 * hardcoded `import("./offices/lumbridge-hq.ts")` it replaces. They are
 * deliberately unalike — a two-storey tower floor 188 m above Transbay, and a
 * hangar four metres above reclaimed ground at Alameda Point — because the
 * thing worth showing is that one engine and one format render both, and that
 * `OfficeSite` is what makes them feel like different places rather than the
 * same room with different furniture.
 *
 * The loaders stay lazy. Every pack is a chunk this page does not fetch until
 * somebody opens that door, which is the arithmetic `loadOffice` explains — and
 * a second pack eagerly imported would put its furniture in the entry bundle
 * for every visitor who never opens it.
 */
const OFFICES: { id: string; label: string; load: () => Promise<{ default: Office }> }[] = [
  { id: "lumbridge-hq", label: "Lumbridge HQ", load: () => import("./offices/lumbridge-hq.ts") },
  { id: "frontier-valley", label: "Frontier Valley", load: () => import("./offices/frontier-valley.ts") },
  { id: "mateo-court", label: "Mateo Court", load: () => import("./offices/mateo-court.ts") },
];

const canvas = document.querySelector<HTMLCanvasElement>("#scene");
if (!canvas) throw new Error("#scene canvas missing");

/**
 * One renderer, one loop, for as long as this page is open.
 *
 * Built here rather than inside `createScene` because a `WebGLRenderer` is a
 * property of the *canvas* and not of the city drawn on it. When each city
 * built its own, every switch between the Bay Area and SoCal abandoned a
 * renderer on the one GL context this page has, and abandoned renderers do not
 * give their textures back: `WebGLRenderer.dispose()` frees no texture at all,
 * so ten switches measured 88 live GPU textures against zero `deleteTexture`
 * calls, 16.8 MB of orphaned shadow map at a time. `stage.ts` has the numbers
 * and the reading of three's source that they come from.
 *
 * Nothing disposes this. It outlives every city and every office on the page,
 * and the page unload takes it — the same arrangement, and the same reasoning,
 * as `officeMaterials` below.
 */
const stage = createStage(canvas);

// `authFetch` so the private-office pack (`/api/v1/offices/:id`, which answers
// 404 rather than 403 to anyone who may not see it) is requested as the signed-in
// viewer. On a `password`-mode or open deployment it is an ordinary fetch.
const tera = createTeraClient({ fetch: authFetch });

let city: SceneHandle | null = null;
let cityId = "california";
/**
 * The city the user last *asked* for, which is not the same as the one that is
 * mounted or even the one that is being built.
 *
 * `building()` defers its work by two animation frames so the boot card can
 * paint, and a frame under load is not 16 ms — measured at 250 ms on a software
 * rasteriser. Clicking SoCal and then changing your mind inside that window
 * used to hit `if (id === cityId) return` against a `cityId` the deferred
 * `mountCity` had not written yet, so the second click was discarded as
 * redundant and you arrived at the city you had just cancelled. The guard has
 * to be against the intention, and the intention is recorded synchronously in
 * the click handler.
 */
let wantedCity = "california";
let office: OfficeScene | null = null;
let inside = false;
let markers: Marker[] = SAMPLE_MARKERS;

/**
 * The buildings you can walk into, as procedural glyphs on the city.
 *
 * This is the one thing that makes Tera and Spaces feel like one product rather
 * than two views sharing a bundle. Both packs carry a real `site` — it is what
 * puts the sun in the right place — and until now that coordinate was known to
 * the lighting and to nothing else. A stranger looking at the board had no way
 * to tell that two of those buildings are ones they can go inside.
 *
 * `OFFICE_SITES` rather than the packs themselves, deliberately: a pack is a
 * lazy chunk worth tens of kilobytes and the city wants these the instant the
 * board appears, long before anybody opens a door. See `offices/sites.ts`.
 *
 * `colorKey` is opaque to the engine, as every `Pin.colorKey` is — the palette
 * resolves it, and giving these their own key is what lets a door look different
 * from a company. `glyph` is equally literal: dimensions and a silhouette, with
 * no office semantics in the renderer.
 */
const OFFICE_MARKERS: Marker[] = OFFICE_SITES.map((entry) => ({
  id: `office:${entry.id}`,
  label: entry.name,
  colorKey: "office",
  blurb: `${entry.site.label ?? "An office"} — click to walk in`,
  lat: entry.site.lat,
  lng: entry.site.lng,
  // Hand-typed from the street grid, like every other coordinate here. Not a
  // placeholder, so it is drawn as a real address.
  located: true,
  ...(entry.site.exterior ? { glyph: entry.site.exterior } : {}),
}));

/**
 * Whether a marker is a door rather than a company.
 *
 * The id prefix is the whole test, and it is deliberately something no marker
 * off the wire can collide with: `markers/gate.ts` serves rows from a synced
 * database and none of them are namespaced this way.
 */
function officeIdOf(marker: Marker): string | null {
  return marker.id.startsWith("office:") ? marker.id.slice("office:".length) : null;
}

/**
 * Whatever the pointer is over, so a click knows what it clicked.
 *
 * The engine reports picks by hover rather than by click — that is what drives
 * the detail card — so the click handler has no argument of its own and reads
 * this instead. `null` whenever the pointer is over open ground, which is what
 * makes a click on the terrain do nothing.
 */
let hoveredMarker: Marker | null = null;
/**
 * The colour a door is drawn in, which no marker feed knows about.
 *
 * `colorKey` is opaque to the engine and resolved by the consuming app, so the
 * palette is this file's business. The office key is merged in **here** rather
 * than added to `SAMPLE_PALETTE`, because it is not a sample of anything: a
 * deployment that replaces the whole marker feed with its own palette
 * (`feed.palette`, further down) must still get doors it can see, and folding
 * this into the sample set would lose it the moment real markers arrived.
 *
 * Amber, to sit with the chapter list and the "Enter the office" button rather
 * than with the marker hues — a door is a piece of this application's
 * navigation, and it should read as one.
 */
const OFFICE_PALETTE: MarkerPalette = { office: 0xf5b53f };

let palette: MarkerPalette = { ...SAMPLE_PALETTE, ...OFFICE_PALETTE };
let liveData = false;
/**
 * What this visitor may do. Resolved once in `boot()`; every gate below reads
 * `access.can.*` and nothing else.
 *
 * The pre-boot value is the **closed** one, deliberately. A handler that
 * somehow fires before `resolveAccess()` has settled — a keystroke on a slow
 * connection, a click on a control that is in the document from first paint —
 * should offer a visitor less than they are entitled to and never more. The
 * rule that produces this value, and the SSO bug that once produced it wrongly,
 * are written out in `src/access.ts`.
 */
let access: Access = {
  tier: "anon",
  subject: null,
  signInUrl: null,
  can: capabilitiesFor("anon"),
  feeds: null,
};
let atmosphere: ReturnType<typeof createAtmosphere> | null = null;
let minimap: Minimap | null = null;
/**
 * The sky over the city currently on screen, polled while it is on screen.
 *
 * One watch at a time and it belongs to the board, not to the page. Stopping it
 * at the top of `mountCity` is the whole of the cancel-on-switch rule: a
 * `/weather` request for San Francisco that lands after the user has moved to
 * SoCal would otherwise put the marine layer over Long Beach, and it is a
 * request in flight for most of the second in which somebody clicks.
 */
let weatherWatch: WeatherWatch | null = null;
/**
 * The live traffic source, when there is one, kept so the corner label can ask
 * it whether the aircraft on screen were observed. `null` means the simulator,
 * which is never live and does not need asking.
 */
let cityFlights: TrafficSource | null = null;
/**
 * The satellite element sets, fetched once for the page rather than once per city.
 *
 * Every other feed here is per-city and is torn down on a switch. These are not,
 * and the asymmetry is the physics: an aircraft at 10,000 m is visible for tens
 * of kilometres and the two boards are six hundred apart, but a satellite at
 * 550 km is above the horizon for a circle two thousand kilometres across. The
 * same element sets serve both cities and would serve a continent.
 *
 * **The elements are shared and the catalogue is not.** A `SatelliteCatalogue`
 * is built around an observer, and the observer is the city centre: reusing one
 * across a city switch would compute the Southland's sky from San Francisco and
 * put every look angle several degrees out, with nothing on screen to say so.
 * So the expensive, universal half is cached here and the cheap, local half is
 * rebuilt per board.
 *
 * `null` until the first fetch is started, and on the overwhelming majority of
 * deployments forever: `TERA_SATELLITES_SOURCE` is off by default. A promise
 * rather than a value so that a second city mounted while the first fetch is
 * still in the air waits for it instead of starting another.
 */
let satelliteElements: Promise<SatelliteElements[]> | null = null;
/**
 * The fabricated-traffic dial for the board on screen, rebuilt with every city.
 *
 * Held here rather than inside the godmode closure because the panel is mounted
 * once and the board is not: a dial captured when the panel opened would keep
 * pushing aircraft at a scene that had been disposed two city switches ago.
 */
let trafficDial: TrafficDial | null = null;
/**
 * Whether the satellite layer is drawn, remembered across city switches.
 *
 * A new board builds a new layer, which starts visible, so a god who turned the
 * sky off and then changed city would have it come back on — a setting that
 * quietly undoes itself is worse than one that is not there.
 */
let satellitesVisible = true;
/**
 * The build in progress. Aborting it is what makes a second click on the other
 * city cheap: `createScene` drops the heightfield, resolves `null`, and has
 * allocated no WebGL context for the abandoned board.
 */
let mounting: AbortController | null = null;
let godmode: Godmode | null = null;
let poseEditor: PoseEditor | null = null;
/**
 * The office, once somebody has asked for it. Both are `null` until the first
 * `loadOffice()` because both live in a chunk this page does not fetch until
 * then — see `loadOffice` for the arithmetic, and the import block above for
 * what keeps them out of the entry chunk.
 *
 * The registry is one texture set for every office this page ever builds.
 * Signing in while standing in the public office is a `dispose()` and a second
 * `createOfficeScene` at `"full"` — cheap only if both are handed the same
 * registry, because drawing the textures is the expensive part and a registry
 * draws them once. Owned here and disposed nowhere: it outlives every scene
 * that borrows it, and the page teardown takes the process with it.
 */
let officePack: Office | null = null;
let officeMaterials: MaterialRegistry | null = null;
/**
 * Which building the door leads to. Changed by the picker while you are inside.
 *
 * `officePack` is the pack for *this* id and is rebuilt on a switch rather than
 * memoised forever, which is the one-line difference from the arrangement that
 * could only ever hold one office.
 */
let officeId = OFFICES[0]?.id ?? "lumbridge-hq";
/**
 * The office's own sky, when its pack says where it stands.
 *
 * A second `Atmosphere` rather than the city's, because the two are at different
 * scales and in different places: the city's is built with `metresPerUnit` near
 * 94 and a fog measured in board spans, and an office runs at 1 m per unit
 * fifty metres across. `null` for a pack with no `site`, which keeps the fixed
 * interior rig and is a supported state rather than a gap.
 */
let officeAtmosphere: Atmosphere | 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;
/**
 * Whether the people currently on the floor came from the deployment or from
 * `sample.ts`.
 *
 * Kept because the difference has to be *said*. Every other gap on this page is
 * a silence — an empty office, a missing scrubber — and a silence you cannot
 * attribute is indistinguishable from a fault; here the failure is the opposite
 * and worse, because fabricated people are not silent. Twenty-five invented
 * names at real desks is a screenshot somebody will take, and it must not be
 * possible to take it without the caption.
 */
let presenceIsSample = false;
/**
 * The deployment's own roster, or `null` while the sample one stands in for it.
 *
 * Held separately from what is on screen because the two are refreshed by
 * different things: a real roster arrives from the API on its own timer, and the
 * sample one is a function of the clock and has to be recomputed whenever the
 * clock moves. Collapsing them would mean either re-rendering a live roster on
 * every scrub or freezing the sample one.
 */
let livePresence: Presence[] | null = null;
/**
 * The running poll of who is in, or `null` when nobody is standing in the room.
 *
 * It belongs to the visit and not to the page, which is the same rule
 * `weatherWatch` follows one line up and for a sharper reason: a roster is
 * requested with a credential and describes people, so a watch left running
 * after somebody stepped back out to the city is a page quietly asking about a
 * room nobody is looking at. Started by `enterOffice`, stopped by `leaveOffice`
 * and by `mountCity`, and there is never more than one.
 */
let presenceWatch: PresenceWatch | null = null;

/**
 * `office.lumbridgecorp.com` and `tera.lumbridgecorp.com` are one bundle behind
 * two names, and which name you arrived at is the whole difference: one is a
 * map with an office in it, the other is an office with a map behind it. The
 * city is still built underneath either way — that is what makes "← Back to the
 * 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 =
  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 -----------------------------------------------------------------

/**
 * `null` follows the wall clock. The override exists because the honest answer
 * at 2 a.m. is a very dark city — correct, and not what you want to be looking
 * at while judging whether the sun is in the right place.
 *
 * A whole `Date` and not an hour, which is the change the godmode panel forced
 * and the right shape anyway. The old scrubber wrote an hour onto *today*, so
 * there was no way to ask for the December solstice, and any control that could
 * set a date would have had it silently discarded on the next scrub. One
 * override, one writer, one type that can carry everything the sun depends on.
 */
let instantOverride: Date | null = null;
/**
 * Where the office's house lights take their level from. Godmode's switch, and
 * it survives the office being rebuilt because the panel re-asserts it.
 */
let houseLights: GodmodeHouseLights = "sun";

/**
 * The sun's height as the *fittings* are told it, which is the real one unless
 * somebody has a hand on the switch.
 *
 * Forcing the level by lying about the elevation rather than opening a second
 * path into `luminaires.ts`, and that is the design rather than a shortcut:
 * `OfficeScene.setSolarElevation` feeds the fittings and nothing else, while
 * the rig outside is computed from the real `env` a line later. So this moves
 * the *interior* and leaves the sky, the key light and the fog exactly where the
 * clock put them — which is the whole point of a control that lets you look at
 * the night office in daylight. ±90° is far outside the 0°–6° ramp in
 * `luminaires.ts`, and stays outside it if that band is ever widened.
 */
function houseElevation(actual: number): number {
  return houseLights === "sun" ? actual : houseLights === "on" ? -90 : 90;
}
/**
 * A fabricated sky, or `null` for whatever the deployment reports.
 *
 * Kept next to the instant because it is the same kind of thing — a god-only
 * lie about the inputs, told to see what the renderer does with it — and it
 * takes precedence over the live observation for exactly as long as it is set.
 */
let weatherOverride: WeatherObservation | null = null;

function currentInstant(): Date {
  return instantOverride ?? new Date();
}

/**
 * What the sky is doing, in the order the answers are trusted.
 *
 * The override wins because somebody typed it. Otherwise the live observation,
 * and `null` — nobody was asked — when there is no watch or it has not landed
 * yet. `null` is not "clear": `atmosphere.ts` treats a *reported* clear sky as
 * authority that suppresses the modelled marine layer, so handing it an
 * invented clear day on every failed poll would permanently kill San
 * Francisco's fog on the zero-config box where the local model is all there is.
 * See `WeatherFeed` in `adapters/http.ts`, which is careful about the same
 * distinction from the other side.
 */
function currentWeather(): WeatherObservation | null {
  return weatherOverride ?? weatherWatch?.current().value ?? null;
}

/**
 * The office's rig for the instant being rendered, in the building's own frame.
 *
 * Same clock, same weather and same `Atmosphere` machinery the city runs on —
 * which is the entire point. An office that dimmed on a schedule of its own
 * would be a second sun, and CONTRACT.md §4 exists to say there is one.
 *
 * `officeDaylight` does the two things a room needs and a map does not: it turns
 * the sun into the building's frame using `site.heading`, so the light comes
 * through the windows the pack actually has, and it moves the fog outdoors.
 */
function officeLighting(site: NonNullable<Office["site"]>) {
  const env = observe(site.lat, site.lng, currentInstant(), currentWeather());
  // `officeAtmosphere` is built alongside the pack; falling back to the fixed
  // rig here would be a flicker rather than a fix, so this is only ever called
  // where one exists.
  const state = officeAtmosphere?.apply(env);
  if (!state) return undefined;

  /**
   * The building's own lights, on top of whatever is left of the sun.
   *
   * The order matters and is the only subtle thing here: the daylight
   * adaptation runs first, because it is about the *sun* — which way the
   * building faces and where the weather starts — and the house lights are
   * added to the result, because they are about the building. Doing it the
   * other way round would rotate the interior lighting by the building's
   * heading, which is meaningless: a ceiling does not face a compass point.
   *
   * `office` may not exist yet — this is called once at construction, before
   * there is a scene to ask — in which case the elevation is fed straight to the
   * ramp so the first frame is already correct rather than a lit room fading
   * down or a dark one fading up.
   */
  office?.setSolarElevation(houseElevation(env.sun.elevation));
  const house = office?.houseLevel() ?? houseLevelFor(houseElevation(env.sun.elevation));
  return withHouseLights(officeDaylight(state, site), house);
}

/**
 * The lights-on ramp, for the one moment there is no office to ask.
 *
 * Duplicating the two bounds from `luminaires.ts` is a smell and is the lesser
 * of the two available ones: the alternative is building the office scene with
 * a rig computed from a light level it cannot report yet, which means the room
 * is visibly wrong for exactly one frame at every entry. Kept in step by being
 * four lines long and named after the thing it mirrors.
 */
function houseLevelFor(solarElevationDeg: number): number {
  return 1 - Math.min(1, Math.max(0, solarElevationDeg / 6));
}

function updateSun() {
  const active = CITIES.find((c) => c.id === cityId)?.city ?? SAN_FRANCISCO;

  // The office follows the same clock, and follows it whether or not you are
  // standing in it — walking back in to a room lit for an hour ago is the
  // failure this avoids.
  const site = officePack?.site;
  if (office && site && officeAtmosphere) {
    const state = officeLighting(site);
    if (state) office.setLighting(state);
  }

  // The roster follows the same clock the sun does. Never over a live answer.
  if (livePresence === null) applyPresence();

  if (!city || !atmosphere) return;
  const env = observe(active.center.lat, active.center.lng, currentInstant(), currentWeather());
  city.setLighting(atmosphere.apply(env));
  city.setSolarElevation(env.sun.elevation);
  /**
   * The sky's own cover, which is a different question from what it does to the
   * light and is why the scene takes it separately.
   *
   * `atmosphere.cloudCover(env)` and **not** `currentWeather()?.cloudCover ?? 0`,
   * and the difference is the whole point of the layer existing. `null` weather
   * is "nobody was asked", which is not an edge case — it is the *default*
   * deployment and the exact configuration this repo is held to: a stranger
   * clones it, runs one command, and gets a city with no account and no key.
   * Falling back to zero meant that stranger's sky was permanently, silently
   * empty, and the cloud layer only ever appeared for somebody who had wired up
   * NWS.
   *
   * `atmosphere` models a sky when nobody has observed one — it already does
   * exactly that for the marine layer — and an observed cover still wins
   * outright when there is one. See `cloudCover` in `atmosphere.ts`.
   */
  city.setCloudCover(atmosphere.cloudCover(env));
  city.setWind(currentWeather()?.windKph ?? null, currentWeather()?.windDirDeg ?? null);
  // The override itself, not `currentInstant()`. Handing over a resolved date
  // would peg the sky to whatever second this ran in, and this runs about once a
  // second — so an unscrubbed sky would advance in visible steps while the
  // aircraft beside it moved smoothly. `null` means "read the clock yourself".
  city.setSkyInstant(instantOverride);
  // The plan view follows the same day the map does. It computes its own
  // palette from this one number rather than reading the rig, because a rig is
  // a set of three.js lights and the minimap has none.
  minimap?.setSolarElevation(env.sun.elevation);

  const clock = document.querySelector<HTMLElement>("#clock");
  if (!clock) return;
  const el = env.sun.elevation;
  const time = env.time.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
  const moon = env.moon ? ` · moon ${Math.round(env.moon.illuminated * 100)}%` : "";
  clock.textContent = `${time} · sun ${el >= 0 ? "+" : ""}${el.toFixed(1)}° · ${daylightPhase(el)}${moon}`;
}

// ---- Cities ---------------------------------------------------------------

/**
 * Switching city tears the old one down completely.
 *
 * Unlike the city↔office move — where the city is paused and kept, because you
 * are coming straight back — nobody flips between metros often enough to
 * justify holding two heightfields and 140k building instances at once.
 */
async function mountCity(id: string) {
  const entry = CITIES.find((c) => c.id === id);
  if (!entry || !canvas) return;

  /**
   * Abandon whatever is still building before touching anything else.
   *
   * Two clicks on the city buttons a second apart used to run two heightfields
   * to completion and race to assign `city`; now the first `createScene` sees
   * its signal go and resolves `null` without ever allocating a renderer. The
   * controller is replaced rather than reused because an aborted signal stays
   * aborted, and the new build must not be born cancelled.
   */
  mounting?.abort();
  const mount = new AbortController();
  mounting = mount;

  wantedCity = id;
  weatherWatch?.stop();
  weatherWatch = null;
  poseEditor?.destroy();
  poseEditor = null;
  stopWatchingOccupancy();
  officePlan?.dispose();
  officePlan = null;
  office?.dispose();
  office = null;
  officeAtmosphere = null;
  inside = false;
  minimap?.dispose();
  minimap = null;
  city?.dispose();
  // Not merely tidy. `city` is read by the frame pump, by `updateSun` and by
  // every render function, and the gap between the dispose above and the
  // assignment below is now an `await` wide rather than a statement — long
  // enough for all three to run against a torn-down scene.
  city = null;

  cityId = id;

  /**
   * The sky and the traffic are per-city and are chosen here, before the build,
   * because `flights` is fixed at scene construction.
   *
   * Still two gates, but only one of them is about the visitor now.
   *
   * `can.liveEnvironment` is true for everybody — the reasoning is in
   * `access.ts`, and it comes down to the sky not being a thing an account can
   * grant you. What remains load-bearing is `feeds`, the *deployment*: it is
   * what stops the ordinary box, where every source is `none`, from polling two
   * endpoints forever for a 404 on every tab that is open.
   *
   * The other half of the old comment is still worth keeping, because it was a
   * real bug: the flights used to be gated on the *markers* flag, which is a
   * different feed entirely, so a deployment with a real ADS-B receiver and no
   * marker file flew the simulator.
   */
  const region = regionOf(entry.city);
  // The hand-authored corridors for *this* city. `SAMPLE_ROUTES` was passed
  // unconditionally and all of it is over San Francisco, so the SoCal board's
  // entire sky projected ~590 km off the world and rendered as nothing at all.
  const routes = sampleRoutesFor(entry.city);
  // The public traffic API is region-oriented and intentionally capped around
  // one metro. A state-wide request would either be rejected or become a data
  // vacuum, so California keeps the honest deterministic sky while its two
  // detailed boards continue to use live ADS-B when available.
  const traffic =
    id !== "california" && access.can.liveEnvironment && access.feeds?.flights
      ? tera.flights(region, routes)
      : null;
  cityFlights = traffic;

  /**
   * Started here and awaited below, so the fetch overlaps the heightfield build
   * rather than following it. Gated on the deployment for the same reason the
   * traffic is: a box with no satellite source answers with an empty catalogue,
   * and asking it once per page load for that is a request nobody needs.
   *
   * Not gated on the visitor. There is no `can.` check because there is nothing
   * to grant — the objects in this catalogue broadcast their positions to
   * anybody with a radio, and every element set in it is a US Government work.
   */
  if (satelliteElements === null && access.feeds?.satellites) {
    satelliteElements = tera.satellites();
  }
  const elements = (await satelliteElements) ?? [];
  // Rebuilt per board: the observer is this city's centre. See the note on
  // `satelliteElements` for why only the elements are shared.
  const catalogue =
    elements.length === 0 ? undefined : new SatelliteCatalogue(elements, entry.city.center);
  // The build may have been abandoned while that was in the air.
  if (mount.signal.aborted) {
    traffic?.dispose();
    if (cityFlights === traffic) cityFlights = null;
    return;
  }

  // Wrapped, not replaced: the dial passes the real sky through untouched and
  // concatenates fabricated aircraft after it, so it composes with a live ADS-B
  // feed as readily as with the simulator. `cityFlights` stays the unwrapped
  // source — the corner label asks it whether what is on screen was observed,
  // and the answer is about the feed rather than about the dial.
  const dial = withTrafficDial(traffic ?? new SimulatedFlights(routes), region);
  // Carried across the switch, so a dial somebody set on the last board is still
  // set on this one.
  dial.setExtra(trafficDial?.extra() ?? 0);
  trafficDial = dial;

  /**
   * Stable destinations are handed to the scene at construction time, not a
   * frame later. A building glyph needs that head start so `blocks.ts` can
   * reserve its footprint before the anonymous one-draw-call skyline is
   * emitted; otherwise both buildings occupy the same address and the useful
   * one is usually hidden inside the random one.
   *
   * A door belongs to the board it stands on. This used to be gated on a
   * hard-coded `id === "sf"`; using the board's own bounds is what keeps Mateo
   * Court on the Southland board and off the Bay Area one. Sample companies
   * stay SF-only because that sample feed is about one city and always was.
   */
  const bounds = entry.city.bounds;
  const doors = OFFICE_MARKERS.filter(
    (m) =>
      m.lat >= bounds.minLat &&
      m.lat <= bounds.maxLat &&
      m.lng >= bounds.minLng &&
      m.lng <= bounds.maxLng,
  );
  const initialMarkers = id === "sf" ? [...markers, ...doors] : doors;

  const handle = await createScene(stage, {
    city: entry.city,
    markerPalette: palette,
    markers: initialMarkers,
    ...(id === "california"
      ? {
          roadTraffic: {
            pack: CALIFORNIA_TRANSPORT,
            routeId: "la-sf-us-101",
            count: 14,
            seed: 115,
          },
        }
      : {}),
    flights: dial.source,
    ...(catalogue ? { satellites: catalogue } : {}),
    /**
     * A pin is a hover *and* a click, and an office pin is a door.
     *
     * `onMarkerPick` fires for both — `scenekit`'s picking calls it on hover
     * with the marker and on leave with `null` — so this cannot simply open a
     * building on every call or the office would fly open the moment the pointer
     * crossed a tower. The hover shows the card; the click is a separate
     * listener below, which reads whatever the hover last resolved.
     */
    onMarkerPick: (m) => {
      hoveredMarker = m;
      showDetail(m ? `${m.label}${m.blurb ? ` — ${m.blurb}` : ""}` : null);
    },
    signal: mount.signal,
    // An abandoned build keeps its worker running for a tick or two after the
    // abort; its percentages must not land on the card the new city is using.
    onProgress: (p) => {
      if (!mount.signal.aborted) bootProgress(entry.label, p.fraction, p.onMainThread);
    },
  });
  if (!handle) {
    /**
     * Superseded. `scene.dispose()` is what normally cancels the traffic
     * source, and there is no scene — so the polling this call started would
     * otherwise outlive the board it was started for, and keep a request in
     * the air for a city nobody is looking at.
     */
    traffic?.dispose();
    if (cityFlights === traffic) cityFlights = null;
    return;
  }
  city = handle;
  // A new board builds a new layer, and a new layer starts visible. Reapply
  // whatever the panel last said, or the setting silently undoes itself on the
  // first city switch.
  handle.setSatellitesVisible(satellitesVisible);

  /**
   * The weather, started only now that the board exists.
   *
   * Deliberately after the build rather than beside it: a poll issued at the
   * top of a two-second heightfield is a request for a city the user may
   * already have left, and the first thing `stop()` would do is throw the
   * answer away. Nothing on screen is waiting for it — `updateSun` runs
   * immediately with `null`, which is climatology, and the observation
   * replaces it when it lands.
   */
  weatherWatch =
    access.can.liveEnvironment && access.feeds?.weather
      ? tera.watchWeather(entry.city.center, () => {
          updateSun();
          renderSource();
        })
      : null;

  // Fog distances are scene units, so they have to follow the board — 210/460
  // was tuned for a 230-unit San Francisco and fogs out most of a 1000-unit
  // Bay Area. They also have to clear the CAMERA, which sits about 0.6 spans
  // out on the whole-board view: a fog starting nearer than that is behind the
  // viewer's own shoulder, and every pixel in frame is then at full fog.
  //
  // That last failure used to be catastrophic and is now only bad, and the
  // difference is worth recording because the comment used to claim the worse
  // version. The night fog colour is derived from the sky, the night sky was
  // nearly black, and so a fog plane behind the camera turned the entire map
  // off. `atmosphere.ts` now floors the night ground rig and stops the
  // obscuration convergence subtracting it again, and the night fog here lands
  // around #16203a — aerial perspective that lifts distance rather than a
  // blackout. The clearance is still required: a board flattened to one uniform
  // value is unreadable at any brightness. It is no longer the difference
  // between a map and a black rectangle.
  const [wx, nz] = city.world.project(entry.city.bounds.maxLat, entry.city.bounds.minLng);
  const [ex, sz] = city.world.project(entry.city.bounds.minLat, entry.city.bounds.maxLng);
  const span = Math.max(Math.abs(ex - wx), Math.abs(sz - nz));

  atmosphere = createAtmosphere({
    lng: entry.city.center.lng,
    metresPerUnit: city.world.metresPerUnit,
    // Pushed out with the camera. `scene.ts` now lets the orbit reach 2.0 spans
    // so the viewer can get above the satellite dome, and at the old far of 2.8
    // the board sat at 51% fog from that pose — the whole city washing out at
    // exactly the moment the shot is meant to be the city under the
    // constellation. 3.9 keeps it near a fifth, which is the haze it had at the
    // old limit.
    clearFog: { near: span * 1.15, far: span * 3.9 },
    // The floor on how far you can see, and it has to know how big the board
    // is. `minVisibilityM` defaults to 4.5 km, which is honest weather and
    // completely wrong here: this board is ninety-four kilometres across, so
    // real visibility correctly hides three quarters of it and the night view
    // renders as a black rectangle. A map is looked at from outside the
    // atmosphere it is depicting.
    minVisibilityM: span * city.world.metresPerUnit * 1.6,
    // The marine layer is a fact about the eastern Pacific at this latitude,
    // not a decoration. LA gets its own weather, not San Francisco's fog.
    marineLayer: id === "sf" ? PACIFIC_MARINE_LAYER : null,
  });
  city.onChapterChange(() => renderLegend());

  /**
   * The plan view, built last, because it reads the finished `World` — the
   * heightfield the terrain has already paid for — and the live camera and
   * controls the scene has just made. It is torn down and rebuilt with the
   * city for the same reason the city is: nothing in it survives a change of
   * board, and it holds a `World` that would otherwise leak.
   */
  minimap = createMinimap({
    world: city.world,
    city: entry.city,
    camera: city.stageScene.camera,
    controls: city.stageScene.controls,
    markerPalette: palette,
    // The plan view is a 2D canvas the same size as a phone's thumb, and on a
    // handheld it is drawn at the same ceiling the WebGL renderer uses. One
    // definition of "phone", in `stage.ts`, read by both.
    maxPixelRatio: deviceProfile().maxPixelRatio,
    onSeek(lat, lng) {
      if (!city) return;
      /**
       * Slide the orbit target and carry the camera with it, keeping the offset
       * between them. A seek is "look over there", not "go to chapter three":
       * snapping to a chapter pose throws away the angle and the distance the
       * user spent the last minute choosing, and doing it from a click on a map
       * is the kind of surprise that stops people clicking on the map.
       *
       * No easing, deliberately. `flyTo` would need a pose, which is the thing
       * being avoided, and an instant move is also the correct answer under
       * `prefers-reduced-motion`.
       */
      const { camera, controls } = city.stageScene;
      const [x, z] = city.world.project(lat, lng);
      const y = city.world.groundAt(lat, lng);
      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, y, z);
      camera.position.set(x + dx, y + dy, z + dz);
    },
    onHover(info) {
      if (!minimapReadout) return;
      minimapReadout.textContent = info
        ? `${info.lat.toFixed(4)}, ${info.lng.toFixed(4)}${info.district ? ` · ${info.district}` : ""}`
        : "";
    },
  });
  showPlan();
  minimap.setMarkers(initialMarkers);

  // The instruments, for the one visitor in a deployment who has them. The pose
  // editor holds a `World`, a camera and a controls, so it belongs to the board
  // and dies with it — the same reason the plan view does.
  mountPoseEditor(city);
  refreshGodmodePlace();

  updateSun();
  renderLegend();
}

/**
 * The minimap's own frame pump.
 *
 * `Stage` owns the render loop and `SceneHandle` exposes no per-frame hook, so
 * the alternative is adding an `onTick` to the scene handle for exactly one
 * call site. This is the smaller change and it costs nothing measurable: an
 * idle `tick()` is a timestamp comparison and a dirty flag, 0.0002 ms, and the
 * loop keeps running unchanged across a city swap, across the office swap, and
 * during the window where there is no minimap at all.
 */
requestAnimationFrame(function pumpMinimap() {
  requestAnimationFrame(pumpMinimap);
  // 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.
  poseEditor?.tick();
  pollLiveness();
});

/**
 * Whether the corner label is still telling the truth, once a second.
 *
 * The two live feeds settle on their own schedule and neither has an event to
 * subscribe to: `TrafficSource.live()` flips when a `/flights` body lands
 * inside the region, which is somewhere in the first fifteen seconds, and the
 * weather watch fires its own callback but only when a *poll* settles. A
 * one-second sample is late by nothing anybody can perceive and costs a
 * subtraction on the frames it skips.
 */
let livenessCheckedAt = 0;
function pollLiveness() {
  const now = performance.now();
  if (now - livenessCheckedAt < 1000) return;
  livenessCheckedAt = now;
  renderSource();
}

// ---- Office ---------------------------------------------------------------

/**
 * Everyone gets in. The tier picks which building they get, not whether the
 * door opens.
 *
 * The office used to be members-only, and the anonymous view of this site was a
 * map with a greyed-out button on it — the single most interesting thing the
 * project does, visible only as something you cannot have. At `"public"` depth
 * the same shell, the same furniture and the same named viewpoints are built,
 * and the only thing missing is the people. That is withheld because the API
 * refuses occupancy to an anonymous caller, not because this function declined
 * to draw it.
 */
async function enterOffice() {
  if (!city) return;
  if (!office) {
    const built = await loadOffice();
    // The chunk arrived after the user had already left for the other city, or
    // 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, createOfficeMinimap, pack, materials } = built;
    const depth = access.can.officeDepth;

    /**
     * The building's own sky, built before the scene because the scene wants the
     * opening rig and the horizon drop at construction.
     *
     * `metresPerUnit: 1` — an office is authored in metres, and this is the only
     * thing `Atmosphere` needs to know about scale. The fog it computes from
     * that is then thrown away and replaced by `officeDaylight`, because a fog
     * sized for a fifty-metre room is a fog inside the room.
     */
    officeAtmosphere = pack.site
      ? createAtmosphere({
          lng: pack.site.lng,
          metresPerUnit: 1,
          /**
           * No marine layer, deliberately — unlike the city, which gets one.
           *
           * The model is a fact about sea level on this coast, and both of these
           * buildings look *out over* it: one from 188 m up a tower, one across
           * an estuary. Handing it to the office put the observer inside the fog
           * it describes, which greyed the sky to near-white at one in the
           * afternoon and took the view with it. The city keeps its fog; the
           * rooms that look at the city do not stand in it.
           */
          marineLayer: null,
        })
      : null;

    office = createOfficeScene(pack, {
      dom: city.stage.renderer.domElement,
      // Only when there is no sky to put behind it. A sited office computes a
      // gradient and a horizon; painting the old flat colour over that is the
      // bug that looks exactly like the sky not working.
      ...(pack.site ? {} : { background: 0x11161c }),
      ...(pack.site ? { lighting: officeLighting(pack.site) } : {}),
      ...(pack.site ? { horizon: { drop: pack.site.elevation } } : {}),
      // Two per floor, whatever floors this pack has — so the two-storey tower
      // gets four and the single-storey hangar gets two, without either pack
      // having to know about robots. Derived from the levels rather than
      // written down, because a pack that gains a storey should not need an
      // edit here to be staffed.
      robots: pack.levels.flatMap((level) => [
        { levelId: level.id, id: `${level.id}-a` },
        { levelId: level.id, id: `${level.id}-b` },
      ]),
      depth,
      materials,
      // Ignored entirely at `"public"` depth, where no layer is built to colour.
      presencePalette: SAMPLE_PRESENCE_PALETTE,
      // Two different questions, so two different callbacks. `onPresencePick`
      // answers "who is at this desk"; `onPlacePick` answers only "this is a
      // desk, and it is the fourteenth one" — which is all a stranger is told.
      ...(depth === "full"
        ? { onPresencePick: (p) => showDetail(p ? p.label : null) }
        : { 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();
  // After the room is on screen, not before. The first ask is a second request
  // and the building is worth looking at while it is in flight; awaiting it here
  // would hold the door shut on a network round trip to draw people into a scene
  // the user cannot see yet.
  watchOccupancy();
}

/**
 * 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 {
  const panel = create({
    plan: scene.plan,
    camera: scene.camera,
    controls: scene.controls,
    // The same ceiling the city plan takes, from the same place. Two 2D canvases
    // in one corner of one page disagreeing about what a phone can afford is the
    // exact drift `deviceProfile` exists in one file to prevent.
    maxPixelRatio: deviceProfile().maxPixelRatio,
    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}` : "";
      // The person last, and after the room, because the readout is read left to
      // right as an address getting more specific: the floor, then the room,
      // then who is in it.
      const room = info.room ? ` · ${info.room}` : "";
      const person = info.person ? ` · ${info.person}` : "";
      minimapReadout.textContent = `${where}${level}${room}${person}`;
    },
  });

  /**
   * Once, not per frame.
   *
   * `scene.robots()` hands back a stable array of vectors the robot layer
   * mutates in place, so the plan reads this frame's positions through a
   * reference taken here — the same handshake `officeScene` already makes with
   * `luminaires.setWalkers`. Polling it every frame would allocate nothing and
   * would imply the array were a snapshot, which it is not.
   */
  panel.setRobots(scene.robots());
  return panel;
}

/**
 * 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;
  // Before the scene swap, so the last thing the watch can do is abort a request
  // rather than publish into a room the user has already left.
  stopWatchingOccupancy();
  city.stage.setScene(city.stageScene);
  inside = false;
  showPlan();
  showDetail(null);
  refreshGodmodePlace();
  renderLegend();
}

/**
 * Fetch Spaces.
 *
 * The office is the largest thing in this build that most visitors never open:
 * the interior, the furniture catalogue, the material registry and the
 * floorplan are 67 kB of chunk — 22 kB across the wire — and they used to be
 * downloaded, parsed and executed on every load of a map page by people who
 * came to look at a city. Behind these three `await import()`s Vite gives them
 * chunks of their own and the door fetches them on the way through. Measured,
 * entry chunk: 780.18 kB / 216.89 kB gzipped before, 720.89 / 198.33 after —
 * the difference is smaller than the chunks because three.js is shared and
 * stays where it was.
 *
 * All three in one `Promise.all` because they are one arrival: the pack without
 * the builder is a data file nobody can draw, so the fetches overlap rather
 * than queue. Rollup happens to emit them as three chunks the browser asks for
 * together; awaiting them in sequence would make that three round trips on a
 * slow link for no reason at all.
 *
 * There is deliberately no retry and no cache-busting. A failed chunk fetch is
 * a deploy that moved the file under an open tab; the honest answer is to say
 * the door did not open and let the next click try again, which it will,
 * because a rejected dynamic import is not memoised by the browser.
 */
async function loadOffice(): Promise<{
  createOfficeScene: typeof import("./interiors/officeScene.ts").createOfficeScene;
  createOfficeMinimap: typeof import("./engine/officeMinimap.ts").createOfficeMinimap;
  pack: Office;
  materials: MaterialRegistry;
} | null> {
  try {
    // 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 entry = OFFICES.find((o) => o.id === officeId) ?? OFFICES[0];
    if (!entry) return null;
    const [interiors, pack, assets, plan] = await Promise.all([
      import("./interiors/officeScene.ts"),
      entry.load(),
      import("./assets/materials.ts"),
      import("./engine/officeMinimap.ts"),
    ]);
    // Assigned rather than memoised with `??=`: the memo was what made this
    // single-office forever, quietly serving the first pack fetched for every
    // later request whatever id was asked for.
    officePack = pack.default;
    officeMaterials ??= new assets.MaterialRegistry({ quality: "high" });
    return {
      createOfficeScene: interiors.createOfficeScene,
      createOfficeMinimap: plan.createOfficeMinimap,
      pack: officePack,
      materials: officeMaterials,
    };
  } catch {
    showDetail("The office did not load. Check the connection and try the door again.");
    return null;
  }
}

/** The office's name, for the two bits of chrome that say where you are. */
function officeName(): string {
  return officePack?.name ?? "Spaces";
}

/**
 * Who is in the building, asked for once per entry.
 *
 * Only at full depth, and that is not an optimisation. `createOfficeScene` at
 * `"public"` builds **no presence layer at all** — not an empty one, not a
 * hidden one — so there is nothing here to populate and the request would be one
 * this visitor's session is going to be refused anyway. `presence.ts` explains
 * at length why the layer is absent rather than emptied; this is the call site
 * that would otherwise quietly reintroduce it.
 *
 * The fallback is `markers`' fallback, one room in: an API that does not answer
 * gets the fabricated roster, because a clone with no server is the flagship
 * case and a member shown the same empty room as a stranger has been told the
 * tier means something when it does not. An API that *does* answer is believed,
 * including when it answers with nobody — an office where everyone has gone home
 * is a real fact about an office, and overwriting it with invented people to
 * make the demo livelier is the one thing this file must never do.
 */
function watchOccupancy() {
  stopWatchingOccupancy();
  const scene = office;
  if (!scene || scene.depth !== "full") return;
  presenceWatch = tera.watchPresence(officePack?.id ?? "lumbridge-hq", (body) => {
    livePresence = body?.people ?? null;
    presenceIsSample = livePresence === null;
    // The scene may have been torn down between a request going out and coming
    // back — a city switch disposes the office — and writing people into a
    // disposed layer is a use-after-free with a friendly name. The watch's own
    // `stop()` already drops late answers; this is the belt to that brace,
    // because the office can also be *replaced* (signing in rebuilds it at full
    // depth) without the watch having been stopped in between.
    if (office !== scene) return;
    applyPresence();
    renderOfficeBadge();
    renderSource();
  });
}

function stopWatchingOccupancy() {
  presenceWatch?.stop();
  presenceWatch = null;
  // Or the next building — or the next entry into this one — opens wearing the
  // previous deployment's roster while its own request is still in the air.
  livePresence = null;
}

/**
 * Put whoever is in the building on screen.
 *
 * The live roster if there is one, and otherwise the sample one **as it would be
 * at the instant being rendered**. That second half is what stops the office
 * showing a full complement of seated people at one in the morning, under house
 * lights that came on because the sun is down — which was the least believable
 * thing left in the room once the clock became real.
 *
 * A live answer always wins. An API that says the building is empty is telling
 * the truth about the building, and dressing it with invented people would be
 * the one lie this whole layer is arranged to avoid.
 */
function applyPresence() {
  if (!office || office.depth !== "full") return;
  const people = livePresence ?? samplePresenceAt(currentInstant());
  office.setPresence(people);
  officePlan?.setPresence(people);
}

// ---- Chrome ---------------------------------------------------------------

const nav = document.querySelector<HTMLElement>("#chapters");
const blurb = document.querySelector<HTMLElement>("#blurb");
const title = document.querySelector<HTMLElement>("#title");
const subtitle = document.querySelector<HTMLElement>("#subtitle");
const enterButton = document.querySelector<HTMLButtonElement>("#enter");
const cityNav = document.querySelector<HTMLElement>("#cities");
const source = document.querySelector<HTMLElement>("#source");
const minimapFrame = document.querySelector<HTMLElement>("#minimap .minimap-frame");
const minimapReadout = document.querySelector<HTMLElement>("#minimap-readout");
const tierBadge = document.querySelector<HTMLElement>("#tier");
const officeBadge = document.querySelector<HTMLElement>("#office-badge");
const panelToggle = document.querySelector<HTMLButtonElement>("#panel-toggle");
const panelToggleLabel = document.querySelector<HTMLElement>("#panel-toggle-label");
const shortcutsCard = document.querySelector<HTMLElement>("#shortcuts");
const helpButton = document.querySelector<HTMLButtonElement>("#help");
const planToggle = document.querySelector<HTMLButtonElement>("#plan-toggle");
const credits = document.querySelector<HTMLElement>("#credits");
const driveControls = document.querySelector<HTMLElement>("#drive-controls");

function showDetail(text: string | null) {
  const card = document.querySelector<HTMLElement>("#detail");
  const body = document.querySelector<HTMLElement>("#detail-text");
  if (!card || !body) return;
  card.hidden = text === null;
  // The text, and not the card: the card also holds the dismiss button, which
  // `textContent` on the card would delete the first time a marker was picked.
  body.textContent = text ?? "";
}

/**
 * The board strip above the legend: world scales outside, buildings inside.
 *
 * One control that answers "which of these am I in", pointed at whichever list
 * is currently the answer. A second, separate office strip was the obvious
 * alternative and is worse: it would sit dead and greyed out for the entire time
 * anybody is looking at the city, which is most of the time.
 */
function renderCityPicker() {
  if (!cityNav) return;
  cityNav.replaceChildren();
  const entries = inside
    ? OFFICES.map((o) => ({ id: o.id, label: o.label, active: o.id === officeId }))
    : CITIES.map((c) => ({ id: c.id, label: c.label, active: c.id === cityId }));

  for (const entry of entries) {
    const b = document.createElement("button");
    // `aria-pressed` rather than a class, because these buttons choose exactly
    // one active board. The stylesheet keys off the attribute
    // so the visual state and the announced state cannot drift apart.
    b.className = "city";
    b.type = "button";
    b.setAttribute("aria-pressed", String(entry.active));
    b.textContent = entry.label;
    b.addEventListener("click", () => {
      if (inside) void switchOffice(entry.id);
      else switchCity(entry.id);
    });
    cityNav.append(b);
  }
}

/**
 * Walk out of one building and into another.
 *
 * A full teardown and rebuild rather than a swap, because everything an office
 * scene holds is derived from its pack: the shell, the plan panel, the camera
 * limits, the horizon drop and the light rig. The expensive part — the texture
 * registry — is deliberately *not* rebuilt, which is the same trick that makes
 * signing in cheap: `officeMaterials` outlives every scene that borrows it, so
 * a switch costs geometry and not the thing that draws the wood grain.
 */
async function switchOffice(id: string) {
  // `entering` is the same guard `toggleOffice` uses, and this has to share it.
  // Without it, two clicks inside the loading window build two office scenes and
  // the first is parked on the stage with nothing holding a reference to it —
  // a whole `OfficeScene`, its geometry and its plan panel, leaked per click.
  if (entering || id === officeId || !OFFICES.some((o) => o.id === id)) return;
  const previous = officeId;
  officeId = id;

  // The roster belongs to the building you have left.
  stopWatchingOccupancy();
  officePlan?.dispose();
  officePlan = null;
  office?.dispose();
  office = null;
  officeAtmosphere = null;

  entering = true;
  try {
    await building("Opening the door…", () => enterOffice());
  } finally {
    entering = false;
  }

  /**
   * The old room is already gone by the time we find out whether the new one
   * arrived, and a failed chunk fetch is a real case — `loadOffice` says so on
   * the card and returns null.
   *
   * So there is no room on the stage and `inside` still claims there is. Put the
   * viewer back in the city rather than in an empty scene, and put the door back
   * on the building they came from, or the picker is wedged pointing at an
   * office that will not open.
   */
  if (office || !city) return;
  officeId = previous;
  inside = false;
  city.stage.setScene(city.stageScene);
  showPlan();
  renderLegend();
}

/** One legend for both places — a city chapter and an office viewpoint are both `View`s. */
function renderLegend() {
  renderCityPicker();
  if (!nav || !city) return;
  const views: View[] = inside && office ? office.views : city.chapters;
  const activeId = inside && office ? office.current() : city.current();

  nav.replaceChildren();
  views.forEach((view, i) => {
    const button = document.createElement("button");
    button.className = "chapter";
    button.type = "button";
    button.setAttribute("aria-pressed", String(view.id === activeId));
    const number = view.number ?? String(i + 1).padStart(2, "0");
    button.innerHTML = `<span class="num">${number}</span><span>${view.shortLabel}</span>`;
    button.addEventListener("click", () => flyToIndex(i));
    nav.append(button);
  });

  const active = views.find((v) => v.id === activeId);
  if (blurb) {
    blurb.textContent = active?.description ?? "";
    blurb.hidden = !active?.description;
  }
  const cityLabel = CITIES.find((c) => c.id === cityId)?.city.name ?? "";
  if (title) title.textContent = inside ? officeName() : cityLabel;
  if (subtitle) {
    subtitle.textContent = inside ? "Spaces · a Lumbridge office" : "Tera · Lumbridge Simulate";
  }
  if (enterButton) {
    // One label for everyone. The door is open at both tiers; what differs is
    // what is behind it, and that is the badge's job to say, not the button's.
    enterButton.textContent = inside ? "← Back to the city" : "Enter the office →";
  }
  renderSource();
  if (panelToggleLabel) panelToggleLabel.textContent = inside ? "Office" : cityLabel;
  if (canvas) {
    canvas.setAttribute(
      "aria-label",
      inside
        ? `${officeName()}, seen from above. Drag to orbit, scroll to zoom.`
        : `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();
  if (driveControls) driveControls.hidden = !routeDriveIsActive();
}

/**
 * The corner label, which now names the parts rather than claiming the whole.
 *
 * Only the *positive* case gets a permanent label. This used to read "sample
 * data · fabricated, not real companies" on every frame of every load, which is
 * the overwhelmingly common case — no deployment has a markers source wired by
 * default — so the disclosure was on screen approximately always and had become
 * furniture. A caption nobody reads is not disclosure, it is a watermark. The
 * fact still has to be somewhere on the same screen as the map, so it is stated
 * on the boot card everyone passes through and again in the `?` card, one
 * keypress away and permanently reachable.
 *
 * What is left is the informative signal, and it is three signals rather than
 * one. The markers, the weather and the traffic arrive from three different
 * places and every combination of them is a deployment that exists; a single
 * flag has to pick one to be about and then lie about the other two. The
 * particular lie this closes is "live data" printed over invented companies
 * because a weather station answered — which is precisely the claim the `live`
 * flag was introduced to prevent. `describeLiveness` in `adapters/http.ts` owns
 * the wording; all three live is the only case that still says "live data".
 *
 * Called from `renderLegend` and once a second from the frame pump, because the
 * feeds settle after the legend has been drawn.
 */
function renderSource() {
  if (!source) return;
  /**
   * The office's provenance line, which outranks the city's.
   *
   * It used to live on `#office-badge` inside `#panel`, and on a phone `#panel`
   * is a bottom sheet that starts closed — so the one sentence standing between
   * twenty-five invented people at real desks and a screenshot presented as a
   * staff list was behind a hamburger, on the device most likely to take the
   * screenshot. This slot is the right home for it anyway and not merely a
   * visible one: `#source` is where this page already says what on screen is and
   * is not real, it is fixed and always drawn, and the phone stylesheet calls it
   * "the one caption that is never allowed to be dropped for space".
   *
   * It replaces the city's label rather than joining it, because while you are
   * standing in the office the markers, the sky and the traffic are facts about
   * a board behind you — and "live data" printed over invented colleagues is the
   * exact species of lie the liveness wording was rewritten to stop telling.
   */
  const sample = inside && presenceIsSample;
  // A class rather than an inline style, so the stylesheet keeps the decision
  // about how a phone lays this out and this keeps the decision about what it
  // says. The rule it turns on is the one that stops the sentence being
  // ellipsised down to the half that reads as reassuring.
  document.body.classList.toggle("sample-occupancy", sample);
  if (sample) {
    const caption = "sample occupancy · these people are invented";
    if (source.textContent !== caption) source.textContent = caption;
    source.hidden = false;
    return;
  }
  const label = describeLiveness({
    markers: liveData,
    // An override is a sky somebody invented, so it retires the claim for as
    // long as it is up — the label is about what is on screen, not about what
    // the deployment could have shown.
    weather: weatherOverride === null && (weatherWatch?.current().live ?? false),
    flights: cityFlights?.live() ?? false,
  });
  if (source.textContent !== label) source.textContent = label;
  source.hidden = label === "";
  /**
   * The green. `.source.live` in `index.html` is the whole visual difference
   * between this line and the rest of the chrome, and the rewrite that replaced
   * `source.className = "source live"` with a `textContent`/`hidden` pair
   * dropped it — so every live label rendered at `--ink-3`, the same muted grey
   * as a key hint, and the stylesheet rule could no longer match anything. This
   * label is only ever on screen when it has something to say; the colour is
   * how it says it is worth reading.
   */
  source.classList.toggle("live", label !== "");
  renderCredits();
}

/**
 * Who to thank for what is on screen, in the `?` card.
 *
 * MET Norway and Open-Meteo publish under CC BY 4.0 and the server emits the
 * credit line each of them asks for — `server/README.md` says in as many words
 * that the consumer is expected to display it — and adsb.lol asks to be named
 * for the positions. All of it arrived, was parsed into `WeatherFeed.attribution`
 * and `FlightsBody.attribution`, and was then read by nobody: a licence
 * obligation plumbed to within one line of being met.
 *
 * It goes in the `?` card rather than on the `#source` line, and that is a
 * choice rather than convenience. The corner label is one short phrase and on a
 * phone it is explicitly clamped to a single ellipsised line, so a licence
 * sentence appended to it would be *truncated* — the one outcome worse than
 * putting it a keypress away. The `?` card is reachable from every state the
 * app can be in, on both layouts, and already carries the sentence about the
 * markers being fabricated; the provenance of the map belongs in one place.
 *
 * Empty when nothing live is on screen, because a credit for data nobody is
 * looking at is noise, and because the zero-config build owes nobody anything.
 */
function renderCredits() {
  if (!credits) return;
  const lines: string[] = [];
  // The weather override is somebody's invention; it is not MET Norway's sky
  // and must not be attributed to them.
  if (weatherOverride === null) lines.push(...(weatherWatch?.current().attribution ?? []));
  lines.push(...(cityFlights?.attribution() ?? []));
  const unique = [...new Set(lines.filter((line) => line !== ""))];
  credits.textContent = unique.join(" · ");
  credits.hidden = unique.length === 0;
}

/**
 * The one thing a public visitor is actually missing, said in the place where
 * they would notice it missing.
 *
 * An empty office with no explanation reads as a bug — a floor that failed to
 * load — and the fix for that is a sentence, not a disabled button. The
 * sign-in link is offered *beside* the office rather than in front of it, so it
 * is an upgrade and never a toll gate.
 */
function renderOfficeBadge() {
  if (!officeBadge) return;
  const publicOffice = inside && office !== null && office.depth === "public";
  // The fabricated-occupancy caption used to be here too and is now on
  // `#source` — see `renderSource`. This badge keeps the message that is a call
  // to action rather than a disclosure, because that one belongs beside the
  // office controls and survives being missed; the other one does not.
  officeBadge.hidden = !publicOffice;
  if (!publicOffice) return;
  officeBadge.replaceChildren(
    document.createTextNode("Public view — the building, not the people. "),
  );
  if (access.signInUrl !== null) {
    const link = document.createElement("a");
    link.href = access.signInUrl;
    link.textContent = "Sign in for the live floor";
    officeBadge.append(link, document.createTextNode("."));
  } else {
    officeBadge.append(document.createTextNode("Sign in to see who's in."));
  }
}

/**
 * Who the site thinks you are, in the corner, always. Three words and a name.
 *
 * It is here rather than buried in a menu because every other difference on
 * this page — an empty office, sample markers, no godmode tab — is a
 * *silence*, and a silence you cannot attribute is indistinguishable from a
 * fault. This is the line that tells you which of the two you are looking at.
 */
function renderTierBadge() {
  if (!tierBadge) return;
  tierBadge.className = `card tier ${access.tier}`;
  const label = document.createElement("span");
  /**
   * The label names what you *get*, not who you are, and that is deliberate.
   * "Signed in" was the first draft and it is a lie in the commonest case:
   * a clean clone with no API at all resolves to `member`, and telling someone
   * they are signed in to a server that does not exist is the sort of small
   * dishonesty that makes the rest of the interface untrustworthy. "Full view"
   * is true whether the tier came from a session or from there being nothing to
   * have a session with; the subject, when there is one, says the rest.
   */
  label.textContent =
    access.tier === "god" ? "Godmode" : access.tier === "member" ? "Full view" : "Public view";
  tierBadge.replaceChildren(label);
  if (access.subject !== null) {
    const who = document.createElement("span");
    who.className = "who";
    who.textContent = access.subject;
    tierBadge.append(who);
  } else if (access.signInUrl !== null) {
    const link = document.createElement("a");
    link.href = access.signInUrl;
    link.textContent = "Sign in";
    tierBadge.append(link);
  }
  tierBadge.hidden = false;
}

// ---- Navigation -------------------------------------------------------------

/** The views on offer right now — city chapters, or office viewpoints inside. */
function currentViews(): View[] {
  if (inside && office) return office.views;
  return city?.chapters ?? [];
}

function flyToIndex(index: number) {
  const view = currentViews()[index];
  if (!view) return;
  if (inside && office) office.flyTo(view.id);
  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;
  const label = CITIES.find((c) => c.id === id)?.label ?? id;
  void building(`Building ${label}…`, () => mountCity(id));
}

function stepCity(delta: number) {
  const at = CITIES.findIndex((c) => c.id === wantedCity);
  const next = CITIES[(at + delta + CITIES.length) % CITIES.length];
  if (next) switchCity(next.id);
}

/**
 * Guards the door against the second click.
 *
 * Entering now begins with a network fetch for the Spaces chunk, so the window
 * between the click and the room is wide enough to click in again — and two
 * `enterOffice()` calls in that window build two office scenes, park the second
 * on the stage and leak the first, textures and all. One flag, cleared in a
 * `finally` so a failed fetch does not wedge the door shut.
 */
let entering = false;

async function toggleOffice() {
  if (inside) {
    if (leaveToCity()) return;
    leaveOffice();
    return;
  }
  if (entering) return;
  // Only the first entry fetches or builds anything; after that the office is
  // parked in memory next to the paused city and the swap is a pointer.
  if (office) {
    void enterOffice();
    return;
  }
  entering = true;
  /**
   * Say so on the button before anything else happens.
   *
   * The boot card comes up too, but it comes up on the *next* frame at the
   * earliest, and on a slow connection the chunk is the long pole rather than
   * the build. A door that does nothing visible for half a second gets clicked
   * again; a door that says "Opening…" gets waited for.
   */
  if (enterButton) {
    enterButton.textContent = "Opening the office…";
    enterButton.setAttribute("aria-busy", "true");
  }
  try {
    await building("Fetching the office…", () => enterOffice());
  } finally {
    entering = false;
    enterButton?.removeAttribute("aria-busy");
    // `renderLegend` writes the real label whichever way it went — "← Back to
    // the city" if we are in, the door again if the fetch failed.
    renderLegend();
  }
}

enterButton?.addEventListener("click", () => void toggleOffice());

/**
 * Clicking a building on the city walks into it.
 *
 * On the canvas rather than on anything the engine owns, because the engine
 * reports picks by *hover* — `onMarkerPick` fires as the pointer crosses a pin
 * and again with `null` as it leaves — so there is no click event to hang this
 * on down there. `hoveredMarker` is whatever that hover last resolved, which is
 * exactly what a click on the same pixel means.
 *
 * Guarded on not already being inside: the city's canvas is the office's canvas
 * too, they share one renderer, and a stray click on the floor of a room should
 * not re-enter the building you are standing in.
 */
canvas.addEventListener("click", () => {
  if (inside) return;
  const marker = hoveredMarker;
  if (!marker) return;
  const id = officeIdOf(marker);
  if (id === null) return;
  officeId = id;
  void building(`Opening ${marker.label}…`, () => enterOffice());
});

// ---- Panels, plan and overlays ----------------------------------------------

/**
 * Two pieces of chrome are a *user* decision rather than a media query, and the
 * distinction matters: a media query that hides the plan below 600px also makes
 * `M` do nothing there, which is the width where a plan view is most useful and
 * least affordable. So the width only seeds the initial state, and the moment
 * someone presses the key the viewport stops having an opinion.
 */
let panelOpen = window.innerWidth > 900;
let planOpen = window.innerWidth > 600;
let planChosen = false;

function applyPanel() {
  document.body.classList.toggle("panel-closed", !panelOpen);
  panelToggle?.setAttribute("aria-expanded", String(panelOpen));
}

function applyPlan() {
  document.body.classList.toggle("minimap-off", !planOpen);
  planToggle?.setAttribute("aria-pressed", String(planOpen));
}

/**
 * One body, two ways in, and the second one is the point.
 *
 * This lived inline in the `M` branch of the keydown handler and was reachable
 * from nowhere else, which made the plan view **unreachable on any touch
 * device**: `planOpen` is seeded `window.innerWidth > 600`, so a phone starts
 * with it off, and a phone has no `M`. Every visible control at 390px was
 * enumerated and none of them could turn it on. `index.html` has carried a
 * designed phone layout for `.corner` — a bottom sheet above the rail, at
 * `min(38dvh, 18rem)` — that no visitor to that layout could ever see, under a
 * comment saying it "costs nothing until it is asked for". There was no way to
 * ask. `#plan-toggle` is that way, shown wherever the pointer is coarse.
 *
 * So the key and the button call this, and `planChosen` is set by both for the
 * same reason it always was: once somebody has an opinion, the viewport stops
 * having one.
 */
function togglePlan() {
  planOpen = !planOpen;
  planChosen = true;
  applyPlan();
}

planToggle?.addEventListener("click", () => togglePlan());

panelToggle?.addEventListener("click", () => {
  panelOpen = !panelOpen;
  applyPanel();
});

/**
 * The scrim behind the phone's panel sheet. It is `display: none` above 600px,
 * so this listener is only ever reachable where the sheet exists.
 */
document.querySelector<HTMLElement>("#scrim")?.addEventListener("click", () => {
  panelOpen = false;
  applyPanel();
});

document.querySelector<HTMLElement>("#detail-close")?.addEventListener("click", () => {
  showDetail(null);
});

window.addEventListener("resize", () => {
  if (!planChosen) {
    planOpen = window.innerWidth > 600;
    applyPlan();
  }
});

function openShortcuts() {
  if (!shortcutsCard || !shortcutsCard.hidden) return;
  shortcutsCard.hidden = false;
  document.querySelector<HTMLButtonElement>("#shortcuts-close")?.focus();
}

function closeShortcuts() {
  if (!shortcutsCard || shortcutsCard.hidden) return;
  shortcutsCard.hidden = true;
  helpButton?.focus();
}

helpButton?.addEventListener("click", () => openShortcuts());
document.querySelector<HTMLElement>("#shortcuts-close")?.addEventListener("click", closeShortcuts);
shortcutsCard?.addEventListener("click", (event) => {
  // The backdrop, not the sheet. Clicking the card itself must not close it.
  if (event.target === shortcutsCard) closeShortcuts();
});

/** Held keyboard state translated into the same snapshot a gamepad/touch UI uses. */
const heldDriveKeys = new Set<string>();

function routeDriveIsActive(): boolean {
  const state = !inside ? city?.vehicleState() : null;
  return state !== null && state !== undefined && city?.current() === state.routeId;
}

function publishVehicleActions(
  supplement: Partial<VehicleActionSnapshot> = {},
): boolean {
  if (!routeDriveIsActive() || !city) return false;
  const left = heldDriveKeys.has("a");
  const right = heldDriveKeys.has("d");
  city.setVehicleActions(
    mergeVehicleActions(
      {
        throttle: heldDriveKeys.has("w") ? 1 : 0,
        brake: heldDriveKeys.has("s") ? 1 : 0,
        steering: (right ? 1 : 0) - (left ? 1 : 0),
        handbrake: heldDriveKeys.has(" "),
      },
      supplement,
    ),
  );
  return true;
}

function toggleVehicleCamera(): boolean {
  if (!routeDriveIsActive() || !city) return false;
  city.setVehicleCamera(city.vehicleCamera() === "driver" ? "chase" : "driver");
  return true;
}

for (const button of driveControls?.querySelectorAll<HTMLButtonElement>("[data-drive-key]") ?? []) {
  const key = button.dataset.driveKey;
  if (key === undefined) continue;
  const release = (event: PointerEvent) => {
    heldDriveKeys.delete(key);
    button.setAttribute("aria-pressed", "false");
    publishVehicleActions();
    event.preventDefault();
  };
  button.addEventListener("pointerdown", (event) => {
    button.setPointerCapture(event.pointerId);
    heldDriveKeys.add(key);
    button.setAttribute("aria-pressed", "true");
    publishVehicleActions();
    event.preventDefault();
  });
  button.addEventListener("pointerup", release);
  button.addEventListener("pointercancel", release);
  button.addEventListener("lostpointercapture", release);
}

driveControls?.querySelector<HTMLButtonElement>("[data-drive-action='assist']")
  ?.addEventListener("click", () => publishVehicleActions({ modeRequest: "assisted" }));
driveControls?.querySelector<HTMLButtonElement>("[data-drive-action='reset']")
  ?.addEventListener("click", () => publishVehicleActions({ reset: true }));
driveControls?.querySelector<HTMLButtonElement>("[data-drive-action='camera']")
  ?.addEventListener("click", () => toggleVehicleCamera());

window.addEventListener("keyup", (event) => {
  const key = event.key.length === 1 ? event.key.toLowerCase() : event.key;
  if (!heldDriveKeys.delete(key)) return;
  if (publishVehicleActions()) event.preventDefault();
});

window.addEventListener("blur", () => {
  heldDriveKeys.clear();
  publishVehicleActions();
});

let gamepadButtons: GamepadButtonState = { assist: false, reset: false };
function pollDriveGamepad() {
  try {
    const pad = navigator.getGamepads?.().find((candidate) => candidate !== null);
    if (pad && routeDriveIsActive()) {
      const sample = sampleStandardGamepad(pad, gamepadButtons);
      gamepadButtons = sample.buttons;
      publishVehicleActions(sample.actions);
    } else {
      gamepadButtons = { assist: false, reset: false };
    }
  } catch {
    // Some privacy-hardened browsers expose the method but throw until a pad
    // has produced a trusted event. Keyboard/touch remain fully functional.
  }
  requestAnimationFrame(pollDriveGamepad);
}
requestAnimationFrame(pollDriveGamepad);

/**
 * Keyboard access to everything the mouse can reach.
 *
 * Bound to `window` rather than to the canvas, because the canvas is only
 * focusable by accident and a shortcut that stops working when you tab to the
 * legend is worse than no shortcut. The guard is the usual one: a keystroke
 * that lands in a text field or on the plan view's own arrow-key handler
 * belongs to that control, not to this.
 */
window.addEventListener("keydown", (event) => {
  if (event.metaKey || event.ctrlKey || event.altKey) return;
  const target = event.target;
  if (
    target instanceof HTMLInputElement ||
    target instanceof HTMLTextAreaElement ||
    (target instanceof HTMLElement && target.isContentEditable)
  ) {
    return;
  }

  if (event.key === "Escape") {
    if (shortcutsCard && !shortcutsCard.hidden) closeShortcuts();
    else if (inside) {
      if (!leaveToCity()) leaveOffice();
    }
    else showDetail(null);
    return;
  }
  if (event.key === "?") {
    if (shortcutsCard && !shortcutsCard.hidden) closeShortcuts();
    else openShortcuts();
    event.preventDefault();
    return;
  }
  if (event.key >= "1" && event.key <= "9") {
    flyToIndex(Number(event.key) - 1);
    return;
  }
  if (event.key === "[") {
    stepCity(-1);
    return;
  }
  if (event.key === "]") {
    stepCity(1);
    return;
  }
  const lower = event.key.toLowerCase();
  if (lower === "w" || lower === "a" || lower === "s" || lower === "d" || event.key === " ") {
    heldDriveKeys.add(event.key === " " ? " " : lower);
    if (publishVehicleActions()) {
      event.preventDefault();
      return;
    }
  }
  if (lower === "p" && publishVehicleActions({ modeRequest: "assisted" })) {
    event.preventDefault();
    return;
  }
  if (lower === "r" && publishVehicleActions({ reset: true })) {
    event.preventDefault();
    return;
  }
  if (lower === "c" && toggleVehicleCamera()) {
    event.preventDefault();
    return;
  }
  if (lower === "m") {
    togglePlan();
    return;
  }
  if (lower === "o") void toggleOffice();
});

// ---- Time -------------------------------------------------------------------

/**
 * The `#hour` slider and its `now` button are gone, replaced rather than kept.
 *
 * They were a second writer for one override, and the weaker of the two:
 * `capabilitiesFor` hands `timeControl` and `debug` to exactly the same tier, so
 * there was never an audience for the simple case — the only person who could
 * see the scrubber was the same person who can open the godmode panel. Keeping
 * both meant the slider wrote an hour onto *today* and silently discarded
 * whatever date the panel had set, which is a bug with no upside.
 *
 * `#clock` stays exactly as it was, and stays visible to everyone: a map that
 * will not say what time it is showing is worse than one you cannot scrub.
 *
 * What is left of the gate is one line, and it is belt and braces — the only
 * writer of `instantOverride` is the panel, and the panel is not constructed
 * unless `can.debug`. It stays because "no control" and "no override" are two
 * different facts, and the second is the one the renderer depends on.
 */
function applyTimeControl() {
  if (!access.can.timeControl) instantOverride = null;
}

// ---- Instruments ------------------------------------------------------------

/**
 * The godmode panel and the pose editor, for the one visitor in a deployment
 * who has them.
 *
 * **Constructed, not hidden.** Everything in this section is behind
 * `access.can.debug`, and for a member or an anonymous visitor the result is
 * not a panel with `display: none` on it — it is no element, no `<style>`, no
 * key binding, and no bytes: `src/tools/` is reached only through the
 * `await import()` below, so a non-god browser never fetches the chunk. A
 * hidden instrument is still an instrument you shipped to a stranger, and the
 * tier that gets these is the tier that can already read the source.
 *
 * That rule is also why the dock's geometry is set as element styles instead of
 * a rule in `index.html`. Every visitor downloads that stylesheet; a
 * `#pose-dock { … }` sitting in it that can never match is the one trace this
 * arrangement would otherwise leave behind, in the bytes if not in the DOM.
 */

/** Cached across boards: the module is fetched once, the tool is rebuilt per city. */
let poseEditorFactory: typeof import("./tools/poseEditor.ts").createPoseEditor | null = null;
let poseDockBody: HTMLElement | null = null;
let poseDockOpen = true;

/**
 * Where the panel thinks it is standing.
 *
 * `null` while there is no board — between a `dispose()` and the next
 * `createScene`, which is now an `await` wide. The stage travels with the place
 * even though there is only ever one of them, because what the panel is being
 * told is *which board these counters are about*: `label`, the centre, the
 * scale and the renderer's ledger are one reading, and handing the panel the
 * stage separately would let it print SoCal's draw calls under the Bay Area's
 * name for the length of a switch.
 *
 * `marineStrength` is deliberately not wired. It is private to `atmosphere.ts`
 * today, and the panel prints "no hook wired" rather than a guess — see the
 * handoff note; one `export` keyword upstream turns the readout on.
 */
function godmodePlace(): GodmodePlace | null {
  if (!city) return null;
  const entry = CITIES.find((c) => c.id === cityId);
  const active = entry?.city ?? SAN_FRANCISCO;
  const world = city.world;
  const label = entry?.label ?? cityId;
  return {
    // The office is a different board for accounting: it is the ledger key, and
    // its draw calls are not the city's.
    label: inside ? `${label} · office` : label,
    lat: active.center.lat,
    lng: active.center.lng,
    stage: city.stage,
    metresPerUnit: world.metresPerUnit,
    unproject: (x, z) => world.unproject(x, z),
  };
}

/** `setPlace` when there is a place. Called on every board and room change. */
function refreshGodmodePlace() {
  const place = godmodePlace();
  if (place) godmode?.setPlace(place);
}

async function mountGodmode() {
  if (!access.can.debug || godmode) return;
  const place = godmodePlace();
  if (!place) return;

  const [tools, poses] = await Promise.all([
    import("./tools/index.ts"),
    import("./tools/poseEditor.ts"),
  ]);
  poseEditorFactory = poses.createPoseEditor;

  godmode = tools.createGodmode({
    container: document.body,
    initial: { instant: currentInstant(), place },
    onTimeChange(instant) {
      instantOverride = instant;
      updateSun();
    },
    onWeatherOverride(w) {
      weatherOverride = w;
      updateSun();
      // The corner label has to stop claiming live weather the moment the sky
      // on screen is one somebody typed.
      renderSource();
    },
    /**
     * Both dials read through the module-level handles rather than closing over
     * a board, because the panel outlives the city: it is mounted once and every
     * later `mountCity` swaps `city` and `trafficDial` underneath it. A closure
     * over the board that was current when the panel opened would go on driving
     * a disposed scene after the first city switch.
     */
    /**
     * Read through the module-level `office`, never closed over — the panel is
     * mounted once and every `switchOffice` replaces the scene underneath it.
     */
    office: {
      onHouseLights(mode) {
        houseLights = mode;
        // `officeLighting` is the only caller of `setSolarElevation` and
        // `updateSun` is the only caller of that, so this is the whole apply.
        updateSun();
      },
      onRobotsVisible(visible) {
        office?.setRobotsVisible(visible);
      },
      onCeilingsVisible(visible) {
        office?.setCeilingsVisible(visible);
      },
      read() {
        // Only while a room is actually on the stage. Out in the city the office
        // is paused and kept, and a dimmer aimed at a scene nobody is rendering
        // is exactly the dead panel this section is arranged to avoid.
        if (!inside || !office) return null;
        return {
          id: officeId,
          depth: office.depth,
          houseLevel: office.houseLevel(),
          robots: office.robots().length,
        };
      },
    },
    sky: {
      onExtraTraffic(count) {
        trafficDial?.setExtra(count);
      },
      onSatellitesVisible(visible) {
        satellitesVisible = visible;
        city?.setSatellitesVisible(visible);
      },
      read() {
        const counts = city?.satelliteCounts();
        return {
          extraTraffic: trafficDial?.extra() ?? 0,
          trafficIsLive: cityFlights?.live() ?? false,
          // `total: 0` is a board with no catalogue, which is not the same as a
          // catalogue with nothing above the horizon — the panel says so.
          satellites: counts && counts.total > 0 ? counts : null,
        };
      },
    },
  });

  // The city was built before this chunk arrived, so its pose editor is built
  // here; every later board gets one from `mountCity`.
  if (city) mountPoseEditor(city);
  addGodmodeShortcut();
}

/**
 * The pose editor, rebuilt with the board.
 *
 * It holds a `World`, a camera and a controls, all three of which die with the
 * city — the same reason the plan view is rebuilt rather than re-pointed.
 */
function mountPoseEditor(handle: SceneHandle) {
  if (!access.can.debug || !poseEditorFactory) return;
  const dock = instrumentDock();
  poseEditor = poseEditorFactory({
    container: dock,
    allowed: access.can.debug,
    world: handle.world,
    camera: handle.stageScene.camera,
    controls: handle.stageScene.controls,
    /**
     * A cut rather than a flight, for now.
     *
     * The tool is forbidden from writing the camera itself and asks for
     * `SceneKit.flyTo`, which `SceneHandle` does not expose — it offers
     * `flyTo(chapterId)` and nothing that takes a pose. Moving both ends of the
     * orbit at once is exactly what the plan view's `onSeek` does a few hundred
     * lines up and it lands on the right pose; what it does not do is ease, so
     * re-flying a captured chapter jumps. `scene.ts` is another agent's file
     * this week; the two-line addition that upgrades this is in the handoff.
     */
    flyTo: (pose: Pose) => {
      handle.stageScene.camera.position.copy(pose.position);
      handle.stageScene.controls.target.copy(pose.target);
      handle.stageScene.controls.update();
    },
    existingChapters: handle.chapters,
  });
  poseEditor.setVisible(poseDockOpen);
}

/**
 * The dock, built once and reused across boards.
 *
 * Bottom right, above the key-hint rail and below the plan view, which is the
 * one column of this layout with room in it: the left panel owns the left edge
 * top to bottom, the godmode drawer opens bottom *centre*, and the plan view
 * stops around 20rem down. The height is capped against both neighbours rather
 * than at a flat `dvh`, so a short window shrinks the dock instead of sliding
 * it under the plan view.
 */
function instrumentDock(): HTMLElement {
  if (poseDockBody) return poseDockBody;

  const dock = document.createElement("aside");
  dock.id = "pose-dock";
  dock.setAttribute("aria-label", "Pose editor");
  Object.assign(dock.style, {
    position: "fixed",
    right: "var(--s4, 16px)",
    bottom: "calc(var(--s4, 16px) + 5rem)",
    zIndex: "5",
    width: "min(20rem, calc(100vw - var(--s4, 16px) * 2))",
    maxHeight: "min(52dvh, calc(100dvh - 26rem))",
    display: "flex",
    flexDirection: "column",
    alignItems: "stretch",
    gap: "var(--s1, 4px)",
  });

  const toggle = document.createElement("button");
  toggle.type = "button";
  toggle.className = "help";
  toggle.setAttribute("aria-expanded", "true");
  Object.assign(toggle.style, { alignSelf: "flex-end", fontFamily: "inherit" });

  const body = document.createElement("div");
  Object.assign(body.style, { flex: "1", minHeight: "0", display: "flex" });

  function applyDock() {
    toggle.textContent = poseDockOpen ? "poses ▾" : "poses ▴";
    toggle.setAttribute("aria-expanded", String(poseDockOpen));
    body.hidden = !poseDockOpen;
    // Collapsed is genuinely idle: `setVisible(false)` is what stops the tool
    // reading the camera on every frame of a map nobody is authoring against.
    poseEditor?.setVisible(poseDockOpen);
  }

  toggle.addEventListener("click", () => {
    poseDockOpen = !poseDockOpen;
    applyDock();
  });

  dock.append(toggle, body);
  document.body.append(dock);
  poseDockBody = body;
  applyDock();
  return body;
}

/**
 * `G` in the `?` card, added from here rather than typed into `index.html`.
 *
 * The card is the one place a visitor goes to find out what this page can do,
 * so a key that exists must be in it — and a key that does not exist must not.
 * Hard-coding the row would advertise an instrument nine visitors in ten have
 * no way to open.
 */
function addGodmodeShortcut() {
  const keys = document.querySelector<HTMLElement>("#shortcuts .keys");
  const before = document.querySelector<HTMLElement>("#key-overlays");
  if (!keys || document.querySelector("#key-godmode")) return;
  const dt = document.createElement("dt");
  dt.id = "key-godmode";
  dt.innerHTML = "<kbd>G</kbd>";
  const dd = document.createElement("dd");
  dd.textContent = "Godmode: the clock, the weather and the counters";
  keys.insertBefore(dt, before);
  keys.insertBefore(dd, before);
}

// ---- The boot card ----------------------------------------------------------

const bootCard = document.querySelector<HTMLElement>("#boot");
const bootStep = document.querySelector<HTMLElement>("#boot-step");

/**
 * Wait until the browser has actually put pixels on the glass.
 *
 * Writing to `textContent` and then immediately building a heightfield paints
 * nothing: the style change and the two seconds of synchronous work are in the
 * same task, so the frame the user sees is the one *after* the work. Two
 * `requestAnimationFrame`s straddle a paint, which is the whole trick — a
 * single one still runs before it.
 */
function painted(): Promise<void> {
  return new Promise((resolve) => {
    requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
  });
}

/**
 * Wait until the renderer has actually drawn the thing that was being built.
 *
 * `painted()` is not enough any more and the difference is visible. The
 * heightfield now builds in a Worker, so `createScene` resolves at a moment
 * when the main thread is *idle* and two animation frames go by in 33 ms —
 * long before the terrain mesh, the 140k building instances and the first
 * WebGL draw have happened. The card faded on an empty canvas.
 *
 * `renderer.info.render.frame` is the renderer counting its own draws, which is
 * the only witness that cannot be fooled by a fast frame: it advances exactly
 * once per `render()`, so an increment means a frame of the new scene has been
 * submitted, and the extra `painted()` after it puts that frame on the glass
 * before the fade starts.
 *
 * The timeout is not a fallback to a timer, it is a bound on a promise that
 * would otherwise never settle — an abandoned build never draws anything, and
 * the boot card must not be the thing that outlives it.
 */
function drawn(timeoutMs = 4000): Promise<void> {
  const from = stage.renderer.info.render.frame;
  const deadline = performance.now() + timeoutMs;
  return new Promise((resolve) => {
    requestAnimationFrame(function wait() {
      if (stage.renderer.info.render.frame > from || performance.now() > deadline) {
        void painted().then(resolve);
        return;
      }
      requestAnimationFrame(wait);
    });
  });
}

/**
 * Which `building()` call the boot card belongs to.
 *
 * A generation counter rather than a boolean because two builds can overlap:
 * clicking SoCal while the Bay Area is still building leaves the first
 * `building()` running, and it must not fade a card the second one is using.
 * Last one in owns it.
 */
let bootGeneration = 0;

/**
 * Run something slow with the boot card up and a sentence saying what it is.
 *
 * Naming the work is most of the value: a blank page for two seconds reads as
 * broken, and "Building the Bay Area…" for two seconds reads as busy. The
 * percentage on top of it comes from `bootProgress`, which the heightfield
 * drives directly from the Worker.
 *
 * `work` may be synchronous or not. It became "or not" when `createScene` did,
 * and the flattening matters: without the `await` here the card faded about
 * 800 ms in, while the city was still building, because a promise is a truthy
 * value that returns instantly.
 */
async function building<T>(label: string, work: () => T | Promise<T>): Promise<T> {
  const generation = ++bootGeneration;
  if (bootStep) bootStep.textContent = label;
  if (bootCard) {
    bootCard.hidden = false;
    bootCard.classList.remove("done");
  }
  await painted();
  const result = await work();
  await drawn();
  // Superseded while we were building: the card belongs to a later call now and
  // fading it would uncover a city that does not exist yet.
  if (generation !== bootGeneration) return result;
  bootCard?.classList.add("done");
  window.setTimeout(() => {
    if (bootCard?.classList.contains("done")) bootCard.hidden = true;
  }, 300);
  return result;
}

/**
 * The heightfield's progress, on the card that is already on screen.
 *
 * Nothing is written while the build is on the main thread, and that is not an
 * oversight: `onMainThread` means the page is frozen for the duration, so every
 * one of these writes would land in the same task and exactly one of them —
 * the last — would ever be seen. The fallback build is the case where the
 * static label is all the honesty available.
 *
 * The caller is responsible for not reporting an abandoned build; see the
 * `onProgress` passed by `mountCity`.
 */
function bootProgress(label: string, fraction: number, onMainThread: boolean) {
  if (!bootStep || onMainThread || bootCard?.hidden !== false) return;
  bootStep.textContent = `Building ${label}… terrain ${Math.round(fraction * 100)}%`;
}

// ---- Boot -----------------------------------------------------------------

/**
 * Access first, then data, then the board.
 *
 * The order is load-bearing in both directions and it used to be wrong. Markers
 * were fetched before the tier was known, which is a request an anonymous
 * visitor should not be making; and both decisions have to be settled before
 * the *first* `mountCity`, because `markerPalette` is fixed at scene
 * construction — the sample palette's keys are not the API's — and the flight
 * source is chosen in the same call.
 *
 * Everything about the API remains optional. No server means the bundled sample
 * set, the simulated traffic, and a label at the bottom of the screen saying
 * which of the two you are looking at.
 */
async function boot() {
  applyPanel();
  applyPlan();

  if (bootStep) bootStep.textContent = "Asking the deployment who you are…";
  access = await resolveAccess();
  applyTimeControl();
  renderTierBadge();

  // Both gates, for the reason `mountCity` gives at length: the tier says
  // whether this visitor may ask, `feeds` says whether there is anything to
  // ask. A box with `markers: "none"` serves the public empty body to everyone,
  // so the request buys a round trip and lands on the same sample set.
  if (access.can.liveMarkers && access.feeds?.markers) {
    try {
      const feed = await tera.markers();
      markers = feed.value;
      // The caller's palette, plus the door colour it cannot know about.
      palette = { ...feed.palette, ...OFFICE_PALETTE };
      liveData = feed.live;
    } catch {
      // A missing API is the self-host default, not an error.
    }
  }

  /**
   * 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 ?? "california"),
  );

  // The instruments, after the first board, because the panel reads a live
  // stage and there is not one before this line.
  await mountGodmode();

  // The `office.` front door. The city is already standing behind this, so the
  // back button is a scene swap and not a rebuild.
  if (OPENS_IN_OFFICE) await building("Fetching the office…", () => enterOffice());

  /**
   * The wall clock, once a minute.
   *
   * Skipped entirely while an override is up — the whole point of an override
   * is that the map has stopped following the clock — and the panel is told the
   * new instant only when it is not the one choosing it, so its own readouts
   * stay pinned to what is being rendered rather than fighting it.
   */
  window.setInterval(() => {
    if (instantOverride !== null) return;
    updateSun();
    godmode?.setInstant(new Date());
  }, 60_000);
}

void boot();
