/**
 * The board, seen from straight above, in a corner of the screen.
 *
 * The perspective view is very good at telling you what a place *looks* like
 * and very bad at telling you where you are in it. Two hundred units above
 * Potrero with the camera pointed north-west, the Bay Area is a wedge of
 * rooftops and one bridge tower; nothing on screen says that three quarters of
 * the board is behind you. This module is the answer to "where am I", and the
 * part of it that earns its place is the **view footprint** — the quad the
 * camera's frustum cuts out of the ground plane, drawn on the plan. Everything
 * else here is context for that one shape.
 *
 * It is Canvas 2D, on purpose and permanently. A second WebGL context to draw
 * forty filled polygons would double the driver-side cost of the page, and
 * browsers cap live contexts at around sixteen — spending one of them on an
 * inset that never animates a pixel of geometry is a bad trade. It also means
 * this file can never accidentally become a second renderer with its own
 * opinions about the sun.
 *
 * Two structural rules, both of which came out of the failures the rest of the
 * engine already paid for:
 *
 *   - **Nothing here knows how big a board is.** Every coordinate is derived
 *     from one rectangle — `MinimapOptions.frame`, which defaults to
 *     `city.bounds` — through the same `World` the scene uses. `scene.ts`
 *     documents what the alternative cost: camera limits tuned for San
 *     Francisco's 230-unit board silently became a property of the engine, and
 *     the Bay Area's 1003 units could not be framed at all. A minimap with a
 *     hardcoded extent would fail the same way and look like a rendering bug.
 *
 *     **Amended.** The rule used to say `city.bounds` outright, and that was
 *     exactly right while a board was one framing and exactly wrong once a board
 *     became a rung of a ladder — a plan that changes shape when the world has
 *     not is the loudest "you are somewhere else now" signal on the screen. So
 *     the rectangle is now the caller's to choose and the derivation is
 *     unchanged. See `MinimapOptions.frame`.
 *   - **`tick()` runs inside the stage's frame loop**, so it allocates nothing
 *     and tessellates nothing in the steady state. The map itself — coastline,
 *     relief, roads — is rasterised once into an offscreen surface and blitted;
 *     the redraw is capped at 30 Hz and skipped entirely when neither the
 *     camera nor the data has moved.
 *
 * This module owns exactly one DOM node: the canvas it hands back. The caller
 * puts it wherever it likes and styles it however it likes.
 */

