/**
 * The instrument panel behind `access.can.debug`.
 *
 * Godmode used to be one hour scrubber, which is a strange thing for the top
 * tier to unlock: the astronomy in this repo is four hundred lines of Meeus in
 * `solar.ts` and another four hundred in `atmosphere.ts`, and a control that can
 * only move within *today* makes almost none of it visible. You cannot watch the
 * arc flatten between June and December, you cannot watch a day get four hours
 * shorter, and the moon — computed to a hundredth of a degree — is a number in
 * the corner that never changes while you are looking at it. Same for the
 * weather: `observe()` takes a `WeatherObservation`, `main.ts` has always passed
 * `null`, and so the cloud, precipitation, visibility and marine-layer paths are
 * correct code that nothing in production has ever run. This panel is the wiring
 * loom for all of it.
 *
 * Four instruments, and each earns its space by showing something that is
 * otherwise invisible rather than something merely convenient:
 *
 *   1. **Time with a date in it.** Day of the year and hour, the solstices and
 *      equinoxes computed for the current year rather than written down, golden
 *      and blue hour solved for *this* place on *this* date, and the next four
 *      lunar quarters found by searching the same Meeus series the renderer
 *      lights the night with.
 *   2. **Weather that can lie**, loudly. Presets and raw sliders for the four
 *      fields `atmosphere.ts` reads, including the difference between "zero" and
 *      "not reported" that the observation type is careful about and a slider
 *      would otherwise erase.
 *   3. **A frame budget**, as a median and a p95 rather than an instantaneous
 *      number that flickers too fast to read, plus the one measurement nothing
 *      else in the app makes: whether a board leaves geometries and textures
 *      behind when you leave it.
 *   4. **Overlays** for the numbers you want while your hands are on the
 *      camera — a paste-ready `Chapter.focus`, both bodies' az/el, the marine
 *      strength, and the fog the atmosphere actually installed.
 *
 * Two more sections belong to whatever board is up rather than to the panel, and
 * are absent when there is nothing to point them at: **sky** (`GodmodeSky`) and
 * **office** (`GodmodeOffice`). Neither is a fifth instrument in the sense above
 * — they are handles onto one scene's dials, they arrive with it and they mean
 * nothing without it, which is why both are optional interfaces rather than
 * fields on `GodmodeOptions`.
 *
 * ## It is not shipped to anyone else
 *
 * `access.ts` is explicit that `debug` is a *drawing* decision and not a
 * boundary — there is no server to enforce a client-side sun against. That
 * argument justifies hiding a slider; it does not justify shipping a whole
 * instrument panel to every anonymous visitor and hiding it with CSS. So this
 * module is constructed only when `can.debug` is true, and it lives under
 * `src/tools/` behind `src/tools/index.ts` precisely so that `main.ts` can reach
 * it through a dynamic `import()` and Vite can keep it out of the bundle
 * everyone else downloads. A visitor who is not god does not get a hidden panel;
 * they do not get the code.
 *
 * ## Shape
 *
 * The same imperative handle `minimap.ts` uses, for the same reasons: there is
 * no framework here, the caller owns the layout, and a module that mounts, is
 * driven through setters and disposes itself completely is the only kind that
 * survives a city swap. The one difference is that a minimap is a canvas the
 * caller styles and this is a panel that styles itself — `index.html` carries
 * the whole interface's CSS inline and is not ours to edit, so the rules live in
 * one injected `<style>` at the bottom of this file. Every custom property it
 * reads has a fallback, so the panel is legible with or without the app's
 * tokens; when the tokens are there it matches the rest of the chrome exactly.
 */

import { GODMODE_HOTKEY } from "../ui/shortcuts.ts";
import {
  moonPosition,
  observe,
  type Environment,
  type SkyCondition,
  type WeatherObservation,
} from "../engine/atmosphere.ts";
import { MAX_EXTRA_TRAFFIC } from "../engine/flights.ts";
import { daylightPhase, solarPosition, sunTimes } from "../engine/solar.ts";
import type * as THREE from "three";
import type { Stage } from "../engine/stage.ts";

/**
 * Where we are, as much of it as the instruments can use.
 *
 * The suggested interface took `initial: { instant, lat, lng }` and stopped
 * there, which works right up until somebody presses `]`: switching city moves
 * the observer, and every one of the sun, moon, golden-hour and marine readouts
 * is wrong from that moment until it is told. So the three facts are one object
 * with a setter, and the two optional hooks that need a `World` ride along with
 * it — they belong to a board and die with it.
 */
export interface GodmodePlace {
  /** For the readouts, and the key the leak ledger groups boards under. */
  label: string;
  lat: number;
  lng: number;
  /**
   * The stage this board is being drawn on, for `renderer.info` and the live
   * camera. Never written to.
   *
   * It lives here rather than in `GodmodeOptions` because in this app a stage
   * does not outlive a board: `createScene` builds its own and
   * `SceneHandle.dispose()` disposes it, so switching city replaces the
   * renderer wholesale. A panel that cached the stage it was constructed with
   * would spend the rest of the session reading a dead renderer's counters and
   * a paused scene's camera, and every number on it would be quietly, plausibly
   * wrong — which is the worst failure an instrument has.
   */
  stage: Stage;
  /**
   * `World.unproject`, if the caller has one. With it, the pose overlay prints a
   * `Chapter.focus` you can paste into a city pack; without it, scene units,
   * which are still useful and still true.
   */
  unproject?(x: number, z: number): [number, number];
  /** `World.metresPerUnit`, so the fog distances can also be stated in km. */
  metresPerUnit?: number;
  /**
   * How strongly the marine layer is running, 0..1, or `null` where the city has
   * no layer configured.
   *
   * A hook rather than an import because `marineStrength()` in `atmosphere.ts`
   * is private to that file today. It is the one number in the fog model you
   * cannot infer from the outside — season, clock and wind gate multiply into
   * it and only the product reaches the rig — so the overlay says so plainly
   * when nobody wired it, rather than printing a plausible guess.
   */
  marineStrength?(env: Environment): number | null;
}

/**
 * The two dials that point at the sky rather than at the light.
 *
 * Optional as a unit, and the whole interface is absent rather than each method
 * being nullable: without a scene there is nothing for either control to do, and
 * a panel that showed a dead traffic slider next to a live sun scrubber would be
 * inviting somebody to drag it and conclude the renderer was broken. `main.ts`
 * passes this only once a board is up.
 *
 * `read()` is polled on the panel's own refresh rather than pushed, because both
 * numbers it returns change without anybody touching a control — traffic goes
 * live when a fetch lands, and the satellite count changes every time the
 * propagation sweep completes a pass.
 */
export interface GodmodeSky {
  /**
   * Fabricate this many aircraft on top of whatever is being drawn, or `0` for
   * none. Composes with a live feed rather than replacing it; see
   * `withTrafficDial` in `engine/flights.ts` for why that direction, and for the
   * argument about fabricating traffic at all.
   */
  onExtraTraffic(count: number): void;
  /** Draw the satellite layer, or do not. */
  onSatellitesVisible(visible: boolean): void;
  read(): {
    extraTraffic: number;
    /** True when the aircraft underneath the fabricated ones were observed. */
    trafficIsLive: boolean;
    /**
     * Objects above the horizon at the last completed propagation pass, and
     * element sets this build could read. `null` on the overwhelming majority of
     * deployments, which serve no catalogue at all — a different state from a
     * catalogue with nothing in it, and one the panel says out loud.
     */
    satellites: { visible: number; total: number } | null;
  };
}

/**
 * Where the office's house lights take their level from.
 *
 * `"sun"` is the building's own behaviour and the position everything boots in;
 * the other two are a hand on the switch, and they stay where they were put
 * until somebody moves them — across a walk out to the city and back, and across
 * the rebuild a city switch performs.
 */
export type GodmodeHouseLights = "sun" | "on" | "off";

/**
 * The office, when the stage is showing one.
 *
 * The same shape and the same argument as `GodmodeSky` — optional as a unit,
 * methods that reach the room through the caller's live handle rather than
 * through one captured at construction — with the one difference that the sky
 * does not have: a city is simply there, and an office is a room you walk into
 * and out of. So `read()` may answer `null` at any moment and the section takes
 * itself off screen when it does. A ceiling switch pointed at a building nobody
 * is looking at is worse than no switch, and greying it out would only put the
 * dead panel back in a paler colour.
 *
 * The panel owns the three switch positions and re-asserts all three the moment
 * `read().id` changes. It has to: in this app an office handle does not survive
 * a city switch — it is disposed and the next one is built at its own defaults,
 * lids off and robots drawn — so a panel whose chips still claimed "ceilings"
 * over a freshly built building would be lying about the only thing it does.
 * Re-asserting is three idempotent calls on an event that happens when somebody
 * changes metro, which is not a budget anybody has to think about.
 */
export interface GodmodeOffice {
  /**
   * Where the house lights take their level from.
   *
   * This is the control the section is worth building for. The fittings ramp on
   * against the sun's height — fully on at or below 0°, fully off at or above
   * +6°, as `LIGHTS_ON_BELOW_DEG` and `LIGHTS_OFF_ABOVE_DEG` in
   * `interiors/luminaires.ts` have it — so until now the only way to see the lit
   * building was to scrub the clock into the evening, which also moves the sun,
   * the sky behind the glazing, the fog and every shadow. You end up comparing
   * two pictures that differ in five ways and learning nothing about any of
   * them. Forcing the level moves the fittings and the interior term and
   * nothing else: `withHouseLights` in `interiors/daylight.ts` lifts the ambient
   * and hemisphere terms and returns the rest of the rig untouched, so the sun
   * outside is still the clock's and the two pictures differ in the one thing
   * that was asked about.
   */
  onHouseLights(mode: GodmodeHouseLights): void;
  /**
   * Draw the robots, or do not — they keep walking either way, and that is the
   * useful half rather than a caveat.
   *
   * A fitting responds to where a robot *is* and not to whether it is drawn:
   * the luminaires hold the live array the robot layer mutates in place, and
   * hiding a mesh does not move a position. So this is how you watch the ceiling
   * follow somebody across an empty floor with nothing else in the frame, which
   * is the one view that shows the occupancy response on its own.
   */
  onRobotsVisible(visible: boolean): void;
  /**
   * Put the lids back on. `OfficeScene.setCeilingsVisible`, which has existed
   * since the shell did and which nothing in the app has ever called — the
   * ceilings are off by default because looking down into the floor plate is the
   * entire view, and that is exactly why being able to close it is worth a chip:
   * a fitting you cannot see the underside of is a fitting you cannot check.
   */
  onCeilingsVisible(visible: boolean): void;
  /**
   * The office on the stage, or `null` when the stage is showing something else.
   *
   * Polled on the panel's own refresh rather than pushed, like
   * `GodmodeSky.read`, and here for a stronger reason than either of the sky's:
   * `houseLevel` is a ramp against the sun and climbs on its own all through
   * dusk while nobody touches a control, which is precisely the moment somebody
   * has this section open.
   */
  read(): {
    /**
     * Which building. Printed, and watched for the rebuild described on this
     * interface — so it must name the *building*, and must change when the room
     * on the stage is a different one.
     */
    id: string;
    /**
     * `"public"` is the stranger's building: same shell, same plan, same
     * furniture, and no presence layer at all. Worth a word on screen, because
     * an empty office is otherwise indistinguishable from an occupancy feed that
     * never landed.
     */
    depth: "full" | "public";
    /** `OfficeScene.houseLevel()`, 0..1, after the last solar elevation it was given. */
    houseLevel: number;
    /** How many robots this pack asked for. Zero is a normal answer, not a fault. */
    robots: number;
  } | null;
}

