/**
 * The office, seen from straight above, in the same corner of the screen the
 * city plan lives in.
 *
 * `minimap.ts` answers "where am I on this board" and it answers it about a
 * board ninety-four kilometres across. Inside the building that widget is not
 * merely unhelpful, it is *wrong*: it goes on drawing the Bay Area while the
 * scene in front of it is a thirty-four-metre floor plate, so the one piece of
 * chrome whose entire job is to say where you are is pointing at another county.
 * This is the same widget for the other place — same corner, same `M` key, same
 * camera footprint, same click-to-seek — reading `Plan` instead of `World`.
 *
 * It is deliberately a second module rather than a mode inside the first. The
 * two share their *shape* and almost none of their content: one projects
 * lat/lng through a `World`, rasterises a coastline and a hillshade and follows
 * the sun; this one is already in metres, has walls instead of a shoreline, and
 * lives under a fixed interior rig where the sun does not reach. Threading both
 * through one file would mean a `if (city)` at the top of every function and a
 * theme that is two themes. What they genuinely share is copied, and the
 * comments say where the original is.
 *
 * The rules it keeps from `minimap.ts`, because they were earned there:
 *
 *   - **Canvas 2D, permanently.** A second WebGL context to draw a few hundred
 *     filled rectangles would double the driver-side cost of the page.
 *   - **Nothing here knows how big an office is.** Every coordinate comes from
 *     `plan.bounds`. A 12 m studio and a 60 m floor plate both fit.
 *   - **`tick()` runs inside the stage's frame loop**, allocates nothing in the
 *     steady state, and bails when neither the camera nor the data has moved.
 *     The plan itself is rasterised once into an offscreen surface and blitted.
 *
 * This module owns exactly one DOM node: the canvas it hands back.
 */