import * as THREE from "three";
import type { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { nightFactor } from "./atmosphere.ts";
import { paletteFor } from "./terrain.ts";
import type {
  Aircraft,
  Chapter,
  City,
  LatLng,
  Marker,
  MarkerPalette,
  ScenePalette,
} from "./types.ts";
import type { World } from "./world.ts";

/** What the pointer is currently over, for a readout line the caller owns. */
export interface MinimapHoverInfo {
  lat: number;
  lng: number;
  /** The district under the pointer, by name, or `null` out in the flats. */
  district: string | null;
}

export interface MinimapOptions {
  world: World;
  /**
   * The pack `world` was built from. Passed separately because every other
   * constructor in the engine takes the city it is drawing, and reading it off
   * the world would make this the one that does not — but it must be the same
   * object, or the projection and the polygons will disagree.
   */
  city: City;
  /** The live scene camera. Read every frame, written only by the wheel dolly. */
  camera: THREE.PerspectiveCamera;
  /** The live orbit controls. `controls.target` is the crosshair. */
  controls: OrbitControls;
  markerPalette?: MarkerPalette;
  /**
   * The geographic rectangle the plan **draws**, which is no longer the same
   * thing as the board's own bounds. Defaults to `city.bounds`.
   *
   * ## Why this exists, against this file's own first rule
   *
   * The header above states the rule "nothing here knows how big a board is —
   * every coordinate is derived from `city.bounds`", and that rule is exactly
   * right while a board is *one framing* and exactly wrong once a board is a
   * rung of a ladder. Both metro rectangles are arbitrary crops: the Bay Area's
   * is 0.85 by 0.89 degrees and the Southland's is 1.08 by 1.66, so the plan
   * came out square on one board and landscape on the other while the world
   * underneath had not changed at all. A plan that changes **shape** when the
   * world does not is the loudest "you are somewhere else now" signal on the
   * screen, and it fires in the same frame the fog dip is trying to hide the
   * change.
   *
   * The rule this replaces it with is narrower rather than looser: the plan is
   * still derived entirely from a rectangle handed to it, and it still knows
   * nothing about *which* board it is drawing. It has simply stopped assuming
   * that rectangle is the board's own.
   *
   * The frame must **contain** `city.bounds`, or the board runs off its own
   * plan. Nothing here enforces that, because a frame that deliberately crops is
   * a legitimate thing to want and a runtime assertion would be a second
   * opinion about a caller's intent.
   */
  frame?: { minLat: number; maxLat: number; minLng: number; maxLng: number };
  /** Fires when the user clicks, drags or commits a keyboard seek. */
  onSeek?(lat: number, lng: number): void;
  /** Fires on hover, and once with `null` when the pointer leaves. */
  onHover?(info: MinimapHoverInfo | null): void;
  /** Device-pixel-ratio ceiling. Matches `stage.ts`: above 2 the gain is not real. */
  maxPixelRatio?: number;
}

export interface MinimapPlayer {
  lat: number;
  lng: number;
  /** Degrees clockwise from true north. */
  headingDeg: number;
  kind: "vehicle" | "actor" | "aircraft";
}

export interface Minimap {
  /** The widget. The caller inserts it into its own container and sizes it in CSS. */
  canvas: HTMLCanvasElement;
  setMarkers(markers: Marker[]): void;
  setAircraft(aircraft: Aircraft[]): void;
  /** Local possessed entity. Separate from live traffic so it cannot be duplicated. */
  setPlayer(player: MinimapPlayer | null): void;
  setChapters(chapters: Chapter[], activeId: string): void;
  /**
   * Solar elevation in degrees, the same number `scene.setSolarElevation` gets.
   * The minimap follows the day the map does; see `buildTheme` for why it does
   * not simply dim the daytime palette.
   */
  setSolarElevation(degrees: number): void;
  /** Call from the stage tick. Cheap by construction — see the file header. */
  tick(): void;
  /** Re-do the backing store at the current size and re-rasterise the map. */
  resize(): void;
  dispose(): void;
}

/**
 * The two 2D context interfaces are structurally identical for everything drawn
 * here, but TypeScript will not resolve an overloaded method — `drawImage`,
 * `fill` — through their union. One cast at the boundary, and the rest of the
 * file is written against a single type.
 */
type Ctx = CanvasRenderingContext2D;
type Surface = HTMLCanvasElement | OffscreenCanvas;

/** Redraw ceiling. The stage runs at 60; the footprint does not need to. */
const FRAME_MS = 33;
/** How long the seek confirmation ring lives, in ms. Suppressed for reduced motion. */
const PING_MS = 420;
/** `markers.ts`'s colour of last resort, so an unmapped key looks the same in both places. */
const FALLBACK_MARKER = 0x9aa4ad;
/** The interface's accent, as `index.html` sets it. Camera, footprint, active chapter. */
const ACCENT = 0xf2b134;

export function createMinimap(options: MinimapOptions): Minimap {
  const { world, city, camera, controls } = options;
  const palette = options.markerPalette ?? {};
  const maxPixelRatio = options.maxPixelRatio ?? 2;

  const canvas = document.createElement("canvas");
  canvas.className = "minimap-canvas";
  canvas.tabIndex = 0;
  canvas.setAttribute("role", "application");
  canvas.setAttribute(
    "aria-label",
    `Plan of ${city.name}. Click or drag to move the view, scroll to zoom, ` +
      `arrow keys to aim and Enter to go.`,
  );
  // Without this a drag on a touch screen scrolls the page out from under the
  // pointer capture and the seek stops mid-gesture.
  canvas.style.touchAction = "none";
  /**
   * The widget takes its size from its container, and it has to.
   *
   * A canvas with no CSS size falls back to its own backing store for layout,
   * and `resize()` sets that backing store to `clientWidth * dpr` — so on any
   * retina display the two chase each other and the map doubles in size every
   * frame until it dies. Filling the container breaks the loop at the cost of
   * one rule the caller cannot override from a stylesheet, which is a trade
   * worth making. **The container must have a real height**; `height: auto`
   * puts the same loop back.
   */
  canvas.style.display = "block";
  canvas.style.width = "100%";
  canvas.style.height = "100%";

  const viewCtx = canvas.getContext("2d") as Ctx | null;

  /**
   * The map is rasterised into its own surface and blitted, rather than redrawn
   * under the overlay each frame. `OffscreenCanvas` where it exists; a detached
   * `<canvas>` where it does not, which is still every Safari in the field.
   */
  const staticSurface: Surface =
    typeof OffscreenCanvas === "function"
      ? new OffscreenCanvas(1, 1)
      : document.createElement("canvas");
  const staticCtx = staticSurface.getContext("2d") as Ctx | null;

  /** The hillshade, held at lattice resolution and stretched over the board. */
  const shadeSurface: Surface =
    typeof OffscreenCanvas === "function"
      ? new OffscreenCanvas(1, 1)
      : document.createElement("canvas");
  const shadeCtx = shadeSurface.getContext("2d") as Ctx | null;

  // ---- The board ------------------------------------------------------------

  /**
   * The drawn rectangle in scene units. `projectZ` negates latitude — north is
   * `-z` — so the northern edge is the *smaller* z and the corners have to be
   * taken in that order or the map comes out upside down.
   *
   * `frame`, not `city.bounds`: see `MinimapOptions.frame`. Everything below
   * this line is unchanged, because everything below this line was already
   * written against these six numbers rather than against a board.
   */
  const frame = options.frame ?? city.bounds;
  const [westX, northZ] = world.project(frame.maxLat, frame.minLng);
  const [eastX, southZ] = world.project(frame.minLat, frame.maxLng);
  const boardW = eastX - westX;
  const boardH = southZ - northZ;
  const boardSpan = Math.max(boardW, boardH);

  const latSpan = frame.maxLat - frame.minLat;
  const lngSpan = frame.maxLng - frame.minLng;

  // Layout, in device pixels. Everything is recomputed by `layout()`.
  let dpr = 1;
  let pxW = 0;
  let pxH = 0;
  let scale = 0;
  let boardX = 0;
  let boardY = 0;
  let boardPxW = 0;
  let boardPxH = 0;
  let ready = false;

  const toPxX = (x: number): number => boardX + (x - westX) * scale;
  const toPxY = (z: number): number => boardY + (z - northZ) * scale;
  const fromPxX = (px: number): number => westX + (px - boardX) / scale;
  const fromPxZ = (py: number): number => northZ + (py - boardY) / scale;

  // ---- State the caller sets ------------------------------------------------

  let markers: Marker[] = [];
  let aircraft: Aircraft[] = [];
  let chapters: Chapter[] = city.chapters;
  let activeChapterId = city.chapters[0]?.id ?? "";
  let player: MinimapPlayer | null = null;
  let night = 0;
  let renderedNight = -1;

  // Laid-out geometry. Flat arrays of device pixels, rebuilt on resize and when
  // the data changes, so the draw loop reads numbers and never projects.
  let markerPx = new Float64Array(0);
  let markerFill: string[] = [];
  let markerHollow: boolean[] = [];
  let chapterPx = new Float64Array(0);
  let activeChapterIndex = -1;
  let aircraftPx = new Float64Array(0);
  let playerPx = new Float64Array(0);

  let landPath = new Path2D();
  let parkPath = new Path2D();
  let lakePath = new Path2D();
  let streetPath = new Path2D();
  let freewayPath = new Path2D();
  let bridgePath = new Path2D();
  let districtPaths: { path: Path2D; weight: number }[] = [];
  let landmarkPx = new Float64Array(0);
  /** Hillshade, one signed value per lattice cell. Kept so a sunset only recolours it. */
  let shadeValues: Float32Array | null = null;
  let shadeCols = 0;
  let shadeRows = 0;

  // ---- Interaction state ----------------------------------------------------

  let dirty = true;
  let lastDraw = 0;
  let hoverX = -1;
  let hoverY = -1;
  let hoverDistrict: string | null = null;
  let dragging = false;
  /** The keyboard's aim point, in device pixels. `null` until an arrow key is pressed. */
  let pendingX = -1;
  let pendingY = -1;
  /** When the seek confirmation ring started, in `performance.now()` ms. 0 = not running. */
  let pinging = 0;
  let pingSceneX = 0;
  let pingSceneZ = 0;

  const motionQuery =
    typeof window.matchMedia === "function"
      ? window.matchMedia("(prefers-reduced-motion: reduce)")
      : null;
  let reducedMotion = motionQuery?.matches ?? false;

  // Camera state as of the last draw, for the bail-out. Compared exactly rather
  // than with an epsilon: OrbitControls' damping asymptotes, so an epsilon
  // freezes the footprint a few frames before the camera has actually stopped,
  // and a stale footprint on a still-drifting map is exactly the kind of small
  // wrongness that makes a tool feel broken.
  let lastCamX = NaN;
  let lastCamY = NaN;
  let lastCamZ = NaN;
  let lastTgtX = NaN;
  let lastTgtY = NaN;
  let lastTgtZ = NaN;
  let lastFov = NaN;
  let lastAspect = NaN;

  // Scratch for the frustum corners. Four vectors, allocated once, reused every
  // frame — `unproject` needs somewhere to work and the draw loop may not
  // allocate.
  const corners = [
    new THREE.Vector3(),
    new THREE.Vector3(),
    new THREE.Vector3(),
    new THREE.Vector3(),
  ];
  /** NDC corners, bottom-left first, so the quad comes out wound consistently. */
  const NDC_X = [-1, 1, 1, -1];
  const NDC_Y = [-1, -1, 1, 1];

  let theme = buildTheme(paletteFor(world), 0);

  // ---- Layout ---------------------------------------------------------------

  /**
   * Fit the board inside the widget, letterboxed.
   *
   * Never stretched. The Bay Area board is 832 x 1003 units and SoCal's is
   * 393 x 308 — one portrait, one landscape — and squeezing either into a
   * square is instantly, viscerally wrong to anyone who has looked at a map of
   * the place. Whatever is left over stays transparent, so the container's own
   * card background shows through.
   */
  function layout() {
    const pad = Math.round(2 * dpr);
    const availW = Math.max(1, pxW - pad * 2);
    const availH = Math.max(1, pxH - pad * 2);
    scale = Math.min(availW / boardW, availH / boardH);
    boardPxW = boardW * scale;
    boardPxH = boardH * scale;
    boardX = pad + (availW - boardPxW) / 2;
    boardY = pad + (availH - boardPxH) / 2;
  }

  function polygonPath(polys: LatLng[][]): Path2D {
    const path = new Path2D();
    for (const poly of polys) {
      if (poly.length < 3) continue;
      for (let i = 0; i < poly.length; i++) {
        const point = poly[i];
        if (!point) continue;
        const x = toPxX(world.projectX(point[1]));
        const y = toPxY(world.projectZ(point[0]));
        if (i === 0) path.moveTo(x, y);
        else path.lineTo(x, y);
      }
      path.closePath();
    }
    return path;
  }

  function linePath(paths: LatLng[][]): Path2D {
    const path = new Path2D();
    for (const line of paths) {
      for (let i = 0; i < line.length; i++) {
        const point = line[i];
        if (!point) continue;
        const x = toPxX(world.projectX(point[1]));
        const y = toPxY(world.projectZ(point[0]));
        if (i === 0) path.moveTo(x, y);
        else path.lineTo(x, y);
      }
    }
    return path;
  }

  function buildPaths() {
    landPath = polygonPath(city.landmasses);
    parkPath = polygonPath(city.parks);
    lakePath = polygonPath(city.inlandWater);
    streetPath = linePath(city.roads.filter((r) => r.kind !== "freeway").map((r) => r.path));
    freewayPath = linePath(city.roads.filter((r) => r.kind === "freeway").map((r) => r.path));
    bridgePath = linePath(city.bridges.map((b) => b.path));

    // Districts carry their own weight rather than their own alpha, so downtown
    // reads denser than the flats without anyone having to hand-tune thirty
    // numbers in two city packs. It is the same fact `blocks.ts` uses to decide
    // how tall to build.
    districtPaths = city.districts.map((d) => ({
      path: polygonPath([d.polygon]),
      weight: d.palette === "downtown" ? 1.7 : d.palette === "industrial" ? 1 : 0.6,
    }));

    const labelled = city.landmarks.filter((l) => l.label);
    landmarkPx = new Float64Array(labelled.length * 2);
    labelled.forEach((l, i) => {
      landmarkPx[i * 2] = toPxX(world.projectX(l.lng));
      landmarkPx[i * 2 + 1] = toPxY(world.projectZ(l.lat));
    });
  }

  /**
   * Relief, as a slope-lit lattice.
   *
   * Without this the flats and the hills are the same colour and the board is a
   * silhouette: the Diablo range, the Santa Monicas and the whole spine down the
   * peninsula simply are not there. It is sampled from the world's *cached*
   * heightfield rather than `elevationAt`, which is the same number and about
   * two orders of magnitude cheaper — the terrain mesh has already paid to build
   * that field, and re-running the hill sum, four octaves of noise and a
   * distance-to-coastline for twenty thousand lattice points to get an answer
   * that is already in memory would be a second and a half of nothing.
   *
   * The values are kept separately from the pixels because dusk recolours the
   * shading but does not move a hill.
   */
  function buildShade() {
    const cssW = boardPxW / dpr;
    const cssH = boardPxH / dpr;
    // About four CSS pixels a cell. Finer than that is invisible under the blit's
    // own smoothing; coarser and the ridges turn into steps.
    shadeCols = Math.max(2, Math.min(160, Math.round(cssW / 4)));
    shadeRows = Math.max(2, Math.min(160, Math.round(cssH / 4)));
    const values = new Float32Array(shadeCols * shadeRows);

    const elevation = new Float32Array(shadeCols * shadeRows);
    for (let j = 0; j < shadeRows; j++) {
      const lat = frame.maxLat - ((j + 0.5) / shadeRows) * latSpan;
      for (let i = 0; i < shadeCols; i++) {
        const lng = frame.minLng + ((i + 0.5) / shadeCols) * lngSpan;
        elevation[j * shadeCols + i] = world.elevationSampled(lat, lng);
      }
    }

    // Cell size in real metres. Scene x and z share a scale — `lngScale` is
    // `latScale * cos(lat)` precisely so that they do — so one number covers
    // both axes.
    const cellM = (boardW / shadeCols) * world.metresPerUnit;
    const exaggeration = city.verticalExaggeration;
    // Light from the north-west and well up. Not a physical sun: the map's own
    // sun swings through 360° over a day and a shaded relief that rotates with
    // it is unreadable, so this one is the cartographic convention and stays put.
    const lx = -0.55;
    const ly = 0.62;
    const lz = -0.56;
    const lLen = Math.hypot(lx, ly, lz);

    for (let j = 0; j < shadeRows; j++) {
      for (let i = 0; i < shadeCols; i++) {
        const k = j * shadeCols + i;
        const west = elevation[k - (i > 0 ? 1 : 0)] ?? 0;
        const east = elevation[k + (i < shadeCols - 1 ? 1 : 0)] ?? 0;
        const north = elevation[k - (j > 0 ? shadeCols : 0)] ?? 0;
        const south = elevation[k + (j < shadeRows - 1 ? shadeCols : 0)] ?? 0;
        const dEast = ((east - west) / (2 * cellM)) * exaggeration;
        const dSouth = ((south - north) / (2 * cellM)) * exaggeration;
        // Surface normal of the cell, unnormalised, then Lambert against the
        // fixed light. Flat ground gives exactly `ly / lLen`, so subtracting it
        // leaves zero on the flats and the whole range for the slopes.
        const nx = -dEast;
        const nz = -dSouth;
        const nLen = Math.hypot(nx, 1, nz);
        const lambert = (nx * lx + ly + nz * lz) / (nLen * lLen);
        values[k] = lambert - ly / lLen;
      }
    }
    shadeValues = values;
    shadeSurface.width = shadeCols;
    shadeSurface.height = shadeRows;
    paintShade();
  }

  /** Turn the cached slope values into pixels in the current palette. */
  function paintShade() {
    if (!shadeCtx || !shadeValues) return;
    const image = new ImageData(shadeCols, shadeRows);
    const data = image.data;
    const lit = theme.shadeLit;
    const dark = theme.shadeDark;
    for (let k = 0; k < shadeValues.length; k++) {
      const s = shadeValues[k] ?? 0;
      const magnitude = Math.min(1, Math.abs(s) * theme.shadeGain);
      const source = s >= 0 ? lit : dark;
      const alpha = s >= 0 ? theme.shadeAlphaLit : theme.shadeAlphaDark;
      data[k * 4] = source.r;
      data[k * 4 + 1] = source.g;
      data[k * 4 + 2] = source.b;
      data[k * 4 + 3] = Math.round(magnitude * alpha * 255);
    }
    shadeCtx.putImageData(image, 0, 0);
  }

  function layoutMarkers() {
    if (scale <= 0) return;
    markerPx = new Float64Array(markers.length * 2);
    markerFill = new Array<string>(markers.length);
    markerHollow = new Array<boolean>(markers.length);
    markers.forEach((m, i) => {
      markerPx[i * 2] = toPxX(world.projectX(m.lng));
      markerPx[i * 2 + 1] = toPxY(world.projectZ(m.lat));
      markerFill[i] = cssHex(palette[m.colorKey] ?? FALLBACK_MARKER);
      // Same tell as `markers.ts` gives a pin whose position is a guess: a
      // different silhouette, not just a different opacity. A map that claims to
      // be real must not quietly invent an address at any zoom.
      markerHollow[i] = m.located === false;
    });
  }

  function layoutChapters() {
    if (scale <= 0) return;
    chapterPx = new Float64Array(chapters.length * 2);
    activeChapterIndex = -1;
    chapters.forEach((c, i) => {
      chapterPx[i * 2] = toPxX(world.projectX(c.focus.lng));
      chapterPx[i * 2 + 1] = toPxY(world.projectZ(c.focus.lat));
      if (c.id === activeChapterId) activeChapterIndex = i;
    });
  }

  function layoutAircraft() {
    if (scale <= 0) return;
    aircraftPx = new Float64Array(aircraft.length * 3);
    aircraft.forEach((a, i) => {
      aircraftPx[i * 3] = toPxX(world.projectX(a.lng));
      aircraftPx[i * 3 + 1] = toPxY(world.projectZ(a.lat));
      aircraftPx[i * 3 + 2] = (a.heading * Math.PI) / 180;
    });
  }

  function layoutPlayer() {
    if (scale <= 0 || !player) {
      playerPx = new Float64Array(0);
      return;
    }
    playerPx = new Float64Array([
      toPxX(world.projectX(player.lng)),
      toPxY(world.projectZ(player.lat)),
      (player.headingDeg * Math.PI) / 180,
    ]);
  }

  // ---- The static map -------------------------------------------------------

  function renderStatic() {
    if (!staticCtx || !ready) return;
    const ctx = staticCtx;
    ctx.clearRect(0, 0, pxW, pxH);
    ctx.save();
    ctx.beginPath();
    ctx.rect(boardX, boardY, boardPxW, boardPxH);
    ctx.clip();

    ctx.fillStyle = theme.water;
    ctx.fillRect(boardX, boardY, boardPxW, boardPxH);

    ctx.fillStyle = theme.land;
    ctx.fill(landPath);

    if (shadeValues) {
      ctx.save();
      ctx.clip(landPath);
      ctx.imageSmoothingEnabled = true;
      ctx.drawImage(shadeSurface as CanvasImageSource, boardX, boardY, boardPxW, boardPxH);
      ctx.restore();
    }

    ctx.fillStyle = theme.park;
    ctx.fill(parkPath);
    ctx.fillStyle = theme.lake;
    ctx.fill(lakePath);

    for (const d of districtPaths) {
      ctx.fillStyle = rgba(theme.districtRgb, theme.districtAlpha * d.weight);
      ctx.fill(d.path);
    }

    ctx.lineCap = "round";
    ctx.lineJoin = "round";
    ctx.strokeStyle = theme.street;
    ctx.lineWidth = 0.8 * dpr;
    ctx.stroke(streetPath);
    ctx.strokeStyle = theme.freeway;
    ctx.lineWidth = 1.6 * dpr;
    ctx.stroke(freewayPath);
    ctx.strokeStyle = theme.bridge;
    ctx.lineWidth = 1.9 * dpr;
    ctx.stroke(bridgePath);

    /**
     * The coastline, stroked over the fills.
     *
     * This is the line that keeps the map honest after dark. Fill contrast alone
     * survives daylight and dies at 2 a.m., which is the exact complaint the 3D
     * view earned: land and water both go to near-black and the shape of the bay
     * disappears. A stroke does not care how dark the fills are, so it gets
     * *more* alpha at night, not less.
     */
    ctx.strokeStyle = theme.coast;
    ctx.lineWidth = dpr;
    ctx.stroke(landPath);

    // Landmarks worth naming get a one-pixel tick rather than a dot. At this
    // scale a dot is a marker and the eye reads it as one; a tick reads as
    // notation.
    ctx.strokeStyle = theme.landmark;
    ctx.lineWidth = dpr;
    ctx.beginPath();
    for (let i = 0; i < landmarkPx.length; i += 2) {
      const x = Math.round(landmarkPx[i] ?? 0) + 0.5 * dpr;
      const y = landmarkPx[i + 1] ?? 0;
      ctx.moveTo(x, y - 2.5 * dpr);
      ctx.lineTo(x, y);
    }
    ctx.stroke();

    ctx.restore();

    ctx.strokeStyle = theme.frame;
    ctx.lineWidth = dpr;
    ctx.strokeRect(
      boardX + dpr / 2,
      boardY + dpr / 2,
      Math.max(0, boardPxW - dpr),
      Math.max(0, boardPxH - dpr),
    );
    renderedNight = night;
  }

  // ---- The overlay ----------------------------------------------------------

  /**
   * The footprint: where the camera's frustum meets the ground.
   *
   * Four corner rays, each intersected with y=0. The case that has to be right
   * is the one where the horizon is on screen — then the two upper rays point
   * *above* the ground plane and never meet it. Solving `t = -camY / dir.y`
   * anyway gives a negative `t`, which puts those corners **behind** the camera
   * and turns the trapezoid inside out: a bow-tie that flickers across the whole
   * board every time you tilt up. So a ray that is not heading downward is
   * clamped to a long finite distance instead, which draws the wedge running off
   * toward the horizon — which is what you are actually looking at.
   */
  function drawFootprint(ctx: Ctx) {
    const camY = camera.position.y;
    // The orbit controls will not let the camera under the ground, but an
    // office swap or a pathological pose could; a footprint from below the
    // plane is meaningless rather than merely wrong.
    if (!(camY > 0.01)) return;
    camera.updateMatrixWorld();
    const maxRay = boardSpan * 4;

    ctx.beginPath();
    for (let i = 0; i < 4; i++) {
      const v = corners[i];
      if (!v) return;
      v.set(NDC_X[i] ?? 0, NDC_Y[i] ?? 0, 0.5).unproject(camera).sub(camera.position);
      const length = v.length();
      if (!(length > 1e-6)) return;
      v.multiplyScalar(1 / length);
      const t = v.y < -1e-4 ? Math.min(-camY / v.y, maxRay) : maxRay;
      const px = toPxX(camera.position.x + v.x * t);
      const py = toPxY(camera.position.z + v.z * t);
      if (i === 0) ctx.moveTo(px, py);
      else ctx.lineTo(px, py);
    }
    ctx.closePath();
    ctx.fillStyle = theme.footprintFill;
    ctx.fill();
    ctx.strokeStyle = theme.footprintStroke;
    ctx.lineWidth = 1.25 * dpr;
    ctx.lineJoin = "round";
    ctx.stroke();
  }

  /** A chevron at the camera, pointing the way it is looking. */
  function drawCamera(ctx: Ctx) {
    const x = toPxX(camera.position.x);
    const y = toPxY(camera.position.z);
    const dx = controls.target.x - camera.position.x;
    const dz = controls.target.z - camera.position.z;
    const len = Math.hypot(dx, dz);
    if (!(len > 1e-6)) return;
    // Scene +x is east and +z is south, and the widget is drawn the same way up,
    // so the heading needs no rotation at all — only the sign flip on z that
    // `toPxY` already carries.
    const nx = dx / len;
    const ny = dz / len;
    const sx = -ny;
    const sy = nx;
    const s = 4.6 * dpr;

    ctx.beginPath();
    ctx.moveTo(x + nx * s * 1.5, y + ny * s * 1.5);
    ctx.lineTo(x - nx * s * 0.7 + sx * s, y - ny * s * 0.7 + sy * s);
    ctx.lineTo(x - nx * s * 0.2, y - ny * s * 0.2);
    ctx.lineTo(x - nx * s * 0.7 - sx * s, y - ny * s * 0.7 - sy * s);
    ctx.closePath();
    ctx.fillStyle = theme.camera;
    ctx.fill();
    ctx.strokeStyle = theme.cameraEdge;
    ctx.lineWidth = dpr;
    ctx.stroke();
  }

  /** The orbit target, as a crosshair. Where a chapter flight lands. */
  function drawTarget(ctx: Ctx) {
    crosshair(ctx, toPxX(controls.target.x), toPxY(controls.target.z), theme.target, 5 * dpr);
  }

  function crosshair(ctx: Ctx, x: number, y: number, color: string, r: number) {
    ctx.strokeStyle = color;
    ctx.lineWidth = dpr;
    ctx.beginPath();
    ctx.moveTo(x - r, y);
    ctx.lineTo(x - r * 0.35, y);
    ctx.moveTo(x + r * 0.35, y);
    ctx.lineTo(x + r, y);
    ctx.moveTo(x, y - r);
    ctx.lineTo(x, y - r * 0.35);
    ctx.moveTo(x, y + r * 0.35);
    ctx.lineTo(x, y + r);
    ctx.stroke();
    ctx.beginPath();
    ctx.arc(x, y, r * 0.32, 0, Math.PI * 2);
    ctx.stroke();
  }

  function drawMarkers(ctx: Ctx) {
    const r = 2.3 * dpr;
    ctx.lineWidth = dpr;
    for (let i = 0; i < markerFill.length; i++) {
      const x = markerPx[i * 2] ?? 0;
      const y = markerPx[i * 2 + 1] ?? 0;
      ctx.beginPath();
      ctx.arc(x, y, r, 0, Math.PI * 2);
      if (markerHollow[i]) {
        ctx.strokeStyle = markerFill[i] ?? theme.target;
        ctx.stroke();
      } else {
        ctx.fillStyle = markerFill[i] ?? theme.target;
        ctx.fill();
        ctx.strokeStyle = theme.pinEdge;
        ctx.stroke();
      }
    }
  }

  function drawChapters(ctx: Ctx) {
    ctx.lineWidth = 1.2 * dpr;
    for (let i = 0; i < chapters.length; i++) {
      const x = chapterPx[i * 2] ?? 0;
      const y = chapterPx[i * 2 + 1] ?? 0;
      ctx.beginPath();
      ctx.arc(x, y, 1.7 * dpr, 0, Math.PI * 2);
      ctx.fillStyle = theme.chapter;
      ctx.fill();
      if (i === activeChapterIndex) {
        ctx.beginPath();
        ctx.arc(x, y, 4.6 * dpr, 0, Math.PI * 2);
        ctx.strokeStyle = theme.chapterActive;
        ctx.stroke();
      }
    }
  }

  function drawAircraft(ctx: Ctx) {
    if (aircraftPx.length === 0) return;
    const s = 3.1 * dpr;
    ctx.fillStyle = theme.aircraft;
    for (let i = 0; i < aircraftPx.length; i += 3) {
      const x = aircraftPx[i] ?? 0;
      const y = aircraftPx[i + 1] ?? 0;
      const a = aircraftPx[i + 2] ?? 0;
      // Heading is degrees clockwise from true north, and north on the widget is
      // up, so the nose is (sin, -cos).
      const nx = Math.sin(a);
      const ny = -Math.cos(a);
      ctx.beginPath();
      ctx.moveTo(x + nx * s * 1.5, y + ny * s * 1.5);
      ctx.lineTo(x - nx * s - ny * s * 0.75, y - ny * s + nx * s * 0.75);
      ctx.lineTo(x - nx * s + ny * s * 0.75, y - ny * s - nx * s * 0.75);
      ctx.closePath();
      ctx.fill();
    }
  }

  function drawPlayer(ctx: Ctx) {
    if (!player || playerPx.length < 3) return;
    const x = playerPx[0] ?? 0;
    const y = playerPx[1] ?? 0;
    const a = playerPx[2] ?? 0;
    const nx = Math.sin(a);
    const ny = -Math.cos(a);
    const sx = -ny;
    const sy = nx;
    const r = (player.kind === "aircraft" ? 5.2 : player.kind === "vehicle" ? 4.5 : 4) * dpr;
    ctx.beginPath();
    ctx.arc(x, y, r + 2.5 * dpr, 0, Math.PI * 2);
    ctx.strokeStyle = theme.chapterActive;
    ctx.lineWidth = 1.2 * dpr;
    ctx.stroke();
    ctx.beginPath();
    ctx.moveTo(x + nx * r * 1.35, y + ny * r * 1.35);
    ctx.lineTo(x - nx * r * 0.75 + sx * r * 0.72, y - ny * r * 0.75 + sy * r * 0.72);
    ctx.lineTo(x - nx * r * 0.4, y - ny * r * 0.4);
    ctx.lineTo(x - nx * r * 0.75 - sx * r * 0.72, y - ny * r * 0.75 - sy * r * 0.72);
    ctx.closePath();
    ctx.fillStyle = theme.chapterActive;
    ctx.fill();
  }

  function drawPing(ctx: Ctx, now: number) {
    if (pinging === 0) return;
    const t = (now - pinging) / PING_MS;
    if (t >= 1) {
      pinging = 0;
      return;
    }
    ctx.beginPath();
    ctx.arc(toPxX(pingSceneX), toPxY(pingSceneZ), (3 + 13 * t) * dpr, 0, Math.PI * 2);
    ctx.strokeStyle = rgba(theme.accentRgb, 0.75 * (1 - t));
    ctx.lineWidth = 1.4 * dpr;
    ctx.stroke();
  }

  function draw(now: number) {
    if (!viewCtx) return;
    const ctx = viewCtx;
    ctx.clearRect(0, 0, pxW, pxH);
    ctx.drawImage(staticSurface as CanvasImageSource, 0, 0);

    ctx.save();
    ctx.beginPath();
    ctx.rect(boardX, boardY, boardPxW, boardPxH);
    ctx.clip();

    drawFootprint(ctx);
    drawMarkers(ctx);
    drawChapters(ctx);
    drawAircraft(ctx);
    drawPlayer(ctx);
    drawTarget(ctx);
    drawCamera(ctx);
    if (pendingX >= 0) crosshair(ctx, pendingX, pendingY, theme.pending, 7 * dpr);
    if (hoverX >= 0) crosshair(ctx, hoverX, hoverY, theme.hover, 6 * dpr);
    drawPing(ctx, now);

    ctx.restore();
  }

  /** True when anything the overlay draws from the camera has changed. */
  function cameraMoved(): boolean {
    return (
      camera.position.x !== lastCamX ||
      camera.position.y !== lastCamY ||
      camera.position.z !== lastCamZ ||
      controls.target.x !== lastTgtX ||
      controls.target.y !== lastTgtY ||
      controls.target.z !== lastTgtZ ||
      camera.fov !== lastFov ||
      camera.aspect !== lastAspect
    );
  }

  function recordCamera() {
    lastCamX = camera.position.x;
    lastCamY = camera.position.y;
    lastCamZ = camera.position.z;
    lastTgtX = controls.target.x;
    lastTgtY = controls.target.y;
    lastTgtZ = controls.target.z;
    lastFov = camera.fov;
    lastAspect = camera.aspect;
  }

  // ---- Interaction ----------------------------------------------------------

  /**
   * Pointer client coordinates to device pixels on the backing store.
   *
   * Via the bounding rect's own ratio rather than `dpr`, because the two are not
   * the same number under a CSS transform or browser page zoom, and a minimap
   * that seeks a few hundred metres from where you clicked is worse than one
   * that does not seek at all.
   */
  function eventToPx(event: PointerEvent | WheelEvent): [number, number] {
    const rect = canvas.getBoundingClientRect();
    const kx = rect.width > 0 ? pxW / rect.width : dpr;
    const ky = rect.height > 0 ? pxH / rect.height : dpr;
    return [(event.clientX - rect.left) * kx, (event.clientY - rect.top) * ky];
  }

  const clampX = (px: number): number => Math.min(boardX + boardPxW, Math.max(boardX, px));
  const clampY = (py: number): number => Math.min(boardY + boardPxH, Math.max(boardY, py));

  function latLngAt(px: number, py: number): [number, number] {
    return world.unproject(fromPxX(px), fromPxZ(py));
  }

  function districtAt(lat: number, lng: number): string | null {
    for (const d of city.districts) {
      if (world.pointInPolygon(lat, lng, d.polygon)) return d.name;
    }
    return null;
  }

  function seekTo(px: number, py: number) {
    if (!ready) return;
    const x = clampX(px);
    const y = clampY(py);
    const [lat, lng] = latLngAt(x, y);
    pingSceneX = fromPxX(x);
    pingSceneZ = fromPxZ(y);
    // The only animation in the widget, and the only thing reduced motion turns
    // off. The seek itself has never been eased — where the caller puts the
    // camera is the caller's business.
    pinging = reducedMotion ? 0 : performance.now();
    dirty = true;
    options.onSeek?.(lat, lng);
  }

  function onPointerDown(event: PointerEvent) {
    if (!ready || event.button !== 0) return;
    const [px, py] = eventToPx(event);
    dragging = true;
    canvas.setPointerCapture(event.pointerId);
    canvas.focus({ preventScroll: true });
    pendingX = -1;
    seekTo(px, py);
    event.preventDefault();
  }

  function onPointerMove(event: PointerEvent) {
    if (!ready) return;
    const [px, py] = eventToPx(event);
    const x = clampX(px);
    const y = clampY(py);
    if (x !== hoverX || y !== hoverY) {
      hoverX = x;
      hoverY = y;
      dirty = true;
      const [lat, lng] = latLngAt(x, y);
      const district = districtAt(lat, lng);
      hoverDistrict = district;
      options.onHover?.({ lat, lng, district });
    }
    if (dragging) seekTo(px, py);
  }

  function endDrag(event: PointerEvent) {
    if (!dragging) return;
    dragging = false;
    if (canvas.hasPointerCapture(event.pointerId)) canvas.releasePointerCapture(event.pointerId);
  }

  function onPointerLeave() {
    // Pointer capture makes the boundary events fire at capture release rather
    // than at the real edge, so a drag that runs off the widget would otherwise
    // drop the readout while it is still seeking.
    if (dragging) return;
    if (hoverX < 0 && hoverDistrict === null) return;
    hoverX = -1;
    hoverY = -1;
    hoverDistrict = null;
    dirty = true;
    options.onHover?.(null);
  }

  /**
   * The wheel dollies the real camera along its own view vector.
   *
   * Written straight into `camera.position` rather than through the controls,
   * which is safe because `OrbitControls.update` re-derives its spherical
   * coordinates from the camera every frame. The limits are the controls' own,
   * so the minimap cannot put the camera anywhere dragging the map could not.
   */
  function onWheel(event: WheelEvent) {
    if (!ready) return;
    event.preventDefault();
    // `deltaMode` 1 is lines, not pixels — Firefox reports a handful of lines
    // where everyone else reports a hundred-odd pixels.
    const raw = event.deltaMode === 1 ? event.deltaY * 16 : event.deltaY;
    const step = Math.exp(Math.max(-160, Math.min(160, raw)) * 0.0022);
    const dx = camera.position.x - controls.target.x;
    const dy = camera.position.y - controls.target.y;
    const dz = camera.position.z - controls.target.z;
    const distance = Math.hypot(dx, dy, dz);
    if (!(distance > 1e-6)) return;
    const next = Math.min(controls.maxDistance, Math.max(controls.minDistance, distance * step));
    const k = next / distance;
    camera.position.set(
      controls.target.x + dx * k,
      controls.target.y + dy * k,
      controls.target.z + dz * k,
    );
    dirty = true;
  }

  /**
   * Keyboard aiming. Arrows move a pending crosshair, Enter commits it.
   *
   * Two steps rather than one because a single arrow press that flew the camera
   * would make holding a key a slideshow of chapter flights. The pending point
   * starts wherever the camera is already looking, so the first press is a nudge
   * and not a jump to the corner.
   */
  function onKeyDown(event: KeyboardEvent) {
    if (!ready) return;
    const step = (event.shiftKey ? 0.06 : 0.015) * Math.max(boardPxW, boardPxH);
    let dx = 0;
    let dy = 0;
    switch (event.key) {
      case "ArrowLeft":
        dx = -step;
        break;
      case "ArrowRight":
        dx = step;
        break;
      case "ArrowUp":
        dy = -step;
        break;
      case "ArrowDown":
        dy = step;
        break;
      case "Enter":
      case " ":
        if (pendingX >= 0) {
          seekTo(pendingX, pendingY);
          event.preventDefault();
        }
        return;
      case "Escape":
        if (pendingX >= 0) {
          pendingX = -1;
          dirty = true;
        }
        return;
      default:
        return;
    }
    if (pendingX < 0) {
      pendingX = clampX(toPxX(controls.target.x));
      pendingY = clampY(toPxY(controls.target.z));
    }
    pendingX = clampX(pendingX + dx);
    pendingY = clampY(pendingY + dy);
    dirty = true;
    event.preventDefault();
  }

  function onBlur() {
    if (pendingX < 0) return;
    pendingX = -1;
    dirty = true;
  }

  function onMotionChange(event: MediaQueryListEvent) {
    reducedMotion = event.matches;
    if (reducedMotion) pinging = 0;
  }

  canvas.addEventListener("pointerdown", onPointerDown);
  canvas.addEventListener("pointermove", onPointerMove);
  canvas.addEventListener("pointerup", endDrag);
  canvas.addEventListener("pointercancel", endDrag);
  canvas.addEventListener("pointerleave", onPointerLeave);
  canvas.addEventListener("wheel", onWheel, { passive: false });
  canvas.addEventListener("keydown", onKeyDown);
  canvas.addEventListener("blur", onBlur);
  motionQuery?.addEventListener("change", onMotionChange);

  // The widget is sized by the caller's CSS, so it has to watch its own box —
  // it can be laid out long after construction, and a container that animates
  // open would otherwise leave a map rasterised at the wrong size.
  const observer =
    typeof ResizeObserver === "function" ? new ResizeObserver(() => resize()) : null;
  observer?.observe(canvas);

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

  function resize() {
    const cssW = canvas.clientWidth;
    const cssH = canvas.clientHeight;
    if (cssW === 0 || cssH === 0) {
      ready = false;
      return;
    }
    const nextDpr = Math.min(window.devicePixelRatio || 1, maxPixelRatio);
    const w = Math.max(1, Math.round(cssW * nextDpr));
    const h = Math.max(1, Math.round(cssH * nextDpr));
    if (ready && w === pxW && h === pxH) return;

    dpr = nextDpr;
    pxW = w;
    pxH = h;
    canvas.width = w;
    canvas.height = h;
    staticSurface.width = w;
    staticSurface.height = h;
    ready = true;

    layout();
    buildPaths();
    buildShade();
    layoutMarkers();
    layoutChapters();
    layoutAircraft();
    layoutPlayer();
    renderStatic();
    dirty = true;
  }

  resize();

  return {
    canvas,

    setMarkers(next) {
      markers = next;
      layoutMarkers();
      dirty = true;
    },

    setAircraft(next) {
      aircraft = next;
      layoutAircraft();
      dirty = true;
    },

    setPlayer(next) {
      if (
        player?.lat === next?.lat && player?.lng === next?.lng &&
        player?.headingDeg === next?.headingDeg && player?.kind === next?.kind
      ) return;
      player = next ? { ...next } : null;
      layoutPlayer();
      dirty = true;
    },

    setChapters(next, activeId) {
      chapters = next;
      activeChapterId = activeId;
      layoutChapters();
      dirty = true;
    },

    setSolarElevation(degrees) {
      night = nightFactor(degrees);
      // Rasterising the whole map is not a per-frame cost, and the scrubber can
      // move the sun a hundredth of a degree at a time. A hundredth of a night
      // is invisible; a rebuild per input event is not.
      if (Math.abs(night - renderedNight) < 0.01) return;
      theme = buildTheme(paletteFor(world), night);
      paintShade();
      renderStatic();
      dirty = true;
    },

    tick() {
      if (!ready || !viewCtx) return;
      const now = performance.now();
      if (now - lastDraw < FRAME_MS) return;
      if (!dirty && pinging === 0 && !cameraMoved()) return;
      lastDraw = now;
      dirty = false;
      recordCamera();
      draw(now);
    },

    resize,

    dispose() {
      observer?.disconnect();
      canvas.removeEventListener("pointerdown", onPointerDown);
      canvas.removeEventListener("pointermove", onPointerMove);
      canvas.removeEventListener("pointerup", endDrag);
      canvas.removeEventListener("pointercancel", endDrag);
      canvas.removeEventListener("pointerleave", onPointerLeave);
      canvas.removeEventListener("wheel", onWheel);
      canvas.removeEventListener("keydown", onKeyDown);
      canvas.removeEventListener("blur", onBlur);
      motionQuery?.removeEventListener("change", onMotionChange);
      ready = false;
      shadeValues = null;
      canvas.remove();
    },
  };
}

// ---- Palette ---------------------------------------------------------------

interface Rgb {
  r: number;
  g: number;
  b: number;
}

interface Theme {
  water: string;
  land: string;
  park: string;
  lake: string;
  coast: string;
  street: string;
  freeway: string;
  bridge: string;
  landmark: string;
  frame: string;
  districtRgb: Rgb;
  districtAlpha: number;
  shadeLit: Rgb;
  shadeDark: Rgb;
  shadeAlphaLit: number;
  shadeAlphaDark: number;
  shadeGain: number;
  footprintFill: string;
  footprintStroke: string;
  camera: string;
  cameraEdge: string;
  target: string;
  hover: string;
  pending: string;
  chapter: string;
  chapterActive: string;
  aircraft: string;
  pinEdge: string;
  accentRgb: Rgb;
}

/**
 * The day palette is the scene's own, so the inset and the map agree about what
 * colour the bay is; the night palette is not.
 *
 * The obvious implementation — take the daytime colours and multiply them down
 * — was tried and produces precisely the failure this whole change exists to
 * fix. Sea 0x4a7a99 at 12% and flats 0x9d9c93 at 12% are two dark grey-blues
 * four values apart, which is to say the coastline is gone, which is to say the
 * minimap at night is a black rectangle with some pins floating in it.
 *
 * So night is authored, not derived. Water goes almost to black and the land
 * goes *up* to a slate that is unambiguously lighter than it, the coastline
 * stroke gains alpha rather than losing it, and the districts stop being a
 * shadow and become the amber glow that a built-up area actually is from the
 * air after dark. It is not a photograph of night. It is a map that works at
 * night, which is the job.
 */
function buildTheme(pal: ScenePalette, night: number): Theme {
  const t = Math.min(1, Math.max(0, night));
  const accent = rgbOf(ACCENT);
  return {
    water: blend(pal.sea, 0x060d16, t),
    land: blend(mixHex(pal.sand, pal.flats, 0.45), 0x2c333b, t),
    park: blend(pal.park, 0x1b2a20, t),
    lake: blend(pal.lake, 0x0a1420, t),
    coast: blend(0x6c6a5f, 0x9fb6c8, t, 0.45 + 0.42 * t),
    street: blend(0x8b8578, 0x474e56, t, 0.5 + 0.28 * t),
    freeway: blend(0x6f6459, 0xc9a35f, t, 0.8),
    bridge: blend(0xc2622c, 0xe08a3c, t, 0.9),
    landmark: blend(0x4a4438, 0xd7c8a8, t, 0.6),
    frame: blend(0x1b2733, 0x9fb4c6, t, 0.3),
    // Downtown reads as a darkening by day and as light by night, because that
    // is what a dense district does to an aerial photograph in each case.
    districtRgb: mix(rgbOf(0x2b2419), accent, t),
    // Lower at night than by day, which is the opposite of what "districts glow
    // amber after dark" suggests and is right anyway: the districts blanket
    // most of the built Bay Area, so amber at the daytime alpha does not read
    // as glowing downtown, it reads as the whole board having been dipped in
    // tea. Measured — 0.095 amber over the night land turned slate 44,51,59
    // into khaki 62,62,58 everywhere at once. The weights carry the contrast.
    districtAlpha: 0.09 - 0.04 * t,
    shadeLit: mix(rgbOf(0xffffff), rgbOf(0x9db4c6), t),
    // Not black at night, and the shadow side gets barely half the alpha the
    // lit side does. Pure black at 0.4 over the night land was tried first and
    // it turned the Santa Cruz mountains and the Diablo range into two solid
    // voids — the same "everything is black" failure the 3D view is being fixed
    // for, reproduced in miniature. After dark the relief is carried by the
    // highlights and the shadows only hint.
    shadeDark: mix(rgbOf(0x2a2418), rgbOf(0x0d131a), t),
    // Tuned against Twin Peaks and the Diablo range at a 4-CSS-pixel lattice.
    // Any more gain and San Francisco's hills become a chrome relief map with
    // the districts and roads underneath unreadable.
    shadeAlphaLit: 0.5 - 0.1 * t,
    shadeAlphaDark: 0.45 - 0.24 * t,
    shadeGain: 1.9,
    // Kept deliberately faint, and fainter at night rather than stronger. On a
    // whole-board view the footprint covers most of the widget, and the same
    // alpha that is a hint over daytime sand is a colour cast over a near-black
    // one: at 0.14 the lit half of the night map came out khaki and the
    // unlit half slate, with a hard amber line between them, which reads as a
    // rendering fault rather than as a frustum. The outline carries the shape;
    // the fill only says which side of it you are on.
    footprintFill: rgba(accent, 0.11 - 0.045 * t),
    footprintStroke: rgba(accent, 0.8),
    camera: rgba(accent, 0.95),
    cameraEdge: blend(0x1a1206, 0x000000, t, 0.55),
    target: blend(0x14202b, 0xe8f1f8, t, 0.8),
    hover: blend(0x14202b, 0xe8f1f8, t, 0.45),
    pending: rgba(accent, 0.75),
    chapter: blend(0x1d2a35, 0xdfe9f1, t, 0.55),
    chapterActive: rgba(accent, 0.9),
    aircraft: blend(0x2b3138, 0xd8e4ee, t, 0.75),
    pinEdge: blend(0x101820, 0x05090d, t, 0.6),
    accentRgb: accent,
  };
}

function rgbOf(hex: number): Rgb {
  return { r: (hex >> 16) & 255, g: (hex >> 8) & 255, b: hex & 255 };
}

function mix(a: Rgb, b: Rgb, t: number): Rgb {
  return { r: a.r + (b.r - a.r) * t, g: a.g + (b.g - a.g) * t, b: a.b + (b.b - a.b) * t };
}

function mixHex(a: number, b: number, t: number): number {
  const c = mix(rgbOf(a), rgbOf(b), t);
  return (Math.round(c.r) << 16) | (Math.round(c.g) << 8) | Math.round(c.b);
}

/** Legacy comma syntax, not `rgb(r g b / a)`: canvas parsing, not CSS, is the floor here. */
function rgba(c: Rgb, alpha: number): string {
  return `rgba(${Math.round(c.r)}, ${Math.round(c.g)}, ${Math.round(c.b)}, ${alpha.toFixed(3)})`;
}

function blend(day: number, dark: number, t: number, alpha = 1): string {
  return rgba(mix(rgbOf(day), rgbOf(dark), t), alpha);
}

function cssHex(hex: number): string {
  return rgba(rgbOf(hex), 1);
}
