/**
 * Aircraft over the city.
 *
 * The engine takes a `FlightSource` rather than talking to any particular
 * service, because the obvious one cannot ship here. FlightRadar24's terms do
 * not permit scraping and do not permit redistributing their data. An
 * Apache-2.0 repo shipping such a client would not merely be breaking a ToS —
 * it would be publishing instructions for doing so, alongside data it has no
 * right to relicense. Commercial sources are adapters in a private deployment;
 * this file holds what we can actually give away. See ARCHITECTURE.md §4, and
 * `server/src/flights/licence.ts` for the allowlist that keeps the open lane
 * open in practice rather than in principle.
 *
 * `SimulatedFlights` is the default and is genuinely enough for the map — what
 * a city view wants is convincing motion in the right corridors, not a
 * spotter's log.
 */

import * as THREE from "three";
import { AIRLINER_LENGTH, airlinerGeometry } from "./aircraftGeometry.ts";
import type { Aircraft, City, FlightSource } from "./types.ts";
import { seededRandom, type World } from "./world.ts";

/** A route the simulator flies: great-circle-ish, with a climb or descent. */
export interface SimRoute {
  callsign: string;
  from: [number, number];
  to: [number, number];
  /** Metres at the start and end of the leg. */
  fromAlt: number;
  toAlt: number;
  /** Seconds for a full traversal. */
  duration: number;
}

// ---- Where the sky is -----------------------------------------------------

/** A point on the ground. `City.center` is one; so is a query to a feed. */
export interface Place {
  lat: number;
  lng: number;
}

/**
 * The patch of sky a source is being asked about.
 *
 * A circle rather than the city's rectangle, because a circle is the query
 * every traffic feed actually offers: adsb.lol and airplanes.live both take a
 * point and a radius, and a receiver on a roof takes nothing at all and gives
 * you whatever it can hear. Turning the board into a circle here means the
 * shape that crosses the wire is the shape the upstream wants, rather than a
 * rectangle each adapter has to circumscribe on its own and get subtly
 * different.
 *
 * This type exists because for a while the server was the only thing that knew
 * where the traffic was — one `TERA_ORIGIN_LAT/LNG` pair, fixed at boot, for a
 * map with two metros nearly six hundred kilometres apart. Every viewer of
 * the SoCal board was being handed San Francisco's aircraft, which do not
 * merely look wrong: they project to scene coordinates a long way off the board
 * and the sky comes up empty. Where to look is a parameter now, and it comes
 * from the city being rendered.
 */
export interface SkyRegion {
  center: Place;
  /** Nautical miles from `center`, because that is the unit ADS-B feeds take. */
  radiusNm: number;
}

/**
 * One nautical mile is one minute of latitude. That is the definition of the
 * unit, not an approximation of it, which is why there is no fudge factor here.
 */
const NM_PER_DEGREE = 60;

/**
 * Distance in nautical miles, on a flat earth.
 *
 * Equirectangular rather than haversine, deliberately. This runs once per
 * aircraft per poll — several hundred times a second in the worst case a busy
 * live feed can produce — and over the hundred kilometres a city board spans
 * the two answers differ by well under a tenth of a percent. Nothing
 * downstream is measuring anything: the answers feed a radius query and an
 * is-this-on-my-board test, and both carry slack counted in tens of kilometres.
 */
export function distanceNm(from: Place, to: Place): number {
  const dLat = to.lat - from.lat;
  const dLng = (to.lng - from.lng) * Math.cos((((from.lat + to.lat) / 2) * Math.PI) / 180);
  return Math.hypot(dLat, dLng) * NM_PER_DEGREE;
}

/**
 * The circle that covers a city's board, measured from the city's own centre.
 *
 * Not from the centre of `bounds`, which is a different point: San Francisco's
 * `center` is the city and its board runs forty kilometres down the peninsula,
 * so the two are about twenty kilometres apart. The radius is therefore taken
 * to the furthest of the four corners, and a circle drawn from that far
 * off-centre reaches well past the board on the near side.
 *
 * That is the right error to make. Aircraft on approach are outside the board
 * by definition and are the ones worth watching; a query clipped to the
 * rendered rectangle would drop every arrival at the moment it became
 * interesting and pop it into existence over the runway. `marginNm` is more of
 * the same, and is why the default is not zero.
 */
export function regionOf(city: Pick<City, "center" | "bounds">, marginNm = 15): SkyRegion {
  const { minLat, maxLat, minLng, maxLng } = city.bounds;
  const corners: Place[] = [
    { lat: minLat, lng: minLng },
    { lat: minLat, lng: maxLng },
    { lat: maxLat, lng: minLng },
    { lat: maxLat, lng: maxLng },
  ];
  let radiusNm = 0;
  for (const corner of corners) radiusNm = Math.max(radiusNm, distanceNm(city.center, corner));
  return { center: city.center, radiusNm: Math.round(radiusNm + marginNm) };
}

/** Whether a position is in the region, with optional slack in nautical miles. */
export function inRegion(region: SkyRegion, lat: number, lng: number, slackNm = 0): boolean {
  return distanceNm(region.center, { lat, lng }) <= region.radiusNm + slackNm;
}

/**
 * Plausible traffic for a region nobody has authored routes for.
 *
 * `adapters/sample.ts` has hand-placed corridors for the two cities in this
 * build and they are much better than this: real arrivals come down the real
 * approach, and that is most of what makes a sky read as *this* city's sky
 * rather than as motion. What follows is what a third city gets on the day it
 * is added and before anybody has done that work — chords across the region at
 * airliner altitudes, deterministic from the seed so that two viewers agree
 * about where everything is.
 *
 * The alternative floor was an empty sky, and an empty sky over a city is not
 * read as "no traffic today", it is read as a broken layer. Every leg here is
 * inside the region by construction, which is the one property the previous
 * arrangement could not offer: the constant it used was San Francisco.
 */
export function syntheticRoutes(region: SkyRegion, count = 6, seed = 20_617): SimRoute[] {
  const rand = seededRandom(seed);
  const degPerNm = 1 / NM_PER_DEGREE;
  // Longitude degrees are shorter than latitude degrees everywhere but the
  // equator, so an east–west offset in nautical miles is more of them.
  const lngPerNm = degPerNm / Math.cos((region.center.lat * Math.PI) / 180);
  const routes: SimRoute[] = [];

  for (let i = 0; i < count; i++) {
    const bearing = rand() * Math.PI * 2;
    // Push the chord off the centre so the legs are not six spokes through
    // downtown. ±60% of the radius crosses the board at a spread of depths.
    const offset = (rand() * 1.2 - 0.6) * region.radiusNm;
    const half = Math.sqrt(Math.max(region.radiusNm ** 2 - offset ** 2, 1));
    const alongE = Math.sin(bearing);
    const alongN = Math.cos(bearing);
    const from = {
      lat: region.center.lat + (-alongN * half - alongE * offset) * degPerNm,
      lng: region.center.lng + (-alongE * half + alongN * offset) * lngPerNm,
    };
    const to = {
      lat: region.center.lat + (alongN * half - alongE * offset) * degPerNm,
      lng: region.center.lng + (alongE * half + alongN * offset) * lngPerNm,
    };

    // A third arriving, a third departing, a third crossing high. A board where
    // everything is at cruise has no altitude ramp to read and no reason for
    // the colour band in `createFlightLayer` to exist.
    const kind = i % 3;
    const fromAlt = kind === 0 ? 3400 : kind === 1 ? 500 : 8600 + rand() * 1800;
    const toAlt = kind === 0 ? 450 : kind === 1 ? 6200 : fromAlt + 400;
    // Eight seconds a nautical mile is about 450 knots, which is an airliner.
    const duration = Math.round(half * 2 * 8);

    routes.push({
      callsign: `SIM ${i + 1}`,
      from: [from.lat, from.lng],
      to: [to.lat, to.lng],
      fromAlt: Math.round(fromAlt),
      toAlt: Math.round(toAlt),
      duration,
    });
  }
  return routes;
}

/**
 * A source with a dial on it: whatever it was going to draw, plus N invented
 * aircraft.
 *
 * This exists for one control in the godmode panel — "how busy would this look
 * with three times the traffic" — and the shape it takes is chosen to make that
 * question answerable without corrupting the answer to any other one.
 *
 * **It composes rather than substitutes.** The base source is polled unchanged
 * and its aircraft are passed through untouched; the fabricated ones are a
 * second list concatenated onto the end. That is what lets the dial work over a
 * *live* ADS-B feed as well as over the simulator — the real traffic stays real
 * and stays complete, and turning the dial back to zero returns exactly the
 * sky that was there before, because nothing was ever taken away.
 *
 * The alternative was to mutate the simulator's route list, and it is worse in
 * both directions: it does nothing at all when the server is serving its own
 * plan (`HttpFlights` ignores its fallback in that mode, so the slider would be
 * inert on every deployment that has an API), and it is destructive when it does
 * work, because the authored corridors would have to be rebuilt to get back.
 *
 * ### On fabricating traffic at all
 *
 * The same argument as `weatherOverride` in `main.ts`: a god-only lie about the
 * inputs, told to see what the renderer does with it. It is deliberately **not**
 * available to anyone else, and the invented aircraft carry a callsign prefix of
 * their own so that a screenshot of a busy sky can be told from a screenshot of
 * a real one. Note what this breaks while it is on — every viewer agreeing about
 * where the aircraft are, which is the property the server's plan exists to buy.
 * That is acceptable for a debug dial and would not be for a feature.
 */
export interface TrafficDial {
  /** The source to hand `createScene`. Stable for the dial's whole life. */
  source: FlightSource;
  /** Fabricate this many additional aircraft. `0` turns the dial off entirely. */
  setExtra(count: number): void;
  extra(): number;
}

/**
 * Callsign prefix for fabricated traffic.
 *
 * Distinct from `syntheticRoutes`'s own `SIM`, and it has to be: `sampleRoute`
 * derives an aircraft's id from its callsign, `createFlightLayer` keys its
 * tracks on that id, and a deployment with no API is already flying `SIM 1`
 * through `SIM 6` from the fallback. Reuse the prefix and every fabricated
 * aircraft would land on an existing track, teleporting it across the board on
 * alternate polls.
 */
const FABRICATED_PREFIX = "GOD";

/** As many as the dial goes to. Past this the sky is soup and the point is made. */
export const MAX_EXTRA_TRAFFIC = 400;

export function withTrafficDial(base: FlightSource, region: SkyRegion): TrafficDial {
  let extra: SimulatedFlights | null = null;
  let count = 0;

  return {
    source: {
      interval: base.interval,
      poll(): Aircraft[] | Promise<Aircraft[]> {
        const theirs = base.poll();
        if (extra === null) return theirs;
        const mine = extra.poll();
        // `poll` is synchronous on every source in this build, but the interface
        // permits a promise and `HttpFlights` documents its synchrony as a
        // deliberate property rather than an accident. Handling both here costs
        // one branch and means the dial cannot be what breaks that.
        return theirs instanceof Promise ? theirs.then((a) => [...a, ...mine]) : [...theirs, ...mine];
      },
      dispose: () => base.dispose?.(),
    },
    setExtra(next: number) {
      count = Math.max(0, Math.min(MAX_EXTRA_TRAFFIC, Math.round(next)));
      if (count === 0) {
        extra = null;
        return;
      }
      const routes = syntheticRoutes(region, count).map((route, i) => ({
        ...route,
        callsign: `${FABRICATED_PREFIX} ${i + 1}`,
      }));
      extra = new SimulatedFlights(routes);
    },
    extra: () => count,
  };
}

/**
 * Traffic that behaves like the real thing without being it: aircraft move
 * along fixed legs at fixed speeds, looping, with each one offset in phase so
 * the sky is never empty and never synchronised.
 */
export class SimulatedFlights implements FlightSource {
  readonly interval = 1;
  private readonly routes: SimRoute[];
  private readonly phase: number[];
  private t = 0;
  private last = 0;

  constructor(routes: SimRoute[], seed = 4711) {
    this.routes = routes;
    const rand = seededRandom(seed);
    this.phase = routes.map(() => rand());
    this.last = nowSeconds();
  }

  poll(): Aircraft[] {
    const now = nowSeconds();
    this.t += Math.min(now - this.last, 5);
    this.last = now;

    return this.routes.map((route, i) => sampleRoute(route, this.t / route.duration + (this.phase[i] ?? 0)));
  }
}

/**
 * One aircraft's state at a fraction of the way along its leg. `p` wraps, so
 * anything can be handed in and 1.4 means the same as 0.4.
 *
 * Split out of `SimulatedFlights.poll` because the HTTP adapter needs exactly
 * this and cannot reuse the class to get it: `SimulatedFlights` runs on a
 * monotonic clock that starts when it is constructed, whereas the wire's
 * `FlightsPlanBody` anchors every route to a fixed epoch so that two browsers
 * agree about where the aircraft are. Same arithmetic, different origin — and
 * two copies of the arithmetic would drift.
 */
