/** * 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, 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 { 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 { 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; /** * 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(); const tracks = new Map(); 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; /** * 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(); 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), ); } // 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, ): number { if (!Number.isFinite(distance) || !Number.isFinite(fovDegrees)) return 1; if (distance <= 0 || fovDegrees <= 0 || fovDegrees >= 180) return 1; 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(1, legibleScale(focusDistance, fovDegrees) * GLYPH_FOCUS_HEADROOM), ) : GLYPH_MAX_SCALE; return Math.min(ceiling, Math.max(1, 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; }