/**
 * Satellites over the city — on a dome, because they cannot be anywhere else.
 *
 * Every other thing in this scene lives in projected metric space: a building is
 * where the building is, an aircraft at 10,000 m is at `world.metres(10000)`
 * above the ground it is over. That rule breaks completely here, and it is worth
 * being explicit about how badly, because the alternative is a layer that renders
 * nothing and looks broken.
 *
 * Starlink flies at about 550 km. The Bay Area board is ~94 m per scene unit with
 * a 3.6× vertical exaggeration, so `world.metres(550_000)` is **21,000 scene
 * units** — against a board 1,000 units across and a camera far plane at 3,000.
 * The satellite is seven times beyond the horizon of the projection, over ground
 * two thousand kilometres away that this city pack does not contain. There is no
 * camera position from which the true placement is both visible and meaningful.
 *
 * So this layer draws **what you would see if you looked up**: each satellite at
 * its real azimuth and elevation from the city centre, on a dome big enough to
 * sit outside the buildings and inside the far plane. A dot due east at 30° above
 * the horizon is genuinely due east at 30°. The dome's *radius* is arbitrary and
 * carries no information; the direction carries all of it.
 *
 * That is not a compromise so much as the correct frame for the question. Nobody
 * looking at a satellite layer wants to know its ECEF coordinates. They want to
 * know whether one is passing over, where to look, and whether it is lit — and
 * the last of those is why this bothers with `shadowFraction` rather than drawing
 * every object the same brightness. A Starlink is visible to the naked eye when
 * it is in sunlight while the ground below it is dark, which is the whole reason
 * anybody ever noticed the constellation existed.
 *
 * ### Where the propagation happens, and why it is here
 *
 * The server sends element sets, not positions (`wire.ts`, `SatellitesBody`), for
 * the same reason it sends a flight plan rather than aircraft: a TLE is already
 * the closed form, SGP4 is the function that evaluates it, and every browser
 * evaluating it agrees. One cacheable fetch every six hours replaces a poll.
 *
 * `satellite.js` is the second runtime dependency this package has ever taken,
 * after three.js, and the bar it had to clear was "would hand-rolling this be
 * better". SGP4 is a 1980 Fortran model with a specific set of drag and
 * resonance terms, a published reference implementation, and a well-known list of
 * ways a re-implementation goes subtly wrong; the library is MIT, is a direct
 * translation of Vallado's C++, and is the one everybody checks against. Writing
 * our own would have been a worse copy of it.
 */

import * as THREE from "three";
import {
  eciToEcf,
  ecfToLookAngles,
  gstime,
  jday,
  propagate,
  shadowFraction,
  sunPos,
  twoline2satrec,
  type SatRec,
} from "satellite.js";
import type { City, SatelliteGroup } from "./types.ts";

/**
 * One satellite's element set, as the catalogue takes it.
 *
 * Structurally `WireSatellite` from `server/wire.ts`, restated rather than
 * imported for the same reason `SimRoute` is: the server must be able to build
 * one without three.js becoming one of its dependencies.
 */
export interface SatelliteElements {
  noradId: number;
  name: string;
  group: SatelliteGroup;
  line1: string;
  line2: string;
}

/** Where one satellite is in the observer's sky, right now. */
export interface SatelliteFix {
  noradId: number;
  name: string;
  group: SatelliteGroup;
  /** Radians clockwise from true north. */
  azimuth: number;
  /** Radians above the horizon. Only non-negative fixes are ever produced. */
  elevation: number;
  /** Observer to satellite, in kilometres. Straight-line, not ground track. */
  rangeKm: number;
  /**
   * Sub-satellite point, degrees. The ground under the spacecraft, not the
   * look-angle on the dome. Follow cameras use this; `starlinkMesh.ts`'s
   * `nadirOf` is attitude of a mesh on that dome and is the other question.
   */
  lat: number;
  lng: number;
  /**
   * How much of the sun's disc the earth is covering, from the satellite's point
   * of view. `0` is full sunlight, `1` is umbra, and the values in between are
   * the penumbra — which is where the fade at the end of a Starlink train comes
   * from, and is the reason this is a fraction rather than a boolean.
   */
  shadow: number;
}