export function sampleRoute(route: SimRoute, p: number): Aircraft {
  const t = ((p % 1) + 1) % 1;
  const lat = route.from[0] + (route.to[0] - route.from[0]) * t;
  const lng = route.from[1] + (route.to[1] - route.from[1]) * t;
  // Ease the altitude so departures climb steeply and level off.
  const ease = 1 - (1 - t) ** 2;
  const altitude = route.fromAlt + (route.toAlt - route.fromAlt) * ease;
  const heading =
    (Math.atan2(route.to[1] - route.from[1], route.to[0] - route.from[0]) * 180) / Math.PI;
  /**
   * The velocity, so a simulated aircraft is the same kind of object a real one
   * is.
   *
   * Not decoration. `createFlightLayer` dead-reckons anything that carries a
   * ground speed and interpolates anything that does not, and a build with no
   * API — a keyless clone, the boot state of every deployment, the fallback
   * whenever the network goes away — flies exactly these aircraft. Leaving them
   * without a velocity would mean the two paths went through different code and
   * only one of them was ever looked at, which is the arrangement that let the
   * live sky sit still for as long as it did.
   *
   * Both numbers are the derivative of the arithmetic three lines up rather
   * than a plausible-looking constant: the speed is the leg's ground distance
   * over its duration, and the climb is `d/dt` of the eased altitude, which
   * is why a departure's rate is steepest at the start and tails to nothing —
   * the same shape the ease was chosen for.
   */
  const seconds = route.duration > 0 ? route.duration : 1;
  const dLatM = (route.to[0] - route.from[0]) * METRES_PER_DEGREE_LAT;
  const dLngM =
    (route.to[1] - route.from[1]) * METRES_PER_DEGREE_LAT * Math.cos((lat * Math.PI) / 180);
  const groundSpeed = Math.hypot(dLatM, dLngM) / seconds;
  const verticalRate = ((route.toAlt - route.fromAlt) * 2 * (1 - t)) / seconds;
  return {
    id: `sim-${route.callsign}`,
    callsign: route.callsign,
    lat,
    lng,
    altitude,
    heading,
    groundSpeed,
    verticalRate,
  };
}

/**
 * Metres in one degree of latitude, and the only geodesy in this file.
 *
 * A sphere, not an ellipsoid. The dead-reckoner integrates this over at most a
 * minute of flight and the WGS-84 meridian varies by about half a percent from
 * pole to equator — half a metre in a hundred, on a board where one scene unit
 * is ninety-four of them. `World.metresPerUnit` is derived from the same
 * constant, so the two agree by construction rather than by coincidence.
 */
const METRES_PER_DEGREE_LAT = 111_320;

/**
 * A caller-supplied string, trimmed, or `null` for anything that is not one.
 *
 * `undefined`, an empty string and a string of spaces are all "the feed said
 * nothing" and must all reach the card as the same `null`, because a card that
 * renders an empty row looks like a card whose data went missing.
 */
function text(value: string | null | undefined): string | null {
  if (typeof value !== "string") return null;
  const trimmed = value.trim();
  return trimmed === "" ? null : trimmed;
}

function nowSeconds(): number {
  return (typeof performance !== "undefined" ? performance.now() : 0) / 1000;
}

/**
 * How long a snapshot is still worth drawing after the feed stops answering.
 *
 * A minute, which at this source's eight-second interval is seven missed polls
 * in a row — well past a dropped request and into "the feed is gone". Below
 * that the last snapshot is held, because the alternative is that one timeout
 * empties the sky, `createFlightLayer` drops every track it was interpolating,
 * and the next good poll builds them all again from scratch: a full-screen
 * flicker of every aircraft and every trail, caused by nothing.
 */
const ADSB_HOLD_SECONDS = 60;

/**
 * Community ADS-B, for when real traffic is wanted.
 *
 * `adsb.lol` and `airplanes.live` both serve open, key-free feeds of
 * volunteer-fed ADS-B and are the sources this project can point at without a
 * licence problem. The best answer long-term is an RTL-SDR on a fleet box:
 * first-party data, nothing to comply with.
 *
 * **`endpoint` is not free-form, even though its type is `string`.** The
 * allowlist of feeds this project will fetch, and the credit line each of them
 * is owed, live in `server/src/flights/licence.ts`, which is where the API's
 * `TERA_ADSB_ENDPOINT` is validated before a request is made. A browser drawing
 * a feed for itself is not republishing it and so is not the exposure that gate
 * exists for — but a self-hoster who constructs this class with some other
 * endpoint is choosing terms nobody here has read, and this is the sentence
 * that says so. Nothing in this repo constructs it: the shipped path is
 * `HttpFlights` against our own API.
 *
 * The region is required and has no default. It used to default to a point in
 * San Francisco, which is a fine centre for one of the two cities in this build
 * and a five-hundred-kilometre error for the other — and a wrong default is
 * worse than a missing one, because it produces a sky rather than a type error.
 */
export class AdsbFlights implements FlightSource {
  readonly interval = 8;
  private held: Aircraft[] = [];
  private heldAt = 0;

  private readonly endpoint: string;
  private readonly region: SkyRegion;

  /**
   * Fields assigned in the body rather than declared as parameter properties.
   *
   * That is not a style preference. A parameter property is the one piece of
   * TypeScript syntax that *emits code* — it is a hidden assignment, not a type
   * annotation — so Node's type stripping refuses the whole module with
   * `ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX`. The bundler never cared, so this went
   * unnoticed until the first `node --test` file tried to import this layer and
   * discovered it could not: the module with the worst bug this project has
   * shipped was, by construction, the one module that could not be tested.
   *
   * The server has run under type stripping from the start and so has always
   * been written this way; the browser engine simply never had to be.
   */
  constructor(endpoint: string, region: SkyRegion) {
    this.endpoint = endpoint;
    this.region = region;
  }

  async poll(): Promise<Aircraft[]> {
    const { lat, lng } = this.region.center;
    const url = `${this.endpoint}/v2/point/${lat}/${lng}/${Math.round(this.region.radiusNm)}`;
    try {
      const res = await fetch(url);
      if (!res.ok) return this.hold();
      const body = (await res.json()) as { ac?: RawAircraft[] };
      this.held = (body.ac ?? [])
        .filter((a) => typeof a.lat === "number" && typeof a.lon === "number")
        // See the note on `heading` below: a record with no track is dropped
        // rather than zeroed, and dropping it here keeps the map's return type
        // honest instead of widening `Aircraft.heading` to admit a null that no
        // consumer could do anything sensible with.
        .filter((a) => typeof a.track === "number")
        // The endpoint takes a radius and is trusted to honour it, but a
        // receiver feeding one of these networks hears whatever it hears and
        // some deployments serve the lot. Anything outside the region projects
        // to a scene coordinate off the board.
        .filter((a) => inRegion(this.region, a.lat as number, a.lon as number))
        .map((a) => ({
          id: a.hex ?? `${a.flight ?? "?"}`,
          callsign: a.flight?.trim(),
          lat: a.lat as number,
          lng: a.lon as number,
          // Feed reports feet; the scene works in metres.
          altitude: typeof a.alt_baro === "number" ? a.alt_baro * 0.3048 : 3000,
          /**
           * A missing track is *skipped*, not zeroed.
           *
           * ADS-B carries position-only records — surface vehicles, TIS-B and
           * MLAT-derived targets — with no `track` field at all, and they pass
           * every other filter here. Substituting `0` used to be harmless
           * because a wrong heading only pointed a symmetrical dart the wrong
           * way. It stopped being harmless when aircraft learned to bank: a
           * target whose real heading is 200° reported as 0° looks like a 160°
           * turn, which pins the roll at its 30° limit and holds it there for as
           * long as the target is in the feed — a sustained, full-scale artefact
           * produced entirely by invented data.
           *
           * Dropped in the filter above. An aeroplane this feed will not say
           * the heading of is one this layer cannot draw honestly.
           */
          heading: a.track as number,
          /**
           * The velocity, when the feed reported one, and the reason this
           * layer's aircraft can fly between snapshots at all.
           *
           * Guarded on being a positive finite number rather than merely
           * present: this feed sends `gs: 0.0` for ground vehicles and parked
           * aircraft, and a zero is a fact about a stationary object rather
           * than a missing measurement, so both end up absent and both are
           * held still by `createFlightLayer`. The track is already known to be
           * a number — the filter above dropped every record without one — so
           * a speed carried here always has a direction to go with it.
           *
           * `server/src/flights/adsb.ts` does the same conversion on the same
           * fields for the shipped path. Two copies of the arithmetic is one
           * more than anybody wants, and the alternative is this class
           * importing server code into the browser bundle.
           */
          ...(typeof a.gs === "number" && Number.isFinite(a.gs) && a.gs > 0
            ? { groundSpeed: a.gs * KNOTS_TO_MS }
            : {}),
          ...(typeof a.baro_rate === "number" && Number.isFinite(a.baro_rate)
            ? { verticalRate: a.baro_rate * FPM_TO_MS }
            : typeof a.geom_rate === "number" && Number.isFinite(a.geom_rate)
              ? { verticalRate: a.geom_rate * FPM_TO_MS }
              : {}),
          ...(typeof a.seen_pos === "number" && a.seen_pos >= 0
            ? { ageSeconds: a.seen_pos }
            : {}),
        }));
      this.heldAt = nowSeconds();
      return this.held;
    } catch {
      // A dead feed must not take the render loop with it.
      return this.hold();
    }
  }

  /** The last snapshot, until it is old enough that an empty sky is the truth. */
  private hold(): Aircraft[] {
    if (nowSeconds() - this.heldAt > ADSB_HOLD_SECONDS) this.held = [];
    return this.held;
  }
}

interface RawAircraft {
  hex?: string;
  flight?: string;
  lat?: number;
  lon?: number;
  alt_baro?: number;
  track?: number;
  /** Ground speed, knots. `0.0` on a ground vehicle or a parked aircraft. */
  gs?: number;
  /** Barometric climb rate, feet per minute, positive up. */
  baro_rate?: number;
  /** Geometric climb rate, feet per minute — what a row carries instead. */
  geom_rate?: number;
  /** Seconds since this row's position was last updated. */
  seen_pos?: number;
}

/** The feed's units, converted once. The engine works in metres and seconds. */
const KNOTS_TO_MS = 0.514_444;
const FPM_TO_MS = 0.00508;

// ---- Detail ---------------------------------------------------------------

/**
 * One aircraft, described well enough to put on a card somebody clicked.
 *
 * The demo this project leads with is a signed-out visitor clicking a dart over
 * a city they recognise and being told what it is, so this type is written for
 * **anon** and carries nothing an account would be needed for. Everything in it
 * is either broadcast unencrypted by the aircraft itself — ADS-B is receivable
 * with a forty-dollar dongle — or arithmetic on top of that.
 *
 * There is no route and no operator here, because the open feeds do not carry
 * them and inventing them would be the same class of lie `synthetic` exists to
 * prevent. The registration and the type **are** here, and used not to be: the
 * comment this replaces said the open feeds did not carry those either, and
 * that was simply a mistake — `r` and `t` are on every row adsb.lol and
 * airplanes.live serve, under the same ODbL as the coordinates, and the server
 * was dropping them on the floor. They were the stated reason to want a
 * commercial feed, which makes getting them right the cheapest thing in this
 * file.
 *
 * Two fields are about the *provenance* rather than the aeroplane, and they are
 * the reason this is a type and not an object literal built in the UI:
 * `observed` says whether anybody actually saw this, and `attribution` carries
 * whatever the feed asks to be credited with **at the point the data is
 * displayed**, which is what an ODbL notice is for. A card is a display. A
 * corner label on the other side of the screen is not obviously one.
 */