export interface GodmodeOptions {
  /**
   * Where to mount. The root positions *itself* — bottom centre, over the map,
   * clear of every other card while collapsed — so this may simply be
   * `document.body`; the container is a mounting point and a disposal boundary,
   * not a layout.
   */
  container: HTMLElement;
  /** The current instant and place, so the panel opens showing the truth. */
  initial: { instant: Date; place: GodmodePlace };
  /** A new instant to render at, or `null` to follow the wall clock again. */
  onTimeChange(instant: Date | null): void;
  /** A fabricated observation, or `null` for whatever the deployment reports. */
  onWeatherOverride(w: WeatherObservation | null): void;
  /** Traffic and satellites, when there is a board to point them at. */
  sky?: GodmodeSky;
  /**
   * House lights, robots and ceilings, when this deployment has an office at
   * all. Absent leaves the section unbuilt; present but `read()`ing `null`
   * leaves it off screen until somebody walks into the building.
   */
  office?: GodmodeOffice;
  /** Start with the drawer open. Default `false`: the tab, and nothing else. */
  open?: boolean;
  /**
   * The key that opens the drawer, lower case. Defaults to `GODMODE_HOTKEY` —
   * the backtick.
   *
   * **It was `g`, and `g` was already taken.** `controlForKey` has mapped `g` to
   * `secondary` — the crow's glide — since the play modes landed, and this
   * handler is registered in the *capture* phase and calls `preventDefault()`.
   * So for the one visitor in a deployment who has godmode, glide silently did
   * nothing, and the `?` card grew a `G` row advertising this panel while the
   * table of shortcuts said `G` was glide. Two bindings, one key, and the loser
   * was the one that belonged to everybody.
   *
   * The backtick is the conventional key for a developer console and collides
   * with nothing. Every other binding lives in `KEYMAP` in `src/ui/shortcuts.ts`
   * — including this one — and a test asserts no key in that table has two
   * meanings, which is what stops this happening again.
   */
  hotkey?: string;
}

export interface Godmode {
  /** The panel's own root. Removed entirely by `destroy()`. */
  root: HTMLElement;
  /**
   * Adopt the caller's instant without firing `onTimeChange` — the caller is the
   * authority on what is being rendered, and this is how the panel stays honest
   * about it. Call it on the wall-clock tick; while an override is up the caller
   * has no reason to.
   */
  setInstant(instant: Date): void;
  /** The board changed under us. Re-aims every place-dependent readout. */
  setPlace(place: GodmodePlace): void;
  setOpen(open: boolean): void;
  isOpen(): boolean;
  destroy(): void;
}

// ---- Constants --------------------------------------------------------------

/** Readout cadence. Fast enough to feel live, slow enough that nobody reads a blur. */
const REFRESH_MS = 250;

/** Frame intervals kept for the median. Two seconds at 60 Hz. */
const FRAME_SAMPLES = 120;

/**
 * How long after a scene swap the memory counters are believed.
 *
 * Textures upload lazily — the first frame that draws a material is the one that
 * allocates it — so a count taken the instant the new board appears is short by
 * however many materials have not been seen yet, and every board would look like
 * it had leaked on the way *out*.
 */
const SETTLE_MS = 1500;

/** Solar elevation photographers call golden, and the one they call blue. */
const GOLDEN_DEG = 6;
const BLUE_DEG = -4;

const MS_PER_MINUTE = 60_000;
const MS_PER_DAY = 86_400_000;

/**
 * The presets, as observations rather than as moods.
 *
 * `marine` is the one with real numbers behind it: `PACIFIC_MARINE_LAYER` gates
 * on a wind from 275° and `windGate` wants 8-35 km/h — calm leaves the layer
 * offshore and a gale mixes it into stratus — so 275° at 20 km/h is the middle
 * of the gate rather than a guess. It is also only a third of the model. Season
 * and time of day are the other two factors and they come from the *date*, so
 * choosing this preset in February renders a clear afternoon and looks broken;
 * that is why picking it offers you a July dawn.
 */
interface WeatherDraft {
  cloudCover: number;
  precipitation: number;
  visibilityKm: number | null;
  windKph: number | null;
  windDirDeg: number | null;
  condition: SkyCondition;
}

const WEATHER_PRESETS: { id: string; label: string; draft: WeatherDraft }[] = [
  {
    id: "clear",
    label: "clear",
    draft: {
      cloudCover: 0.05,
      precipitation: 0,
      visibilityKm: 40,
      windKph: 9,
      windDirDeg: 290,
      condition: "clear",
    },
  },
  {
    id: "partly",
    label: "partly",
    draft: {
      cloudCover: 0.45,
      precipitation: 0,
      visibilityKm: 24,
      windKph: 13,
      windDirDeg: 280,
      condition: "partly-cloudy",
    },
  },
  {
    id: "overcast",
    label: "overcast",
    draft: {
      cloudCover: 1,
      precipitation: 0,
      visibilityKm: 11,
      windKph: 15,
      windDirDeg: 200,
      condition: "overcast",
    },
  },
  {
    id: "rain",
    label: "rain",
    draft: {
      cloudCover: 0.95,
      precipitation: 0.55,
      visibilityKm: 6,
      windKph: 24,
      windDirDeg: 190,
      condition: "rain",
    },
  },
  {
    id: "storm",
    label: "storm",
    draft: {
      cloudCover: 1,
      precipitation: 0.9,
      visibilityKm: 2.5,
      windKph: 48,
      windDirDeg: 200,
      condition: "thunderstorm",
    },
  },
  {
    id: "marine",
    label: "marine layer",
    draft: {
      cloudCover: 0.95,
      precipitation: 0,
      visibilityKm: 5,
      windKph: 20,
      windDirDeg: 275,
      condition: "fog",
    },
  },
];

const CONDITIONS: SkyCondition[] = [
  "clear",
  "partly-cloudy",
  "cloudy",
  "overcast",
  "fog",
  "rain",
  "snow",
  "thunderstorm",
];

/** The peak of the Pacific layer's season, as `PACIFIC_MARINE_LAYER.peakDay` has it. */
const MARINE_PEAK_MONTH = 6;
const MARINE_PEAK_DATE = 15;
const MARINE_PEAK_HOUR = 5.5;

// ---- The panel --------------------------------------------------------------

/** Only for the `aria-controls` pairs; two panels on one page would collide. */
let instances = 0;