import * as THREE from "three";
import type { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { kit, type AssetRegistry } from "../assets/kit.ts";
import type { LevelPlan, Plan, ResolvedRoom } from "../interiors/plan.ts";
import type { Presence } from "../interiors/types.ts";

/** What the pointer is over, for a readout line the caller owns. */
export interface OfficePlanHoverInfo {
  /** Office-world metres. The same numbers a pack is authored in. */
  x: number;
  z: number;
  /** The room under the pointer, by name, or `null` out in the circulation. */
  room: string | null;
  /**
   * Who is at the desk under the pointer, by label, or `null`.
   *
   * A label and never an id. The plan is drawn from the pack, which knows seat
   * `eng-04` and nothing else; the name arrives separately over an authenticated
   * request, and handing back the id when there is nobody there would leak the
   * seating chart into a widget that is otherwise pure geometry.
   */
  person: string | null;
  /** The level being drawn, by name. Printed only when there is more than one. */
  level: string;
}

/**
 * One robot walking about the building, as this widget needs it.
 *
 * Structural, and deliberately *not* `RobotView` imported from
 * `interiors/robots.ts` — the same call `luminaires.ts` makes with its `Walker`,
 * and made here for a stronger reason. This file is drawn from a `Plan` and
 * nothing else; a type import from the robot layer would tie the widget's public
 * contract to a module it otherwise has no business knowing exists, and the next
 * thing that walks about a floor would have to be a robot to be drawable. Two
 * fields is the whole of what a mark on a floor plan needs. A `RobotView`
 * satisfies this as it stands and nothing has to be adapted.
 *
 * The robot's own `id` is read nowhere, on purpose. `drawOccupied` sets out why
 * the plan answers "is anybody there" rather than "who" even for people, and a
 * robot is further down that road again — `robots.ts` is explicit that a robot is
 * nobody, so there is not even a name to decline to print.
 */
export interface PlanRobot {
  /** Which storey it is on. It is drawn only while that storey is the one shown. */
  levelId: string;
  /**
   * Office-world metres, at its feet. **Live**: whoever owns the robot mutates
   * this vector in place every frame. This file reads it and never writes it.
   */
  position: THREE.Vector3;
}

export interface OfficeMinimapOptions {
  /** The resolved office. The same `Plan` the scene was built from, or the drawing lies. */
  plan: Plan;
  /** The live office camera. Read every frame, written only by the wheel dolly. */
  camera: THREE.PerspectiveCamera;
  /** The live orbit controls. `controls.target` is the crosshair. */
  controls: OrbitControls;
  /**
   * Where prop footprints come from. Defaults to the shared `kit`, which is what
   * `officeScene` defaults to as well — pass the same registry you passed the
   * scene, or a prop it knows and this does not comes out as a 0.6 m square.
   */
  registry?: AssetRegistry;
  /** Fires when the user clicks, drags or commits a keyboard seek. Office metres. */
  onSeek?(x: number, z: number): void;
  /** Fires on hover, and once with `null` when the pointer leaves. */
  onHover?(info: OfficePlanHoverInfo | null): void;
  /** Device-pixel-ratio ceiling. Matches `stage.ts`: above 2 the gain is not real. */
  maxPixelRatio?: number;
}

export interface OfficeMinimapPlayer {
  levelId: string;
  x: number;
  z: number;
  /** Radians in office X/Z space; zero faces local north (-Z). */
  headingRad: number;
  kind: "humanoid" | "anonymous-dog";
}

export interface OfficeMinimap {
  /** The widget. The caller inserts it into its own container and sizes it in CSS. */
  canvas: HTMLCanvasElement;
  /**
   * Which viewpoint the legend is showing as current, so the plan can ring the
   * same one. `null` rings nothing, which is the state between a `flyTo` being
   * asked for and arriving.
   */
  setActiveView(id: string | null): void;
  /**
   * Who is in, so the plan can mark their desks.
   *
   * Takes `Presence[]` rather than a set of seat ids so the hover readout can
   * name somebody without a second lookup by the caller. A presence whose seat
   * is not on this plan is dropped, exactly as `presence.ts` drops it in the
   * scene and for the same reason: there is nowhere to put it, and inventing a
   * spot would turn a private id into a public coordinate.
   */
  setPresence(people: readonly Presence[]): void;
  /**
   * The robots walking about the building, so the plan shows them moving.
   *
   * Shaped like `setPresence` — the caller hands over the domain objects and the
   * widget does its own resolving, rather than the caller pre-chewing them into
   * pixels — with one difference that comes out of the data and not out of
   * taste. Presence arrives from a poll every few seconds and each answer is a
   * *snapshot*, so `setPresence` does its work when it is called. The robot layer
   * publishes a stable array of vectors it mutates in place, so this is called
   * **once**, with that array, and every frame afterwards is read straight out of
   * it by `tick`. That is the same handshake `officeScene` already makes with
   * `luminaires.setWalkers`, and it is what lets the plan show something moving
   * at sixty hertz without anybody allocating anything.
   *
   * Calling it every frame is harmless — it costs one reference compare — so a
   * caller that would rather push than be read is not punished for it. Handing
   * over a *different* array drops the old one, and the new robots have no
   * heading until they have taken a step.
   */
  setRobots(robots: readonly PlanRobot[]): void;
  setPlayer(player: OfficeMinimapPlayer | null): void;
  /** Call from the stage tick. Cheap by construction — see the file header. */
  tick(): void;
  /** Re-do the backing store at the current size and re-rasterise the plan. */
  resize(): void;
  dispose(): void;
}

/** See `minimap.ts`: the 2D context union will not resolve overloads. One cast, one type. */
type Ctx = CanvasRenderingContext2D;
type Surface = HTMLCanvasElement | OffscreenCanvas;

/** Redraw ceiling, in ms. The stage runs at 60; the footprint does not need to. */
const FRAME_MS = 33;
/** How long the seek confirmation ring lives, in ms. Suppressed for reduced motion. */
const PING_MS = 420;
/** The interface's accent, as `index.html` sets it. Camera, footprint, active viewpoint. */
const ACCENT = 0xf2b134;

/**
 * A prop smaller than this on the longer axis is not drawn.
 *
 * Half the props in the reference pack are mugs, monitors, plants and desk
 * tidies. At the widget's scale — about seven device pixels to the metre on a
 * 14 rem frame over a 34 m floor — a 0.25 m object is under two pixels, so it
 * contributes no shape, only a speckle over the desks that reads as noise on
 * the one drawing whose job is legibility. The threshold is in metres rather
 * than in pixels on purpose: a plan that gains and loses its furniture as the
 * panel is resized is worse than one that draws a stable subset.
 */
/**
 * The riser height the plan's tread ticks are spaced at, in metres.
 *
 * `TARGET_RISER_M` in `src/interiors/shell.ts`, restated rather than imported:
 * this widget draws no meshes and has no business importing the file that does.
 * Fourteen ticks on a flight means fourteen risers on the same flight in the
 * scene, and `src/test/render/stairRisers.test.ts` is where the two are pinned
 * to each other.
 */
const MINIMAP_RISER_M = 0.178;

const MIN_PROP_M = 0.35;

/** Props standing above head height are fittings, not furniture. See `drawProps`. */
const MAX_PROP_ELEVATION_M = 1.6;

/**
 * The empty robot list, shared and frozen by convention.
 *
 * Module-level so that an office with no robots — which is every pack that does
 * not ask for them, and the default — never allocates for the feature at all.
 * What it pays instead is one `length === 0` test per frame in three functions.
 */
const NO_ROBOTS: readonly PlanRobot[] = [];

export function createOfficeMinimap(options: OfficeMinimapOptions): OfficeMinimap {
  const { plan, camera, controls } = options;
  const registry = options.registry ?? kit;
  const maxPixelRatio = options.maxPixelRatio ?? 2;

  const canvas = document.createElement("canvas");
  canvas.className = "minimap-canvas";
  canvas.tabIndex = 0;
  canvas.setAttribute("role", "application");
  canvas.setAttribute(
    "aria-label",
    `Floor plan of ${plan.office.name}. Click or drag to move the view, scroll to zoom, ` +
      `arrow keys to aim and Enter to go.`,
  );
  // Without this a drag on a touch screen scrolls the page out from under the
  // pointer capture and the seek stops mid-gesture.
  canvas.style.touchAction = "none";
  // The widget takes its size from its container, and it has to — see the long
  // note in `minimap.ts` about the backing store / layout feedback loop this
  // breaks. The container must have a real height.
  canvas.style.display = "block";
  canvas.style.width = "100%";
  canvas.style.height = "100%";

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

  /** The plan is rasterised once into its own surface and blitted under the overlay. */
  const staticSurface: Surface =
    typeof OffscreenCanvas === "function"
      ? new OffscreenCanvas(1, 1)
      : document.createElement("canvas");
  const staticCtx = staticSurface.getContext("2d") as Ctx | null;

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

  /**
   * The extent, from the office's own bounds, in office-world metres.
   *
   * Scene +x is right and +z is *down* the drawing, which is the office pack's
   * own convention — `Yaw` zero faces -Z, so -Z is the top of the plan — and it
   * happens to match the city widget's north-up orientation exactly. Nothing is
   * negated anywhere in this file, and that is why.
   *
   * A small margin, because a building whose outer wall is exactly on the board
   * edge loses half that wall's thickness to the clip.
   */
  const margin = Math.max(0.4, Math.max(plan.bounds.width, plan.bounds.depth) * 0.02);
  const westX = plan.bounds.minX - margin;
  const northZ = plan.bounds.minZ - margin;
  const boardW = Math.max(1e-3, plan.bounds.width + margin * 2);
  const boardH = Math.max(1e-3, plan.bounds.depth + margin * 2);

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

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

  // ---- State ----------------------------------------------------------------

  /**
   * The storey being drawn.
   *
   * A one-level pack — which the reference office is, and which nearly every
   * pack will be — never changes this. A stacked pack does, and it is chosen by
   * where the camera is *looking* rather than where it is standing: on a
   * mezzanine the camera is routinely a storey above the floor it is showing
   * you, so `camera.position.y` would draw the wrong plan for the whole of a
   * viewpoint that is framed correctly.
   */
  let level: LevelPlan | null = plan.levels[0] ?? null;
  let activeViewId: string | null = null;
  let player: OfficeMinimapPlayer | null = null;
  /** Occupied seats on this storey: x, y device pixels per person, laid out once. */
  let occupiedPx = new Float64Array(0);
  /** Seat id -> label, for the hover readout. Every seat in the building, not just this storey. */
  let peopleBySeat = new Map<string, string>();
  /**
   * The robots, live. The array belongs to whoever called `setRobots` and its
   * contents change underneath this file between one draw and the next.
   */
  let robotList: readonly PlanRobot[] = NO_ROBOTS;
  /**
   * Where each robot was as of the last draw — office metres, x then z — and the
   * unit direction it was last seen travelling in, again x then z. Two flat
   * arrays rather than an array of objects, for the reason every other buffer in
   * this file is flat: the draw loop may not allocate and may not chase pointers.
   *
   * **The heading is derived here rather than published by the layer**, which
   * looks like a gap and is not one. A `RobotView` carries a position and no yaw;
   * the layer knows its yaw perfectly well and simply does not hand it out, and
   * asking it to would be a change to a contract that three other callers read.
   * Differencing two positions recovers the heading to better than a pixel: the
   * layer advances a robot *exactly* along its own yaw — `x -= sin(yaw) · ds`,
   * `z -= cos(yaw) · ds` — so the step between two draws **is** the yaw, one
   * redraw stale, which at this widget's 30 Hz ceiling and the layer's 2.2 rad/s
   * turn rate is under four degrees. Four degrees on a mark five pixels long is
   * not visible.
   *
   * The one case where the derived heading and the rig's yaw genuinely part
   * company is a robot rotating while barely moving — yielding to another robot,
   * or pivoting into a doorway with its pace scaled to nearly nothing. Then this
   * keeps pointing the way the machine last actually went, which is the better
   * answer for a plan: a plan records what happened on the floor, not what a
   * transform is doing this instant.
   */
  let robotLast = new Float64Array(0);
  let robotDir = new Float64Array(0);

  // Laid-out geometry. Flat arrays and paths of device pixels, rebuilt on resize
  // and on a change of storey, so the draw loop reads numbers and never projects.
  let roomPaths: { path: Path2D; open: boolean }[] = [];
  let zonePath = new Path2D();
  let wallPath = new Path2D();
  let glazingPath = new Path2D();
  let propPath = new Path2D();
  let stairPath = new Path2D();
  let labels: { text: string; x: number; y: number }[] = [];
  /** Viewpoint pins on this storey: x, y device pixels, then the index into `viewpoints`. */
  let viewpointPx = new Float64Array(0);
  let viewpointIds: string[] = [];

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

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

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

  // Camera state as of the last draw, for the bail-out. Compared exactly rather
  // than with an epsilon, for the reason `minimap.ts` sets out: OrbitControls'
  // damping asymptotes, and a footprint frozen a few frames early on a
  // still-drifting map is the kind of small wrongness that reads as a fault.
  let lastCamX = NaN;
  let lastCamY = NaN;
  let lastCamZ = NaN;
  let lastTgtX = NaN;
  let lastTgtY = NaN;
  let lastTgtZ = NaN;
  let lastFov = NaN;
  let lastAspect = NaN;

  // Scratch for the frustum corners. Allocated once; the draw loop may not
  // allocate.
  const corners = [
    new THREE.Vector3(),
    new THREE.Vector3(),
    new THREE.Vector3(),
    new THREE.Vector3(),
  ];
  /** NDC corners, bottom-left first, so the quad comes out wound consistently. */
  const NDC_X = [-1, 1, 1, -1];
  const NDC_Y = [-1, -1, 1, 1];

  const theme = buildTheme();

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

  /**
   * Fit the floor plate inside the widget, letterboxed, never stretched.
   *
   * The reference office is 34 x 18 metres — very nearly two to one — and
   * squeezing that into a square frame is instantly wrong to anyone who has
   * stood in the room. Whatever is left over stays transparent so the panel's
   * own card background shows through, exactly as the city widget does.
   */
  function layout() {
    const pad = Math.round(2 * dpr);
    const availW = Math.max(1, pxW - pad * 2);
    const availH = Math.max(1, pxH - pad * 2);
    scale = Math.min(availW / boardW, availH / boardH);
    boardPxW = boardW * scale;
    boardPxH = boardH * scale;
    boardX = pad + (availW - boardPxW) / 2;
    boardY = pad + (availH - boardPxH) / 2;
  }

  /**
   * Which storey the camera is looking at.
   *
   * Nearest floor at or below the orbit target, falling back to the nearest
   * floor outright, so a target that has drifted under the slab still resolves
   * to the ground floor rather than to nothing.
   */
  function levelForCamera(): LevelPlan | null {
    if (plan.levels.length <= 1) return plan.levels[0] ?? null;
    const y = controls.target.y;
    let best: LevelPlan | null = null;
    let bestGap = Infinity;
    for (const candidate of plan.levels) {
      const gap = Math.abs(candidate.floorY - y);
      const below = candidate.floorY <= y + 0.01;
      // A storey you are standing on beats one you are under, at any distance:
      // `below` is preferred outright and distance only breaks the tie.
      const score = below ? gap : gap + 1e6;
      if (score < bestGap) {
        bestGap = score;
        best = candidate;
      }
    }
    return best ?? plan.levels[0] ?? null;
  }

  // ---- Building the drawing --------------------------------------------------

  function outlinePath(outline: readonly { x: number; z: number }[]): Path2D {
    const path = new Path2D();
    outline.forEach((point, i) => {
      const x = toPxX(point.x);
      const y = toPxY(point.z);
      if (i === 0) path.moveTo(x, y);
      else path.lineTo(x, y);
    });
    path.closePath();
    return path;
  }

  /**
   * A rectangle standing in the office, as a path in device pixels.
   *
   * `w` runs along the object's local +X and `d` along its local +Z. For yaw φ
   * those axes point at `(cos φ, -sin φ)` and `(sin φ, cos φ)` — three.js's
   * rotation about +Y, which is what `Yaw` promises and what `plan.ts` computes
   * its wall yaws with. Getting this sign wrong mirrors every desk in the
   * building about its own centre, which is invisible on a square and obvious on
   * a wall run.
   */
  function boxPath(path: Path2D, cx: number, cz: number, w: number, d: number, yaw: number) {
    const c = Math.cos(yaw);
    const s = Math.sin(yaw);
    const hw = w / 2;
    const hd = d / 2;
    for (let i = 0; i < 4; i++) {
      const u = i === 0 || i === 3 ? -hw : hw;
      const v = i < 2 ? -hd : hd;
      const x = cx + u * c + v * s;
      const z = cz - u * s + v * c;
      if (i === 0) path.moveTo(toPxX(x), toPxY(z));
      else path.lineTo(toPxX(x), toPxY(z));
    }
    path.closePath();
  }

  function buildGeometry() {
    roomPaths = [];
    zonePath = new Path2D();
    wallPath = new Path2D();
    glazingPath = new Path2D();
    propPath = new Path2D();
    stairPath = new Path2D();
    labels = [];
    viewpointPx = new Float64Array(0);
    viewpointIds = [];
    if (!level || scale <= 0) return;

    // Rooms, in pack order, because that is the order they are drawn in the
    // scene: the open floor is laid down first and the meeting rooms sit on top.
    for (const room of level.rooms) {
      roomPaths.push({ path: outlinePath(room.outline), open: room.ceiling === null });
    }

    for (const zone of level.zones) {
      const path = outlinePath(zone.outline);
      // One path for every zone rather than one per zone: they are drawn in a
      // single flat tint, so the only thing separate paths would buy is the
      // ability to tint them differently, which needs a palette this widget
      // deliberately does not have. Overlapping zones double the tint; the pack
      // that does that is describing overlapping zones.
      zonePath.addPath(path);
    }

    /**
     * Walls, as the solid runs only.
     *
     * `Plan` has already split every wall around its openings, so taking the
     * `solid` runs and ignoring the lintels and aprons leaves a gap at every
     * door, window and arch — which is exactly how a floor plan is drawn, and
     * it costs nothing because the decomposition was done for the collider
     * anyway. Nothing here re-derives where a hole is.
     */
    for (const run of level.runs) {
      if (run.role !== "solid") continue;
      boxPath(wallPath, run.center.x, run.center.z, run.length, run.thickness, run.yaw);
    }

    /**
     * Glazing, as a thin line across the hole it fills.
     *
     * Only windows. A door and an arch are gaps you walk through and the gap is
     * the drawing; a window is a gap you cannot, and leaving it blank breaks the
     * building's outline into disconnected stubs — the reference office is
     * glazed along its whole north edge, so without this the top wall simply is
     * not there.
     */
    for (const opening of level.openings) {
      if (opening.kind !== "window") continue;
      boxPath(
        glazingPath,
        opening.center.x,
        opening.center.z,
        opening.width,
        // A hairline in metres, so it stays a hairline at every widget size
        // rather than swelling into a second wall on a wide panel.
        Math.min(opening.thickness, 0.06),
        opening.yaw,
      );
    }

    /**
     * Furniture, at its real footprint.
     *
     * The registry already knows how big every asset is — it has to, to build
     * them — so a desk on this plan is the desk's own width and depth turned by
     * its own yaw, not a generic dot. That is the difference between a diagram
     * of a floor and a picture of a floor: four benches of twelve read as four
     * benches of twelve, and the circulation between them is the space that is
     * actually there.
     *
     * Props are drawn without their `params`, because a `PropPlacement` does not
     * carry any — the pack's props are placed by id and take the asset's
     * defaults. An asset whose footprint depends on parameters it was never
     * given comes out at its default size, which is the same size the scene
     * builds it at.
     */
    for (const prop of level.props) {
      // Wall-mounted screens, ceiling fittings and anything else off the floor.
      // They are above where a plan is cut, and drawing them puts a solid
      // rectangle over the room they hang in.
      if (prop.position.y - level.floorY > MAX_PROP_ELEVATION_M) continue;
      const footprint = registry.footprintOf(prop.kind);
      const w = footprint.width * prop.scale[0];
      const d = footprint.depth * prop.scale[2];
      if (Math.max(w, d) < MIN_PROP_M) continue;
      boxPath(propPath, prop.position.x, prop.position.z, w, d, prop.rotation);
    }

    /**
     * The ways up, drawn the way a floor plan draws them: treads.
     *
     * Both ends of a transition sit at the same place in plan — a staircase is
     * one object seen from two storeys — so the flight is drawn on *either*
     * level that touches it, which is also what a real drawing set does. The
     * ticks are the shell's own tread spacing rather than a decorative hatch, so
     * a plan with fourteen ticks on a flight is a flight with fourteen risers.
     *
     * It exists for one reason: on the storey below, this is the only thing on
     * the plan that says there *is* a way up, and a walker who cannot find the
     * stair is in the same position as one the engine refuses to carry.
     */
    for (const transition of plan.transitions) {
      if (transition.lower.levelId !== level.id && transition.upper.levelId !== level.id) continue;
      for (let index = 1; index < transition.path.length; index += 1) {
        const from = transition.path[index - 1]!;
        const to = transition.path[index]!;
        const dx = to.x - from.x;
        const dz = to.z - from.z;
        const run = Math.hypot(dx, dz);
        if (run < 1e-4) continue;
        const yaw = Math.atan2(-dz, dx) + 0;
        const rise = to.y - from.y;
        // A flat leg is a landing: one rectangle, no ticks.
        const steps = rise <= 1e-4
          ? 1
          : Math.min(40, Math.max(2, Math.round(rise / MINIMAP_RISER_M)));
        const going = run / steps;
        for (let step = 0; step < steps; step += 1) {
          const along = rise <= 1e-4 ? run / 2 : going * (step + 0.5);
          boxPath(
            stairPath,
            from.x + (dx / run) * along,
            from.z + (dz / run) * along,
            rise <= 1e-4 ? run : Math.max(going * 0.72, MIN_PROP_M),
            transition.width,
            yaw,
          );
        }
      }
    }

    layoutLabels();
    layoutViewpoints();
    layoutOccupied();
  }

  /**
   * Room names, where they fit.
   *
   * Measured against the room's own bounds and dropped when they do not fit,
   * rather than shrunk or ellipsised. A plan with six names on it is read; a
   * plan with fifteen names on it, four of them clipped and two overlapping, is
   * looked at and then ignored. Which six survive is decided by the geometry and
   * therefore changes with the panel width, which is correct: a wider panel has
   * room for more of them.
   */
  function layoutLabels() {
    if (!staticCtx || !level) return;
    const ctx = staticCtx;
    ctx.save();
    ctx.font = labelFont(dpr);
    for (const room of level.rooms) {
      const w = ctx.measureText(room.name).width;
      const boxW = (room.bounds.maxX - room.bounds.minX) * scale;
      const boxH = (room.bounds.maxZ - room.bounds.minZ) * scale;
      if (w > boxW * 0.88 || boxH < 11 * dpr) continue;
      labels.push({
        text: room.name,
        x: toPxX(room.centroid.x),
        y: toPxY(room.centroid.z),
      });
    }
    ctx.restore();
  }

  /**
   * Where the occupied desks are, in device pixels.
   *
   * Resolved through `plan.seat()` rather than through the caller, because the
   * seat's position is the plan's fact and a second copy of it would be a second
   * thing to get wrong. Seats on another storey resolve fine and are skipped
   * here — they are drawn when that storey is.
   */
  function layoutOccupied() {
    if (!level || scale <= 0) return;
    const points: number[] = [];
    for (const seatId of peopleBySeat.keys()) {
      const seat = plan.seat(seatId);
      if (!seat || seat.levelId !== level.id) continue;
      points.push(toPxX(seat.position.x), toPxY(seat.position.z));
    }
    occupiedPx = new Float64Array(points);
  }

  function layoutViewpoints() {
    if (!level) return;
    const here = plan.viewpoints.filter((v) => v.levelId === level?.id);
    viewpointPx = new Float64Array(here.length * 2);
    viewpointIds = here.map((v) => v.id);
    here.forEach((v, i) => {
      viewpointPx[i * 2] = toPxX(v.focus.at.x);
      viewpointPx[i * 2 + 1] = toPxY(v.focus.at.z);
    });
  }

  // ---- The static plan --------------------------------------------------------

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

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

    for (const room of roomPaths) {
      // An atrium — a room the pack said explicitly has no ceiling — is drawn
      // lighter, because from above it is the one part of the floor you can
      // actually see into. It is the only room distinction this widget makes,
      // and it is made from a fact in the data rather than from a name.
      ctx.fillStyle = room.open ? theme.atrium : theme.floor;
      ctx.fill(room.path);
    }

    ctx.fillStyle = theme.zone;
    ctx.fill(zonePath);

    ctx.strokeStyle = theme.roomEdge;
    ctx.lineWidth = dpr;
    for (const room of roomPaths) ctx.stroke(room.path);

    // Under the props, so a handrail or a planter at the head of a flight still
    // reads as furniture standing on it.
    ctx.fillStyle = theme.prop;
    ctx.fill(stairPath);
    ctx.fillStyle = theme.prop;
    ctx.fill(propPath);
    ctx.strokeStyle = theme.propEdge;
    ctx.lineWidth = dpr * 0.6;
    ctx.stroke(propPath);

    ctx.fillStyle = theme.glazing;
    ctx.fill(glazingPath);

    // Walls last, over everything. A desk pushed against a partition should be
    // clipped by it rather than drawn across it, and the wall is the line the
    // eye uses to find the room.
    ctx.fillStyle = theme.wall;
    ctx.fill(wallPath);

    /**
     * Names last, over a halo.
     *
     * The halo is not decoration. A room's centroid is very often the middle of
     * its furniture — "The Floor" centres on a desk bank, which is the whole
     * point of a desk bank — so a name drawn flat lands on top of the one part
     * of the drawing with the most edges in it and becomes unreadable exactly
     * where it is most needed. Stroking the ground colour behind the glyphs
     * buys the contrast back without moving the label somewhere it does not
     * belong, which is the alternative and is worse: a name floating in the
     * corridor beside its room is a name attached to the wrong room.
     */
    ctx.font = labelFont(dpr);
    ctx.textAlign = "center";
    ctx.textBaseline = "middle";
    ctx.lineWidth = 3 * dpr;
    ctx.lineJoin = "round";
    ctx.strokeStyle = theme.labelHalo;
    ctx.fillStyle = theme.label;
    for (const label of labels) {
      ctx.strokeText(label.text, label.x, label.y);
      ctx.fillText(label.text, label.x, label.y);
    }

    ctx.restore();

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

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

  /**
   * The footprint: where the camera's frustum meets *this storey's floor*.
   *
   * The city widget intersects with y = 0 because a city's ground is y = 0. An
   * office's is `level.floorY`, and on a stacked pack the difference is a whole
   * storey — a footprint drawn against the wrong plane is offset by the camera's
   * height over the storey gap, which is a large and confidently-wrong number.
   *
   * The clamp on rays that are not heading downward is `minimap.ts`'s and is
   * kept for the same reason: solving `t = -h / dir.y` for an upward ray gives a
   * negative `t`, which puts the corner behind the camera and turns the
   * trapezoid into a bow-tie that flickers across the plan every time you tilt
   * up. Indoors this happens constantly, because a viewpoint two metres off the
   * floor looking across the room has most of its frustum above the floor plane.
   */
  function drawFootprint(ctx: Ctx) {
    if (!level) return;
    const height = camera.position.y - level.floorY;
    if (!(height > 0.01)) return;
    camera.updateMatrixWorld();
    const maxRay = Math.max(boardW, boardH) * 4;

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

  /** A chevron at the camera, pointing the way it is looking. */
  function drawCamera(ctx: Ctx) {
    const x = toPxX(camera.position.x);
    const y = toPxY(camera.position.z);
    const dx = controls.target.x - camera.position.x;
    const dz = controls.target.z - camera.position.z;
    const len = Math.hypot(dx, dz);
    if (!(len > 1e-6)) return;
    const nx = dx / len;
    const ny = dz / len;
    const sx = -ny;
    const sy = nx;
    const s = 4.6 * dpr;

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

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

  /** The pack's viewpoints, as the same dots the city widget gives its chapters. */
  function drawViewpoints(ctx: Ctx) {
    ctx.lineWidth = 1.2 * dpr;
    for (let i = 0; i < viewpointIds.length; i++) {
      const x = viewpointPx[i * 2] ?? 0;
      const y = viewpointPx[i * 2 + 1] ?? 0;
      ctx.beginPath();
      ctx.arc(x, y, 1.7 * dpr, 0, Math.PI * 2);
      ctx.fillStyle = theme.viewpoint;
      ctx.fill();
      if (viewpointIds[i] === activeViewId) {
        ctx.beginPath();
        ctx.arc(x, y, 4.6 * dpr, 0, Math.PI * 2);
        ctx.strokeStyle = theme.viewpointActive;
        ctx.stroke();
      }
    }
  }

  /**
   * An occupied desk, as a filled dot with a dark rim.
   *
   * Drawn in the overlay rather than into the static raster, because occupancy
   * is the one thing on this plan that changes without the building changing —
   * re-rasterising fifteen rooms and two hundred props to move one dot would be
   * the wrong trade by three orders of magnitude.
   *
   * One colour for everybody, deliberately, where the scene has four. The scene
   * has the room to distinguish heads-down from in-a-meeting and this does not:
   * at three device pixels a hue is a guess, and four guesses on one plan is a
   * legend nobody asked for. The plan answers "is anyone there", the room
   * answers "who, and what are they doing".
   */
  function drawOccupied(ctx: Ctx) {
    if (occupiedPx.length === 0) return;
    const r = 2.4 * dpr;
    ctx.lineWidth = dpr;
    ctx.fillStyle = theme.occupied;
    ctx.strokeStyle = theme.occupiedEdge;
    for (let i = 0; i < occupiedPx.length; i += 2) {
      ctx.beginPath();
      ctx.arc(occupiedPx[i] ?? 0, occupiedPx[i + 1] ?? 0, r, 0, Math.PI * 2);
      ctx.fill();
      ctx.stroke();
    }
  }

  /**
   * A robot, as a turned chassis with a bow on the front.
   *
   * **The shape carries this, not the colour.** `drawOccupied` has already
   * established that a hue is a guess at three device pixels, and it is right; a
   * robot drawn as a differently-tinted dot is a person to anybody who has not
   * been told otherwise, and this widget has no legend to tell them with. So the
   * marker is built out of the one channel that survives at five pixels —
   * silhouette — and the plan's silhouettes are a small closed vocabulary:
   *
   *   - a **circle** is somebody: an occupied desk, or a viewpoint pin;
   *   - an **axis-aligned rectangle** is the building or its furniture, drawn
   *     once into the raster and never moving again;
   *   - a **notched amber chevron** is the camera, and there is exactly one.
   *
   * A robot is therefore a *turned* rectangle with a point on the front. Hard
   * corners, so it reads machined rather than grown. Wider across than it is
   * deep, so the turn is visible at all and the thing has shoulders. Convex,
   * unnotched, cool and about 60% of the linear size of the chevron, so it is
   * never mistaken for the camera — which is still this widget's first job.
   *
   * A plain square was the first attempt and is useless twice over: four-fold
   * symmetry means turning it conveys nothing, so the heading has to be a second
   * mark stuck on the outside, and a square sitting unturned among the desks is a
   * desk. A detached tick ahead of the body was the second attempt, and two
   * pixels of ink with a gap in front of them reads as dirt on the screen rather
   * than as a nose. Folding the point into the body path costs no extra ink, no
   * extra fill, and cannot come adrift from the thing it belongs to.
   *
   * The colour is a mint green — the third hue on the drawing, after the
   * people-blue and the camera-amber, and the last one this plan will get. Green
   * is the furthest free hue from both of them; it is the brightest mark per unit
   * of ink on a near-black ground, because luminance lives mostly in the green
   * channel, which is what something moving among a hundred static grey
   * rectangles wants; and it is already the colour a viewer reads as a machine
   * that is running. Its riskiest confusion is with the camera's amber, since
   * red-green colour blindness pulls both toward yellow — which is precisely the
   * pair separated by silhouette and by size above, and is why the shape had to
   * do the work first and the hue second.
   */
  function drawRobots(ctx: Ctx) {
    if (robotList.length === 0 || !level) return;
    // Half the beam, the distance from the middle to the transom, and the point
    // out in front of it. A touch smaller than the occupied dot on purpose: there
    // are only ever a few of these, they are the only thing on the plan that
    // moves, and a moving mark of a given size already shouts louder than a still
    // one.
    const half = 2.5 * dpr;
    const rear = 1.7 * dpr;
    const bow = 2.3 * dpr;
    ctx.lineWidth = dpr;
    ctx.fillStyle = theme.robot;
    ctx.strokeStyle = theme.robotEdge;
    for (let i = 0; i < robotList.length; i++) {
      const robot = robotList[i];
      // The level test is the whole of the storey handling, and it is per-draw
      // rather than laid out like `occupiedPx` because a robot moves and a seat
      // does not: there is nothing to cache that would still be true next frame.
      if (!robot || robot.levelId !== level.id) continue;
      const x = toPxX(robot.position.x);
      const y = toPxY(robot.position.z);
      // A direction in office metres is already a direction on the drawing —
      // `toPxX` and `toPxY` are the same positive scale on both axes with no
      // negation anywhere, which the header explains at length. `drawCamera`
      // leans on the same fact and the two would break together if the plan were
      // ever mirrored.
      const fx = robotDir[i * 2] ?? 0;
      const fy = robotDir[i * 2 + 1] ?? 0;
      // Both zero only before a robot's first step: `recordRobots` writes a unit
      // vector or nothing at all.
      const known = fx !== 0 || fy !== 0;
      const nx = known ? fx : 0;
      const ny = known ? fy : 1;
      // Starboard, from forward. Same derivation as the camera chevron's.
      const sx = -ny;
      const sy = nx;
      // With no heading yet the body is drawn as a square and keeps its bow: a
      // rectangle turned some arbitrary way is a claim about which way a machine
      // is pointing, and this is the one state — a robot that has not moved since
      // it was handed over — where there is honestly nothing to claim.
      const back = known ? rear : half;
      ctx.beginPath();
      ctx.moveTo(x - nx * back - sx * half, y - ny * back - sy * half);
      ctx.lineTo(x + nx * back - sx * half, y + ny * back - sy * half);
      if (known) ctx.lineTo(x + nx * (back + bow), y + ny * (back + bow));
      ctx.lineTo(x + nx * back + sx * half, y + ny * back + sy * half);
      ctx.lineTo(x - nx * back + sx * half, y - ny * back + sy * half);
      ctx.closePath();
      ctx.fill();
      // The ground colour, hairline, exactly as an occupied desk gets: a machine
      // crossing a desk bank has to keep its outline against the furniture it is
      // walking over, and the fill alone does not manage it.
      ctx.stroke();
    }
  }

  function drawPlayer(ctx: Ctx) {
    if (!player || !level || player.levelId !== level.id) return;
    const x = toPxX(player.x);
    const y = toPxY(player.z);
    const nx = -Math.sin(player.headingRad);
    const ny = -Math.cos(player.headingRad);
    const sx = -ny;
    const sy = nx;
    const r = (player.kind === "anonymous-dog" ? 3.6 : 4.2) * dpr;
    ctx.beginPath();
    ctx.arc(x, y, r + 2.5 * dpr, 0, Math.PI * 2);
    ctx.strokeStyle = theme.viewpointActive;
    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.65 + sx * r * 0.65, y - ny * r * 0.65 + sy * r * 0.65);
    ctx.lineTo(x - nx * r * 0.65 - sx * r * 0.65, y - ny * r * 0.65 - sy * r * 0.65);
    ctx.closePath();
    ctx.fillStyle = theme.viewpointActive;
    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(pingX), toPxY(pingZ), (3 + 13 * t) * dpr, 0, Math.PI * 2);
    ctx.strokeStyle = rgba(theme.accentRgb, 0.75 * (1 - t));
    ctx.lineWidth = 1.4 * dpr;
    ctx.stroke();
  }

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

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

    drawFootprint(ctx);
    drawOccupied(ctx);
    drawViewpoints(ctx);
    // Over the furniture, the desks and the viewpoint pins, and under the
    // crosshair and the camera. A robot standing on a viewpoint is the thing you
    // want to see; the camera is the thing you want to see over everything, and
    // that has been the order here since the widget was one function.
    drawRobots(ctx);
    drawPlayer(ctx);
    crosshair(ctx, toPxX(controls.target.x), toPxY(controls.target.z), theme.target, 5 * dpr);
    drawCamera(ctx);
    if (pendingX >= 0) crosshair(ctx, pendingX, pendingY, theme.pending, 7 * dpr);
    if (hoverX >= 0) crosshair(ctx, hoverX, hoverY, theme.hover, 6 * dpr);
    drawPing(ctx, now);

    ctx.restore();
  }

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

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

  /**
   * True when a robot on the storey being drawn has moved since the last draw.
   *
   * Split from `recordRobots` exactly as `cameraMoved` is split from
   * `recordCamera`, and compared exactly rather than with an epsilon for the
   * reason given there and one of its own: a robot eases into its destination
   * over the last 0.9 m, so its final frames are fractions of a millimetre, and
   * any tolerance worth having would strand the marker short of where the figure
   * in the scene is standing.
   *
   * **Only the storey being drawn counts.** A robot pacing about a mezzanine
   * nobody is looking at must not hold this widget open at thirty frames a second
   * for the whole session, drawing nothing, which is exactly what it would do if
   * this looked at all of them.
   */
  function robotsMoved(): boolean {
    if (robotList.length === 0 || !level) return false;
    for (let i = 0; i < robotList.length; i++) {
      const robot = robotList[i];
      if (!robot || robot.levelId !== level.id) continue;
      if (robot.position.x !== robotLast[i * 2]) return true;
      if (robot.position.z !== robotLast[i * 2 + 1]) return true;
    }
    return false;
  }

  /**
   * Take the positions this draw is about to use, and turn the step since the
   * last one into a heading.
   *
   * Every robot and not only the visible ones, unlike `robotsMoved`. The
   * alternative is that a robot on another storey keeps whatever position it had
   * when that storey was last on screen, and the first frame after changing
   * floors derives its heading from a stride several metres long taken minutes
   * ago — a marker confidently pointing across the building. A handful of robots
   * is a handful of subtractions; being clever here would cost more to explain
   * than to skip.
   *
   * A zero step leaves the heading alone rather than clearing it. That is what
   * lets a robot that has stopped keep facing the way it arrived instead of
   * losing its nose every time it pauses for a few seconds, which is most of the
   * time — and the figure in the scene does exactly the same thing, because the
   * rig's yaw is not reset when it halts either.
   */
  function recordRobots() {
    for (let i = 0; i < robotList.length; i++) {
      const robot = robotList[i];
      if (!robot) continue;
      const x = robot.position.x;
      const z = robot.position.z;
      // NaN on the first pass after `setRobots`, which is deliberate and is why
      // `robotLast` is filled with it: `NaN > 1e-6` is false, so the first draw
      // records a position and claims no heading from it.
      const dx = x - (robotLast[i * 2] ?? NaN);
      const dz = z - (robotLast[i * 2 + 1] ?? NaN);
      const step = Math.hypot(dx, dz);
      if (step > 1e-6) {
        robotDir[i * 2] = dx / step;
        robotDir[i * 2 + 1] = dz / step;
      }
      robotLast[i * 2] = x;
      robotLast[i * 2 + 1] = z;
    }
  }

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

  /**
   * Pointer client coordinates to device pixels on the backing store, via the
   * bounding rect's own ratio rather than `dpr` — see `minimap.ts`. The two
   * differ under a CSS transform or browser page zoom, and in a 34 m room a
   * seek a few metres from where you clicked lands in the wrong room.
   */
  function eventToPx(event: PointerEvent | WheelEvent): [number, number] {
    const rect = canvas.getBoundingClientRect();
    const kx = rect.width > 0 ? pxW / rect.width : dpr;
    const ky = rect.height > 0 ? pxH / rect.height : dpr;
    return [(event.clientX - rect.left) * kx, (event.clientY - rect.top) * ky];
  }

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

  function roomAt(x: number, z: number): ResolvedRoom | null {
    if (!level) return null;
    return plan.roomAt(level.id, { x, z });
  }

  /**
   * Whoever is sitting within a desk's width of the pointer, or nobody.
   *
   * A radius rather than a hit test on the seat itself, because a seat is a
   * point and a pointer on a 14 rem widget is worth about fifteen centimetres of
   * office. Three quarters of a metre is close enough to be unambiguous — desks
   * in a bench are 1.7 m apart — and forgiving enough to be usable.
   *
   * Linear over the occupied seats, which is the right algorithm at this size: a
   * full floor is a few dozen people, this runs on pointer moves already
   * throttled by the browser, and a spatial index would be more code than the
   * thing it indexes.
   */
  function personNear(x: number, z: number): string | null {
    if (!level) return null;
    const reach = 0.75;
    let best: string | null = null;
    let bestGap = reach * reach;
    for (const [seatId, label] of peopleBySeat) {
      const seat = plan.seat(seatId);
      if (!seat || seat.levelId !== level.id) continue;
      const dx = seat.position.x - x;
      const dz = seat.position.z - z;
      const gap = dx * dx + dz * dz;
      if (gap < bestGap) {
        bestGap = gap;
        best = label;
      }
    }
    return best;
  }

  function seekTo(px: number, py: number) {
    if (!ready) return;
    const x = clampX(px);
    const y = clampY(py);
    pingX = fromPxX(x);
    pingZ = fromPxZ(y);
    // The only animation in the widget, and the only thing reduced motion turns
    // off. The seek itself has never been eased.
    pinging = reducedMotion ? 0 : performance.now();
    dirty = true;
    options.onSeek?.(pingX, pingZ);
  }

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

  function onPointerMove(event: PointerEvent) {
    if (!ready) return;
    const [px, py] = eventToPx(event);
    const x = clampX(px);
    const y = clampY(py);
    if (x !== hoverX || y !== hoverY) {
      hoverX = x;
      hoverY = y;
      dirty = true;
      const wx = fromPxX(x);
      const wz = fromPxZ(y);
      const room = roomAt(wx, wz);
      hoverRoom = room?.name ?? null;
      options.onHover?.({
        x: wx,
        z: wz,
        room: hoverRoom,
        person: personNear(wx, wz),
        level: level?.name ?? "",
      });
    }
    if (dragging) seekTo(px, py);
  }

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

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

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

  /** Keyboard aiming. Arrows move a pending crosshair, Enter commits it. */
  function onKeyDown(event: KeyboardEvent) {
    if (!ready) return;
    const step = (event.shiftKey ? 0.06 : 0.015) * Math.max(boardPxW, boardPxH);
    let dx = 0;
    let dy = 0;
    switch (event.key) {
      case "ArrowLeft":
        dx = -step;
        break;
      case "ArrowRight":
        dx = step;
        break;
      case "ArrowUp":
        dy = -step;
        break;
      case "ArrowDown":
        dy = step;
        break;
      case "Enter":
      case " ":
        if (pendingX >= 0) {
          seekTo(pendingX, pendingY);
          event.preventDefault();
        }
        return;
      case "Escape":
        if (pendingX >= 0) {
          pendingX = -1;
          dirty = true;
        }
        return;
      default:
        return;
    }
    if (pendingX < 0) {
      pendingX = clampX(toPxX(controls.target.x));
      pendingY = clampY(toPxY(controls.target.z));
    }
    pendingX = clampX(pendingX + dx);
    pendingY = clampY(pendingY + dy);
    dirty = true;
    event.preventDefault();
  }

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

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

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

  // The widget is sized by the caller's CSS, so it watches its own box: it is
  // inserted into a panel that may be closed, and a container that animates open
  // would otherwise leave a plan rasterised at the wrong size.
  const observer =
    typeof ResizeObserver === "function" ? new ResizeObserver(() => resize()) : null;
  observer?.observe(canvas);

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

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

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

    layout();
    buildGeometry();
    renderStatic();
    dirty = true;
  }

  resize();

  return {
    canvas,

    setActiveView(id) {
      if (id === activeViewId) return;
      activeViewId = id;
      dirty = true;
    },

    setPresence(people) {
      peopleBySeat = new Map();
      for (const person of people) {
        // Last writer wins on a duplicated seat, which matches what the scene
        // does with two meshes at one position: you see one person. A roster
        // that seats two people at one desk is wrong in the roster.
        peopleBySeat.set(person.seatId, person.label);
      }
      layoutOccupied();
      dirty = true;
    },

    setRobots(next) {
      // In the intended wiring this is the same array object every time, so the
      // common path is a reference compare and a return. That is not a
      // micro-optimisation: marking the widget dirty on every call would defeat
      // the bail-out in `tick` outright and pin the panel at its full redraw rate
      // in an office where nothing whatsoever is moving.
      if (next === robotList) return;
      robotList = next;
      robotLast = new Float64Array(next.length * 2);
      // NaN, not the zero a fresh `Float64Array` comes with. Zero is a perfectly
      // ordinary coordinate — plenty of packs put the corner of a floor plate
      // near the origin — so a zeroed previous position makes the first step look
      // like a stride from the origin to wherever the robot actually is, and
      // every robot spends its first frame pointing away from the middle of the
      // building. NaN makes that first difference no difference at all, which is
      // the truth: nothing is known yet about where this machine came from.
      robotLast.fill(NaN);
      robotDir = new Float64Array(next.length * 2);
      dirty = true;
    },

    setPlayer(next) {
      if (
        player?.levelId === next?.levelId && player?.x === next?.x &&
        player?.z === next?.z && player?.headingRad === next?.headingRad &&
        player?.kind === next?.kind
      ) return;
      player = next ? { ...next } : null;
      dirty = true;
    },

    tick() {
      if (!ready || !viewCtx) return;
      const now = performance.now();
      if (now - lastDraw < FRAME_MS) return;
      // A storey change is the one thing that invalidates the raster, and it is
      // checked here rather than watched, because the only thing that can cause
      // it is the camera moving and this is the function the camera's movement
      // already runs through. On a single-level pack it is one identity compare.
      const next = levelForCamera();
      if (next !== level) {
        level = next;
        buildGeometry();
        renderStatic();
        dirty = true;
      }
      // `robotsMoved` last of the three, because it is the only one that walks a
      // list, and an office with no robots settles it on a length compare.
      if (!dirty && pinging === 0 && !cameraMoved() && !robotsMoved()) return;
      lastDraw = now;
      dirty = false;
      recordCamera();
      // Before `draw`, not after: the headings this frame's markers are turned by
      // are derived from the step that has just been taken, so recording after
      // drawing would render every robot one frame behind its own nose.
      recordRobots();
      draw(now);
    },

    resize,

    dispose() {
      observer?.disconnect();
      canvas.removeEventListener("pointerdown", onPointerDown);
      canvas.removeEventListener("pointermove", onPointerMove);
      canvas.removeEventListener("pointerup", endDrag);
      canvas.removeEventListener("pointercancel", endDrag);
      canvas.removeEventListener("pointerleave", onPointerLeave);
      canvas.removeEventListener("wheel", onWheel);
      canvas.removeEventListener("keydown", onKeyDown);
      canvas.removeEventListener("blur", onBlur);
      motionQuery?.removeEventListener("change", onMotionChange);
      ready = false;
      roomPaths = [];
      labels = [];
      // Back to the shared empty. The robot list is somebody else's live array
      // and it is the one thing this widget holds that outlives it — a disposed
      // panel keeping a reference to a disposed scene's robots is how a torn-down
      // office stays reachable from a DOM node nobody can see any more.
      robotList = NO_ROBOTS;
      canvas.remove();
    },
  };
}