/**
 * Observer height above the ellipsoid, in kilometres.
 *
 * Zero. The correction for a city at 50 m matters to a radar and not to a dot on
 * a dome — it moves a look angle by well under a hundredth of a degree — and
 * pretending otherwise would mean threading a ground elevation through here for
 * no visible change.
 */
const OBSERVER_HEIGHT_KM = 0;

/**
 * How much of a frame the rolling sweep may spend propagating.
 *
 * SGP4 costs roughly ten microseconds per object, so a six-thousand-object
 * catalogue is about sixty milliseconds — four frames' worth, in one lump, and
 * a visible hitch if it happens all at once. Doing it every frame is out of the
 * question and doing it on a timer just moves the hitch somewhere less
 * predictable.
 *
 * So the sweep is **time-budgeted and rolling**: each call propagates as many
 * objects as fit in this budget and remembers where it stopped, wrapping around
 * the catalogue continuously. Two milliseconds is under a seventh of a 60 Hz
 * frame, and it walks six thousand objects in about half a second.
 *
 * The staleness that buys is the thing to check, and it is negligible: half a
 * second at Starlink's ~0.8° per second of apparent motion overhead is under
 * half a degree of arc. Nobody can see that. An aircraft interpolated half a
 * second late would be visibly behind; a satellite is not, because the dome is
 * angular and the angles barely move.
 *
 * (`satellite.js` ships a WASM `BulkPropagator` that would make this a
 * non-question. It is not used here because it needs a binary loaded at runtime
 * and a fallback path for when that fails, which is a lot of machinery to buy
 * back two milliseconds a frame that are already accounted for.)
 */
const SWEEP_BUDGET_MS = 2;

/**
 * The observer's own coordinates, in the radians `ecfToLookAngles` wants.
 *
 * Built once. `geodeticToEcf` would recompute the same three numbers on every
 * call otherwise, several thousand times a second.
 */
interface Observer {
  longitude: number;
  latitude: number;
  height: number;
}

/**
 * A catalogue of element sets, propagated continuously, reporting what is up.
 *
 * Deliberately shaped like `SimulatedFlights` and `AdsbFlights` — construct with
 * data, ask for the current state — but it is **not** a `FlightSource` and does
 * not implement that interface. A `FlightSource.poll()` returns positions on the
 * ground; this returns look angles on a dome, and collapsing the two into one
 * interface would mean a renderer that could not tell which space it was in.
 */
export class SatelliteCatalogue {
  private readonly records: { rec: SatRec; meta: SatelliteElements }[] = [];
  private readonly observer: Observer;

  /** Latest fix per NORAD id. Entries are deleted as they set below the horizon. */
  private readonly current = new Map<number, SatelliteFix>();

  /** Where the rolling sweep stopped last time. */
  private cursor = 0;

  /**
   * How many objects the last full pass found above the horizon, for the panel.
   * Counted rather than derived from `current.size` so that a partial sweep does
   * not make the number jump around while it is still walking the catalogue.
   */
  private lastPassVisible = 0;
  private passVisible = 0;

  constructor(elements: SatelliteElements[], center: City["center"]) {
    for (const el of elements) {
      // A TLE this build cannot read is one satellite missing, never a throw:
      // the catalogue arrives over the network and one malformed line must not
      // take the layer down.
      //
      // `rec.error` is necessary and **not sufficient**, which is worth stating
      // because the obvious version of this check is wrong. `twoline2satrec`
      // reads fixed columns with `parseFloat` and does not validate: hand it two
      // lines of prose that merely start "1 " and "2 " and it returns `error: 0`
      // with `NaN` in the orbital elements. Those propagate to `NaN` positions,
      // which become `NaN` look angles, which land in the layer's vertex buffer
      // — and one `NaN` vertex is enough to make a `Points` draw call render
      // nothing at all. So the elements are checked for being numbers.
      const rec = twoline2satrec(el.line1, el.line2);
      if (rec.error !== 0) continue;
      if (!Number.isFinite(rec.no) || !Number.isFinite(rec.inclo) || !Number.isFinite(rec.ecco)) {
        continue;
      }
      this.records.push({ rec, meta: el });
    }

    this.observer = {
      longitude: (center.lng * Math.PI) / 180,
      latitude: (center.lat * Math.PI) / 180,
      height: OBSERVER_HEIGHT_KM,
    };
  }