export function createGodmode(options: GodmodeOptions): Godmode {
  const hotkey = (options.hotkey ?? GODMODE_HOTKEY).toLowerCase();
  const id = `gm-${++instances}`;

  let place = options.initial.place;
  /** `null` follows the wall clock. The same convention `main.ts` uses. */
  let override: Date | null = null;
  let shown = options.initial.instant;
  let weatherOn = false;
  let activePreset: string | null = null;
  const draft: WeatherDraft = clearDraft();

  let open = options.open ?? false;
  let showPose = false;
  let showBodies = false;
  let showAir = false;

  const sheet = acquireStylesheet();

  // ---- Root -----------------------------------------------------------------

  const root = el("div", "gm-root");
  root.setAttribute("role", "region");
  root.setAttribute("aria-label", "Godmode instruments");

  /**
   * The overlay block, top centre.
   *
   * Fixed, and therefore deliberately *not* laid out by the root's flex column —
   * you want these numbers next to the thing they describe, not stacked under
   * the controls at the far end of the screen. It is a child of the root anyway
   * so that one `root.remove()` still takes everything, which is only safe
   * because the root carries no `transform`: a transformed ancestor becomes the
   * containing block for fixed descendants, and this would silently reposition
   * itself against the drawer instead of the viewport. Centring the root is done
   * with `left: 0; right: 0` and flex for exactly that reason.
   */
  const hud = el("pre", "gm-hud");
  hud.hidden = true;
  hud.setAttribute("aria-live", "off");

  const body = el("div", "gm-body");
  body.id = `${id}-body`;
  body.hidden = !open;

  /**
   * The standing declaration that something on screen is fabricated.
   *
   * It is outside the drawer on purpose: an override survives the panel being
   * closed, and an instrument that can lie about the weather has to say so while
   * you are looking at the *map*, not while you are looking at the instrument.
   * The dashed rim is the tell, and it is borrowed rather than invented —
   * `markers.ts` and `minimap.ts` both draw a pin whose position is a guess as a
   * hollow one rather than a differently coloured one, on the principle that a
   * different *silhouette* survives a glance and a different shade does not.
   * Amber because this is an active state and amber is what active means here;
   * the dashes are what "not real" means.
   */
  const banner = el("div", "gm-banner");
  banner.setAttribute("role", "status");
  banner.hidden = true;
  const bannerText = el("span", "gm-banner-text");
  const bannerClear = button("gm-mini", "clear", () => {
    setOverride(null);
    setWeather(false, null);
  });
  banner.append(bannerText, bannerClear);

  const tab = el("button", "gm-tab");
  tab.type = "button";
  tab.setAttribute("aria-expanded", String(open));
  tab.setAttribute("aria-controls", body.id);
  tab.append(
    el("span", "gm-tab-label", "godmode"),
    el("kbd", "gm-kbd", hotkey.toUpperCase()),
  );
  tab.addEventListener("click", () => setOpen(!open));

  root.append(hud, body, banner, tab);
  options.container.append(root);

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

  const timeSection = section("time");
  const stamp = el("div", "gm-stamp");
  const sunLine = el("div", "gm-line");
  const dayLine = el("div", "gm-line");

  const moonCanvas = document.createElement("canvas");
  moonCanvas.className = "gm-moon";
  moonCanvas.width = 40;
  moonCanvas.height = 40;
  moonCanvas.setAttribute("role", "img");
  const moonCtx = moonCanvas.getContext("2d");
  const moonLine = el("div", "gm-line");
  const moonRow = el("div", "gm-moonrow");
  moonRow.append(moonCanvas, moonLine);

  const dateSlider = slider("Day of year", 1, 365, 1, (value) => {
    const next = new Date(currentInstant());
    // `setMonth(0, n)` with n past the end of January rolls forward through the
    // calendar correctly, leap years included, which is the whole reason the day
    // of the year is stored as one number and never as a month and a date.
    next.setMonth(0, value);
    setOverride(next);
  });
  const hourSlider = slider("Hour of day", 0, 24, 1 / 60, (value) => {
    const next = new Date(currentInstant());
    next.setHours(Math.floor(value), Math.round((value % 1) * 60), 0, 0);
    setOverride(next);
  });

  const lightChips = el("div", "gm-chips");
  const seasonChips = el("div", "gm-chips");
  const moonChips = el("div", "gm-chips");

  const lightButtons: { el: HTMLButtonElement; at(): Date | null }[] = [];
  for (const spec of [
    { label: "blue ↑", target: BLUE_DEG, rising: true },
    { label: "golden ↑", target: GOLDEN_DEG, rising: true },
    { label: "noon", target: null, rising: true },
    { label: "golden ↓", target: GOLDEN_DEG, rising: false },
    { label: "blue ↓", target: BLUE_DEG, rising: false },
  ]) {
    const at = () =>
      spec.target === null
        ? sunTimes(place.lat, place.lng, currentInstant()).solarNoon
        : elevationCrossing(place.lat, place.lng, currentInstant(), spec.target, spec.rising);
    const b = button("gm-chip", spec.label, () => {
      const when = at();
      if (when) setOverride(when);
    });
    lightButtons.push({ el: b, at });
    lightChips.append(b);
  }

  for (const marker of ["Mar eq", "Jun sol", "Sep eq", "Dec sol"] as const) {
    seasonChips.append(
      button("gm-chip", marker, () => {
        const events = seasonMarkers(currentInstant().getFullYear());
        const when = events[marker];
        if (!when) return;
        /**
         * The date moves and the clock does not, which is the opposite of the
         * lunar buttons and is the point of these four. Holding the hour fixed
         * across the solstices is what makes the change visible: the same 09:00
         * that is a low raking winter sun is a high one in June, and the arc,
         * the shadow length and the day length all move together. Jumping to the
         * exact instant of the solstice instead would land you at some arbitrary
         * hour and prove nothing.
         */
        const next = new Date(currentInstant());
        next.setFullYear(when.getFullYear(), when.getMonth(), when.getDate());
        setOverride(next);
      }),
    );
  }

  for (const spec of [
    { label: "new", phase: 0 },
    { label: "1Q", phase: 0.25 },
    { label: "full", phase: 0.5 },
    { label: "3Q", phase: 0.75 },
  ]) {
    moonChips.append(
      button("gm-chip", spec.label, () => {
        // Here the whole instant moves, hour included: the quarter *is* an
        // instant, and rendering it an hour early renders a different moon.
        const when = nextPhase(place.lat, place.lng, currentInstant(), spec.phase);
        if (when) setOverride(when);
      }),
    );
  }

  const followButton = button("gm-chip gm-chip-wide", "follow the wall clock", () => {
    setOverride(null);
  });

  timeSection.body.append(
    stamp,
    sunLine,
    moonRow,
    dayLine,
    labelled("date", dateSlider.el, dateSlider.value),
    labelled("hour", hourSlider.el, hourSlider.value),
    lightChips,
    seasonChips,
    labelled("moon", moonChips),
    followButton,
  );

  // ---- Weather --------------------------------------------------------------

  const weatherSection = section("weather");
  const weatherState = el("div", "gm-line");
  const weatherChips = el("div", "gm-chips");
  const presetButtons: { id: string; el: HTMLButtonElement }[] = [];

  const realChip = button("gm-chip", "real", () => setWeather(false, null));
  weatherChips.append(realChip);
  for (const preset of WEATHER_PRESETS) {
    const b = button("gm-chip", preset.label, () => {
      Object.assign(draft, preset.draft);
      setWeather(true, preset.id);
    });
    presetButtons.push({ id: preset.id, el: b });
    weatherChips.append(b);
  }

  /**
   * The marine layer's other two thirds, offered only when it is the thing you
   * just asked for. Season and clock are facts about the date and no weather
   * observation can carry them, so the honest options are to say nothing and let
   * the preset quietly do a tenth of what its name promises, or to say what else
   * it needs and offer the jump. `offSeason` is 0.1 — a February marine chip is
   * doing a tenth of the work — so this is not a nicety.
   */
  const marineHint = el("div", "gm-hint");
  const marineJump = button("gm-mini", "15 Jul, 05:30", () => {
    const next = new Date(currentInstant());
    next.setMonth(MARINE_PEAK_MONTH, MARINE_PEAK_DATE);
    next.setHours(Math.floor(MARINE_PEAK_HOUR), (MARINE_PEAK_HOUR % 1) * 60, 0, 0);
    setOverride(next);
  });
  marineHint.append(
    document.createTextNode("the layer also wants a summer dawn — "),
    marineJump,
  );
  marineHint.hidden = true;

  const cloudSlider = slider("Cloud cover", 0, 1, 0.01, (v) => {
    draft.cloudCover = v;
    setWeather(true, null);
  });
  const precipSlider = slider("Precipitation", 0, 1, 0.01, (v) => {
    draft.precipitation = v;
    setWeather(true, null);
  });
  /**
   * Visibility on a log-ish scale, because the interesting half of the range is
   * the bottom two kilometres — `observedObscuration` is a ramp from 10 km down
   * to 500 m and everything above 12 km is the same clear day — and a linear
   * 0-40 slider spends nine tenths of its travel there.
   */
  const visSlider = slider("Visibility", 0, 1, 0.005, (v) => {
    draft.visibilityKm = Math.round(Math.exp(Math.log(0.2) + v * Math.log(60 / 0.2)) * 10) / 10;
    setWeather(true, null);
  });
  const windSlider = slider("Wind speed", 0, 90, 1, (v) => {
    draft.windKph = v;
    setWeather(true, null);
  });
  const dirSlider = slider("Wind direction", 0, 359, 1, (v) => {
    draft.windDirDeg = v;
    setWeather(true, null);
  });

  /**
   * `null` is not zero, and the observation type says so in a comment: a station
   * that does not measure visibility is not a station reporting perfect
   * visibility. A slider cannot express that difference, so each optional field
   * gets a toggle beside it and the slider goes dead when the answer is "nobody
   * asked". Without these three buttons this panel could not produce the
   * commonest real observation there is.
   */
  const visReported = reportedToggle(() => {
    draft.visibilityKm = draft.visibilityKm === null ? 10 : null;
    setWeather(true, null);
  });
  const windReported = reportedToggle(() => {
    draft.windKph = draft.windKph === null ? 15 : null;
    setWeather(true, null);
  });
  const dirReported = reportedToggle(() => {
    draft.windDirDeg = draft.windDirDeg === null ? 275 : null;
    setWeather(true, null);
  });

  const conditionSelect = document.createElement("select");
  conditionSelect.className = "gm-select";
  conditionSelect.setAttribute("aria-label", "Sky condition");
  for (const c of CONDITIONS) {
    const opt = document.createElement("option");
    opt.value = c;
    opt.textContent = c;
    conditionSelect.append(opt);
  }
  conditionSelect.addEventListener("change", () => {
    draft.condition = conditionSelect.value as SkyCondition;
    setWeather(true, null);
  });

  weatherSection.body.append(
    weatherState,
    weatherChips,
    marineHint,
    labelled("cloud", cloudSlider.el, cloudSlider.value),
    labelled("precip", precipSlider.el, precipSlider.value),
    labelled("vis", visSlider.el, visSlider.value, visReported.el),
    labelled("wind", windSlider.el, windSlider.value, windReported.el),
    labelled("from", dirSlider.el, dirSlider.value, dirReported.el),
    labelled("cond", conditionSelect),
  );

  // ---- Sky ------------------------------------------------------------------

  /**
   * Built unconditionally and appended only when `options.sky` is present.
   *
   * The alternative — branching around the whole block — means every readout
   * below has to be nullable and `refreshSky` becomes a pyramid of guards. One
   * check at append time is cheaper to read and the unappended nodes are
   * garbage a moment later.
   */
  const skySection = section("sky");
  const trafficLine = el("div", "gm-line");
  const trafficSlider = slider("Fabricated aircraft", 0, MAX_EXTRA_TRAFFIC, 1, (value) => {
    options.sky?.onExtraTraffic(value);
    refresh();
  });
  const trafficNote = el("div", "gm-hint");
  trafficNote.textContent =
    "invented traffic, added to whatever is being drawn — trails run out past ~190 aircraft";
  const satelliteLine = el("div", "gm-line");
  const satelliteChips = el("div", "gm-chips");
  let satellitesOn = true;
  const satelliteChip = button("gm-chip", "satellites", () => {
    satellitesOn = !satellitesOn;
    options.sky?.onSatellitesVisible(satellitesOn);
    refresh();
  });
  const satelliteNote = el("div", "gm-hint");
  satelliteNote.textContent =
    "drawn on a dome at true azimuth and elevation — 550 km will not fit on the board";
  satelliteChips.append(satelliteChip);
  skySection.body.append(
    trafficLine,
    labelled("extra", trafficSlider.el, trafficSlider.value),
    trafficNote,
    satelliteLine,
    satelliteChips,
    satelliteNote,
  );

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

  /**
   * Built unconditionally and appended only with `options.office`, exactly as
   * the sky block above is, and then hidden whenever `read()` says the stage is
   * not showing a room. The `hidden` attribute rather than a detach: the section
   * is a grid item, `[hidden]` is `display: none` here in a rule that outranks
   * the grid, and taking a node out and putting it back would also have to
   * remember where in the column order it belonged.
   */
  const officeSection = section("office");
  const officeLine = el("div", "gm-stamp");
  const houseLine = el("div", "gm-line");
  const houseChips = el("div", "gm-chips");
  let houseLights: GodmodeHouseLights = "sun";
  const houseButtons: { mode: GodmodeHouseLights; el: HTMLButtonElement }[] = [];
  for (const spec of [
    { mode: "sun", label: "sun" },
    { mode: "on", label: "force on" },
    { mode: "off", label: "force off" },
  ] as const) {
    const b = button("gm-chip", spec.label, () => {
      houseLights = spec.mode;
      options.office?.onHouseLights(spec.mode);
      refresh();
    });
    houseButtons.push({ mode: spec.mode, el: b });
    houseChips.append(b);
  }
  const houseNote = el("div", "gm-hint");
  houseNote.textContent =
    "the fittings and the interior term only — the sun outside the glazing is still the clock's";

  const robotLine = el("div", "gm-line");
  const showChips = el("div", "gm-chips");
  let robotsOn = true;
  const robotChip = button("gm-chip", "robots", () => {
    robotsOn = !robotsOn;
    options.office?.onRobotsVisible(robotsOn);
    refresh();
  });
  let ceilingsOn = false;
  const ceilingChip = button("gm-chip", "ceilings", () => {
    ceilingsOn = !ceilingsOn;
    options.office?.onCeilingsVisible(ceilingsOn);
    refresh();
  });
  showChips.append(robotChip, ceilingChip);
  const officeNote = el("div", "gm-hint");
  officeNote.textContent =
    "hidden robots keep walking and the fittings above them still come up; " +
    "the lids are off by default, which is why putting them back is a control";

  officeSection.body.append(
    officeLine,
    houseLine,
    labelled("lights", houseChips),
    houseNote,
    robotLine,
    labelled("show", showChips),
    officeNote,
  );

  // ---- Performance ----------------------------------------------------------

  const perfSection = section("performance");
  const frameLine = el("div", "gm-stamp");
  const frameNote = el("div", "gm-hint");
  frameNote.textContent = "interval between frames; vsync floors it at the refresh rate";
  const drawLine = el("div", "gm-line");
  const memLine = el("div", "gm-line");
  const ledgerNote = el("div", "gm-hint");
  ledgerNote.textContent =
    "geometries and textures held by the renderer, per board, against its first visit";
  const ledgerTable = el("div", "gm-ledger");
  const ledgerReset = button("gm-mini", "reset ledger", () => {
    ledger.clear();
    ledgerOrder.length = 0;
    renderLedger();
  });

  /**
   * GPU time per frame, which is the number the interval above cannot give you.
   *
   * The interval is floored at the refresh rate by vsync, so a board that
   * renders in 1.2 ms and one that renders in 15 both read 16.7 and look
   * identical — and a whole day was once spent on a frame-drop hypothesis that
   * one glance at this readout would have settled. `EXT_disjoint_timer_query_webgl2`
   * is the only way to ask a browser what the GPU actually spent.
   *
   * Three properties of the extension decide the shape of this code:
   *
   *   - **It is optional**, and absent on most Safari and on any browser with
   *     the mitigation for the timing side channels enabled. Absent means the
   *     row says so and nothing else changes.
   *   - **The result is not ready on the frame that issued it.** A query is
   *     polled on later frames until `QUERY_RESULT_AVAILABLE`, which is why
   *     there is a queue rather than a variable.
   *   - **A disjoint result is garbage and must be thrown away**, not averaged
   *     in. `GPU_DISJOINT_EXT` latches when the driver preempted or reclocked
   *     mid-query — which on this repo's box is exactly the condition worth
   *     measuring around, so a lying number here would be worse than no number.
   *
   * One query in flight at a time. The panel is an instrument and instruments
   * that cost frames are lying about the thing they measure.
   */
  const gpuLine = el("div", "gm-line");
  const gpuNote = el("div", "gm-hint");
  gpuNote.textContent =
    "time the GPU spent on one frame, which vsync hides from the interval above";

  /**
   * Per-layer visibility, with what turning each one off is worth.
   *
   * Matched by `Object3D.name` against the scene root rather than through a
   * handle, deliberately: this panel must keep working across a city switch that
   * disposes the whole stage, and across an office, without `main.ts` having to
   * publish a layer registry that exists only for a debug drawer. The names are
   * the ones the layers already set on themselves.
   *
   * The delta beside each chip is the honest way to answer "what does this
   * cost": hide it, let the next frame land, and subtract the renderer's own
   * counters. Every measurement behind the handheld shadow-caster cut in
   * `blocks.ts` was taken this way, with a patched bundle, because the panel
   * could not do it yet.
   *
   * **`terrain` is a group of chunks on the merged board**, not one mesh —
   * seventeen of them plus an un-chunked shadow caster, so that three can
   * reject the ones off screen. The name is on the group and hiding it hides
   * the whole subtree, so the chip still reads as one layer; what changed is
   * that its delta is now *pose-dependent* and is the whole layer's cost at the
   * pose it was measured from, not a constant for the board.
   */
  const layerChips = el("div", "gm-chips");
  const layerNote = el("div", "gm-hint");
  layerNote.textContent = "hidden layers, and what the last hide was worth";
  const layerDelta = el("div", "gm-line");
  const LAYERS: { name: string; label: string }[] = [
    { name: "blocks", label: "buildings" },
    { name: "terrain", label: "terrain" },
    { name: "clouds", label: "clouds" },
    { name: "satellites", label: "satellites" },
    { name: "nightlights", label: "night lights" },
    { name: "flights", label: "aircraft" },
    { name: "sky", label: "sky dome" },
  ];
  const hidden = new Set<string>();
  /** The counters from the frame before the last toggle, for the delta. */
  let beforeToggle: Sample | null = null;
  let toggledLabel = "";

  function sceneRoot(): THREE.Object3D | null {
    return place.stage.current()?.scene ?? null;
  }

  function applyLayerVisibility() {
    const root = sceneRoot();
    if (!root) return;
    for (const { name } of LAYERS) {
      const object = root.getObjectByName(name);
      if (object) object.visible = !hidden.has(name);
    }
  }

  for (const layer of LAYERS) {
    const chip: HTMLButtonElement = button("gm-chip", layer.label, () => {
      beforeToggle = sample();
      toggledLabel = layer.label;
      if (hidden.has(layer.name)) hidden.delete(layer.name);
      else hidden.add(layer.name);
      applyLayerVisibility();
      // The panel's existing convention for a latched chip, so it picks up the
      // amber `gm-chip[aria-pressed="true"]` rule and reads correctly to a screen
      // reader rather than only to a stylesheet.
      chip.setAttribute("aria-pressed", String(hidden.has(layer.name)));
      // The delta needs a frame drawn with the new state before it means
      // anything; `refreshLive` picks it up on its next tick.
      setText(layerDelta, `${toggledLabel}: measuring…`);
    });
    chip.setAttribute("aria-pressed", "false");
    layerChips.append(chip);
  }

  perfSection.body.append(frameLine, frameNote, gpuLine, gpuNote, drawLine, memLine);
  perfSection.body.append(layerNote, layerChips, layerDelta);
  perfSection.body.append(ledgerNote, ledgerTable);
  perfSection.body.append(ledgerReset);

  // ---- Overlays -------------------------------------------------------------

  const overlaySection = section("overlays");
  const overlayChips = el("div", "gm-chips");
  const poseChip = button("gm-chip", "pose", () => {
    showPose = !showPose;
    refresh();
  });
  const bodiesChip = button("gm-chip", "sun & moon", () => {
    showBodies = !showBodies;
    refresh();
  });
  const airChip = button("gm-chip", "air", () => {
    showAir = !showAir;
    refresh();
  });
  const copyButton = button("gm-chip", "copy pose", () => copyPose());
  /**
   * Stand on the board and look up — the reverse of every other pose in this
   * product, and the only one from which the sky is a subject rather than a
   * backdrop.
   *
   * This matters more than a debug convenience. A map camera looks *down*: at
   * the boards' resting pose the top of the frame is still several degrees below
   * horizontal, so nothing above the horizon is ever in shot — not the moon
   * `atmosphere.ts` computes to a hundredth of a degree, not the constellation,
   * not a moonrise. Every one of those is drawn and correct and, from the poses
   * the product ships with, invisible. This is the one button that shows them,
   * and it is how the sky work in this round was judged at all.
   *
   * It drives the controls directly rather than going through a flight, because
   * `SceneKit.flyTo` interpolates between two *orbit* poses and this is not one:
   * the target is put a board span away in the direction the camera is already
   * facing and lifted well above it, which is an orbit whose polar angle is
   * past anything `maxPolarAngle` permits by hand. Restoring is a second press,
   * from the pose remembered on the first.
   */
  let restorePose: { position: THREE.Vector3; target: THREE.Vector3 } | null = null;
  const lookUpChip: HTMLButtonElement = button("gm-chip", "look up", () => {
    const current = place.stage.current();
    if (!current) return;
    const { camera, controls } = current;
    if (restorePose) {
      camera.position.copy(restorePose.position);
      controls.target.copy(restorePose.target);
      restorePose = null;
      lookUpChip.setAttribute("aria-pressed", "false");
    } else {
      restorePose = { position: camera.position.clone(), target: controls.target.clone() };
      // The heading the camera already has, flattened, so pressing this does not
      // also spin the board: whatever compass direction was being looked at is
      // the one the sky is read in.
      const heading = controls.target.clone().sub(camera.position);
      heading.y = 0;
      if (heading.lengthSq() < 1e-6) heading.set(0, 0, -1);
      heading.normalize();
      const reach = Math.max(1, camera.position.distanceTo(controls.target));
      // Forty degrees up: high enough to clear the horizon band and to hold a
      // moon at any elevation it reaches from this latitude, low enough that the
      // ground is still in the bottom of the frame and the shot has somewhere to
      // stand.
      // Written out per component rather than through a second `Vector3`,
      // because this module imports three for its types only — a value import
      // for one addition would pull the whole library into a panel that is
      // dynamically imported precisely so that it does not.
      controls.target.set(
        camera.position.x + heading.x * reach * 0.6,
        camera.position.y + reach * 0.5,
        camera.position.z + heading.z * reach * 0.6,
      );
      lookUpChip.setAttribute("aria-pressed", "true");
    }
    controls.update();
    refresh();
  });
  lookUpChip.setAttribute("aria-pressed", "false");
  const copyNote = el("div", "gm-hint");
  overlayChips.append(poseChip, bodiesChip, airChip, lookUpChip, copyButton);
  overlaySection.body.append(overlayChips, copyNote);

  body.append(
    timeSection.el,
    weatherSection.el,
    ...(options.sky ? [skySection.el] : []),
    ...(options.office ? [officeSection.el] : []),
    perfSection.el,
    overlaySection.el,
  );

  // ---- Sampling -------------------------------------------------------------

  /**
   * One rAF, running whether the drawer is open or not, because the leak ledger
   * has to see a board it was not watching — you switch cities, then open the
   * panel to ask what that cost. It is a subtraction and a modulo per frame, and
   * the readouts it feeds are only recomputed four times a second.
   *
   * What it measures is the *interval* between frames, not the time spent
   * rendering one. There is no hook inside the stage's loop and adding one for a
   * debug panel would be the tail wagging the dog; the interval is the number
   * that matters anyway, since it is the one the eye sees. It does mean a scene
   * that renders in 4 ms reads as 16.7 at vsync, which is what the note under it
   * says.
   */
  const intervals = new Float64Array(FRAME_SAMPLES);
  const scratch = new Float64Array(FRAME_SAMPLES);
  let intervalCount = 0;
  let intervalAt = 0;
  let lastFrame = 0;
  let lastRefresh = 0;
  let raf = 0;

  /*
   * The GPU timer. See `gpuLine` for the argument; this is the machinery.
   *
   * Typed structurally rather than against `WebGL2RenderingContext` because
   * `EXT_disjoint_timer_query_webgl2` has no lib.dom type — it is an extension
   * object with two constants and two methods, and writing them out is smaller
   * than any declaration file that would cover it.
   */
  interface TimerExtension {
    TIME_ELAPSED_EXT: number;
    GPU_DISJOINT_EXT: number;
  }
  const gl = (() => {
    const context = place.stage.renderer.getContext();
    return typeof WebGL2RenderingContext !== "undefined" &&
      context instanceof WebGL2RenderingContext
      ? context
      : null;
  })();
  const timer = (gl?.getExtension("EXT_disjoint_timer_query_webgl2") ??
    null) as TimerExtension | null;
  /** The query currently being measured or polled, and the last good answer. */
  let gpuQuery: WebGLQuery | null = null;
  let gpuMeasuring = false;
  let gpuMs: number | null = null;

  /**
   * One query in flight, opened before the frame is drawn and closed after.
   *
   * This runs from the panel's own rAF, which is registered before the stage's
   * — `createGodmode` is constructed after `createStage`, and rAF callbacks fire
   * in registration order — so the begin lands ahead of the scene's draw calls
   * and the end lands after them, without a hook inside the stage's loop. That
   * ordering is the whole trick and is why this can measure a renderer it does
   * not own.
   */
  function gpuBegin() {
    if (!gl || !timer || gpuMeasuring) return;
    if (gpuQuery === null) gpuQuery = gl.createQuery();
    if (gpuQuery === null) return;
    gl.beginQuery(timer.TIME_ELAPSED_EXT, gpuQuery);
    gpuMeasuring = true;
  }

  function gpuEnd() {
    if (!gl || !timer || !gpuMeasuring) return;
    gl.endQuery(timer.TIME_ELAPSED_EXT);
    gpuMeasuring = false;
  }

  /** Poll the outstanding query. A disjoint result is discarded, never averaged. */
  function gpuPoll() {
    if (!gl || !timer || gpuQuery === null || gpuMeasuring) return;
    if (gl.getQueryParameter(gpuQuery, gl.QUERY_RESULT_AVAILABLE) !== true) return;
    const disjoint = gl.getParameter(timer.GPU_DISJOINT_EXT) === true;
    const nanoseconds = gl.getQueryParameter(gpuQuery, gl.QUERY_RESULT) as number;
    gl.deleteQuery(gpuQuery);
    gpuQuery = null;
    // A disjoint means the driver preempted or reclocked mid-query, which on the
    // box this was written on is exactly the condition worth measuring around.
    // A lying number here would be worse than no number.
    if (!disjoint && Number.isFinite(nanoseconds)) gpuMs = nanoseconds / 1e6;
  }

  interface Sample {
    geometries: number;
    textures: number;
    programs: number;
    calls: number;
    triangles: number;
  }

  interface LedgerEntry {
    visits: number;
    first: Sample;
    last: Sample;
  }

  const ledger = new Map<string, LedgerEntry>();
  const ledgerOrder: string[] = [];
  let knownScene = place.stage.current();
  let settleAt = performance.now() + SETTLE_MS;

  function sample(): Sample {
    const info = place.stage.renderer.info;
    return {
      geometries: info.memory.geometries,
      textures: info.memory.textures,
      programs: info.programs?.length ?? 0,
      calls: info.render.calls,
      triangles: info.render.triangles,
    };
  }

  function settle() {
    const now = sample();
    const key = place.label;
    const entry = ledger.get(key);
    if (!entry) {
      ledger.set(key, { visits: 1, first: now, last: now });
      ledgerOrder.push(key);
    } else {
      entry.visits += 1;
      entry.last = now;
    }
    renderLedger();
  }

  function frame(now: number) {
    raf = requestAnimationFrame(frame);
    // Close the query this callback opened one frame ago, then poll it, then
    // open the next one — so the stage's draw calls, which run after this
    // callback returns, land inside the query that was just begun.
    gpuEnd();
    gpuPoll();
    gpuBegin();
    if (lastFrame !== 0) {
      intervals[intervalAt] = now - lastFrame;
      intervalAt = (intervalAt + 1) % FRAME_SAMPLES;
      if (intervalCount < FRAME_SAMPLES) intervalCount += 1;
    }
    lastFrame = now;

    const scene = place.stage.current();
    if (scene !== knownScene) {
      knownScene = scene;
      settleAt = now + SETTLE_MS;
    }
    if (settleAt !== 0 && now >= settleAt) {
      settleAt = 0;
      settle();
    }

    if (now - lastRefresh < REFRESH_MS) return;
    lastRefresh = now;
    // The office rides the fast path with the counters rather than waiting for
    // the next `refresh()`. Everything else on this panel changes because
    // somebody moved something or because `main.ts` announced a new instant —
    // and `main.ts` announces one once a minute. The house level is neither: it
    // ramps against the sun's height, so at dusk it is a number that visibly
    // moves, and it is the number this whole section exists to explain.
    if (open) {
      refreshLive();
      refreshOffice();
    }
    if (!hud.hidden) refreshHud();
  }
  raf = requestAnimationFrame(frame);

  function percentile(p: number): number {
    if (intervalCount === 0) return 0;
    const window = scratch.subarray(0, intervalCount);
    window.set(intervals.subarray(0, intervalCount));
    window.sort();
    const index = Math.min(intervalCount - 1, Math.floor(p * intervalCount));
    return window[index] ?? 0;
  }

  // ---- State transitions ----------------------------------------------------

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

  function setOverride(instant: Date | null) {
    override = instant;
    shown = instant ?? new Date();
    options.onTimeChange(instant);
    refresh();
  }

  function setWeather(on: boolean, preset: string | null) {
    weatherOn = on;
    activePreset = on ? preset : null;
    options.onWeatherOverride(on ? { ...draft } : null);
    refresh();
  }

  function setOpen(next: boolean) {
    if (next === open) return;
    open = next;
    body.hidden = !open;
    tab.setAttribute("aria-expanded", String(open));
    if (open) refresh();
  }

  // ---- Rendering ------------------------------------------------------------

  /** The observation the map is being rendered from, as this panel understands it. */
  function environment(): Environment {
    return observe(place.lat, place.lng, shown, weatherOn ? { ...draft } : null);
  }

  function refresh() {
    shown = currentInstant();
    refreshBanner();
    if (open) {
      refreshTime();
      refreshWeather();
      refreshSky();
      refreshOffice();
      refreshLive();
    }
    refreshHud();
  }

  function refreshSky() {
    const sky = options.sky;
    if (!sky) return;
    const state = sky.read();

    // The slider is not written back from `state` — it is an input somebody may
    // be dragging, and four writes a second to its value while they do that
    // fights the pointer. `state.extraTraffic` is read for the line above it,
    // which is the readout, and the two agree because the app is the only other
    // writer and there is nothing else to disagree with.
    setText(
      trafficLine,
      `traffic  ${state.trafficIsLive ? "observed" : "simulated"}` +
        `${state.extraTraffic > 0 ? `  +${state.extraTraffic} fabricated` : ""}`,
    );
    setText(trafficSlider.value, String(state.extraTraffic));

    // Three states, not two, and the middle one is the one worth distinguishing:
    // a deployment with no catalogue is not the same as a catalogue on a night
    // when nothing happens to be up, and an operator debugging an empty sky needs
    // to know which they have.
    if (state.satellites === null) {
      setText(satelliteLine, "satellites  no catalogue — set TERA_SATELLITES_SOURCE=celestrak");
    } else {
      setText(
        satelliteLine,
        `satellites  ${state.satellites.visible} above the horizon ` +
          `of ${state.satellites.total} tracked`,
      );
    }
    satelliteChip.disabled = state.satellites === null;
    satelliteChip.setAttribute("aria-pressed", String(satellitesOn && state.satellites !== null));
  }

  /**
   * The building this office handle last described, or `null` for "no room on
   * the stage". See `GodmodeOffice` for what a change in it means and why the
   * switches are pushed back down when it happens.
   */
  let knownOffice: string | null = null;

  function refreshOffice() {
    const office = options.office;
    if (!office) return;
    const state = office.read();
    officeSection.el.hidden = state === null;
    if (!state) {
      // Walking out to the city forgets the building rather than the switches,
      // so walking back in re-asserts them onto whatever handle is there now —
      // which may not be the one that was there when they were set.
      knownOffice = null;
      return;
    }

    if (state.id !== knownOffice) {
      // Written before the calls, not after. None of the three re-enters this
      // panel today, and a debug control that could loop the render loop by
      // being wired slightly differently tomorrow is not worth the two lines it
      // saves.
      knownOffice = state.id;
      office.onHouseLights(houseLights);
      office.onRobotsVisible(robotsOn);
      office.onCeilingsVisible(ceilingsOn);
    }

    setText(
      officeLine,
      `${state.id}  ·  ${state.depth === "full" ? "full" : "public — no presence layer"}`,
    );
    setText(
      houseLine,
      `house ${Math.round(state.houseLevel * 100)}%  ·  ` +
        (houseLights === "sun" ? "following the sun" : `forced ${houseLights}`),
    );
    for (const entry of houseButtons) {
      entry.el.setAttribute("aria-pressed", String(entry.mode === houseLights));
    }

    // Three states again, and the middle one is again the one worth saying out
    // loud: a pack that asked for no robots is not a pack whose robots are
    // hidden, and the chip that would toggle nothing says so by going dead
    // rather than by appearing to work.
    if (state.robots === 0) {
      setText(robotLine, "robots  none — this pack asked for none");
    } else {
      setText(
        robotLine,
        `robots  ${state.robots} walking${robotsOn ? "" : ", hidden"}`,
      );
    }
    robotChip.disabled = state.robots === 0;
    robotChip.setAttribute("aria-pressed", String(robotsOn && state.robots > 0));
    ceilingChip.setAttribute("aria-pressed", String(ceilingsOn));
  }

  function refreshBanner() {
    const parts: string[] = [];
    if (override) parts.push(`time ${fmtStamp(override)}`);
    if (weatherOn) {
      const vis = draft.visibilityKm === null ? "vis n/r" : `vis ${draft.visibilityKm} km`;
      parts.push(
        `weather ${draft.condition}, cloud ${Math.round(draft.cloudCover * 100)}%, ${vis}`,
      );
    }
    banner.hidden = parts.length === 0;
    setText(bannerText, `overridden — ${parts.join(" · ")} — not real`);
  }

  function refreshTime() {
    const env = environment();
    const times = sunTimes(place.lat, place.lng, shown);
    setText(stamp, `${fmtStamp(shown)}${override ? "" : "  ·  wall clock"}`);
    setText(
      sunLine,
      `sun  az ${fmtDeg(env.sun.azimuth)}  el ${fmtSigned(env.sun.elevation)}  ` +
        `${daylightPhase(env.sun.elevation)}`,
    );
    setText(
      moonLine,
      `moon  az ${fmtDeg(env.moon.azimuth)}  el ${fmtSigned(env.moon.elevation)}  ` +
        `${Math.round(env.moon.illuminated * 100)}% ${phaseName(env.moon.phase)}`,
    );
    moonCanvas.setAttribute(
      "aria-label",
      `Moon, ${Math.round(env.moon.illuminated * 100)} per cent lit, ${phaseName(env.moon.phase)}`,
    );
    drawMoon(moonCtx, env.moon.illuminated, env.moon.phase);

    const { sunrise, sunset, solarNoon } = times;
    const noonElevation = solarPosition(place.lat, place.lng, solarNoon).elevation;
    if (sunrise && sunset) {
      const length = sunset.getTime() - sunrise.getTime();
      setText(
        dayLine,
        `day ${fmtDuration(length)}  ·  rise ${fmtClock(sunrise)}  set ${fmtClock(sunset)}  ` +
          `·  noon ${fmtSigned(noonElevation)}`,
      );
    } else {
      // A legitimate answer at high latitude, and `sunTimes` returns it rather
      // than throwing; saying "no sunrise" is more useful than an empty line.
      setText(
        dayLine,
        `${noonElevation > 0 ? "no sunset" : "no sunrise"}  ·  noon ${fmtSigned(noonElevation)}`,
      );
    }

    if (document.activeElement !== dateSlider.el) {
      dateSlider.el.max = String(daysInYear(shown.getFullYear()));
      dateSlider.el.value = String(dayOfYear(shown));
    }
    if (document.activeElement !== hourSlider.el) {
      hourSlider.el.value = String(shown.getHours() + shown.getMinutes() / 60);
    }
    setText(dateSlider.value, fmtDate(shown));
    setText(hourSlider.value, fmtClock(shown));
    followButton.setAttribute("aria-pressed", String(override === null));

    for (const entry of lightButtons) {
      const when = entry.at();
      entry.el.disabled = when === null;
      entry.el.title = when ? fmtClock(when) : "the sun does not reach it here today";
    }
  }

  function refreshWeather() {
    setText(
      weatherState,
      weatherOn
        ? "fabricated — this is not an observation of anywhere"
        : "following the deployment; no source configured ships no weather at all",
    );
    realChip.setAttribute("aria-pressed", String(!weatherOn));
    for (const preset of presetButtons) {
      preset.el.setAttribute("aria-pressed", String(weatherOn && activePreset === preset.id));
    }
    marineHint.hidden = activePreset !== "marine";

    if (document.activeElement !== cloudSlider.el) cloudSlider.el.value = String(draft.cloudCover);
    if (document.activeElement !== precipSlider.el) {
      precipSlider.el.value = String(draft.precipitation);
    }
    setText(cloudSlider.value, `${Math.round(draft.cloudCover * 100)}%`);
    setText(precipSlider.value, `${Math.round(draft.precipitation * 100)}%`);

    const visKnown = draft.visibilityKm !== null;
    visSlider.el.disabled = !visKnown;
    if (visKnown && document.activeElement !== visSlider.el) {
      const km = draft.visibilityKm ?? 10;
      visSlider.el.value = String(
        (Math.log(Math.max(0.2, km)) - Math.log(0.2)) / Math.log(60 / 0.2),
      );
    }
    setText(visSlider.value, visKnown ? `${draft.visibilityKm} km` : "n/r");
    visReported.set(visKnown);

    const windKnown = draft.windKph !== null;
    windSlider.el.disabled = !windKnown;
    if (windKnown && document.activeElement !== windSlider.el) {
      windSlider.el.value = String(draft.windKph ?? 0);
    }
    setText(windSlider.value, windKnown ? `${draft.windKph} km/h` : "n/r");
    windReported.set(windKnown);

    const dirKnown = draft.windDirDeg !== null;
    dirSlider.el.disabled = !dirKnown;
    if (dirKnown && document.activeElement !== dirSlider.el) {
      dirSlider.el.value = String(draft.windDirDeg ?? 0);
    }
    setText(dirSlider.value, dirKnown ? `${draft.windDirDeg}°` : "n/r");
    dirReported.set(dirKnown);

    conditionSelect.value = draft.condition;
  }

  /** The numbers that move on their own: frame budget and renderer counters. */
  function refreshLive() {
    const median = percentile(0.5);
    const p95 = percentile(0.95);
    setText(
      frameLine,
      `${median.toFixed(1)} ms median  ·  ${p95.toFixed(1)} p95  ·  ` +
        `${median > 0 ? Math.round(1000 / median) : 0} fps`,
    );
    setText(
      gpuLine,
      timer === null
        ? "gpu — EXT_disjoint_timer_query_webgl2 not available in this browser"
        : gpuMs === null
          ? "gpu — waiting for a query that is not disjoint"
          : `gpu ${gpuMs.toFixed(2)} ms`,
    );
    const now = sample();
    if (beforeToggle !== null) {
      const dCalls = now.calls - beforeToggle.calls;
      const dTris = now.triangles - beforeToggle.triangles;
      setText(
        layerDelta,
        `${toggledLabel}: ${fmtSigned(dCalls)} calls  ·  ${fmtSigned(dTris)} tris`,
      );
      beforeToggle = null;
    }
    setText(drawLine, `draw ${fmtInt(now.calls)} calls  ·  ${fmtTris(now.triangles)} tris`);
    setText(
      memLine,
      `geom ${fmtInt(now.geometries)}  ·  tex ${fmtInt(now.textures)}  ` +
        `·  prog ${fmtInt(now.programs)}`,
    );
  }

  function renderLedger() {
    ledgerTable.replaceChildren();
    for (const key of ledgerOrder) {
      const entry = ledger.get(key);
      if (!entry) continue;
      const row = el("div", "gm-ledger-row");
      const dGeom = entry.last.geometries - entry.first.geometries;
      const dTex = entry.last.textures - entry.first.textures;
      row.append(
        el("span", "gm-ledger-name", key),
        el("span", "gm-ledger-n", `×${entry.visits}`),
        el("span", "gm-ledger-n", `${fmtInt(entry.last.geometries)} geom`),
        delta(dGeom, entry.visits),
        el("span", "gm-ledger-n", `${fmtInt(entry.last.textures)} tex`),
        delta(dTex, entry.visits),
      );
      ledgerTable.append(row);
    }
    if (ledgerOrder.length === 0) {
      ledgerTable.append(el("div", "gm-hint", "no board has settled yet"));
    }
  }

  /**
   * A delta against the first visit, and the only cell here that is allowed to
   * shout.
   *
   * These are not heap numbers and there is no collector to wait for:
   * `WebGLRenderer` decrements them on an explicit `dispose()` and at no other
   * time. What a rise means depends on which way you got here, and both readings
   * are worth the colour:
   *
   *   - **Within one board**, watching the number climb while you sit still is a
   *     leak outright — something is being built every frame or every poll and
   *     nothing is releasing it.
   *   - **Across visits to the same board**, it is duplicated *work* rather than
   *     a missed dispose, because in this app a board brings its own renderer:
   *     `createScene` calls `createStage` and `SceneHandle.dispose()` disposes
   *     it, so visit two starts from zero and only reaches a higher number if it
   *     built more than visit one did. A `MaterialRegistry` that stopped being
   *     shared would show up here and nowhere else.
   *
   * The cell stays blank on a first visit rather than claiming a confident zero.
   */
  function delta(value: number, visits: number): HTMLElement {
    if (visits < 2) return el("span", "gm-ledger-d", "—");
    const cls = value > 0 ? "gm-ledger-d gm-leak" : "gm-ledger-d";
    return el("span", cls, value === 0 ? "±0" : `${value > 0 ? "+" : ""}${fmtInt(value)}`);
  }

  // ---- Overlays -------------------------------------------------------------

  function poseBlock(): { text: string; paste: string } {
    const active = place.stage.current();
    if (!active) return { text: "no scene on the stage", paste: "" };
    const { camera, controls } = active;
    const dx = camera.position.x - controls.target.x;
    const dy = camera.position.y - controls.target.y;
    const dz = camera.position.z - controls.target.z;
    const distance = Math.hypot(dx, dz);
    // `scene.ts` builds a chapter pose as target + (sin r · d, h, cos r · d)
    // above the ground at the focus, so this is that arithmetic run backwards.
    // `height` is therefore height above the ground under the target, which is
    // only the same as the camera's altitude when the target is on the ground —
    // and it always is, because both `chapterPose` and the minimap's seek put it
    // there.
    const rotation = Math.atan2(dx, dz);
    const scene = `cam ${trim(camera.position.x, 1)}, ${trim(camera.position.y, 1)}, ` +
      `${trim(camera.position.z, 1)} · target ${trim(controls.target.x, 1)}, ` +
      `${trim(controls.target.y, 1)}, ${trim(controls.target.z, 1)}`;
    if (!place.unproject) {
      return {
        text:
          `${scene}\ndistance ${trim(distance, 1)} · height ${trim(dy, 1)} · ` +
          `rotation ${trim(rotation, 2)}\n(no unproject wired — scene units, not degrees)`,
        paste: "",
      };
    }
    const [lat, lng] = place.unproject(controls.target.x, controls.target.z);
    const paste =
      `focus: { lat: ${lat.toFixed(4)}, lng: ${lng.toFixed(4)}, ` +
      `distance: ${trim(distance, 1)}, height: ${trim(dy, 1)}, ` +
      `rotation: ${trim(rotation, 2)} },`;
    return { text: `${paste}\n${scene}`, paste };
  }

  function refreshHud() {
    const on = showPose || showBodies || showAir;
    poseChip.setAttribute("aria-pressed", String(showPose));
    bodiesChip.setAttribute("aria-pressed", String(showBodies));
    airChip.setAttribute("aria-pressed", String(showAir));
    copyButton.disabled = !place.unproject;
    hud.hidden = !on;
    if (!on) return;

    const blocks: string[] = [];
    if (showPose) blocks.push(poseBlock().text);
    if (showBodies) {
      const env = environment();
      blocks.push(
        `sun   az ${fmtDeg(env.sun.azimuth)}  el ${fmtSigned(env.sun.elevation)}  ` +
          `dec ${fmtSigned(env.sun.declination)}  eot ${fmtMinutes(env.sun.equationOfTime)}\n` +
          `moon  az ${fmtDeg(env.moon.azimuth)}  el ${fmtSigned(env.moon.elevation)}  ` +
          `${Math.round(env.moon.illuminated * 100)}% ${phaseName(env.moon.phase)}  ` +
          `${fmtInt(Math.round(env.moon.distanceKm))} km`,
      );
    }
    if (showAir) blocks.push(airBlock());
    setText(hud, blocks.join("\n\n"));
  }

  function airBlock(): string {
    const strength = place.marineStrength ? place.marineStrength(environment()) : undefined;
    const marine =
      strength === undefined
        ? "marine —  (atmosphere.ts keeps marineStrength private; no hook wired)"
        : strength === null
          ? "marine —  (no layer configured for this board)"
          : `marine ${strength.toFixed(3)}`;

    const scene = place.stage.current();
    const fog = scene?.scene.fog ?? null;
    let fogLine = "fog   none";
    if (fog && "near" in fog) {
      const km =
        place.metresPerUnit === undefined
          ? ""
          : `  (${trim((fog.near * place.metresPerUnit) / 1000, 1)}–` +
            `${trim((fog.far * place.metresPerUnit) / 1000, 1)} km)`;
      fogLine =
        `fog   ${trim(fog.near, 1)} – ${trim(fog.far, 1)} u${km}  ` +
        `#${fog.color.getHexString()}`;
    } else if (fog) {
      fogLine = `fog   exponential, density ${fog.density}`;
    }
    return `${marine}\n${fogLine}`;
  }

  /**
   * Clipboard where there is one, a selection where there is not.
   *
   * `navigator.clipboard` is undefined on any non-secure origin, which includes
   * every `http://` dev server anyone runs this from, so the fallback is not
   * theoretical. Selecting the text is a real answer: the block is already on
   * screen, and one Ctrl-C finishes the job.
   */
  function copyPose() {
    const { paste } = poseBlock();
    if (!paste) return;
    if (!navigator.clipboard) {
      selectHud();
      setText(copyNote, "no clipboard on this origin — selected, press copy");
      return;
    }
    void navigator.clipboard.writeText(paste).then(
      () => setText(copyNote, "copied"),
      () => {
        selectHud();
        setText(copyNote, "clipboard refused — selected, press copy");
      },
    );
  }

  function selectHud() {
    showPose = true;
    refreshHud();
    const range = document.createRange();
    range.selectNodeContents(hud);
    const selection = window.getSelection();
    selection?.removeAllRanges();
    selection?.addRange(range);
  }

  // ---- Keyboard -------------------------------------------------------------

  /**
   * Capture, and only capture.
   *
   * `main.ts` binds its own `keydown` on `window` at module scope, which is long
   * before this panel exists, so a bubble-phase listener here would always run
   * second — and `Esc` would have closed the office out from under you before
   * the drawer heard about it. Capture runs first regardless of registration
   * order. It is used for exactly two keys and gives `Esc` back the moment focus
   * leaves this panel, which is the whole difference between borrowing a binding
   * and stealing one.
   */
  function onKeyDown(event: KeyboardEvent) {
    if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey) return;
    const target = event.target;

    if (event.key === "Escape") {
      if (!open || target instanceof HTMLSelectElement) return;
      if (!(target instanceof Node) || !root.contains(target)) return;
      event.preventDefault();
      event.stopPropagation();
      setOpen(false);
      tab.focus();
      return;
    }

    if (
      target instanceof HTMLInputElement ||
      target instanceof HTMLTextAreaElement ||
      target instanceof HTMLSelectElement ||
      (target instanceof HTMLElement && target.isContentEditable)
    ) {
      return;
    }
    if (event.key.toLowerCase() !== hotkey || event.repeat) return;
    event.preventDefault();
    setOpen(!open);
  }
  window.addEventListener("keydown", onKeyDown, true);

  // ---- Lifecycle ------------------------------------------------------------

  // No callback is fired here. Construction announces nothing: the caller's
  // state is already "no override" or this panel would not be being built, and
  // an `onWeatherOverride(null)` during `createGodmode` would run inside the
  // caller's own constructor expression.
  refresh();
  renderLedger();

  return {
    root,

    setInstant(instant) {
      // The caller is telling us what it rendered. If an override is up it has
      // presumably rendered that; either way the panel shows what the map shows
      // and nothing is fired back at the caller.
      shown = instant;
      if (override !== null) override = instant;
      refresh();
    },

    setPlace(next) {
      place = next;
      refresh();
    },

    setOpen,
    isOpen: () => open,

    destroy() {
      cancelAnimationFrame(raf);
      window.removeEventListener("keydown", onKeyDown, true);
      // Every other listener in this module is on a node inside `root`, and goes
      // when the node does. The stylesheet is shared, so it is refcounted rather
      // than removed outright — two panels have never existed at once, and a
      // teardown that deletes the CSS out from under a second one would be a
      // very confusing bug to meet later.
      root.remove();
      sheet.release();
      ledger.clear();
      ledgerOrder.length = 0;
    },
  };
}