export interface AircraftDetail {
  /** The source's own id. The ICAO address for a live feed; a route name for the simulator. */
  id: string;
  /** Flight number or tail as broadcast, trimmed, or `null` when the feed said nothing. */
  callsign: string | null;
  /**
   * The transponder's 24-bit ICAO address, lowercase hex, or `null`.
   *
   * `null` rather than a guess for anything that does not look like one — the
   * simulator's ids are route names and a `~`-prefixed id on a real feed is a
   * non-ICAO address (TIS-B and MLAT targets carry them), which is genuinely
   * not an ICAO24 and must not be presented as one. Somebody can paste this
   * into a registry lookup, so a wrong one sends them to another aircraft.
   */
  icao24: string | null;
  lat: number;
  lng: number;
  /** Barometric altitude, metres — the unit the wire and the engine both use. */
  altitudeM: number;
  /** The same altitude in feet, which is the unit aviation is actually read in. */
  altitudeFt: number;
  /** Degrees clockwise from true north. */
  headingDeg: number;
  /** The heading as a 16-point compass name, for a card a human reads. */
  headingCompass: string;
  /**
   * The tail number, e.g. `"N68834"`, or `null`.
   *
   * The paragraph above this type used to say there is no registration and no
   * type here because the open feeds do not carry them. **That was wrong**, and
   * it was wrong in the expensive direction: it was the stated reason to want a
   * commercial feed. Both community feeds have carried `r` and `t` on every row
   * all along, under the same ODbL as the position beside them — so this is the
   * enrichment that was wanted, obtained legitimately, and an anonymous visitor
   * clicking a dart now reads "B739 · N68834" rather than a hex address.
   *
   * `null` and never a guess. A registration is something somebody pastes into
   * a registry lookup, so a wrong one names a different aeroplane — the same
   * argument `icao24` makes, about the same kind of identifier.
   */
  registration: string | null;
  /** ICAO type designator, e.g. `"B739"`, or `null`. Never expanded to a name. */
  type: string | null;
  /**
   * Ground speed in knots, or `null` where the feed did not say.
   *
   * Knots because that is the unit a speed over the ground is read in, and
   * rounded because the tenth of a knot the feed publishes is precision about
   * a number that changes while the card is open. `null` for anything not
   * moving: a parked aircraft reporting `gs: 0.0` reaches here with no speed at
   * all rather than with a zero, and "—" is the honest thing to draw for it.
   */
  groundSpeedKt: number | null;
  /** Climb rate in feet per minute, positive up, or `null`. Aviation's unit. */
  verticalRateFpm: number | null;
  /** Nautical miles from the board's centre, or `null` when no centre was given. */
  distanceNm: number | null;
  /**
   * Did somebody observe this, or did this repo invent it?
   *
   * The same statement `TrafficSource.live()` makes about the whole feed, made
   * about one aircraft, and it must travel with the aircraft: a card is read on
   * its own, away from any corner label, and a fabricated flight number
   * presented in the same frame as a real one is the confusion the `live` flag
   * exists to prevent.
   */
  observed: boolean;
  /** Credit lines owed for this aircraft, to be shown on the card itself. */
  attribution: string[];
}

/**
 * An ICAO 24-bit address as the feeds write it: six hex digits, lowercase.
 *
 * Anchored, so `sim-BA286` fails and `~abc123` — the anonymous-address form
 * both community feeds emit for targets whose real address is not known — fails
 * too, which is the point. See `AircraftDetail.icao24`.
 */
const ICAO24 = /^[0-9a-f]{6}$/;

/** The sixteen names, in the order the compass runs. */
const COMPASS = [
  "N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE",
  "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW",
];

/**
 * A bearing as a compass point.
 *
 * Sixteen points rather than eight because the difference between "north-east"
 * and "east-north-east" is the difference between two departure corridors, and
 * rather than thirty-two because nobody reads "NNE by N" off a card. Negative
 * and out-of-range degrees are wrapped rather than refused: a heading is an
 * angle and every angle names a direction.
 */
export function compassPoint(degrees: number): string {
  if (!Number.isFinite(degrees)) return "—";
  const wrapped = ((degrees % 360) + 360) % 360;
  return COMPASS[Math.round(wrapped / 22.5) % 16] ?? "N";
}

/** Metres to feet. The wire carries metres; aviation is read in feet. */
const FEET_PER_METRE = 3.280_84;

export interface AircraftDetailOptions {
  /**
   * The transponder address, when the caller was told one separately.
   *
   * `HttpFlights` is: `WireAircraft.icao24` is a field on the body and
   * `Aircraft` has nowhere to put it, so the adapter keeps the wire record
   * beside the position and hands it back here. Absent, the id is tested
   * against `ICAO24` — which is right for every feed that keys on the hex, and
   * correctly declines for the simulator.
   */
  icao24?: string | null;
  /**
   * The registration and the ICAO type designator, when the caller was told
   * them separately.
   *
   * Here rather than on `Aircraft` for the same reason `icao24` is, and it is
   * the same boundary: `Aircraft` is what the *renderer* needs — a position, a
   * height and a direction — and a tail number moves no pixels. The adapter
   * keeps the wire record beside the position and hands these back when a card
   * is asked for. Absent, they are `null`; nothing here is derived from
   * anything else, because a type designator inferred from a callsign is a
   * guess about a real aeroplane.
   */
  registration?: string | null;
  type?: string | null;
  /** Whether these coordinates were observed. Defaults to `false`: invented until said otherwise. */
  observed?: boolean;
  /** Credit lines the feed asks for, shown on the card. */
  attribution?: readonly string[];
  /** Board centre, for the distance readout. Omit and `distanceNm` is `null`. */
  from?: Place;
}

/**
 * Turn an `Aircraft` into something a panel can render, without the panel
 * knowing where aircraft come from.
 *
 * Pure, total and free of I/O, so the interface layer can call it on a click
 * without awaiting anything, and so it can be tested without a network. It
 * invents nothing: every field is a restatement, a unit conversion or a `null`.
 */
export function aircraftDetail(
  aircraft: Aircraft,
  options: AircraftDetailOptions = {},
): AircraftDetail {
  const callsign = aircraft.callsign?.trim();
  const declared = options.icao24?.trim().toLowerCase();
  const fromId = aircraft.id.trim().toLowerCase();
  const icao24 =
    declared !== undefined && ICAO24.test(declared)
      ? declared
      : ICAO24.test(fromId)
        ? fromId
        : null;

  return {
    id: aircraft.id,
    callsign: callsign === undefined || callsign === "" ? null : callsign,
    icao24,
    lat: aircraft.lat,
    lng: aircraft.lng,
    altitudeM: aircraft.altitude,
    altitudeFt: Math.round(aircraft.altitude * FEET_PER_METRE),
    headingDeg: aircraft.heading,
    headingCompass: compassPoint(aircraft.heading),
    registration: text(options.registration),
    type: text(options.type),
    // From the aircraft rather than from the options, because unlike the two
    // above it these are numbers the renderer genuinely uses: the layer
    // dead-reckons on them, so they are already on `Aircraft` and reading them
    // from a second place would be a second chance to disagree.
    groundSpeedKt:
      typeof aircraft.groundSpeed === "number" && Number.isFinite(aircraft.groundSpeed)
        ? Math.round(aircraft.groundSpeed / KNOTS_TO_MS)
        : null,
    verticalRateFpm:
      typeof aircraft.verticalRate === "number" && Number.isFinite(aircraft.verticalRate)
        ? Math.round(aircraft.verticalRate / FPM_TO_MS)
        : null,
    distanceNm:
      options.from === undefined
        ? null
        : Math.round(distanceNm(options.from, { lat: aircraft.lat, lng: aircraft.lng }) * 10) / 10,
    observed: options.observed === true,
    attribution: [...(options.attribution ?? [])],
  };
}

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

export interface FlightLayer {
  group: THREE.Group;
  /**
   * What a pointer can hit, as a **live** array, each entry carrying
   * `userData.aircraftId`.
   *
   * Here rather than on the caller because only this layer knows which object is
   * which track: the map from id to mesh is private and the group's child order
   * is an artefact of when each aircraft appeared. It is the same shape
   * `MarkerLayer.pickables` publishes and it exists for the same reason — a
   * pick is resolved from the object that was hit, and something has to say
   * what the object stands for.
   *
   * What a ray meets is a **sphere** around the aeroplane rather than its
   * triangles — see `raycastGlyph` — because a seventeen-pixel glyph with
   * two-pixel wings is a game of marksmanship rather than an interface, and
   * under a fingertip it is not even that. The array itself holds one object per
   * *drawn* aircraft, and only while it is drawn: a track kept through a dropped
   * refresh stops being clickable at the moment it stops being visible.
   *
   * `owner-decisions.md` is why this is not gated on anything: an ADS-B
   * position is broadcast unencrypted to anybody with a receiver, so the card
   * it opens is available to an anonymous visitor and the picking that reaches
   * it must be too.
   */
  pickables: THREE.Object3D[];
  /**
   * Hand over a fresh observation. Called on the source's own timer, which is
   * once a second for the simulator and once every several seconds for a real
   * feed; the motion in between is this layer's problem, not the caller's.
   */
  update(aircraft: Aircraft[]): void;
  /**
   * Move everything to where it should be at this instant.
   *
   * A pure function of the wall clock and the last two observations, so calling
   * it twice in a frame does the same thing as calling it once. That matters:
   * the layer drives itself from the trail geometry's `onBeforeRender` — see
   * `createFlightLayer` — and a scene that also ticks it explicitly must not end
   * up advancing time twice as fast.
   */
  tick(): void;
  /**
   * How far the camera is from **what it is looking at**, in scene units.
   *
   * The missing input, and the one that turns the glyph's ceiling from a
   * constant into a rule. `glyphScale` is handed the distance to the *aircraft*,
   * which is the right ruler only when everything in frame is equally far away;
   * at a chapter pose the landmark is a few units off and the traffic is a few
   * thousand, so the floor fires hard on the aeroplane and not at all on the
   * thing beside it. See `GLYPH_FOCUS_HEADROOM`.
   *
   * A setter and not an `OrbitControls` reference, deliberately. This layer must
   * keep working in an office sky, in a chase camera and under a test with no
   * controls at all, and a layer that reaches into the camera rig is a layer
   * that can only be used by the rig it was written against. The caller already
   * has the number: `camera.position.distanceTo(controls.target)`.
   *
   * Not calling it at all is a supported state and reproduces exactly the
   * behaviour this layer had before it existed — the ceiling stays the flat
   * `GLYPH_MAX_SCALE`.
   */
  setFocusDistance(distance: number): void;
  dispose(): void;
}

/**
 * How many observations a trail remembers, and how long it may hold one.
 *
 * Both limits are needed. The count keeps the shared vertex buffer bounded, and
 * the age keeps a slow feed from drawing a trail across the entire bay.
 *
 * These were 20 and 45, which made a trail about twenty seconds long — enough to
 * say "this thing is moving" and not enough to say where it came from. At 72
 * points the simulator, polled at 1 Hz, draws about seventy seconds of flying:
 * a quarter to a half of one of `sample.ts`'s legs, so an arrival trails a
 * visible curve down the approach rather than a tick behind it.
 *
 * The count is what binds for a 1 Hz source; the age binds for a slow one. 240 s
 * is four minutes, which at a live feed's 5–15 s refresh is 16–48 samples — so
 * a real ADS-B track fills a good part of the buffer without either limit
 * cutting it short.
 *
 * The cost is bounded and was measured rather than guessed: the trail is one
 * preallocated `LineSegments` and therefore one draw call at any length, and the
 * buffer goes from 210 KiB to 756 KiB. What actually scales is the per-vertex
 * work in `rebuildTrails`, which is why `writeTrailVertex` now uploads only the
 * range it wrote instead of the whole array.
 */
const TRAIL_POINTS = 72;
const TRAIL_SECONDS = 240;

/**
 * How far two positions may differ and still be "the same one repeated".
 *
 * Scene units. See `samePosition`, and the note on the repeat check in
 * `update` for why a repeated observation must not become a sample.
 */
const SAME_POSITION_EPSILON = 1e-4;

/**
 * Ceiling on tracks that get a trail, so the buffer can be allocated once.
 *
 * It is a real ceiling now. It was declared and then referenced only by the
 * buffer sizing, so `tracks` grew without limit and `rebuildTrails` silently
 * ran out of vertices — which mattered the moment the godmode dial could put
 * four hundred aircraft in the sky. Tracks past this many still get an aeroplane;
 * what they do not get is a trail, which is the graceful half to drop.
 */
const MAX_TRACKS = 192;

/**
 * How long a track survives not being in a snapshot before it is forgotten.
 *
 * Two refreshes of a slow live feed. See the note where it is used.
 */
const TRACK_GRACE_SECONDS = 32;

/**
 * Opacity at the head of a trail, fading to nothing at the tail. Well under 1
 * on purpose: the trail is context for the aircraft, not a second subject, and a
 * dozen opaque lines over a city read as a wiring diagram.
 */
const TRAIL_ALPHA = 0.55;

/**
 * Bounds on how long a leg between two observations may be taken to be.
 *
 * The span is measured rather than declared, because a `FlightSource` announces
 * an `interval` and then misses it — a tab in the background, a slow upstream,
 * a fetch that took two seconds. Interpolating over the announced interval when
 * the real gap was four times that gives an aircraft that darts and then waits.
 */
const MIN_SPAN = 0.2;
/**
 * 30 rather than 15, because the repeat check in `update` changed what this
 * measures. It used to bound a poll interval; it now bounds the gap between two
 * positions that actually differ, and for a live feed that gap *is* the server's
 * cache TTL — 5 to 15 seconds, plus whatever the network adds. Clamping at 15
 * would have made every slow refresh look like a dart-and-wait again.
 */
const MAX_SPAN = 30;

/**
 * Above this, a step is a teleport rather than a flight.
 *
 * Scene units per second, and generous: a fast jet at this city's ~94 m per
 * unit covers about three. The case this exists for is the simulator's routes
 * looping — an aircraft reaching the end of its leg reappears at the start,
 * which is several hundred units in one poll — and without the check the trail
 * draws a bright line straight across San Francisco every time one wraps.
 */
const JUMP_UNITS_PER_SECOND = 8;