/** The label face, sized in device pixels so it is the same physical size everywhere. */
function labelFont(dpr: number): string {
  return `${Math.round(9 * dpr)}px ui-monospace, SFMono-Regular, Menlo, monospace`;
}

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

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

interface Theme {
  ground: string;
  floor: string;
  atrium: string;
  zone: string;
  roomEdge: string;
  wall: string;
  glazing: string;
  prop: string;
  propEdge: string;
  label: string;
  labelHalo: string;
  occupied: string;
  occupiedEdge: string;
  robot: string;
  robotEdge: string;
  frame: string;
  footprintFill: string;
  footprintStroke: string;
  camera: string;
  cameraEdge: string;
  target: string;
  hover: string;
  pending: string;
  viewpoint: string;
  viewpointActive: string;
  accentRgb: Rgb;
}

/**
 * One palette, and no day-night pair.
 *
 * The city plan authors two and crossfades them because the map it is drawing is
 * lit by a sun that swings through 360° over a day. An office is not: the
 * interior rig in `officeScene` is fixed, deliberately, because a floor plate
 * under a rotating sun is a room where you cannot find the meeting room at 2
 * a.m. The scene does not change with the hour, so neither does this, and a
 * `setSolarElevation` here would be a method that had to exist and do nothing.
 *
 * The values are the panel's own — `index.html`'s ink ramp over the office
 * background — so the widget reads as part of the card it sits in rather than as
 * a photograph pasted into it. Contrast runs floor → furniture → wall, in that
 * order and with real gaps between them, because that is the order the eye needs
 * them: the slab is context, the desks are content, and the walls are the lines
 * you navigate by.
 */