// ---- Astronomy the panel needs and the engine does not ----------------------

/**
 * The instant, this year, of each equinox and solstice.
 *
 * Computed rather than tabulated, for the same reason `solar.ts` computes the
 * sun rather than fetching it: a table is four numbers a year that are wrong the
 * moment the clock passes new year, and this is thirty lines that are right
 * forever. The equinoxes are where the sun's declination crosses zero and the
 * solstices are where it turns round, both of which `solarTerms` already knows —
 * so the search is over `solarPosition` at the prime meridian, whose declination
 * is a property of the instant and not of the observer.
 *
 * A day-resolution scan to bracket, then bisection to the minute. Roughly eight
 * hundred evaluations of a handful of trig terms, once per year per session, all
 * of it behind a memo.
 */
const seasonCache = new Map<number, Record<string, Date>>();

function seasonMarkers(year: number): Record<string, Date> {
  const cached = seasonCache.get(year);
  if (cached) return cached;

  const dec = (ms: number) => solarPosition(0, 0, new Date(ms)).declination;
  const start = Date.UTC(year, 0, 1);
  const days = daysInYear(year);
  const samples = new Float64Array(days + 1);
  for (let i = 0; i <= days; i++) samples[i] = dec(start + i * MS_PER_DAY);

  const events: Record<string, Date> = {};
  let maxAt = 0;
  let minAt = 0;
  for (let i = 1; i <= days; i++) {
    const previous = samples[i - 1] ?? 0;
    const current = samples[i] ?? 0;
    if (current > (samples[maxAt] ?? 0)) maxAt = i;
    if (current < (samples[minAt] ?? 0)) minAt = i;
    if (previous < 0 && current >= 0) {
      events["Mar eq"] = new Date(
        bisect(start + (i - 1) * MS_PER_DAY, start + i * MS_PER_DAY, dec),
      );
    }
    if (previous > 0 && current <= 0) {
      events["Sep eq"] = new Date(
        bisect(start + (i - 1) * MS_PER_DAY, start + i * MS_PER_DAY, (ms) => -dec(ms)),
      );
    }
  }
  events["Jun sol"] = new Date(extremum(start + (maxAt - 1) * MS_PER_DAY, dec, 1));
  events["Dec sol"] = new Date(extremum(start + (minAt - 1) * MS_PER_DAY, dec, -1));

  seasonCache.set(year, events);
  return events;
}