/**
 * How much of the screen an aeroplane is never allowed to fall below.
 *
 * A fraction of the viewport's **height**, and the single number that decides
 * whether the live thing in this sky is visible at all.
 *
 * The arithmetic is unforgiving and it is worth writing down, because every
 * board in this repo lost it. A glyph `AIRLINER_LENGTH` long at distance `d`
 * covers `length / (2·d·tan(fov/2))` of the frame; at the 42° field of view
 * `scene.ts` uses that is `length / (0.767·d)`. The camera sits at up to two
 * board spans out, and a span is 230 units over San Francisco, 393 over the
 * Southland and about 580 across California — so a 0.42-unit aeroplane on the
 * default board came to **0.0005 of the frame, which is two thirds of one
 * pixel**. That is not a small aeroplane, it is a dead pixel, and a person who
 * has just arrived reads it as one: the feed was live, the callsigns were real,
 * and the whole layer was indistinguishable from a smudge on the monitor.
 *
 * So the glyph is given a floor in *apparent* size and grows with distance to
 * hold it. 0.016 is about seventeen pixels of aeroplane on a 1080-tall window
 * and eleven on a phone — the size a flight-tracker icon is drawn at, which is
 * the reference this is aiming for and not an accident. Below about 0.012 the
 * wings stop resolving and it degenerates into the cross it used to be; much
 * above 0.02 and a dozen of them start to look like a squadron flying formation
 * over a state, which is the cartoon this is trying not to be.
 *
 * Two things this deliberately is *not*:
 *
 *   - It is not a size in metres. `aircraftGeometry.ts` already argues that
 *     traffic here is a map symbol drawn in 3-D — 40 m over San Francisco, 164 m
 *     over the Southland, the same 0.42 units on both — and a floor in screen
 *     space is the same claim taken to its conclusion. What a person needs from
 *     an aeroplane on a map is its position and its heading, and neither of
 *     those is legible at half a pixel however truthful the span is.
 *   - It is not applied unconditionally. The scale is `max(1, …)`, so the
 *     authored geometry wins whenever the camera is close enough for it to be
 *     legible on its own — about 34 units, or a chapter's worth of standoff.
 *     Zooming in therefore *shrinks* an aeroplane back to the size the file that
 *     drew it intended, rather than leaving a state-sized airliner parked over a
 *     downtown.
 */
const GLYPH_MIN_SCREEN_FRACTION = 0.016;

/**
 * A ceiling on the same scale, in multiples of the authored glyph.
 *
 * The floor above is a screen-space rule and it is right about a map: an
 * aeroplane is drawn at a readable size wherever it is, because position and
 * heading are what a reader wants and neither survives half a pixel. But the
 * rule scales by the distance to *that aircraft*, not by how far the camera has
 * zoomed, so the two are only the same thing when everything in frame is
 * equally far away. On a whole-board pose they are. Beside a landmark they are
 * not: at the Golden Gate chapter the bridge is a couple of units from the
 * camera and the traffic over the Pacific is a couple of hundred, so the floor
 * fires hard on the aeroplane and not at all on the bridge, and an airliner is
 * drawn about two and a half times the length of the main span.
 *
 * A world-space ceiling is the missing half of the rule. The floor says "never
 * smaller than legible"; this says "never larger than an aeroplane could
 * plausibly be", and between them the glyph is a map symbol where there is
 * nothing to compare it against and an aircraft where there is.
 *
 * 52 IS A MITIGATION AND NOT A CURE, and the arithmetic says why. The furthest
 * a visitor orbits on the California corridor is about 1,160 units, where the
 * raw scale is 33.9 at a 42-degree field of view and 51.0 at 60 — so any ceiling
 * below 52 shrinks an aeroplane at a pose people actually use, and at 26 the
 * glyph fell to 0.0123 of the frame against the 0.012 at which this file says
 * the wings stop resolving. The Golden Gate case sits at about 2,280 units and a
 * raw 81. A ceiling of 52 therefore takes the worst case down by a third — from
 * roughly two and a half times the bridge's main span to about one and a half —
 * and costs nothing at any board distance. It does not make the aeroplane
 * smaller than the bridge.
 *
 * THE COMPLETE FIX IS A DIFFERENT INPUT, not a lower number. This function is
 * handed the distance to the *aircraft*, and the thing that actually makes the
 * glyph look wrong is how far the camera is from what it is LOOKING AT: at a
 * whole-board pose everything in frame is equally far away and the floor is
 * right about all of it, while at a chapter the bridge is two units away and the
 * traffic is two thousand. Clamping against the camera's own focus distance
 * would let the glyph collapse toward its authored size whenever the viewer has
 * zoomed in on something near, at any aircraft range. That is a signature
 * change through `tick` and its callers, and it wants its own pass rather than
 * being smuggled into a constant.
 */
const GLYPH_MAX_SCALE = 52;

/**
 * How many times larger than legible-at-the-focus-distance an aeroplane may be
 * drawn.
 *
 * This is the complete fix the paragraph above defers, and it is the same rule
 * as the floor with the ruler corrected. The floor asks "how big does this have
 * to be to read from `distance`"; the ceiling now asks "how big is anything the
 * viewer is actually looking at", and lets the glyph collapse toward its
 * authored size whenever the answer is "very close", at any aircraft range.
 *
 * What that does at the two poses that matter, at a 42-degree field:
 *
 *   - **Whole board, California corridor.** The camera is ~1,160 units out and
 *     the aircraft are 900-1,400 away, so the focus distance and the aircraft
 *     distance are the same number to within a third. The ceiling lands near
 *     34 x HEADROOM, far above the ~34 the floor asks for, and the glyph is
 *     untouched — which is required, because at a whole-board pose the floor is
 *     right about everything in frame.
 *   - **The Golden Gate chapter.** The camera settles ten to twenty units off
 *     the bridge while the traffic over the Pacific is two thousand away.
 *     `legible(focus)` is under 1, the ceiling collapses to HEADROOM itself, and
 *     the airliner is drawn at a few times its authored 0.42 units instead of
 *     52 x it. That is the two-and-a-half-times-the-main-span defect, gone
 *     rather than merely reduced.
 *
 * 3 rather than 2 or 4, chosen by shooting the four framings this has to serve
 * and reading the pictures. At 2 the aeroplanes over the Bay are present but
 * their heading stops being readable at chapter zoom, which is half of what the
 * glyph is for. At 4 the aircraft is still visibly larger than a container ship
 * beside it at the SoMa chapter. 3 keeps the heading legible and puts the glyph
 * under the landmarks it shares a frame with.
 *
 * `GLYPH_MAX_SCALE` stays as an absolute backstop above this: a caller that
 * never sets a focus distance, or one that sets a nonsensical one, still cannot
 * produce a state-sized aeroplane.
 */
const GLYPH_FOCUS_HEADROOM = 3;

/**
 * How long an airliner actually is, in metres.
 *
 * **`AIRLINER_LENGTH` is 0.42 *scene units*, and that is the same bug `LOT` had
 * in `blocks.ts`.** A constant denominated in scene units means a different real
 * size on every board: `aircraftGeometry.ts` states it plainly — 40 m over San
 * Francisco, 164 m over the Southland, the same 0.42 on both — and on the
 * statewide board at 1,919.3 m to the unit it is **806 metres**. That was
 * invisible while the only board you ever came close on was a metro board.
 * `cities/unify.ts` put cities on the state board, and the first photograph of
 * San Francisco from 9 km has aeroplanes the size of neighbourhoods lying over
 * it.
 *
 * 60 m is a mid-size airliner — an A320 is 38, a 777 is 74 — and the exact
 * number matters much less than the units, because the screen-space floor above
 * still lifts the glyph to a legible size wherever it is far away. What this
 * changes is only the size it collapses *to* when the camera is close enough
 * for a real one to be legible on its own.
 *
 * **Floored at 1 in `flightBaseScale`, so no board's aeroplanes ever grow.** On
 * San Francisco the authored 0.42 units is already 39.6 m and the floor keeps it
 * exactly as it ships; on the Southland and the statewide board, where the
 * authored size is 2.7x and 13.5x life, it is corrected downward. A fix that
 * only ever removes an exaggeration cannot surprise a board that did not have
 * one.
 */
export const AIRLINER_TRUE_METRES = 60;

/**
 * The multiplier that makes the authored glyph true size on this board, never
 * larger than the glyph as drawn.
 */
export function flightBaseScale(metresPerUnit: number): number {
  if (!Number.isFinite(metresPerUnit) || metresPerUnit <= 0) return 1;
  return Math.min(1, AIRLINER_TRUE_METRES / metresPerUnit / AIRLINER_LENGTH);
}

/**
 * The radius of the sphere a pointer actually has to hit, in glyph lengths.
 *
 * The aeroplane's *triangles* are not the hit target and must not be. Even at
 * the floor above it is seventeen pixels of aeroplane, which is four pixels of
 * fuselage and a pair of wings a couple of pixels thick — a raycast against
 * those is a test of mouse marksmanship, and on a touch screen, where the tap
 * lands under a fingertip eight millimetres across, it is not winnable at all.
 * The owner's ask was that clicking a plane works for a stranger, and a target
 * you have to aim at does not.
 *
 * One glyph length gives a sphere two aeroplane-lengths across — about
 * thirty-four pixels at the legibility floor, which is a comfortable tap and is
 * still small enough that the pointer has to be *on* the aeroplane rather than
 * merely in the same part of the sky. Two aircraft whose spheres overlap still
 * resolve to the nearer one: `Raycaster` sorts its hits by distance, so what
 * wins is the aeroplane in front rather than the one that happened to be created
 * first.
 */
const PICK_RADIUS_GLYPHS = 1;

/**
 * Altitude, as colour.
 *
 * The obvious cue is a drop line to the ground, and it was tried first and
 * removed: this city renders at ~94 m per scene unit with a 3.6× vertical
 * exaggeration, so an aircraft at cruise sits about 230 units above a downtown
 * whose tallest tower is 10, and its drop line is a full-height wire through the
 * middle of the frame. Twelve of those is a birdcage. Colour costs nothing, is
 * readable at any camera distance, and — because the trail carries it too — a
 * climb shows up as a gradient along the ribbon rather than as a number nobody
 * reads.
 */
const LOW_COLOR = new THREE.Color(0xffb277);
const HIGH_COLOR = new THREE.Color(0xdfeaf6);
/** Metres at which the ramp reaches `HIGH_COLOR`. Roughly a cruising airliner. */
const CRUISE_METRES = 9000;
/** Distinct materials along the ramp. Enough to look continuous, few enough to cache. */
const COLOR_BANDS = 12;

/** Steepest nose-up or nose-down attitude an aircraft is drawn at, in radians. */
const MAX_PITCH = 0.42;

/**
 * Steepest bank an aircraft is drawn at, in radians — thirty degrees.
 *
 * A transport aircraft in normal operation does not go past this: airline
 * procedure and autopilot bank limiters sit at 25–30°, and steeper than that
 * is a manoeuvre rather than a turn.
 *
 * The cap is not really here for the aeroplanes, though — it is here for the
 * data. A reported heading that jumps forty degrees because a receiver lost a
 * target and reacquired it is, at this end of the wire, indistinguishable from
 * a genuine turn, and it arrives with the position barely moved, so the
 * teleport test in `update` does not catch it — that test is about distance and
 * this is a lie about attitude. Without a ceiling, one such sample knife-edges
 * an airliner over the city, and a frame of that is worse than the flat turns
 * this whole channel exists to replace.
 */
const MAX_BANK = (30 * Math.PI) / 180;

/**
 * Nominal true airspeed over g, in seconds. The one constant in the bank.
 *
 * An aircraft in a coordinated turn holds `tan(bank) = ω·V / g`: the
 * horizontal component of lift is the only thing turning it, so the bank
 * needed for a given rate of turn rises with how fast the aeroplane is going.
 * That relation is what `bankAngle` evaluates, with V fixed at 200 m/s
 * (389 kt) rather than measured.
 *
 * **Fixed rather than measured, deliberately.** The speed is derivable from
 * the same pair of samples the turn rate comes from — ground distance over
 * `span`, which `climbAngle` already computes half of — but both terms then
 * carry a 1/span, so their product carries 1/span², and position noise on a
 * live feed would swing the bank about on its own. A constant is wrong by a
 * factor the eye cannot read; noise is wrong in a way it can.
 *
 * 200 m/s is a compromise and knowingly one. `syntheticRoutes` flies its legs
 * at eight seconds a nautical mile, which is about 450 kt and is cruise; an
 * arrival on final is doing a third of that. Sitting between them draws
 * terminal-area turns a little flatter than they fly and high-level ones a
 * little steeper — the cheap direction to be wrong in, because the steep case
 * saturates against `MAX_BANK` and the flat case still reads as a bank.
 *
 * V/g works out at 20.4 s, which puts a half-standard-rate turn (1.5 °/s, the
 * airline norm at altitude) at 28° and reaches the 30° ceiling at 1.62 °/s.
 */
const SPEED_OVER_G = 200 / 9.80665;