  /** How many element sets this build could actually read. */
  get size(): number {
    return this.records.length;
  }

  /** How many were above the horizon at the end of the last complete pass. */
  get visibleCount(): number {
    return this.lastPassVisible;
  }

  /**
   * Advance the rolling sweep and return everything currently above the horizon.
   *
   * `when` is passed in rather than read from the clock because godmode scrubs
   * time — the whole panel exists to put the scene at an arbitrary instant, and
   * a layer that quietly used `new Date()` would be the one thing on screen that
   * ignored the scrubber. It is also what makes a time-lapse capture possible at
   * all: `shots/` steps the clock rather than recording it.
   */
  fixes(when: Date): SatelliteFix[] {
    if (this.records.length === 0) return [];

    const gmst = gstime(when);
    // The sun moves a degree a day; computing its position once per sweep call
    // rather than once per satellite is free accuracy-wise and saves a few
    // thousand redundant evaluations.
    const sun = sunPos(jday(when));

    const deadline = performance.now() + SWEEP_BUDGET_MS;
    let stepped = 0;

    // At least one per call, so a machine so slow that `performance.now()` has
    // already passed the deadline still makes progress instead of freezing the
    // sky forever.
    do {
      const entry = this.records[this.cursor];
      if (entry !== undefined) {
        const fix = this.fixOne(entry.rec, entry.meta, when, gmst, sun.rsun);
        if (fix === null) this.current.delete(entry.meta.noradId);
        else {
          this.current.set(entry.meta.noradId, fix);
          this.passVisible += 1;
        }
      }

      this.cursor += 1;
      if (this.cursor >= this.records.length) {
        // A pass completed: publish its count and start the next one's tally.
        this.cursor = 0;
        this.lastPassVisible = this.passVisible;
        this.passVisible = 0;
      }
      stepped += 1;
    } while (performance.now() < deadline && stepped < this.records.length);

    return [...this.current.values()];
  }

  /** One satellite, or `null` if it is below the horizon or will not propagate. */
  private fixOne(
    rec: SatRec,
    meta: SatelliteElements,
    when: Date,
    gmst: number,
    sunEciAU: { x: number; y: number; z: number },
  ): SatelliteFix | null {
    // `propagate` returns null for a decayed object and for an element set it
    // cannot carry to this date — both of which are ordinary in a catalogue that
    // is hours old, and neither of which is this layer's problem.
    const state = propagate(rec, when);
    const eci = state?.position;
    if (eci === undefined || typeof eci === "boolean") return null;

    const ecf = eciToEcf(eci, gmst);
    const look = ecfToLookAngles(this.observer, ecf);
    // Below the horizon is the common case by a wide margin — a few hundred of
    // several thousand objects are up at any instant — so this returns before
    // the shadow calculation rather than after it.
    //
    // `NaN < 0` is false, so this comparison alone would let a degenerate
    // element set through to the vertex buffer. The constructor screens for that
    // and this is the belt: an object can also decay or go numerically unstable
    // partway through a session, long after it was admitted.
    if (!(look.elevation >= 0)) return null;

    const nadir = geodeticFromEcf(ecf);
    if (nadir === null) return null;

    return {
      noradId: meta.noradId,
      name: meta.name,
      group: meta.group,
      azimuth: look.azimuth,
      elevation: look.elevation,
      rangeKm: look.rangeSat,
      shadow: shadowFraction(sunEciAU, eci),
      lat: nadir.lat,
      lng: nadir.lng,
    };
  }
}

/**
 * Sub-satellite point from an ECEF position in kilometres.
 *
 * Spherical geodetic is enough: Starlink is 550 km up, and the ellipsoid vs a
 * sphere moves the ground point by a few kilometres — smaller than a board
 * cell on the state pack. Follow cameras need "over California", not a survey.
 */