/** Where a monotonically rising `f` crosses zero, to about a second. */
function bisect(lo: number, hi: number, f: (ms: number) => number): number {
  let a = lo;
  let b = hi;
  for (let i = 0; i < 26; i++) {
    const mid = (a + b) / 2;
    if (f(mid) < 0) a = mid;
    else b = mid;
  }
  return (a + b) / 2;
}

/** A turning point, by ten-minute scan over two days. Declination moves slowly. */
function extremum(from: number, f: (ms: number) => number, sign: 1 | -1): number {
  let best = from;
  let bestValue = sign * f(from);
  for (let ms = from; ms <= from + 2 * MS_PER_DAY; ms += 10 * MS_PER_MINUTE) {
    const value = sign * f(ms);
    if (value > bestValue) {
      bestValue = value;
      best = ms;
    }
  }
  return best;
}

/**
 * When the sun passes `target` degrees on the way up or the way down, today,
 * here — or `null` if it never gets there.
 *
 * Bisected inside the half-day either side of solar noon, where the elevation is
 * monotonic at every latitude this renderer has a city at. (Inside the polar
 * circles it is still monotonic; what fails there is that the crossing may not
 * exist, which is the `null`.) `sunTimes` supplies solar noon, so the window is
 * the sun's own day rather than the calendar's and an evening call after
 * midnight UTC does not silently answer for tomorrow.
 */