/**
 * Time constant for how quickly the bank follows the turn, in seconds.
 *
 * The roll is not set to the geometric answer, it is eased toward it: each new
 * leg closes a fraction `1 - exp(-span / ROLL_SETTLE_SECONDS)` of the gap. A
 * first-order lag, and nothing with a second derivative in it, because a
 * first-order lag cannot overshoot. The requirement is that a straight leg
 * settles to wings-level and *stays* there; a spring-and-damper would sit
 * rocking about zero for several seconds after every turn, which is a worse
 * artefact than the flat turns it would have been introduced to fix.
 *
 * Expressed as a time constant rather than as a per-update fraction because
 * `span` is not one quantity here: it is a second for the simulator and five
 * to fifteen for a live feed — see the repeat check in `update`. A flat "close
 * 30% of the gap each time" would be about three seconds of lag on one source
 * and forty-five on the other. At 2.5 s a 1 Hz simulator covers 63% of a roll
 * in 2.5 s and 90% in 5.8, while a 10 s live refresh takes 98% of it in a
 * single step — which is right, because on that source `tick` is already
 * spreading the movement across ten seconds of interpolation.
 */
const ROLL_SETTLE_SECONDS = 2.5;

/**
 * How long the layer will keep flying an aircraft on its last known velocity.
 *
 * Dead reckoning is an *interpolation of the near future*, not a simulation. A
 * position and a velocity describe where something will be in the next few
 * seconds very well and where it will be in ten minutes not at all — an
 * airliner turns, descends and lands, and none of that is in the two numbers
 * this layer was handed. So the propagation time is clamped: an aircraft coasts
 * for a minute and then holds station until somebody tells it something new.
 *
 * A minute rather than a shorter, tidier number because a minute is what the
 * rest of the file already treats as "the feed is gone" — `ADSB_HOLD_SECONDS`
 * is the same figure, and both sources hold their last snapshot for exactly
 * that long. Reckoning past the point where the snapshot itself would have
 * expired would be flying an aeroplane on the strength of data the layer has
 * already agreed to stop believing.
 */
const MAX_RECKON_SECONDS = 60;

/**
 * How quickly a dead-reckoned track slides onto a fresh observation, in seconds.
 *
 * **This constant is the whole difference between a fix and a flinch.** The
 * reckoner is always a little wrong — the aircraft banked, or the wind changed,
 * or the fix that started it was itself a second stale — so every observation
 * arrives with the drawn aeroplane a few hundred metres from where the feed
 * says it is. Teleporting it there is the artefact this layer has spent its
 * whole life removing on other channels: a visible twitch on every aircraft on
 * every refresh, five to fifteen seconds apart, forever.
 *
 * So the error is measured once, at the instant of the observation, and then
 * *decayed*: the aeroplane flies the newly-truthful track and carries a
 * shrinking offset on top of it. A first-order decay rather than a ramp for the
 * same reason `ROLL_SETTLE_SECONDS` is one — it cannot overshoot, and it has no
 * end time to be interrupted at, so an observation landing early is not a
 * special case.
 *
 * Three seconds puts 96% of the correction inside a ten-second refresh while
 * keeping the closing speed below the aircraft's own: a 400 m error closes at
 * 133 m/s against an airliner's 250, so the correction reads as a course
 * adjustment rather than as a sideways lurch. Much shorter and it is a twitch
 * again; much longer and two aircraft on the same approach never quite agree
 * about where the centreline is.
 */
const RECKON_SETTLE_SECONDS = 3;

/**
 * The largest error the layer will slide out rather than jump.
 *
 * Scene units, and a safety valve rather than a tuning knob. A correction is
 * only worth easing if the two positions describe the same flight a moment
 * apart; a fix that lands two kilometres from the reckoned position is a
 * different claim altogether — a receiver reacquiring a target, an id reused,
 * a feed skipping a minute — and easing that would drag the aeroplane across
 * the county at three hundred knots with its trail attached. Past this the
 * offset is simply dropped and the aircraft is where the feed says it is.
 *
 * 20 units is 1.9 km on the San Francisco board and about 3.3 km on California.
 * A ten-second reckon of a turning airliner errs by well under a kilometre, so
 * this bites only when something has genuinely gone wrong.
 */
const MAX_RECKON_CORRECTION_UNITS = 20;

/**
 * A position and the velocity it is moving with: everything needed to say where
 * something will be shortly.
 *
 * Geographic rather than scene coordinates on purpose. `World.project` is
 * linear in latitude and longitude but the two axes have different scales — a
 * degree of longitude is shorter than a degree of latitude everywhere but the
 * equator, and `lngScale` carries that — so integrating a heading in scene
 * space would need the projection undone and redone anyway. Doing it in degrees
 * means one `project` at the end and no assumptions about the board.
 */
export interface Reckoning {
  lat: number;
  lng: number;
  /** Metres. */
  altitude: number;
  /** Degrees clockwise from true north. */
  heading: number;
  /** Metres per second over the ground. Zero holds station. */
  speed: number;
  /** Metres per second, positive climbing. */
  climb: number;
}

/**
 * Where a track will be `seconds` from the state it was in.
 *
 * Pure, total, and exported so the arithmetic can be tested without a scene: a
 * dead-reckoner that turns the wrong way, or converts degrees to metres at the
 * wrong latitude, produces a sky that renders perfectly and is wrong by
 * kilometres, which is precisely the class of defect this file's comments keep
 * recording.
 *
 * Compass convention throughout: heading 0 is north and increases clockwise, so
 * north is `+cos` on latitude and east is `+sin` on longitude. The longitude
 * step is divided by the cosine of the latitude, because a degree of longitude
 * carries fewer metres the further from the equator it is — get that backwards
 * and every eastbound aircraft over California flies at four fifths of its
 * reported speed.
 *
 * Negative time is refused rather than run backwards: every caller is asking
 * about the present or the near future, and a negative interval here would mean
 * a clock had gone backwards, which is a thing browser clocks do.
 */
export function reckonForward(
  state: Reckoning,
  seconds: number,
): { lat: number; lng: number; altitude: number } {
  const dt = clamp(seconds, 0, MAX_RECKON_SECONDS);
  if (!Number.isFinite(dt) || dt === 0 || !Number.isFinite(state.speed)) {
    return { lat: state.lat, lng: state.lng, altitude: altitudeAfter(state, seconds) };
  }
  const distance = state.speed * dt;
  const radians = (state.heading * Math.PI) / 180;
  const lat = state.lat + (Math.cos(radians) * distance) / METRES_PER_DEGREE_LAT;
  // The cosine is taken at the *starting* latitude rather than at the mean of
  // the two. A minute of flying moves an airliner about half a degree at most,
  // over which the correction differs in the sixth decimal place, and using the
  // start keeps this a closed form rather than an iteration.
  const metresPerDegreeLng = METRES_PER_DEGREE_LAT * Math.cos((state.lat * Math.PI) / 180);
  const lng =
    metresPerDegreeLng > 1
      ? state.lng + (Math.sin(radians) * distance) / metresPerDegreeLng
      : state.lng;
  return { lat, lng, altitude: altitudeAfter(state, dt) };
}

/**
 * The altitude after an interval, with the one floor that matters: nothing
 * descends through the ground.
 *
 * A steady 1,300 ft/min descent reckoned for a full minute puts an aircraft
 * 400 m *below* the terrain if it landed in the meantime — which is exactly
 * what happens to an arrival that lands while its feed is quiet, and it renders
 * as an aeroplane buried in the bay.
 */
function altitudeAfter(state: Reckoning, seconds: number): number {
  const dt = clamp(seconds, 0, MAX_RECKON_SECONDS);
  if (!Number.isFinite(state.climb) || !Number.isFinite(dt)) return state.altitude;
  return Math.max(0, state.altitude + state.climb * dt);
}

/**
 * A track's dead-reckoned state, plus the error it is still sliding out of.
 *
 * `since` is when `state` was true; `offset` is where the aeroplane was drawn
 * at `offsetAt` minus where this state says it was, in scene units, and decays
 * to nothing over `RECKON_SETTLE_SECONDS`. Keeping the two apart is what makes
 * the correction continuous: the *truth* jumps when an observation lands, and
 * the *drawing* does not, because the jump is absorbed into an offset that is
 * already on its way out.
 */
interface ReckonTrack {
  state: Reckoning;
  /** Seconds on `nowSeconds`'s monotonic clock. */
  since: number;
  offset: THREE.Vector3;
  offsetAt: number;
}

interface TrailSample {
  position: THREE.Vector3;
  altitude: number;
  /** Compass degrees, as reported. */
  heading: number;
  /** Seconds on `nowSeconds`'s monotonic clock. */
  at: number;
}

interface Track {
  mesh: THREE.Mesh;
  /**
   * Whether the aeroplane is currently in `pickables`.
   *
   * Tracked rather than derived because the answer changes on a rule the array
   * cannot see — a track that has gone stale is still held and still has a mesh,
   * and must stop being clickable the moment it stops being drawn. `Raycaster`
   * has not consulted `visible` since r119, so an aeroplane hidden by `tick` is
   * still a hit until somebody takes it out of the list, and a card raised on an
   * aeroplane nobody can see is a card about an aeroplane that is not there.
   */
  picking: boolean;
  /** Observations, oldest first. The last is where the aircraft is heading. */
  samples: TrailSample[];
  /** Seconds the current leg should take: the measured gap between the last two. */
  span: number;
  /** Climb angle of the current leg, radians, positive nose-up. */
  pitch: number;
  /**
   * Bank at the two ends of the current leg, radians, positive right-wing-down.
   *
   * Two numbers rather than one because the roll is interpolated across the leg
   * exactly as the position, the altitude and the heading are. A single value
   * would step once per poll, and a step is *more* conspicuous on the roll
   * channel than on the others: yaw and position move continuously either side
   * of it so the discontinuity is small, whereas a bank that arrives all at once
   * is an aircraft snapping onto its wingtip. On a live feed that would be a
   * visible flick every five to fifteen seconds, on every aircraft that is
   * turning.
   *
   * `rollFrom` is simply what `rollTo` was on the previous leg, so the two
   * always meet and the interpolation is continuous across a poll even though
   * the target it is chasing is not.
   */
  rollFrom: number;
  rollTo: number;
  /** Which cached material is on the mesh, so a band change is the only write. */
  band: number;
  /** Interpolated position, reused rather than reallocated every frame. */
  head: THREE.Vector3;
  /**
   * When this track first went missing from a snapshot, or `0` while it is
   * present. See `TRACK_GRACE_SECONDS`.
   */
  missingSince: number;
  /**
   * Missing, and finished moving toward wherever it was last seen — so no
   * longer drawn, while its history is still held. See `tick`.
   */
  stale: boolean;
  /** Altitude at `head`, which is what the aircraft's colour is chosen from. */
  headAltitude: number;
  /**
   * The dead-reckoned state, or `null` for a source that reported no velocity.
   *
   * The presence of this object is what decides which of the layer's two motion
   * models a track gets, and both are needed:
   *
   *   - **`null` — interpolate.** The head is lerped between the last two
   *     observations and stops when it arrives. This is the original behaviour
   *     and it is right for anything whose speed is unknown: a ground vehicle,
   *     a parked airframe, a position-only TIS-B target, a source that predates
   *     the velocity fields. Guessing a speed for those would be inventing
   *     motion, which is a worse lie than showing none.
   *   - **Present — reckon.** The head is integrated forward from the last fix
   *     along the aircraft's own track at its own speed, every frame, and slides
   *     onto each new observation instead of snapping to it. This is what makes
   *     a live feed refreshing every ten seconds look like flying rather than
   *     like a slideshow.
   */
  reckon: ReckonTrack | null;
}

/**
 * Aircraft as small airliners, each dragging a fading trail of where it has been.
 *
 * Rendered at true altitude through the world's vertical exaggeration, so a jet
 * on approach sits visibly below one at cruise, and coloured by that altitude so
 * the difference survives a camera far enough away that the heights stop being
 * separable.
 *
 * The layer moves things every frame while being told where they are only every
 * poll. Positions are interpolated between the last two observations rather than
 * extrapolated past the newest one: that costs one interval of lag — a second
 * for the simulator — and in exchange an aircraft never overshoots and then
 * snaps back, which is what extrapolation does the moment a feed stutters.
 */