export function geodeticFromEcf(ecf: { x: number; y: number; z: number }): { lat: number; lng: number } | null {
  const r = Math.hypot(ecf.x, ecf.y, ecf.z);
  if (!(r > 0) || !Number.isFinite(r)) return null;
  const lat = (Math.asin(Math.min(1, Math.max(-1, ecf.z / r))) * 180) / Math.PI;
  const lng = (Math.atan2(ecf.y, ecf.x) * 180) / Math.PI;
  if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null;
  return { lat, lng };
}

export function nadirInBounds(
  fix: Pick<SatelliteFix, "lat" | "lng">,
  bounds: { minLat: number; maxLat: number; minLng: number; maxLng: number },
): boolean {
  return (
    fix.lat >= bounds.minLat &&
    fix.lat <= bounds.maxLat &&
    fix.lng >= bounds.minLng &&
    fix.lng <= bounds.maxLng
  );
}

/**
 * The satellite to possess next: nadir inside `bounds`, nearest to `from`
 * if given, else the lowest NORAD id. Deterministic — two frames with the
 * same fix list agree.
 */
export function nextOverCalifornia(
  fixes: readonly SatelliteFix[],
  bounds: { minLat: number; maxLat: number; minLng: number; maxLng: number },
  from?: { lat: number; lng: number },
): SatelliteFix | null {
  const inside = fixes.filter((fix) => nadirInBounds(fix, bounds));
  if (inside.length === 0) return null;
  if (from === undefined) {
    let best = inside[0]!;
    for (const fix of inside) {
      if (fix.noradId < best.noradId) best = fix;
    }
    return best;
  }
  let best = inside[0]!;
  let bestD = (best.lat - from.lat) ** 2 + (best.lng - from.lng) ** 2;
  for (const fix of inside) {
    const d = (fix.lat - from.lat) ** 2 + (fix.lng - from.lng) ** 2;
    if (d < bestD - 1e-12 || (Math.abs(d - bestD) <= 1e-12 && fix.noradId < best.noradId)) {
      best = fix;
      bestD = d;
    }
  }
  return best;
}

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

export interface SatelliteLayer {
  group: THREE.Group;
  /** Redraw from a set of fixes. Cheap enough to call every frame, and is. */
  update(fixes: SatelliteFix[]): void;
  /**
   * How dark the sky is, 0..1 — `nightFactor(sun.elevation)` from
   * `atmosphere.ts`, and nothing else.
   *
   * This layer draws additively, which is correct at night and catastrophic in
   * daylight: at 15:55 with the sun at +44° every dot was adding to an already
   * bright sky and clipping to a hard white square, which is what a first-time
   * visitor to the California board saw scattered across the frame before
   * anything else registered. A satellite in daylight is not visible to the
   * naked eye, so the honest alpha is zero — and the fade is the *same* curve
   * `nightlights.ts` switches the city on with, so the sky does not empty at a
   * different dusk from the one the windows light up at.
   */
  setSkyDarkness(darkness: number): void;
  /**
   * Whether the layer draws at all. The catalogue keeps propagating either way —
   * see `setVisible` for why that is deliberate rather than wasteful.
   */
  setVisible(visible: boolean): void;
  dispose(): void;
}

/**
 * The dome's radius, as a fraction of **how far the board reaches from the scene
 * origin** — `boardRadius` in `scene.ts`, not the board's width.
 *
 * That distinction was got wrong once and is worth stating plainly. Scene space
 * is centred on `city.center`, and the Bay Area board runs forty kilometres down
 * the peninsula from there, so its furthest corner is 0.94 spans from the origin
 * while its half-diagonal is 0.65. A dome sized at 0.7 *spans* therefore sat
 * **inside the southern third of its own city** — satellites rendering below the
 * terrain and behind the hills, which looks like a depth bug and is a units bug.
 *
 * **You are meant to be able to get above this.** That is a deliberate reversal:
 * an earlier version put the dome at 2.0 spans specifically so the camera could
 * never leave it, on the theory that a sky you can step outside of is not a sky.
 * That theory loses to the thing people actually want to look at — a
 * constellation is an object, and the view of it from above, with the city
 * underneath, is the shot. Standing inside a sphere of dots is a planetarium.
 *
 * So the geometry is worked out from the other end. The board's half-diagonal is
 * 0.649 spans for San Francisco and 0.635 for the Southland, so 0.70 clears
 * every corner of the city while sitting well inside the camera's new 2.0-span
 * orbit. At the far end of the zoom the whole dome is in frame at this fov and
 * the board still fills about two thirds of the screen height.
 *
 * Two bugs died with the old number, and both were invisible from the default
 * pose. At 2.0 the dome reached 3.5 spans from the far side of the orbit against
 * a far plane at 3.0, so roughly a sixth of the sky was being **clipped**; and
 * everything past 2.8 spans was **fully fogged** by the city's own linear fog.
 * The band where the constellation was both unclipped and unfogged did not
 * overlap the band where it fitted on screen at all.
 */