function buildTheme(): Theme {
  const accent = rgbOf(ACCENT);
  return {
    // Outside the building. Near-black, so the floor plate reads as a lit object
    // on a dark ground rather than as a hole in a light one.
    ground: rgba(rgbOf(0x0a0d11), 1),
    floor: rgba(rgbOf(0x1c232b), 1),
    // An atrium is drawn lighter because from above it is the part of the floor
    // you can actually see into.
    atrium: rgba(rgbOf(0x252e38), 1),
    zone: rgba(rgbOf(0xffffff), 0.035),
    roomEdge: rgba(rgbOf(0xffffff), 0.07),
    // The strongest thing on the drawing, and the only near-white. Everything
    // else is a step down from this.
    wall: rgba(rgbOf(0xd6dee6), 0.92),
    // Glazing is a wall you can see through, and it is drawn as one: same hue,
    // half the presence.
    glazing: rgba(rgbOf(0x9fc4d8), 0.62),
    prop: rgba(rgbOf(0x8f9aa6), 0.5),
    propEdge: rgba(rgbOf(0xc3ccd6), 0.32),
    label: rgba(rgbOf(0xffffff), 0.58),
    // The ground colour, near-opaque, so a name over a desk bank sits in its own
    // small clearing rather than in the middle of the desks.
    labelHalo: rgba(rgbOf(0x0a0d11), 0.82),
    // Brighter than the furniture it sits on and cooler than the amber the
    // camera owns, so a busy floor never competes with "where am I looking",
    // which is still this widget's first job.
    occupied: rgba(rgbOf(0x8ec3e8), 0.95),
    occupiedEdge: rgba(rgbOf(0x0a0d11), 0.7),
    // The only green on the plan, and the only mark on it that moves. The full
    // argument for a hue of its own rather than a second blue is at `drawRobots`,
    // and the short version is that the silhouette is what says "machine" and the
    // colour only has to stay out of the way of the people and of the camera.
    robot: rgba(rgbOf(0x5fd9a6), 0.95),
    // The ground colour behind it, exactly as an occupied desk gets. Written out
    // again rather than sharing `occupiedEdge`: the two are the same value today
    // and they are not the same decision, and a plan that changed how it rims its
    // people because somebody adjusted its robots would be a small mystery.
    robotEdge: rgba(rgbOf(0x0a0d11), 0.7),
    frame: rgba(rgbOf(0x9fb4c6), 0.3),
    // Faint, for the reason the city widget's is faint: on the whole-floor view
    // the footprint covers most of the widget, and a fill that is a hint over
    // one room is a colour cast over the building. The outline carries the
    // shape; the fill only says which side of it you are on.
    footprintFill: rgba(accent, 0.1),
    footprintStroke: rgba(accent, 0.8),
    camera: rgba(accent, 0.95),
    cameraEdge: rgba(rgbOf(0x0a0d11), 0.55),
    target: rgba(rgbOf(0xe8f1f8), 0.8),
    hover: rgba(rgbOf(0xe8f1f8), 0.45),
    pending: rgba(accent, 0.75),
    viewpoint: rgba(rgbOf(0xdfe9f1), 0.55),
    viewpointActive: rgba(accent, 0.9),
    accentRgb: accent,
  };
}

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

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