export function createFlightLayer(world: World): FlightLayer {
  const group = new THREE.Group();
  group.name = "flights";
  // Mutated in place as tracks appear and expire, so `setPicking` can hold the
  // array itself as its target list rather than re-reading it every pointer move.
  const pickables: THREE.Object3D[] = [];

  const geo = airlinerGeometry();
  const materials = new Map<number, THREE.MeshLambertMaterial>();
  const tracks = new Map<string, Track>();

  const scratch = new THREE.Color();

  /**
   * The camera the layer was last drawn for, or `null` before the first frame.
   *
   * The layer has no camera of its own and CONTRACT §1 is why it must not
   * acquire one: the camera belongs to the scene's `SceneKit`, and a layer that
   * took a second reference would have to be told when the scene swapped. It is
   * read off `onBeforeRender` instead, which is handed the camera actually being
   * rendered for — so an office looking at the same layer through a different
   * camera would size the glyphs for *that* view without anything having to be
   * wired up.
   *
   * Before the first frame there is no answer and the glyphs stay at their
   * authored size, which is the size they were before any of this existed.
   */
  let viewer: THREE.PerspectiveCamera | null = null;
  /**
   * The camera's distance to what it is looking at, in scene units, or `null`
   * until a caller says. `null` reproduces the behaviour this layer had before
   * the setter existed — the ceiling is the flat `GLYPH_MAX_SCALE` — which is
   * what an office sky, a chase camera and every test with no controls get.
   */
  let focusDistance: number | null = null;
  /*
   * Computed once: the board's scale does not change under it, and this is read
   * per aircraft per frame.
   */
  const baseScale = flightBaseScale(world.metresPerUnit);

  /**
   * One material per altitude band, built on demand.
   *
   * The emissive term is small and deliberate. Aircraft are lit by the same rig
   * as the city, and after sunset that rig is a tenth of an intensity — a dart
   * of pure diffuse white simply disappears at night, which is the one time of
   * day the sky is worth looking at.
   */
  function materialFor(band: number): THREE.MeshLambertMaterial {
    const existing = materials.get(band);
    if (existing) return existing;
    const color = scratch.copy(LOW_COLOR).lerp(HIGH_COLOR, band / (COLOR_BANDS - 1)).getHex();
    const mat = new THREE.MeshLambertMaterial({
      color,
      emissive: color,
      emissiveIntensity: 0.35,
    });
    materials.set(band, mat);
    return mat;
  }

  // ---- The trail ----------------------------------------------------------

  // One `LineSegments` for every trail in the scene rather than one per
  // aircraft: the vertex count is trivial either way, and a single draw call
  // with a preallocated buffer avoids allocating and disposing geometry every
  // time traffic changes. Per-vertex alpha does the fade, which needs a
  // four-component colour attribute — three.js reads the item size and switches
  // the shader on it.
  const maxVertices = MAX_TRACKS * TRAIL_POINTS * 2;
  const trailPositions = new Float32Array(maxVertices * 3);
  const trailColors = new Float32Array(maxVertices * 4);
  const trailGeo = new THREE.BufferGeometry();
  trailGeo.setAttribute("position", new THREE.BufferAttribute(trailPositions, 3));
  trailGeo.setAttribute("color", new THREE.BufferAttribute(trailColors, 4));
  trailGeo.setDrawRange(0, 0);
  const trailMat = new THREE.LineBasicMaterial({
    vertexColors: true,
    transparent: true,
    // Trails cross each other constantly and are the faintest thing in the
    // scene; letting them write depth makes the one that happened to draw first
    // punch a hole in every one behind it.
    depthWrite: false,
  });
  const trailLine = new THREE.LineSegments(trailGeo, trailMat);
  trailLine.name = "flight-trails";
  // The buffer is rewritten from scene-space coordinates every frame, so its
  // bounding sphere is permanently wrong and culling it would be culling the
  // whole layer.
  trailLine.frustumCulled = false;
  // The layer is handed observations on the source's timer and is otherwise
  // never called, so the interpolation hangs off the one thing guaranteed to
  // happen every frame: this line being drawn. `tick` is idempotent, so a scene
  // that would rather drive the layer itself can call it and nothing here
  // double-counts.
  // It also carries the camera in, which is the only reason this layer knows how
  // far away it is being looked at from — see `viewer`. Like every other write
  // `tick` makes, the scale it computes here lands on the *next* frame: world
  // matrices were resolved before any `onBeforeRender` ran. That is how this
  // layer's position and attitude have always worked, it is one frame at 60 Hz,
  // and the alternative is a camera reference this file is not entitled to hold.
  trailLine.onBeforeRender = (_renderer, _scene, camera) => {
    if ((camera as THREE.PerspectiveCamera).isPerspectiveCamera) {
      viewer = camera as THREE.PerspectiveCamera;
    }
    tick();
  };
  group.add(trailLine);

  // ---- Observations -------------------------------------------------------

  function update(aircraft: Aircraft[]) {
    const now = nowSeconds();
    const seen = new Set<string>();

    for (const a of aircraft) {
      seen.add(a.id);
      const [x, z] = world.project(a.lat, a.lng);
      const position = new THREE.Vector3(x, world.metres(a.altitude), z);
      const sample: TrailSample = { position, altitude: a.altitude, heading: a.heading, at: now };

      let track = tracks.get(a.id);
      if (!track) {
        const mesh = new THREE.Mesh(geo, materialFor(0));
        // Yaw then pitch, because the heading is about the world's vertical and
        // the climb angle is about the aircraft's own wing.
        mesh.rotation.order = "YXZ";
        // The id, on the object, so a raycast hit resolves to an aeroplane
        // without this layer having to expose its private track table.
        mesh.userData.aircraftId = a.id;
        // The pointer aims at the aeroplane and hits a sphere around it. See
        // `raycastGlyph`.
        mesh.raycast = raycastGlyph;
        group.add(mesh);
        pickables.push(mesh);
        track = {
          mesh,
          picking: true,
          samples: [],
          span: MIN_SPAN,
          pitch: 0,
          // Wings level: a track with one observation has no pair of headings to
          // have turned between, and `tick` reads both of these on its very first
          // frame, so neither may start undefined.
          rollFrom: 0,
          rollTo: 0,
          band: -1,
          head: position.clone(),
          headAltitude: a.altitude,
          missingSince: 0,
          stale: false,
          // Filled in by `adoptReckoning` below, on this same observation, if
          // the source said how fast the thing is going.
          reckon: null,
        };
        tracks.set(a.id, track);
      }

      const previous = track.samples[track.samples.length - 1];

      /**
       * A source repeating itself is not a new observation, and treating it as
       * one is what stopped live traffic from ever moving.
       *
       * `HttpFlights` is polled at 1 Hz and holds a *frozen* snapshot between
       * network refreshes, which the server caches for 5–15 s. So a live feed
       * hands over the identical position five to fifteen times in a row and
       * then jumps. Pushing each repeat as its own sample had two consequences,
       * and both of them were visible on the deployed site:
       *
       * 1. `span` measured the poll interval (~1 s) rather than the gap between
       *    the two positions that actually differ (5–15 s). The teleport test
       *    below then saw an airliner covering 36 units in a "second" against a
       *    ceiling of 8 and **wiped the entire trail on every refresh** — so no
       *    live aircraft could ever grow a trail at all, however large
       *    `TRAIL_POINTS` was set.
       * 2. The trail filled with a dozen coincident points, so the spine had no
       *    length and the dart sat still and then jumped.
       *
       * Skipping the repeat fixes both at once: `span` becomes the real gap, the
       * jump test sees ~2.5 units/s and passes, and the interpolation in `tick`
       * spreads the movement smoothly across the whole refresh interval.
       *
       * Compared on position rather than on an observation timestamp because a
       * `FlightSource` is not required to carry one — `Aircraft` has no `at`
       * field, and the two real sources disagree about whether they could
       * supply one honestly.
       */
      if (previous && samePosition(previous, sample)) {
        // Nothing to record. The head keeps interpolating toward the newest
        // distinct sample, which is what makes the motion continuous.
        continue;
      }

      /**
       * Whether this observation was a teleport rather than a flight.
       *
       * Carried out of the branch below because the dead-reckoner needs to know
       * it: a wrapped simulator route or a reused id is a *different aircraft*
       * at this position, so its new state must be adopted outright rather than
       * eased onto from where the old one was being drawn. Easing it would
       * produce exactly what the teleport guard exists to prevent — an aeroplane
       * sliding across the board over three seconds, trail attached.
       */
      let jumped = false;

      if (previous) {
        // The clamp is load-bearing on both ends. Two polls arriving in the same
        // millisecond — a manual refresh, a tab waking up — divide by nearly
        // zero and make every aircraft look like it teleported; a source that
        // stalled for a minute makes the next honest step look like one too.
        const span = clamp(now - previous.at, MIN_SPAN, MAX_SPAN);
        // Ground distance only. Scene height is exaggerated 3.6× here, so a
        // healthy climb contributes more to a straight 3-D distance than the
        // aircraft's actual speed does, and a departure out of SFO would trip
        // the teleport test on every poll.
        const travelled = Math.hypot(
          position.x - previous.position.x,
          position.z - previous.position.z,
        );
        if (travelled / span > JUMP_UNITS_PER_SECOND) {
          jumped = true;
          // A source that has moved something further than anything flies has
          // either looped a simulated route or reused an id. Either way the
          // history is about a different flight; keeping it would draw a trail
          // across the map.
          track.samples.length = 0;
          track.head.copy(position);
          track.pitch = 0;
          // The attitude is history too. A simulated route that has just looped
          // was, one poll ago, banked into whatever its last leg was doing, and
          // that leg is now several hundred units away and belongs to a
          // different flight — carrying the bank across the wrap would put the
          // aircraft on its ear at the start of a dead-straight departure. Both
          // ends are cleared so the interpolation has nothing left to run out.
          track.rollFrom = 0;
          track.rollTo = 0;
        } else {
          track.span = span;
          track.pitch = climbAngle(world, previous, sample);
          // Where the last leg's roll finished is where this one's begins, which
          // is what makes the bank continuous across a poll boundary.
          track.rollFrom = track.rollTo;
          track.rollTo = bankAngle(previous, sample, span, track.rollTo);
        }
      }

      track.samples.push(sample);
      trim(track, now);
      adoptReckoning(track, a, now, jumped);
    }

    /**
     * An aircraft missing from one snapshot has not landed.
     *
     * This used to delete the track the instant an id was absent, which meant a
     * single dropped target — an ADS-B receiver losing a line of sight for one
     * refresh, which is routine — threw away its entire history and rebuilt it
     * from nothing. At a 20-point trail that cost twenty seconds; at 72 it would
     * cost over a minute, so the longer trails are what made this worth fixing
     * rather than merely worth noticing.
     *
     * `ADSB_HOLD_SECONDS` covers the whole feed going dark. This covers one
     * target going quiet inside an otherwise healthy snapshot, which is a
     * different failure and needs a different answer.
     */
    for (const [id, track] of tracks) {
      if (seen.has(id)) {
        track.missingSince = 0;
        continue;
      }
      if (track.missingSince === 0) track.missingSince = now;
      if (now - track.missingSince < TRACK_GRACE_SECONDS) continue;
      setPickable(track, false);
      // The geometry and the material are shared by every aircraft in the sky
      // and belong to the layer, which frees them once in `dispose`.
      group.remove(track.mesh);
      tracks.delete(id);
    }

    tick();
  }

  /**
   * Scratch vectors for the reckoner. Reused because `adoptReckoning` runs once
   * per aircraft per observation and `reckonedHead` once per aircraft per
   * frame, and four hundred of either allocating a `Vector3` is a
   * garbage-collection pause a pointer can feel.
   */
  const reckonScratch = new THREE.Vector3();
  const truthScratch = new THREE.Vector3();

  /**
   * Take a fresh observation as the truth a track flies from, without letting
   * the aeroplane jump to it.
   *
   * Three things happen here and they are in this order for a reason.
   *
   * **The fix is advanced to now.** `Aircraft.ageSeconds` says how stale the
   * coordinates already were when the source handed them over — the receiver's
   * last message, plus this box's cache TTL, plus whatever the browser was
   * holding. Adopting them as though they described this instant would draw the
   * entire sky that far behind, uniformly, which is the kind of error nobody
   * ever notices because everything is wrong together.
   *
   * **The error is measured before the state is replaced.** Where the aeroplane
   * is being *drawn* right now is a property of the old reckoning, so it has to
   * be read while the old reckoning still exists; a moment later there is
   * nothing left to compare against and the correction would be zero, which is
   * the same thing as snapping.
   *
   * **A teleport is adopted outright.** See `jumped`.
   *
   * A source that reports no usable speed leaves `reckon` null and the track
   * falls back to interpolating between observations, which is what every
   * aircraft in this layer did before this function existed.
   */
  function adoptReckoning(track: Track, a: Aircraft, now: number, jumped: boolean): void {
    const speed = a.groundSpeed;
    if (typeof speed !== "number" || !Number.isFinite(speed) || speed <= 0) {
      track.reckon = null;
      return;
    }
    const climb =
      typeof a.verticalRate === "number" && Number.isFinite(a.verticalRate) ? a.verticalRate : 0;
    const age =
      typeof a.ageSeconds === "number" && Number.isFinite(a.ageSeconds) && a.ageSeconds > 0
        ? a.ageSeconds
        : 0;

    const fix: Reckoning = {
      lat: a.lat,
      lng: a.lng,
      altitude: a.altitude,
      heading: a.heading,
      speed,
      climb,
    };
    const caughtUp = reckonForward(fix, age);
    const state: Reckoning = {
      ...fix,
      lat: caughtUp.lat,
      lng: caughtUp.lng,
      altitude: caughtUp.altitude,
    };

    // Where this track is currently being drawn, read off the reckoning that is
    // about to be replaced. `null` for a track that has never had one — a brand
    // new arrival has nothing to be eased from and belongs at the fix.
    const drawn =
      track.reckon !== null && !jumped ? reckonedHead(track.reckon, now, reckonScratch) : null;

    const offset = track.reckon?.offset ?? new THREE.Vector3();
    if (drawn === null) {
      offset.set(0, 0, 0);
    } else {
      const [x, z] = world.project(state.lat, state.lng);
      truthScratch.set(x, world.metres(state.altitude), z);
      offset.subVectors(reckonScratch, truthScratch);
      // Past the valve, the two positions are not the same flight a moment
      // apart and easing between them would drag the aeroplane across the
      // board. See `MAX_RECKON_CORRECTION_UNITS`.
      if (offset.lengthSq() > MAX_RECKON_CORRECTION_UNITS ** 2) offset.set(0, 0, 0);
    }

    track.reckon = { state, since: now, offset, offsetAt: now };

    /**
     * The climb angle, from the feed's own rate rather than from two altitudes.
     *
     * Better on both ends of the arithmetic: `climbAngle` divides a barometric
     * difference by a horizontal distance, so it carries the noise of two
     * altitude readings and the error of the span, and it reports a climb of
     * zero for anything that has not moved. The vertical rate is a measurement
     * the aircraft transmits, so a departure is nose-up on the first
     * observation of it rather than on the second.
     *
     * `Math.max(speed, 1)` keeps the ratio finite for something crawling; at a
     * metre a second the pitch saturates against `MAX_PITCH` anyway, which is
     * the right answer for a helicopter going straight up.
     */
    track.pitch = clamp(Math.atan2(climb, Math.max(speed, 1)), -MAX_PITCH, MAX_PITCH);
  }

  /**
   * Where a reckoned track is at this instant, written into `out`; returns the
   * altitude in metres, which is what the colour band is chosen from.
   *
   * The offset decays exponentially from the moment it was measured, so the
   * drawn position starts at wherever the aeroplane already was and converges
   * on the truth without ever stopping to do it. Below a thousandth of a unit
   * it is dropped rather than added, which is not an optimisation: it is what
   * guarantees a straight leg eventually draws at *exactly* the reckoned
   * position instead of asymptotically near it.
   */
  function reckonedHead(r: ReckonTrack, now: number, out: THREE.Vector3): THREE.Vector3 {
    const forward = reckonForward(r.state, now - r.since);
    const [x, z] = world.project(forward.lat, forward.lng);
    out.set(x, world.metres(forward.altitude), z);
    const fade = Math.exp(-Math.max(0, now - r.offsetAt) / RECKON_SETTLE_SECONDS);
    if (fade > 1e-3) out.addScaledVector(r.offset, fade);
    reckonedAltitude = forward.altitude;
    return out;
  }

  /**
   * The altitude `reckonedHead` last computed.
   *
   * A second return value, in a file that would otherwise allocate an object
   * per aircraft per frame to carry it. The two are always read together and
   * one statement apart.
   */
  let reckonedAltitude = 0;

  /**
   * Put a track into the pick list, or take it out.
   *
   * Idempotent, and the flag is what makes it cheap: a hundred aircraft holding
   * station would otherwise walk the array with `indexOf` on every frame to
   * discover that nothing had changed.
   *
   * `pickables` is spliced rather than rebuilt because `scene.ts` hands the
   * array itself to the picker as a live target list — see `FlightLayer` — so
   * the identity of the array has to survive.
   */
  function setPickable(track: Track, on: boolean) {
    if (track.picking === on) return;
    track.picking = on;
    if (on) {
      pickables.push(track.mesh);
      return;
    }
    const at = pickables.indexOf(track.mesh);
    if (at >= 0) pickables.splice(at, 1);
  }

  /**
   * Whether a feed has handed back the same position it did last time.
   *
   * Scene units, and the tolerance is deliberately tiny: this is asking "did the
   * source repeat itself", not "did it move much". A genuinely stationary
   * aircraft on a taxiway still reports jitter well above this, and if it did
   * not, an aircraft that is not moving has nothing to draw a trail from anyway.
   */
  function samePosition(a: TrailSample, b: TrailSample): boolean {
    return (
      Math.abs(a.position.x - b.position.x) < SAME_POSITION_EPSILON &&
      Math.abs(a.position.z - b.position.z) < SAME_POSITION_EPSILON &&
      Math.abs(a.altitude - b.altitude) < 1
    );
  }

  /** Forget history that is too old or too long to be worth drawing. */
  function trim(track: Track, now: number) {
    while (track.samples.length > TRAIL_POINTS) track.samples.shift();
    while (track.samples.length > 2) {
      const oldest = track.samples[0];
      if (!oldest || now - oldest.at <= TRAIL_SECONDS) break;
      track.samples.shift();
    }
  }

  // ---- Per-frame ----------------------------------------------------------

  function tick() {
    const now = nowSeconds();
    for (const track of tracks.values()) {
      const n = track.samples.length;
      const to = track.samples[n - 1];
      if (!to) continue;
      const from = track.samples[n - 2] ?? to;
      const alpha = from === to ? 1 : clamp((now - to.at) / track.span, 0, 1);

      /**
       * A missing aircraft stops being drawn once it has finished arriving.
       *
       * The grace period holds a track's *history* across a dropped refresh,
       * which is the point of it — but holding the history is not the same as
       * going on drawing the aeroplane. Left drawn, a target that genuinely
       * left the feed froze in mid-air at full opacity with its whole trail
       * attached, for the full thirty-two seconds, indistinguishable from an
       * aircraft that had stopped flying. The godmode dial made it unmissable:
       * turning 400 fabricated aircraft down to zero left 400 darts hanging.
       *
       * Gated on having run out of interpolation rather than simply on being
       * missing, so a target absent for one refresh keeps moving to where it was
       * last seen and never blinks — and if it comes back, it resumes its trail
       * instead of rebuilding it.
       */
      track.stale = track.missingSince !== 0 && now - to.at > track.span;
      track.mesh.visible = !track.stale;
      // An aeroplane that is no longer drawn must no longer be clickable, and
      // saying so is not optional: `Raycaster` does not consult `visible`, so a
      // hidden track left in the list goes on opening its card from a patch of
      // empty sky for the rest of the grace period.
      setPickable(track, !track.stale);
      if (track.stale) continue;

      /**
       * Two motion models, and which one a track gets is decided by whether the
       * source told it how fast it is going. See `Track.reckon`.
       *
       * The reckoned branch is the one that matters for a live feed and it is
       * deliberately *not* clamped to the newest observation: it flies past it,
       * because the aircraft did. The interpolated branch below is the original
       * behaviour and stops on arrival, which is the only honest thing to do
       * with a position whose velocity nobody stated.
       */
      if (track.reckon !== null) {
        reckonedHead(track.reckon, now, track.head);
        track.headAltitude = reckonedAltitude;
      } else {
        track.head.lerpVectors(from.position, to.position, alpha);
        track.headAltitude = from.altitude + (to.altitude - from.altitude) * alpha;
      }

      track.mesh.position.copy(track.head);
      /**
       * Big enough to be an aeroplane from wherever this is being watched.
       *
       * Per aircraft rather than once for the layer, because the board is deep:
       * on the California corridor an arrival over Los Angeles and one over the
       * Bay are hundreds of units apart along the view axis, and a single scale
       * taken from the camera's orbit distance would leave the far one half the
       * size of the near one — which reads as depth on a photograph and as an
       * inconsistency on a map, where two aeroplanes at the same altitude are
       * the same aeroplane.
       *
       * Uniform, so nothing about the shape changes: this is the glyph the
       * geometry file drew, held at a legible size, and not a stretched one.
       */
      if (viewer !== null) {
        track.mesh.scale.setScalar(
          glyphScale(
            viewer.position.distanceTo(track.head),
            viewer.fov,
            focusDistance ?? undefined,
            baseScale,
          ),
        );
      }
      // A heading of 0 is north, and north is -z, so an aircraft whose nose is
      // modelled along +z has to be turned all the way round before the compass
      // and the scene agree. The previous mapping was a bare negation of the
      // heading, which flew every aircraft tail-first and put an easterly
      // departure over the Pacific.
      track.mesh.rotation.y = Math.PI - (interpolateHeading(from.heading, to.heading, alpha) * Math.PI) / 180;
      // Negative, because rotating the nose (+z) about +x by a positive angle
      // pushes it down.
      track.mesh.rotation.x = -track.pitch;
      /**
       * Bank, and the sign of it is the entire point of the channel.
       *
       * `rotation.order` is "YXZ", so z is applied first and therefore turns in
       * the *body* frame — about whatever axis the nose has ended up on rather
       * than about the world's z. That is what makes this a roll at all instead
       * of a lean, and it is why one sign works at every heading.
       *
       * Which sign: the nose is modelled along +Z and the aircraft's own up is
       * +Y — `aircraftGeometry` builds the fin in the x = 0 plane reaching up to
       * y = +0.098, so there is no ambiguity about which way is up on this mesh
       * and therefore none about which way its wings go. A positive rotation
       * about +Z takes +Y to (−sin θ, cos θ, 0), so the up vector tilts toward
       * local −X; and local −X is the aircraft's right-hand side, because
       * right = nose × up = (+Z) × (+Y) = −X.
       * **Positive `rotation.z` drops the right wing.**
       *
       * A compass heading increasing is a turn to the right — north through east
       * — and `headingDelta` is positive for exactly that. The two conventions
       * already agree, which is why there is no negation here, unlike on the two
       * channels above.
       *
       * Verified against three.js rather than reasoned about alone, because
       * getting this backwards is the failure everybody sees and nobody can
       * name: with rotation.y = π (heading 000) and rotation.z = +30°, the
       * mesh's world up comes out (0.5, 0.866, 0) — tilted toward +x, and +x is
       * east, which is the right hand of a northbound aircraft. At heading 090
       * the same roll tilts it to (0, 0.866, 0.5), toward +z, which is south and
       * is the right hand of an eastbound one.
       */
      track.mesh.rotation.z = track.rollFrom + (track.rollTo - track.rollFrom) * alpha;

      const band = bandFor(track.headAltitude);
      if (band !== track.band) {
        track.band = band;
        track.mesh.material = materialFor(band);
      }
    }
    rebuildTrails();
  }

  /**
   * Rewrite the shared trail buffer.
   *
   * The spine is every observation except the newest, followed by the
   * interpolated head — the newest observation is where the aircraft is *going*,
   * and drawing to it would put the trail in front of the aircraft.
   */
  function rebuildTrails() {
    let vertex = 0;
    let drawn = 0;
    for (const track of tracks.values()) {
      // Past the ceiling, an aircraft keeps its dart and loses its trail. The
      // buffer was sized for this many and the constant meant nothing until now.
      if (drawn >= MAX_TRACKS) break;
      // Before `drawn`, so an expiring ghost does not hold a trail slot ahead of
      // a genuinely new arrival — `tracks` is walked in insertion order, and the
      // ghosts are the oldest entries in it.
      if (track.stale) continue;
      /**
       * How many observations the trail is drawn through before the head.
       *
       * The two motion models differ here, and the difference is not cosmetic.
       * An **interpolated** track's newest observation is where it is *going*,
       * so drawing to it would put the trail in front of the aeroplane — the
       * spine stops one short and the last segment runs to the interpolated
       * head, which lies between the two. A **reckoned** track has already flown
       * past its newest observation, so that observation is history like every
       * other one: leaving it out would cut the corner between the previous fix
       * and the reckoned head, and a turning aircraft would trail a chord
       * across the inside of its own turn.
       */
      const spine = track.reckon !== null ? track.samples.length : track.samples.length - 1;
      if (spine < 1) continue;
      drawn += 1;
      const points = spine + 1; // the spine, plus the head

      /**
       * Where to start drawing this track, so that a buffer that cannot hold
       * everything loses the **oldest** segments rather than the newest.
       *
       * The loop writes tail-first, so the previous `break`-when-full dropped
       * the segments nearest the aircraft. That is the worst possible end to
       * lose: it left a streak floating in open air with no aeroplane attached
       * to it, which reads as a rendering fault rather than as a shortened
       * trail. It was invisible at 7–8 aircraft and unmissable the moment the
       * godmode dial put four hundred in the sky.
       *
       * Clamping the start index instead means a crowded sky draws shorter
       * trails, every one of them still joined to its dart.
       */
      const budget = Math.max(0, (maxVertices - vertex) / 2);
      if (budget < 1) break;
      const first = Math.max(1, points - Math.floor(budget));

      for (let i = first; i < points; i++) {
        if (vertex + 2 > maxVertices) break;
        const a = track.samples[i - 1];
        if (!a) continue;
        const bSample = i < spine ? track.samples[i] : null;
        const bPosition = bSample ? bSample.position : track.head;
        const bAltitude = bSample ? bSample.altitude : track.headAltitude;

        // Alpha runs from nothing at the tail to `TRAIL_ALPHA` at the aircraft,
        // eased so that the fade happens mostly in the older half and the
        // segment behind the dart stays legible.
        writeTrailVertex(vertex++, a.position, a.altitude, ((i - 1) / spine) ** 1.7);
        writeTrailVertex(vertex++, bPosition, bAltitude, (i / spine) ** 1.7);
      }
    }
    trailGeo.setDrawRange(0, vertex);

    /**
     * Upload only the vertices actually written this frame.
     *
     * `needsUpdate` alone re-sends the entire `Float32Array` — three.js takes an
     * empty update range to mean "all of it" — which was 12.9 MB/s at 60 Hz with
     * a 20-point trail and would have been 46 MB/s at 72 for a scene that
     * normally holds seven aircraft and writes under 2% of the buffer. The rest
     * of the array is stale data nothing draws, because `setDrawRange` already
     * bounds what is read.
     *
     * `clearUpdateRanges` first, or the ranges accumulate frame on frame and the
     * saving disappears within a second.
     */
    const positionAttr = trailGeo.attributes.position as THREE.BufferAttribute;
    const colorAttr = trailGeo.attributes.color as THREE.BufferAttribute;
    positionAttr.clearUpdateRanges();
    colorAttr.clearUpdateRanges();
    if (vertex > 0) {
      positionAttr.addUpdateRange(0, vertex * 3);
      colorAttr.addUpdateRange(0, vertex * 4);
      positionAttr.needsUpdate = true;
      colorAttr.needsUpdate = true;
    }
  }

  function writeTrailVertex(index: number, position: THREE.Vector3, altitude: number, fade: number) {
    const p = index * 3;
    trailPositions[p] = position.x;
    trailPositions[p + 1] = position.y;
    trailPositions[p + 2] = position.z;
    // `THREE.Color` holds working-space values, which is what a vertex colour
    // attribute is read as — so the ramp and the dart materials, which come from
    // the same two colours, agree.
    scratch.copy(LOW_COLOR).lerp(HIGH_COLOR, ramp(altitude));
    const c = index * 4;
    trailColors[c] = scratch.r;
    trailColors[c + 1] = scratch.g;
    trailColors[c + 2] = scratch.b;
    trailColors[c + 3] = fade * TRAIL_ALPHA;
  }

  return {
    group,
    pickables,
    update,
    tick,
    setFocusDistance(distance) {
      focusDistance = Number.isFinite(distance) && distance > 0 ? distance : null;
    },
    dispose() {
      geo.dispose();
      for (const m of materials.values()) m.dispose();
      materials.clear();
      trailGeo.dispose();
      trailMat.dispose();
      tracks.clear();
      pickables.length = 0;
      group.clear();
    },
  };
}