export const DOME_RADIUS_FACTOR = 1.05;

/**
 * How large a dot is drawn, in **pixels**, at any camera distance.
 *
 * `sizeAttenuation` is off, and that is the physically honest choice rather than
 * a convenience. Everything on this dome is the same distance away and the dome
 * is a stand-in for something 550 km up: a satellite does not get bigger because
 * you zoomed the map in, and a point of light at effectively infinite distance
 * has an apparent size set by the eye rather than by the range. Attenuation was
 * on first and did the wrong thing twice over — it shrank the whole sky as the
 * camera pulled back, and at the far end of the zoom it took a 324-object
 * constellation down to specks a pixel across that read as noise in the sky
 * texture.
 *
 * Three and a half pixels is about what an actual naked-eye Starlink looks like
 * against a dark sky, which is the reference this is aiming at.
 */
const DOT_PIXELS = 3.5;

/**
 * The range, in kilometres, at which an object is drawn at `DOT_PIXELS`, and how
 * hard apparent size follows range.
 *
 * Every dot used to be the same object: the same size, the same square silhouette,
 * the same colour per constellation, with alpha the only thing that varied. A few
 * hundred pixel-identical squares on a sphere sample the pixel grid as a regular
 * lattice, which is the moire this exists to break. The two cues that turn a
 * lattice into a population are **size** and **something else in the sky to read
 * it against**; the moon is the second, in `scenekit.ts`.
 *
 * The size cue has to come from something true or it is decoration, and there is
 * exactly one such number already in a `SatelliteFix`: the slant range. It is
 * genuinely what sets how bright a naked-eye pass looks, and on this catalogue it
 * spans a factor of ninety — a station at 400 km against a navigation bird at
 * 36,000. Rendered as an inverse square that would be a factor of 8,000 in
 * brightness and most of the sky would vanish, so the exponent is 0.35: a gentle
 * curve that puts a low pass at four and a half pixels, a Starlink overhead at
 * three and a half, one near the horizon at about three, and a distant navigation
 * satellite on the floor. Enough spread that no two neighbouring dots are the
 * same, nowhere near enough for a Starlink train to smear.
 *
 * 800 km is the reference because it is a Starlink a little off zenith, which is
 * the object `DOT_PIXELS` was chosen against in the first place.
 */
const RANGE_REFERENCE_KM = 800;
const RANGE_EXPONENT = 0.35;

/** Floor and ceiling on the drawn size, in pixels. Below 2 a dot is noise; above 6 it is a planet. */
const MIN_DOT_PIXELS = 2.1;
const MAX_DOT_PIXELS = 6;

/**
 * How much of the drawn size an eclipsed object loses.
 *
 * A point source at the threshold of vision blooms: a bright one occupies more of
 * the retina — and more of a sensor — than a faint one at the same true angular
 * size, which is why stars on a photograph have magnitudes you can read off their
 * diameters. So a fully lit satellite is drawn at its full size and one in the
 * earth's shadow shrinks toward this, which is the same fact `SHADOW_ALPHA`
 * already states about its brightness and reinforces rather than repeats.
 */
const SHADOW_SIZE = 0.72;

/** Ceiling on dots, so the buffers are allocated once and never grow. */
const MAX_DOTS = 4096;