function elevationCrossing(
  lat: number,
  lng: number,
  when: Date,
  target: number,
  rising: boolean,
): Date | null {
  const noon = sunTimes(lat, lng, when).solarNoon.getTime();
  const elevation = (ms: number) => solarPosition(lat, lng, new Date(ms)).elevation - target;
  const edge = rising ? noon - MS_PER_DAY / 2 : noon + MS_PER_DAY / 2;
  if (elevation(noon) < 0) return null;
  if (elevation(edge) > 0) return null;
  // `bisect` wants the negative end first, which is the small hours on the way
  // up and solar noon on the way down — the two calls are not symmetric and
  // swapping them silently returns the wrong side of the day.
  const found = rising
    ? bisect(edge, noon, elevation)
    : bisect(noon, edge, (ms) => -elevation(ms));
  return new Date(found);
}

/**
 * The next instant the moon reaches a given phase, within thirty-two days.
 *
 * `moonPosition().phase` is the elongation from the sun over 360°, so it climbs
 * from 0 at new to 1 at the next new and wraps. Six-hour steps bracket the
 * crossing — the phase moves about 0.0085 an hour, so a step cannot skip a
 * target — then the usual bisection. Thirty-two days rather than thirty: a
 * synodic month is 29.53 and the search has to start from wherever you are.
 */