/**
 * How much to enlarge an aeroplane so that it is still an aeroplane from here.
 *
 * Pure arithmetic on two numbers the camera already knows, extracted so it can
 * be tested without a WebGL context — the defect it exists to fix is a *visual*
 * one and can only be confirmed with a picture, but the ratio it turns on is
 * exactly the kind of thing that regresses silently under a refactor.
 *
 * `2·distance·tan(fov/2)` is the world-space height of the frustum at that
 * distance — the ruler the frame is measured with — so the glyph's share of the
 * screen is its length over that. Solving for the length that hits
 * `GLYPH_MIN_SCREEN_FRACTION` and dividing by the length the geometry was
 * authored at gives the scale, and `Math.max(1, …)` is the floor rather than a
 * fit: an aeroplane close enough to read at its authored size keeps it.
 *
 * Degenerate inputs return 1 rather than throwing. A camera at zero distance
 * from an aircraft is the chase view, a camera with no field of view is a
 * caller in the middle of setting one up, and neither is a reason for the sky
 * to disappear.
 */
export function glyphScale(
  distance: number,
  fovDegrees: number,
  focusDistance?: number,
  minScale = 1,
): number {
  if (!Number.isFinite(distance) || !Number.isFinite(fovDegrees)) return minScale;
  if (distance <= 0 || fovDegrees <= 0 || fovDegrees >= 180) return minScale;
  const legible = legibleScale(distance, fovDegrees);
  /*
   * The third argument is optional and omitting it must reproduce the previous
   * answer exactly — not approximately. Twenty existing assertions in
   * `src/test/render/glyphScale.test.ts` pin the two-argument behaviour, and a
   * ceiling that moved by a hair under a refactor would be the kind of silent
   * visual drift this whole function exists to prevent. So the focus-aware
   * ceiling is computed only when a focus distance was actually supplied, and
   * `GLYPH_MAX_SCALE` remains the backstop above it either way.
   */
  const ceiling =
    focusDistance !== undefined && Number.isFinite(focusDistance) && focusDistance > 0
      ? Math.min(
          GLYPH_MAX_SCALE,
          Math.max(minScale, legibleScale(focusDistance, fovDegrees) * GLYPH_FOCUS_HEADROOM),
        )
      : GLYPH_MAX_SCALE;
  return Math.min(ceiling, Math.max(minScale, legible));
}