/**
 * Colour per constellation.
 *
 * Starlink is the one that gets a colour of its own, for the same reason it gets
 * its own group in the wire type: it is what people are looking for. The rest are
 * deliberately close to white — a sky where every object is a different hue is a
 * chart, not a sky.
 */
const GROUP_COLORS: Record<SatelliteGroup, THREE.Color> = {
  starlink: new THREE.Color(0xbfd8ff),
  comms: new THREE.Color(0xd8e2ee),
  navigation: new THREE.Color(0xe6e0cf),
  station: new THREE.Color(0xfff0d0),
  weather: new THREE.Color(0xd4ecdf),
  other: new THREE.Color(0xdcdcdc),
};

/**
 * Below this elevation a dot is faded out entirely.
 *
 * Not because the geometry is wrong down there but because it is *useless*: an
 * object one degree above the horizon is behind the hills, behind the buildings,
 * and behind more atmosphere than it can be seen through. Fading the last few
 * degrees also hides the pop that a hard cut-off produces every time something
 * rises, which on a busy constellation is several times a minute.
 */
const HORIZON_FADE_DEG = 8;

/**
 * Alpha for an object in full shadow, relative to a sunlit one.
 *
 * Not zero. A satellite in the earth's shadow is genuinely invisible to the eye,
 * and drawing nothing would be the physically honest choice — but this layer is
 * also a map of what is overhead, and a sky that empties itself at local midnight
 * reads as a broken feed rather than as a correct one. So an eclipsed object is
 * drawn faintly: present, obviously not lit, and clearly a different thing from
 * the one crossing above it in sunlight.
 */
const SHADOW_ALPHA = 0.16;

/**
 * How large one object is drawn, in pixels.
 *
 * Exported and pure because it is the whole of the "satellites are objects rather
 * than a lattice" claim, and a claim like that is worth a test rather than a
 * screenshot: a regression here is a sky that quietly goes back to being graph
 * paper, which nobody notices until somebody photographs it at dusk.
 *
 * Both inputs are already in a `SatelliteFix` and neither is invented. See
 * `RANGE_REFERENCE_KM` for why the range curve is so gentle and `SHADOW_SIZE`
 * for why an eclipsed object also shrinks.
 */
export function dotPixels(fix: Pick<SatelliteFix, "rangeKm" | "shadow">): number {
  const range = Math.max(1, fix.rangeKm);
  const lit = 1 - Math.min(1, Math.max(0, fix.shadow));
  const scaled = DOT_PIXELS * Math.pow(RANGE_REFERENCE_KM / range, RANGE_EXPONENT);
  const bloomed = scaled * (SHADOW_SIZE + (1 - SHADOW_SIZE) * lit);
  return Math.min(MAX_DOT_PIXELS, Math.max(MIN_DOT_PIXELS, bloomed));
}