function nextPhase(lat: number, lng: number, from: Date, target: number): Date | null {
  const signed = (ms: number) => {
    const p = moonPosition(lat, lng, new Date(ms)).phase - target;
    return ((p % 1) + 1.5) % 1 - 0.5;
  };
  const step = 6 * 3_600_000;
  let a = from.getTime() + 60_000;
  let previous = signed(a);
  for (let i = 1; i <= 128; i++) {
    const b = from.getTime() + i * step;
    const current = signed(b);
    if (previous < 0 && current >= 0) {
      return new Date(bisect(a, b, signed));
    }
    a = b;
    previous = current;
  }
  return null;
}

function phaseName(phase: number): string {
  const p = ((phase % 1) + 1) % 1;
  if (p < 0.03 || p >= 0.97) return "new";
  if (p < 0.22) return "waxing crescent";
  if (p < 0.28) return "first quarter";
  if (p < 0.47) return "waxing gibbous";
  if (p < 0.53) return "full";
  if (p < 0.72) return "waning gibbous";
  if (p < 0.78) return "last quarter";
  return "waning crescent";
}

/**
 * The moon, at 20 CSS pixels, drawn rather than fetched — `src/` carries no
 * binary assets, and eight phases as images would be eight of them.
 *
 * The construction is the classical one: a dark disc, the lit half of it, and
 * then one ellipse whose semi-minor axis is `r·|1 − 2k|` filled light for a
 * gibbous moon and dark for a crescent. That single ellipse is the terminator in
 * projection, and it is exact, not an approximation of a circle by an oval.
 *
 * Lit on the right while waxing, which is a northern-hemisphere fact rather than
 * a universal one — south of the equator it is the other limb. Both cities on
 * this map are at 34° and 38° north, and a moon that is correct for the viewer's
 * hemisphere rather than the scene's would be the wrong kind of clever.
 */
function drawMoon(ctx: CanvasRenderingContext2D | null, illuminated: number, phase: number) {
  if (!ctx) return;
  const size = ctx.canvas.width;
  const r = size / 2 - 2;
  const c = size / 2;
  const k = Math.min(1, Math.max(0, illuminated));
  const waxing = ((phase % 1) + 1) % 1 < 0.5;
  const lit = "#e8eef6";
  const dark = "#161d29";

  ctx.clearRect(0, 0, size, size);
  ctx.beginPath();
  ctx.arc(c, c, r, 0, Math.PI * 2);
  ctx.fillStyle = dark;
  ctx.fill();

  ctx.beginPath();
  ctx.arc(c, c, r, -Math.PI / 2, Math.PI / 2, !waxing);
  ctx.closePath();
  ctx.fillStyle = lit;
  ctx.fill();

  ctx.beginPath();
  ctx.ellipse(c, c, r * Math.abs(1 - 2 * k), r, 0, 0, Math.PI * 2);
  ctx.fillStyle = k > 0.5 ? lit : dark;
  ctx.fill();

  ctx.beginPath();
  ctx.arc(c, c, r, 0, Math.PI * 2);
  ctx.strokeStyle = "rgba(255, 255, 255, 0.22)";
  ctx.lineWidth = 1;
  ctx.stroke();
}

// ---- Formatting -------------------------------------------------------------

function dayOfYear(d: Date): number {
  const startOfYear = new Date(d.getFullYear(), 0, 1).getTime();
  const startOfDay = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
  // Rounded rather than floored: a spring-forward between the two puts the
  // difference an hour short of a whole number of days.
  return Math.round((startOfDay - startOfYear) / MS_PER_DAY) + 1;
}

function daysInYear(year: number): number {
  return dayOfYear(new Date(year, 11, 31));
}

function fmtStamp(d: Date): string {
  return `${fmtDate(d)}  ${fmtClock(d)}`;
}

function fmtDate(d: Date): string {
  return d.toLocaleDateString([], { weekday: "short", day: "numeric", month: "short" });
}

function fmtClock(d: Date): string {
  return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false });
}

function fmtDuration(ms: number): string {
  const minutes = Math.round(ms / MS_PER_MINUTE);
  return `${Math.floor(minutes / 60)}h ${String(minutes % 60).padStart(2, "0")}m`;
}

function fmtDeg(value: number): string {
  return `${value.toFixed(1)}°`;
}

function fmtSigned(value: number): string {
  return `${value >= 0 ? "+" : ""}${value.toFixed(1)}°`;
}

/** The equation of time, which is the one signed quantity here that is not an angle. */
function fmtMinutes(value: number): string {
  return `${value >= 0 ? "+" : ""}${value.toFixed(1)} min`;
}

function fmtInt(value: number): string {
  return value.toLocaleString("en-US");
}

function fmtTris(value: number): string {
  return value >= 1e6 ? `${(value / 1e6).toFixed(2)} M` : fmtInt(value);
}

/** Fixed decimals with the trailing zeros dropped, so a pasted pose reads hand-written. */
function trim(value: number, places: number): string {
  return String(Number(value.toFixed(places)));
}

/**
 * What the sliders read before anyone has touched them, and it is not zeroes.
 * A wind of `null` rather than 0 because that is what an unwired deployment
 * reports, and starting the panel on the state it is overriding makes the first
 * drag a change of one thing rather than of five.
 */
function clearDraft(): WeatherDraft {
  return {
    cloudCover: 0.05,
    precipitation: 0,
    visibilityKm: 40,
    windKph: null,
    windDirDeg: null,
    condition: "clear",
  };
}

// ---- DOM ---------------------------------------------------------------------

function el<K extends keyof HTMLElementTagNameMap>(
  tag: K,
  className?: string,
  text?: string,
): HTMLElementTagNameMap[K] {
  const node = document.createElement(tag);
  if (className) node.className = className;
  if (text !== undefined) node.textContent = text;
  return node;
}

function button(className: string, label: string, onClick: () => void): HTMLButtonElement {
  const b = el("button", className, label);
  b.type = "button";
  b.addEventListener("click", onClick);
  return b;
}

/**
 * Only write when the string has actually changed.
 *
 * Four readouts a second across thirty nodes is not a lot of work, but every one
 * of those writes is a layout invalidation on an element sitting over a WebGL
 * canvas, and the panel exists to measure frame time rather than to spend it.
 */
function setText(node: HTMLElement, text: string) {
  if (node.textContent !== text) node.textContent = text;
}

interface Slider {
  el: HTMLInputElement;
  value: HTMLElement;
}

function slider(
  label: string,
  min: number,
  max: number,
  step: number,
  onInput: (value: number) => void,
): Slider {
  const input = el("input", "gm-slider");
  input.type = "range";
  input.min = String(min);
  input.max = String(max);
  input.step = String(step);
  input.setAttribute("aria-label", label);
  input.addEventListener("input", () => onInput(Number(input.value)));
  return { el: input, value: el("span", "gm-value") };
}

/** The "somebody actually measured this" switch beside an optional field. */
function reportedToggle(onToggle: () => void): { el: HTMLButtonElement; set(on: boolean): void } {
  const b = button("gm-mini gm-nr", "n/r", onToggle);
  b.title = "reported, or not reported at all — which is not the same as zero";
  return {
    el: b,
    set(on) {
      b.setAttribute("aria-pressed", String(!on));
      b.textContent = on ? "rep" : "n/r";
    },
  };
}

function labelled(name: string, ...controls: HTMLElement[]): HTMLElement {
  const row = el("div", "gm-row");
  row.append(el("span", "gm-label", name), ...controls);
  return row;
}

function section(title: string): { el: HTMLElement; body: HTMLElement } {
  const wrap = el("section", "gm-section");
  wrap.append(el("h2", "gm-h", title));
  const inner = el("div", "gm-section-body");
  wrap.append(inner);
  return { el: wrap, body: inner };
}

// ---- Stylesheet --------------------------------------------------------------

let sheetNode: HTMLStyleElement | null = null;
let sheetUsers = 0;

function acquireStylesheet(): { release(): void } {
  if (!sheetNode) {
    sheetNode = document.createElement("style");
    sheetNode.dataset.godmode = "";
    sheetNode.textContent = STYLE;
    document.head.append(sheetNode);
  }
  sheetUsers += 1;
  let released = false;
  return {
    release() {
      if (released) return;
      released = true;
      sheetUsers -= 1;
      if (sheetUsers > 0) return;
      sheetNode?.remove();
      sheetNode = null;
    },
  };
}