/**
 * How many times the authored glyph a distance of `d` needs to hold
 * `GLYPH_MIN_SCREEN_FRACTION` of the frame. Unclamped on purpose: the floor and
 * the ceiling clamp it in opposite directions and both want the raw number.
 *
 * `2·d·tan(fov/2)` is the world-space height of the frustum at that distance —
 * the ruler the frame is measured with — so the glyph's share of the screen is
 * its length over that.
 */
function legibleScale(distance: number, fovDegrees: number): number {
  const frustumHeight = 2 * distance * Math.tan((fovDegrees * Math.PI) / 360);
  return (GLYPH_MIN_SCREEN_FRACTION * frustumHeight) / AIRLINER_LENGTH;
}

/**
 * Scratch for `raycastGlyph`. Module-level and reused: a raycast runs once a
 * frame against every aeroplane in the sky, and four hundred of them allocating
 * a `Sphere` and a `Vector3` apiece is a garbage-collection pause a pointer can
 * feel.
 */
const pickSphere = new THREE.Sphere();
const pickPoint = new THREE.Vector3();
const pickScale = new THREE.Vector3();

/**
 * What a pointer hits when it aims at an aeroplane: a sphere, not the aeroplane.
 *
 * Assigned onto each aircraft mesh in place of `Mesh.raycast`, which is the
 * extension point three.js publishes for exactly this — `Object3D.raycast` is
 * documented as the method a subclass or an instance supplies to say how it
 * meets a ray, and `Points` and `Line` already answer it with a threshold
 * instead of with geometry for the same reason this does.
 *
 * The reason is `PICK_RADIUS_GLYPHS`: at any camera distance where this layer is
 * worth looking at, the aeroplane's own triangles are a few pixels of fuselage
 * and a wing two pixels thick, and a pointer test against those is a game of
 * marksmanship rather than an interface. A sphere at the glyph's own scale is
 * the target a person thinks they are aiming at.
 *
 * Doing it here rather than with a second invisible object in the scene is worth
 * the unusual assignment. A proxy mesh would be another `Object3D` per aircraft
 * — four hundred more nodes to walk and four hundred more world matrices to
 * compose every frame, for something that is never drawn — and it would have to
 * be kept in step with the aeroplane's position and scale by hand. This costs
 * one sphere test, allocates nothing, and cannot drift out of step because there
 * is nothing to keep in step with.
 *
 * The scale is read off `matrixWorld` rather than off `this.scale` on purpose:
 * the layer's group is at the identity today, and a raycast that silently starts
 * lying if somebody ever moves or scales it is precisely the class of bug this
 * file's comments keep recording.
 */
function raycastGlyph(
  this: THREE.Mesh,
  raycaster: THREE.Raycaster,
  intersects: THREE.Intersection[],
): void {
  pickSphere.center.setFromMatrixPosition(this.matrixWorld);
  pickScale.setFromMatrixScale(this.matrixWorld);
  pickSphere.radius = PICK_RADIUS_GLYPHS * AIRLINER_LENGTH * pickScale.x;
  // `intersectSphere` answers with the near hit, or with the far one when the
  // ray starts inside — so a camera flying through the sphere still picks the
  // aeroplane it is inside rather than nothing at all.
  if (raycaster.ray.intersectSphere(pickSphere, pickPoint) === null) return;
  const distance = raycaster.ray.origin.distanceTo(pickPoint);
  // The near/far clamp is the caller's contract and `Mesh.raycast` honours it;
  // an aeroplane behind the camera must not be pickable through the back of it.
  if (distance < raycaster.near || distance > raycaster.far) return;
  // Cloned rather than shared: the caller keeps the intersection, and every hit
  // in a frame would otherwise be handed the same point object.
  intersects.push({ distance, point: pickPoint.clone(), object: this });
}

/**
 * The climb angle of a leg, from the real numbers rather than the scene's.
 *
 * Scene height is exaggerated 3.6× here, so an angle measured off the rendered
 * positions would put a routine departure at forty degrees nose-up. Horizontal
 * distance in scene units *is* proportional to distance on the ground, so one
 * multiplication converts it and the altitudes are already metres.
 */
function climbAngle(world: World, from: TrailSample, to: TrailSample): number {
  const dx = to.position.x - from.position.x;
  const dz = to.position.z - from.position.z;
  const horizontal = Math.hypot(dx, dz) * world.metresPerUnit;
  if (horizontal < 1) return 0;
  return clamp(Math.atan2(to.altitude - from.altitude, horizontal), -MAX_PITCH, MAX_PITCH);
}

/**
 * How far to bank for the turn between two observations, in radians.
 *
 * Three things are happening here and each is load-bearing.
 *
 * **The rate uses the clamped `span`, not the real gap between the samples.**
 * That looks like a bug and is not: `span` is the time this layer is going to
 * spend *rendering* the heading change, and the bank has to match the turn the
 * viewer is watching rather than the one that happened. A feed that stalled for
 * two minutes and came back a hundred and eighty degrees round has its recovery
 * drawn by `tick` as a thirty-second turn, and an aircraft pivoting through half
 * the compass in thirty seconds with its wings level is precisely the tell this
 * function exists to remove.
 *
 * **`headingDelta` takes the short way round**, so 358° → 002° is +4° over the
 * span and not −356°. Without that, a track crossing north would slam to the
 * ceiling in the wrong direction for one leg and then unwind — the same wrap
 * that `interpolateHeading` was already written for, on a channel where it would
 * be far more obvious.
 *
 * **The lag is applied here rather than in `tick`**, so it advances once per
 * observation and in proportion to how much time that observation covered.
 * Putting it on the frame instead would make the settling rate depend on the
 * frame rate, and a 144 Hz monitor would bank aircraft differently from a 30 Hz
 * one.
 *
 * The `Number.isFinite` guard is not defensive padding. Unlike the yaw, which
 * `tick` recomputes from the samples every frame and which therefore repairs
 * itself, the roll is *state* — it is fed its own previous value. One
 * non-numeric heading from a feed would not cost a frame, it would poison the
 * track for as long as it lives, because every later value is computed from this
 * one. Returning `current` costs a leg of staleness and nothing else.
 */
function bankAngle(from: TrailSample, to: TrailSample, span: number, current: number): number {
  const radiansPerSecond = (headingDelta(from.heading, to.heading) * Math.PI) / 180 / span;
  if (!Number.isFinite(radiansPerSecond)) return current;
  const target = clamp(Math.atan(radiansPerSecond * SPEED_OVER_G), -MAX_BANK, MAX_BANK);
  return current + (target - current) * (1 - Math.exp(-span / ROLL_SETTLE_SECONDS));
}

/**
 * The signed shortest angle from one compass heading to another, in degrees.
 *
 * Positive is a turn to the right: clockwise on the compass, north toward east.
 * The result is in [−180, 180) — an exact reversal comes out as a left turn,
 * arbitrarily, because two headings 180° apart carry no information about which
 * way the aircraft went round.
 *
 * The yaw and the bank both need this and they have to agree about it. If one of
 * them took the long way round, a track crossing north would spin one way while
 * banking the other.
 */
function headingDelta(from: number, to: number): number {
  return ((((to - from) % 360) + 540) % 360) - 180;
}

/**
 * Blend two compass headings the short way round.
 *
 * A straight lerp from 350° to 10° spins the aircraft 340° through south over
 * the course of a second, which is the most conspicuous artefact this whole file
 * could have.
 */
function interpolateHeading(from: number, to: number, t: number): number {
  return from + headingDelta(from, to) * t;
}

/** 0 on the deck, 1 at cruise. Curved, because the low end is where the eye is. */
function ramp(altitude: number): number {
  return clamp(altitude / CRUISE_METRES, 0, 1) ** 0.6;
}

function bandFor(altitude: number): number {
  return Math.round(ramp(altitude) * (COLOR_BANDS - 1));
}

function clamp(x: number, lo: number, hi: number): number {
  return x < lo ? lo : x > hi ? hi : x;
}