export function createSatelliteLayer(boardRadius: number): SatelliteLayer {
  const group = new THREE.Group();
  group.name = "satellites";

  const radius = boardRadius * DOME_RADIUS_FACTOR;

  const positions = new Float32Array(MAX_DOTS * 3);
  const colors = new Float32Array(MAX_DOTS * 4);
  const sizes = new Float32Array(MAX_DOTS);
  // Held as locals rather than looked up through `geo.attributes` on every
  // update: the lookup is a string index into a dictionary typed as possibly
  // holding nothing, and the alternative to keeping the references is a
  // non-null assertion on the hot path twice a frame.
  const positionAttr = new THREE.BufferAttribute(positions, 3);
  const colorAttr = new THREE.BufferAttribute(colors, 4);
  const sizeAttr = new THREE.BufferAttribute(sizes, 1);
  const geo = new THREE.BufferGeometry();
  geo.setAttribute("position", positionAttr);
  geo.setAttribute("color", colorAttr);
  geo.setAttribute("aSize", sizeAttr);
  geo.setDrawRange(0, 0);

  /**
   * A hand-written points material, and the two reasons `PointsMaterial` could
   * not stay.
   *
   * **Per-object size.** `PointsMaterial.size` is a uniform; there is no
   * per-vertex size in it at all, and size is the cue that turns this lattice
   * into a population. That alone forces a shader.
   *
   * **The silhouette.** An untextured point is a hard square — `gl_PointCoord`
   * covers a square and nothing rounds it — so every object in the sky was a
   * three-and-a-half-pixel axis-aligned box. Photographed at dusk with the camera
   * tilted to the horizon, a few hundred of those read as graph paper. The round
   * falloff below is computed analytically rather than sampled from a sprite,
   * which is both cheaper and sharper at three pixels: a 64-texel sprite at this
   * size is several mip levels down and comes back as a soft grey blur.
   *
   * Still one draw call, still one `Points`, still `MAX_DOTS` vertices. Nothing
   * about the cost of this layer changed.
   */
  const material = new THREE.ShaderMaterial({
    uniforms: {
      /**
       * three multiplies `PointsMaterial.size` by the renderer's pixel ratio
       * before uploading it, and `gl_PointSize` is in physical pixels — so a
       * hand-written points shader that skips this draws dots at a third of the
       * size on a 3x phone. Written from `onBeforeRender`, which is the only
       * place this layer can see a renderer.
       */
      uPixelRatio: { value: 1 },
    },
    vertexShader: `
/*
 * DECLARED HERE, and it has to be.
 *
 * ShaderMaterial prepends three own built-ins — projectionMatrix,
 * modelViewMatrix, position and the rest — but it does NOT prepend the custom
 * uniforms declared in the uniforms object above. Putting one there and using it
 * in the body without declaring it here compiles nowhere: the program fails with
 * "uPixelRatio : undeclared identifier", three logs a shader error, and this
 * layer silently draws nothing while every other layer carries on. That shipped
 * once, and the console error was the only symptom.
 */
uniform float uPixelRatio;
attribute vec4 color;
attribute float aSize;
varying vec4 vColor;
void main() {
  vColor = color;
  // No size attenuation, deliberately: see DOT_PIXELS. Everything on this dome
  // is the same distance away and stands for something 550 km up, so an object
  // does not get bigger because the map was zoomed in.
  gl_PointSize = aSize * uPixelRatio;
  gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
}
`,
    fragmentShader: `
varying vec4 vColor;
void main() {
  // gl_PointCoord runs 0..1 across a square; this is the distance from its
  // centre in units of the half-width, so 1.0 is the inscribed circle.
  vec2 offset = gl_PointCoord - vec2( 0.5 );
  float r = length( offset ) * 2.0;
  /*
   * A bright core inside a soft halo, which is what a point source does to any
   * optic including an eye. One smoothstep would give a flat disc with a
   * feathered edge and would read as a bubble; the product of the two puts most
   * of the energy in the middle pixel and lets the rest fall away, so a dot
   * still looks like a dot at two pixels and like a small star at six.
   */
  float core = 1.0 - smoothstep( 0.0, 0.55, r );
  float halo = 1.0 - smoothstep( 0.35, 1.0, r );
  float alpha = vColor.a * ( 0.65 * core + 0.35 * halo * halo );
  if ( alpha <= 0.0 ) discard;
  gl_FragColor = vec4( vColor.rgb, alpha );
}
`,
    transparent: true,
    // Dots are drawn over the sky and over each other; letting them write depth
    // makes whichever drew first punch a hole in the ones behind, which on a
    // dense constellation is most of them.
    depthWrite: false,
    // The sky is the darkest thing in the frame at the hour this layer matters,
    // and additive blending is what makes a lit satellite read as a light source
    // rather than as a grey sticker.
    blending: THREE.AdditiveBlending,
    /**
     * Satellites are not in the weather.
     *
     * A material that opts into fog gets mixed toward the fog colour by
     * distance, and the city runs a linear fog whose far plane is 2.8 board
     * spans — so the constellation dimmed as the camera pulled back, exactly
     * when more of it came into view. Haze is a property of the twelve
     * kilometres of air a city sits in; an object 550 km up is on the far side of
     * all of it. A `ShaderMaterial` has no fog unless its shader asks, so this is
     * now true by construction rather than by a flag.
     */
    fog: false,
  });

  const points = new THREE.Points(geo, material);
  points.name = "satellite-dots";
  points.onBeforeRender = (renderer) => {
    material.uniforms.uPixelRatio!.value = renderer.getPixelRatio();
  };
  // The buffer is rewritten in scene space every update, so its bounding sphere
  // is permanently stale and culling on it would cull the whole sky.
  points.frustumCulled = false;
  group.add(points);

  const scratch = new THREE.Color();

  /**
   * The dome is centred on the board's origin and not on the camera.
   *
   * Centring it on the camera would keep every dot at a constant apparent size
   * and would be the right call for a true skybox. It is the wrong call here,
   * because this dome is *anchored to a place*: the look angles were computed for
   * the city centre, so a dot means "from the middle of this board, look there".
   * Following the camera would silently turn a measured direction into a
   * decoration.
   */
  function place(fix: SatelliteFix, into: THREE.Vector3): void {
    const cosEl = Math.cos(fix.elevation);
    // Azimuth is clockwise from north, and scene north is −Z with +X east —
    // which is exactly `sin` on X and `−cos` on Z, with no sign fudge. The same
    // convention `world.project` uses; see `District.gridAngle` for the other
    // place this rule is stated.
    into.set(
      Math.sin(fix.azimuth) * cosEl * radius,
      Math.sin(fix.elevation) * radius,
      -Math.cos(fix.azimuth) * cosEl * radius,
    );
  }

  const scratchVec = new THREE.Vector3();

  /**
   * 1 until somebody says otherwise, so a caller that never calls
   * `setSkyDarkness` gets exactly the behaviour this layer had before it
   * existed. A silent regression to an invisible sky would be worse than the
   * defect being fixed.
   */
  let skyDarkness = 1;
  /** Whether the godmode switch wants this layer at all. Two questions, two flags. */
  let wanted = true;

  /** The hour outranks the switch: a god at noon still gets no white squares. */
  function applyVisibility(): void {
    // Below a fiftieth the dots contribute nothing a screen can show, and
    // skipping the draw entirely is what makes the daytime cost of this layer
    // zero rather than merely invisible.
    group.visible = wanted && skyDarkness > 0.02;
  }

  function update(fixes: SatelliteFix[]): void {
    let n = 0;
    for (const fix of fixes) {
      if (n >= MAX_DOTS) break;

      const elevationDeg = (fix.elevation * 180) / Math.PI;
      const horizon = Math.min(1, elevationDeg / HORIZON_FADE_DEG);
      if (horizon <= 0) continue;

      place(fix, scratchVec);
      positions[n * 3] = scratchVec.x;
      positions[n * 3 + 1] = scratchVec.y;
      positions[n * 3 + 2] = scratchVec.z;

      scratch.copy(GROUP_COLORS[fix.group] ?? GROUP_COLORS.other);
      const lit = 1 - fix.shadow;
      colors[n * 4] = scratch.r;
      colors[n * 4 + 1] = scratch.g;
      colors[n * 4 + 2] = scratch.b;
      colors[n * 4 + 3] = horizon * (SHADOW_ALPHA + (1 - SHADOW_ALPHA) * lit) * skyDarkness;
      sizes[n] = dotPixels(fix);
      n += 1;
    }

    applyVisibility();
    geo.setDrawRange(0, n);
    positionAttr.needsUpdate = true;
    colorAttr.needsUpdate = true;
    sizeAttr.needsUpdate = true;
  }

  return {
    group,
    update,
    setSkyDarkness(darkness) {
      skyDarkness = Math.min(1, Math.max(0, darkness));
      applyVisibility();
    },
    /**
     * Hiding the layer stops it drawing and does **not** stop the catalogue
     * propagating, which is the right way round: turning the sky back on should
     * show where things are now, not resume a sweep from wherever it was
     * abandoned and then crawl back into agreement with reality over the next
     * half second. The propagation is two milliseconds a frame; correctness on
     * re-entry is worth more than reclaiming it.
     */
    setVisible(visible: boolean) {
      wanted = visible;
      applyVisibility();
    },
    dispose() {
      geo.dispose();
      material.dispose();
    },
  };
}