/**
 * One stylesheet, injected, every token with a fallback.
 *
 * The fallbacks are not defensive padding. This module is meant to be liftable
 * into any page that has a `Stage` — that is what `src/tools/` is for — and a
 * panel that renders as unstyled black-on-white text because the host has no
 * `--glass` would be useless in exactly the situation where you most need to
 * read it. With `index.html` present every value resolves to the app's own and
 * the panel is indistinguishable from the rest of the chrome.
 *
 * The collapsed panel is one tab, bottom centre, sitting above the key-hint rail
 * and clear of every other card at every width. Open, it is a wide sheet over
 * the middle of the map and it will overlap the corners on a small screen; that
 * is the correct trade for an instrument you opened deliberately and close with
 * one key, and it is why the sheet uses the strong glass rather than the light
 * one.
 */
const STYLE = `
.gm-root {
  position: fixed;
  left: 0;
  right: 0;
  bottom: calc(var(--s4, 16px) + 2.4rem + env(safe-area-inset-bottom));
  /* The instrument layer, named rather than numbered. See src/ui/tokens.ts:
     above the play surfaces on purpose — a debug readout that a play HUD can
     hide is a debug readout you will misread — and below the modal layer, so a
     dialog can still open over it. */
  z-index: var(--z-instrument, 6);
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--s1, 4px);
  pointer-events: none;
  font-family: ui-monospace, "SF Mono", Menlo, monospace;
  color: var(--ink, rgba(255, 255, 255, 0.78));
}
.gm-root > * { pointer-events: auto; }
/*
 * The hidden attribute has to keep working, and on its own it does not.
 *
 * The UA stylesheet's [hidden] { display: none } is an attribute selector at the
 * lowest cascade origin, so any class rule here that sets a display — the drawer
 * is a grid, the banner is a flex row — silently outranks it, and both were
 * still on screen with the attribute set. index.html happens to carry an
 * !important version of the same rule, which is exactly the kind of accident
 * this module must not inherit: it would work in the app and be broken anywhere
 * else, including in a harness, which is where it was found.
 */
.gm-root [hidden] { display: none !important; }
.gm-root :is(button, input, select):focus-visible {
  outline: 2px solid var(--amber, #f2b134);
  outline-offset: 2px;
}

.gm-tab {
  display: flex;
  align-items: center;
  gap: var(--s2, 8px);
  font: inherit;
  font-size: 9px;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  padding: 5px 10px;
  cursor: pointer;
  color: var(--amber-ink, #ffd68a);
  background: var(--glass, rgba(9, 13, 18, 0.62));
  backdrop-filter: var(--blur, blur(14px) saturate(1.2));
  -webkit-backdrop-filter: var(--blur, blur(14px) saturate(1.2));
  border: 1px solid var(--hairline, rgba(255, 255, 255, 0.11));
  border-radius: var(--r-sm, 5px);
  box-shadow: var(--shadow, 0 6px 22px rgba(3, 6, 10, 0.45));
}
.gm-tab[aria-expanded="true"] { background: rgba(242, 177, 52, 0.22); }
.gm-kbd {
  font: inherit;
  font-size: 9px;
  padding: 0 4px;
  border: 1px solid var(--hairline, rgba(255, 255, 255, 0.11));
  border-radius: 3px;
  color: var(--ink-3, rgba(255, 255, 255, 0.4));
}

.gm-banner {
  display: flex;
  align-items: center;
  gap: var(--s2, 8px);
  max-width: calc(100vw - var(--s4, 16px) * 2);
  padding: 4px var(--s2, 8px);
  font-size: 10px;
  letter-spacing: 0.06em;
  color: var(--amber-ink, #ffd68a);
  background: var(--glass-strong, rgba(9, 13, 18, 0.86));
  backdrop-filter: var(--blur, blur(14px) saturate(1.2));
  -webkit-backdrop-filter: var(--blur, blur(14px) saturate(1.2));
  border: 1px dashed rgba(242, 177, 52, 0.75);
  border-radius: var(--r-sm, 5px);
}
.gm-banner-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

.gm-body {
  width: min(46rem, calc(100vw - var(--s4, 16px) * 2));
  max-height: min(56dvh, 30rem);
  overflow-y: auto;
  overscroll-behavior: contain;
  scrollbar-width: thin;
  scrollbar-color: rgba(255, 255, 255, 0.18) transparent;
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(13.5rem, 1fr));
  gap: var(--s3, 12px);
  padding: var(--s3, 12px);
  background: var(--glass-strong, rgba(9, 13, 18, 0.86));
  backdrop-filter: var(--blur, blur(14px) saturate(1.2));
  -webkit-backdrop-filter: var(--blur, blur(14px) saturate(1.2));
  border: 1px solid var(--hairline, rgba(255, 255, 255, 0.11));
  border-radius: var(--r, 8px);
  box-shadow: var(--shadow, 0 6px 22px rgba(3, 6, 10, 0.45));
}

.gm-h {
  margin: 0 0 var(--s2, 8px);
  font-size: 9px;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--amber, #f2b134);
}
.gm-section-body { display: flex; flex-direction: column; gap: var(--s1, 4px); }
.gm-stamp {
  font-size: 11px;
  color: var(--ink, rgba(255, 255, 255, 0.78));
  font-variant-numeric: tabular-nums;
}
.gm-line {
  font-size: 10px;
  color: var(--ink-2, rgba(255, 255, 255, 0.56));
  font-variant-numeric: tabular-nums;
}
.gm-hint {
  font-size: 9px;
  line-height: 1.5;
  color: var(--ink-3, rgba(255, 255, 255, 0.4));
}
.gm-moonrow { display: flex; align-items: center; gap: var(--s2, 8px); }
.gm-moon { width: 20px; height: 20px; flex: none; }

.gm-row { display: flex; align-items: center; gap: var(--s2, 8px); min-height: 18px; }
.gm-label {
  flex: none;
  width: 3.2rem;
  font-size: 9px;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--ink-3, rgba(255, 255, 255, 0.4));
}
.gm-value {
  flex: none;
  min-width: 4rem;
  text-align: right;
  font-size: 10px;
  color: var(--ink-2, rgba(255, 255, 255, 0.56));
  font-variant-numeric: tabular-nums;
}
.gm-slider { flex: 1; min-width: 0; accent-color: var(--amber, #f2b134); height: 14px; }
.gm-slider:disabled { opacity: 0.35; }
.gm-select {
  flex: 1;
  min-width: 0;
  font: inherit;
  font-size: 10px;
  padding: 2px 4px;
  color: var(--ink, rgba(255, 255, 255, 0.78));
  background: rgba(255, 255, 255, 0.08);
  border: 1px solid var(--hairline, rgba(255, 255, 255, 0.11));
  border-radius: 3px;
}

.gm-chips { display: flex; flex-wrap: wrap; gap: var(--s1, 4px); }
.gm-chip, .gm-mini {
  font: inherit;
  font-size: 9px;
  letter-spacing: 0.06em;
  padding: 3px 6px;
  cursor: pointer;
  border: 1px solid var(--hairline, rgba(255, 255, 255, 0.11));
  border-radius: var(--r-sm, 5px);
  background: rgba(255, 255, 255, 0.08);
  color: var(--ink-2, rgba(255, 255, 255, 0.56));
  transition: background var(--t, 150ms ease), color var(--t, 150ms ease);
}
.gm-chip-wide { width: 100%; }
.gm-chip:hover:not(:disabled), .gm-mini:hover { background: rgba(255, 255, 255, 0.16); }
.gm-chip:disabled { opacity: 0.3; cursor: default; }
.gm-chip[aria-pressed="true"] {
  background: rgba(242, 177, 52, 0.22);
  color: var(--amber-ink, #ffd68a);
}
.gm-nr[aria-pressed="true"] { border-style: dashed; color: var(--ink-3, rgba(255,255,255,0.4)); }

.gm-ledger { display: flex; flex-direction: column; gap: 2px; }
.gm-ledger-row {
  display: flex;
  align-items: baseline;
  gap: var(--s1, 4px);
  font-size: 9px;
  font-variant-numeric: tabular-nums;
  color: var(--ink-2, rgba(255, 255, 255, 0.56));
}
/* One board to a line, ellipsised. A wrapped name turns a four-row table into a
   nine-row one and the deltas stop lining up under each other, which is the only
   reason the table is a table. */
.gm-ledger-name {
  flex: 1;
  min-width: 0;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.gm-ledger-n { flex: none; color: var(--ink-3, rgba(255, 255, 255, 0.4)); }
.gm-ledger-d { flex: none; min-width: 2.6rem; text-align: right; }
.gm-leak { color: var(--amber-lit, #ffc555); }

/*
 * The pose editor's dock, styled here rather than in index.html.
 *
 * main.ts builds #pose-dock and used to set its whole geometry through
 * Object.assign(dock.style, ...) — position, offsets, width, max-height, flex —
 * with a comment explaining why: every visitor downloads index.html, and a
 * #pose-dock rule sitting in it that can never match is a trace of an
 * instrument that was not shipped to them.
 *
 * That reasoning is right and the conclusion was one file off. This stylesheet
 * is injected by createGodmode, which is reached only through the dynamic
 * import behind access.can.debug, so these bytes reach exactly the same
 * audience the inline styles did — and unlike them, they can carry a media
 * query and a safe-area inset. The dock had neither, which put it under the home
 * indicator on the one device class that has one.
 */
#pose-dock {
  position: fixed;
  right: var(--s4, 16px);
  bottom: calc(var(--s4, 16px) + 5rem + env(safe-area-inset-bottom));
  z-index: var(--z-play, 5);
  width: min(20rem, calc(100vw - var(--s4, 16px) * 2));
  max-height: min(52dvh, calc(100dvh - 26rem));
  display: flex;
  flex-direction: column;
  align-items: stretch;
  gap: var(--s1, 4px);
}
#pose-dock > button { align-self: flex-end; font-family: inherit; }
#pose-dock > div { flex: 1; min-height: 0; display: flex; }
@media (max-width: 600px) {
  #pose-dock {
    right: var(--s3, 12px);
    left: var(--s3, 12px);
    width: auto;
    bottom: calc(var(--s3, 12px) + 8.5rem + env(safe-area-inset-bottom));
  }
}

.gm-hud {
  position: fixed;
  top: calc(var(--s4, 16px) + env(safe-area-inset-top));
  left: 50%;
  transform: translateX(-50%);
  margin: 0;
  z-index: var(--z-instrument, 6);
  max-width: min(34rem, calc(100vw - var(--s4, 16px) * 2));
  overflow-x: auto;
  padding: var(--s2, 8px) var(--s3, 12px);
  font-family: inherit;
  font-size: 10px;
  line-height: 1.6;
  white-space: pre;
  color: var(--ink, rgba(255, 255, 255, 0.78));
  background: var(--glass-strong, rgba(9, 13, 18, 0.86));
  backdrop-filter: var(--blur, blur(14px) saturate(1.2));
  -webkit-backdrop-filter: var(--blur, blur(14px) saturate(1.2));
  border: 1px solid var(--hairline, rgba(255, 255, 255, 0.11));
  border-radius: var(--r, 8px);
  box-shadow: var(--shadow, 0 6px 22px rgba(3, 6, 10, 0.45));
}

@media (max-width: 600px) {
  .gm-body { max-height: 62dvh; }
  .gm-hud { max-width: calc(100vw - var(--s3, 12px) * 2); }
}

@media (prefers-reduced-motion: reduce) {
  .gm-chip, .gm-mini { transition: none; }
}
`;
