1
0
This repository has been archived on 2026-08-25. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
tera/src/engine/flights.ts
T
karti b25f217e3e feat: real fire on the boards, the LA office as a twin, and a night sky worth reading
The world stops being a simulation of California and starts being California.

**THE PROMOTION GATE WAS THE FIRST COMMIT, BEFORE ANY ORANGE PIXEL EXISTED.**
On today's live store the SoCal board contains 22 incidents. Every one has NULL
acreage and fifteen are nameless LA County dispatch numbers. Drawn naively that
is 22 orange marks over Los Angeles on a day nothing is burning — in a frame that
contains no other warm colour, so one glyph would be the most salient object on
the board and twenty-two would spend its credibility permanently.

`acres >= 10 AND contained < 80 AND type != 'RX' AND last_seen = max(last_seen)`
returns 0 on SoCal, exactly 5 on California, 0 on the Bay — same body, same day,
three correct answers. The empty board is a deliverable, not a fallback: it says
"No active fire on this board — CAL FIRE and WFIGS, just now", states that 21
records were gated and why, lists the largest fires burning OUTSIDE the frame
with distances, and counts the hot pixels it is deliberately not drawing.

**The privacy leak is structurally impossible rather than carefully avoided.**
cloud-1 serves a projection; the four home-relative columns never leave that box.
`observations.threat` was the one that nearly got through — it is
`(16/distance)^2 x log10(acres) x momentum x containment x wind-alignment`, so
with acreage and containment public it inverts to a distance circle around a
house and three fires give an intersection. A grep of the built bundle for
distance_km, bearing_deg, threat, 7762 and the street name returns nothing.

**Deliberately not used, and both would have produced a confident wrong answer:**
the store's `air` table retains only the last parameter of each poll, so all 78
rows read "Good" while the live feed reports ozone 101 "Unhealthy for Sensitive
Groups" — haze driven off it would clear the sky during a smoke event. And
`weather` is written only inside the NWS alerts loop, so a quiet day stores no
wind at all. Tera's own per-region NWS wind is already correct and already what
the clouds drift on.

Satellite detections are drawn as evidence and never as incidents. The permanent
industrial heat source 4.7 km from the owner's house is flagged persistent and
dropped, asserted by a test that first proves it is present in the fixture.
MODIS integer confidence and VIIRS string confidence are branched on `sat`.

**The LA office is a twin.** Its entire authored second storey — Model Loft,
Model Bay, The Materials Room, 430 lines nobody had ever stood in — is reachable
on foot: a walker crosses level-1 to level-2 in 73 fixed steps, floorY 0 to 5,
verified against the real pack rather than a synthetic plan. Its two studio
devices read real hardware through a field-allowlisted bridge: mute, volume and
reachability only. Never level, because there is no passive level upstream and
obtaining one would record a room with people in it. Never dB, because upstream
is gainPct across four different native scales. The bridge refuses all writes.

Fixed at its root: an anonymous visitor was getting permanently at-rest
instruments backing off against a 401. The tier moves into `createDeviceSource`,
so anon gets the living simulator three file headers already promised.

**Item 8 is closed, not fixed, and the correction is the point.** The Bay Area
"stutter" was GPU power management — the card sat at 500 MHz of 2725 through
every run that reproduced it, 4096/2048/1024/256 shadow maps all render in
1.21-1.31 ms, and two consecutive runs over a byte-identical dist gave 33.4 then
16.7. The allowance is removed and the cell is back to 16.7. Geometry is the
gate; frame time is advisory.

Item 7 was re-scoped after measuring: 1,069,006 of the Bay Area's 2,265,056
triangles were the second submission of the same buildings into the shadow pass.
Mobile now has its own triangle caps and bay-area mobile draws 1,266,096.

Also: bridges and the freeway corridor light up at night as emission, not lights
— 1,614 deck lamps and 18 tower heads on the Bay in two draw calls. The single
change that made US-101 legible was moving its edge lines from the lit material
to the unlit one: retroreflective paint, the argument the SFO night frame already
makes. California went 21,991 lamps to 4,051, clustered at the 17 town districts,
because a rural interurban corridor genuinely is unlit.

Tests 1137 -> 1340, server 280. All ten budget cells pass on first attempt with
no cap raised.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 18:01:11 -07:00

2359 lines
108 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Aircraft over the city.
*
* The engine takes a `FlightSource` rather than talking to any particular
* service, because the obvious one cannot ship here. FlightRadar24's terms do
* not permit scraping and do not permit redistributing their data. An
* Apache-2.0 repo shipping such a client would not merely be breaking a ToS —
* it would be publishing instructions for doing so, alongside data it has no
* right to relicense. Commercial sources are adapters in a private deployment;
* this file holds what we can actually give away. See ARCHITECTURE.md §4, and
* `server/src/flights/licence.ts` for the allowlist that keeps the open lane
* open in practice rather than in principle.
*
* `SimulatedFlights` is the default and is genuinely enough for the map — what
* a city view wants is convincing motion in the right corridors, not a
* spotter's log.
*/
import * as THREE from "three";
import { AIRLINER_LENGTH, airlinerGeometry } from "./aircraftGeometry.ts";
import type { Aircraft, City, FlightSource } from "./types.ts";
import { seededRandom, type World } from "./world.ts";
/** A route the simulator flies: great-circle-ish, with a climb or descent. */
export interface SimRoute {
callsign: string;
from: [number, number];
to: [number, number];
/** Metres at the start and end of the leg. */
fromAlt: number;
toAlt: number;
/** Seconds for a full traversal. */
duration: number;
}
// ---- Where the sky is -----------------------------------------------------
/** A point on the ground. `City.center` is one; so is a query to a feed. */
export interface Place {
lat: number;
lng: number;
}
/**
* The patch of sky a source is being asked about.
*
* A circle rather than the city's rectangle, because a circle is the query
* every traffic feed actually offers: adsb.lol and airplanes.live both take a
* point and a radius, and a receiver on a roof takes nothing at all and gives
* you whatever it can hear. Turning the board into a circle here means the
* shape that crosses the wire is the shape the upstream wants, rather than a
* rectangle each adapter has to circumscribe on its own and get subtly
* different.
*
* This type exists because for a while the server was the only thing that knew
* where the traffic was — one `TERA_ORIGIN_LAT/LNG` pair, fixed at boot, for a
* map with two metros nearly six hundred kilometres apart. Every viewer of
* the SoCal board was being handed San Francisco's aircraft, which do not
* merely look wrong: they project to scene coordinates a long way off the board
* and the sky comes up empty. Where to look is a parameter now, and it comes
* from the city being rendered.
*/
export interface SkyRegion {
center: Place;
/** Nautical miles from `center`, because that is the unit ADS-B feeds take. */
radiusNm: number;
}
/**
* One nautical mile is one minute of latitude. That is the definition of the
* unit, not an approximation of it, which is why there is no fudge factor here.
*/
const NM_PER_DEGREE = 60;
/**
* Distance in nautical miles, on a flat earth.
*
* Equirectangular rather than haversine, deliberately. This runs once per
* aircraft per poll — several hundred times a second in the worst case a busy
* live feed can produce — and over the hundred kilometres a city board spans
* the two answers differ by well under a tenth of a percent. Nothing
* downstream is measuring anything: the answers feed a radius query and an
* is-this-on-my-board test, and both carry slack counted in tens of kilometres.
*/
export function distanceNm(from: Place, to: Place): number {
const dLat = to.lat - from.lat;
const dLng = (to.lng - from.lng) * Math.cos((((from.lat + to.lat) / 2) * Math.PI) / 180);
return Math.hypot(dLat, dLng) * NM_PER_DEGREE;
}
/**
* The circle that covers a city's board, measured from the city's own centre.
*
* Not from the centre of `bounds`, which is a different point: San Francisco's
* `center` is the city and its board runs forty kilometres down the peninsula,
* so the two are about twenty kilometres apart. The radius is therefore taken
* to the furthest of the four corners, and a circle drawn from that far
* off-centre reaches well past the board on the near side.
*
* That is the right error to make. Aircraft on approach are outside the board
* by definition and are the ones worth watching; a query clipped to the
* rendered rectangle would drop every arrival at the moment it became
* interesting and pop it into existence over the runway. `marginNm` is more of
* the same, and is why the default is not zero.
*/
export function regionOf(city: Pick<City, "center" | "bounds">, marginNm = 15): SkyRegion {
const { minLat, maxLat, minLng, maxLng } = city.bounds;
const corners: Place[] = [
{ lat: minLat, lng: minLng },
{ lat: minLat, lng: maxLng },
{ lat: maxLat, lng: minLng },
{ lat: maxLat, lng: maxLng },
];
let radiusNm = 0;
for (const corner of corners) radiusNm = Math.max(radiusNm, distanceNm(city.center, corner));
return { center: city.center, radiusNm: Math.round(radiusNm + marginNm) };
}
/** Whether a position is in the region, with optional slack in nautical miles. */
export function inRegion(region: SkyRegion, lat: number, lng: number, slackNm = 0): boolean {
return distanceNm(region.center, { lat, lng }) <= region.radiusNm + slackNm;
}
/**
* Plausible traffic for a region nobody has authored routes for.
*
* `adapters/sample.ts` has hand-placed corridors for the two cities in this
* build and they are much better than this: real arrivals come down the real
* approach, and that is most of what makes a sky read as *this* city's sky
* rather than as motion. What follows is what a third city gets on the day it
* is added and before anybody has done that work — chords across the region at
* airliner altitudes, deterministic from the seed so that two viewers agree
* about where everything is.
*
* The alternative floor was an empty sky, and an empty sky over a city is not
* read as "no traffic today", it is read as a broken layer. Every leg here is
* inside the region by construction, which is the one property the previous
* arrangement could not offer: the constant it used was San Francisco.
*/
export function syntheticRoutes(region: SkyRegion, count = 6, seed = 20_617): SimRoute[] {
const rand = seededRandom(seed);
const degPerNm = 1 / NM_PER_DEGREE;
// Longitude degrees are shorter than latitude degrees everywhere but the
// equator, so an eastwest offset in nautical miles is more of them.
const lngPerNm = degPerNm / Math.cos((region.center.lat * Math.PI) / 180);
const routes: SimRoute[] = [];
for (let i = 0; i < count; i++) {
const bearing = rand() * Math.PI * 2;
// Push the chord off the centre so the legs are not six spokes through
// downtown. ±60% of the radius crosses the board at a spread of depths.
const offset = (rand() * 1.2 - 0.6) * region.radiusNm;
const half = Math.sqrt(Math.max(region.radiusNm ** 2 - offset ** 2, 1));
const alongE = Math.sin(bearing);
const alongN = Math.cos(bearing);
const from = {
lat: region.center.lat + (-alongN * half - alongE * offset) * degPerNm,
lng: region.center.lng + (-alongE * half + alongN * offset) * lngPerNm,
};
const to = {
lat: region.center.lat + (alongN * half - alongE * offset) * degPerNm,
lng: region.center.lng + (alongE * half + alongN * offset) * lngPerNm,
};
// A third arriving, a third departing, a third crossing high. A board where
// everything is at cruise has no altitude ramp to read and no reason for
// the colour band in `createFlightLayer` to exist.
const kind = i % 3;
const fromAlt = kind === 0 ? 3400 : kind === 1 ? 500 : 8600 + rand() * 1800;
const toAlt = kind === 0 ? 450 : kind === 1 ? 6200 : fromAlt + 400;
// Eight seconds a nautical mile is about 450 knots, which is an airliner.
const duration = Math.round(half * 2 * 8);
routes.push({
callsign: `SIM ${i + 1}`,
from: [from.lat, from.lng],
to: [to.lat, to.lng],
fromAlt: Math.round(fromAlt),
toAlt: Math.round(toAlt),
duration,
});
}
return routes;
}
/**
* A source with a dial on it: whatever it was going to draw, plus N invented
* aircraft.
*
* This exists for one control in the godmode panel — "how busy would this look
* with three times the traffic" — and the shape it takes is chosen to make that
* question answerable without corrupting the answer to any other one.
*
* **It composes rather than substitutes.** The base source is polled unchanged
* and its aircraft are passed through untouched; the fabricated ones are a
* second list concatenated onto the end. That is what lets the dial work over a
* *live* ADS-B feed as well as over the simulator — the real traffic stays real
* and stays complete, and turning the dial back to zero returns exactly the
* sky that was there before, because nothing was ever taken away.
*
* The alternative was to mutate the simulator's route list, and it is worse in
* both directions: it does nothing at all when the server is serving its own
* plan (`HttpFlights` ignores its fallback in that mode, so the slider would be
* inert on every deployment that has an API), and it is destructive when it does
* work, because the authored corridors would have to be rebuilt to get back.
*
* ### On fabricating traffic at all
*
* The same argument as `weatherOverride` in `main.ts`: a god-only lie about the
* inputs, told to see what the renderer does with it. It is deliberately **not**
* available to anyone else, and the invented aircraft carry a callsign prefix of
* their own so that a screenshot of a busy sky can be told from a screenshot of
* a real one. Note what this breaks while it is on — every viewer agreeing about
* where the aircraft are, which is the property the server's plan exists to buy.
* That is acceptable for a debug dial and would not be for a feature.
*/
export interface TrafficDial {
/** The source to hand `createScene`. Stable for the dial's whole life. */
source: FlightSource;
/** Fabricate this many additional aircraft. `0` turns the dial off entirely. */
setExtra(count: number): void;
extra(): number;
}
/**
* Callsign prefix for fabricated traffic.
*
* Distinct from `syntheticRoutes`'s own `SIM`, and it has to be: `sampleRoute`
* derives an aircraft's id from its callsign, `createFlightLayer` keys its
* tracks on that id, and a deployment with no API is already flying `SIM 1`
* through `SIM 6` from the fallback. Reuse the prefix and every fabricated
* aircraft would land on an existing track, teleporting it across the board on
* alternate polls.
*/
const FABRICATED_PREFIX = "GOD";
/** As many as the dial goes to. Past this the sky is soup and the point is made. */
export const MAX_EXTRA_TRAFFIC = 400;
export function withTrafficDial(base: FlightSource, region: SkyRegion): TrafficDial {
let extra: SimulatedFlights | null = null;
let count = 0;
return {
source: {
interval: base.interval,
poll(): Aircraft[] | Promise<Aircraft[]> {
const theirs = base.poll();
if (extra === null) return theirs;
const mine = extra.poll();
// `poll` is synchronous on every source in this build, but the interface
// permits a promise and `HttpFlights` documents its synchrony as a
// deliberate property rather than an accident. Handling both here costs
// one branch and means the dial cannot be what breaks that.
return theirs instanceof Promise ? theirs.then((a) => [...a, ...mine]) : [...theirs, ...mine];
},
dispose: () => base.dispose?.(),
},
setExtra(next: number) {
count = Math.max(0, Math.min(MAX_EXTRA_TRAFFIC, Math.round(next)));
if (count === 0) {
extra = null;
return;
}
const routes = syntheticRoutes(region, count).map((route, i) => ({
...route,
callsign: `${FABRICATED_PREFIX} ${i + 1}`,
}));
extra = new SimulatedFlights(routes);
},
extra: () => count,
};
}
/**
* Traffic that behaves like the real thing without being it: aircraft move
* along fixed legs at fixed speeds, looping, with each one offset in phase so
* the sky is never empty and never synchronised.
*/
export class SimulatedFlights implements FlightSource {
readonly interval = 1;
private readonly routes: SimRoute[];
private readonly phase: number[];
private t = 0;
private last = 0;
constructor(routes: SimRoute[], seed = 4711) {
this.routes = routes;
const rand = seededRandom(seed);
this.phase = routes.map(() => rand());
this.last = nowSeconds();
}
poll(): Aircraft[] {
const now = nowSeconds();
this.t += Math.min(now - this.last, 5);
this.last = now;
return this.routes.map((route, i) => sampleRoute(route, this.t / route.duration + (this.phase[i] ?? 0)));
}
}
/**
* One aircraft's state at a fraction of the way along its leg. `p` wraps, so
* anything can be handed in and 1.4 means the same as 0.4.
*
* Split out of `SimulatedFlights.poll` because the HTTP adapter needs exactly
* this and cannot reuse the class to get it: `SimulatedFlights` runs on a
* monotonic clock that starts when it is constructed, whereas the wire's
* `FlightsPlanBody` anchors every route to a fixed epoch so that two browsers
* agree about where the aircraft are. Same arithmetic, different origin — and
* two copies of the arithmetic would drift.
*/
export function sampleRoute(route: SimRoute, p: number): Aircraft {
const t = ((p % 1) + 1) % 1;
const lat = route.from[0] + (route.to[0] - route.from[0]) * t;
const lng = route.from[1] + (route.to[1] - route.from[1]) * t;
// Ease the altitude so departures climb steeply and level off.
const ease = 1 - (1 - t) ** 2;
const altitude = route.fromAlt + (route.toAlt - route.fromAlt) * ease;
const heading =
(Math.atan2(route.to[1] - route.from[1], route.to[0] - route.from[0]) * 180) / Math.PI;
/**
* The velocity, so a simulated aircraft is the same kind of object a real one
* is.
*
* Not decoration. `createFlightLayer` dead-reckons anything that carries a
* ground speed and interpolates anything that does not, and a build with no
* API — a keyless clone, the boot state of every deployment, the fallback
* whenever the network goes away — flies exactly these aircraft. Leaving them
* without a velocity would mean the two paths went through different code and
* only one of them was ever looked at, which is the arrangement that let the
* live sky sit still for as long as it did.
*
* Both numbers are the derivative of the arithmetic three lines up rather
* than a plausible-looking constant: the speed is the leg's ground distance
* over its duration, and the climb is `d/dt` of the eased altitude, which
* is why a departure's rate is steepest at the start and tails to nothing —
* the same shape the ease was chosen for.
*/
const seconds = route.duration > 0 ? route.duration : 1;
const dLatM = (route.to[0] - route.from[0]) * METRES_PER_DEGREE_LAT;
const dLngM =
(route.to[1] - route.from[1]) * METRES_PER_DEGREE_LAT * Math.cos((lat * Math.PI) / 180);
const groundSpeed = Math.hypot(dLatM, dLngM) / seconds;
const verticalRate = ((route.toAlt - route.fromAlt) * 2 * (1 - t)) / seconds;
return {
id: `sim-${route.callsign}`,
callsign: route.callsign,
lat,
lng,
altitude,
heading,
groundSpeed,
verticalRate,
};
}
/**
* Metres in one degree of latitude, and the only geodesy in this file.
*
* A sphere, not an ellipsoid. The dead-reckoner integrates this over at most a
* minute of flight and the WGS-84 meridian varies by about half a percent from
* pole to equator — half a metre in a hundred, on a board where one scene unit
* is ninety-four of them. `World.metresPerUnit` is derived from the same
* constant, so the two agree by construction rather than by coincidence.
*/
const METRES_PER_DEGREE_LAT = 111_320;
/**
* A caller-supplied string, trimmed, or `null` for anything that is not one.
*
* `undefined`, an empty string and a string of spaces are all "the feed said
* nothing" and must all reach the card as the same `null`, because a card that
* renders an empty row looks like a card whose data went missing.
*/
function text(value: string | null | undefined): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed === "" ? null : trimmed;
}
function nowSeconds(): number {
return (typeof performance !== "undefined" ? performance.now() : 0) / 1000;
}
/**
* How long a snapshot is still worth drawing after the feed stops answering.
*
* A minute, which at this source's eight-second interval is seven missed polls
* in a row — well past a dropped request and into "the feed is gone". Below
* that the last snapshot is held, because the alternative is that one timeout
* empties the sky, `createFlightLayer` drops every track it was interpolating,
* and the next good poll builds them all again from scratch: a full-screen
* flicker of every aircraft and every trail, caused by nothing.
*/
const ADSB_HOLD_SECONDS = 60;
/**
* Community ADS-B, for when real traffic is wanted.
*
* `adsb.lol` and `airplanes.live` both serve open, key-free feeds of
* volunteer-fed ADS-B and are the sources this project can point at without a
* licence problem. The best answer long-term is an RTL-SDR on a fleet box:
* first-party data, nothing to comply with.
*
* **`endpoint` is not free-form, even though its type is `string`.** The
* allowlist of feeds this project will fetch, and the credit line each of them
* is owed, live in `server/src/flights/licence.ts`, which is where the API's
* `TERA_ADSB_ENDPOINT` is validated before a request is made. A browser drawing
* a feed for itself is not republishing it and so is not the exposure that gate
* exists for — but a self-hoster who constructs this class with some other
* endpoint is choosing terms nobody here has read, and this is the sentence
* that says so. Nothing in this repo constructs it: the shipped path is
* `HttpFlights` against our own API.
*
* The region is required and has no default. It used to default to a point in
* San Francisco, which is a fine centre for one of the two cities in this build
* and a five-hundred-kilometre error for the other — and a wrong default is
* worse than a missing one, because it produces a sky rather than a type error.
*/
export class AdsbFlights implements FlightSource {
readonly interval = 8;
private held: Aircraft[] = [];
private heldAt = 0;
private readonly endpoint: string;
private readonly region: SkyRegion;
/**
* Fields assigned in the body rather than declared as parameter properties.
*
* That is not a style preference. A parameter property is the one piece of
* TypeScript syntax that *emits code* — it is a hidden assignment, not a type
* annotation — so Node's type stripping refuses the whole module with
* `ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX`. The bundler never cared, so this went
* unnoticed until the first `node --test` file tried to import this layer and
* discovered it could not: the module with the worst bug this project has
* shipped was, by construction, the one module that could not be tested.
*
* The server has run under type stripping from the start and so has always
* been written this way; the browser engine simply never had to be.
*/
constructor(endpoint: string, region: SkyRegion) {
this.endpoint = endpoint;
this.region = region;
}
async poll(): Promise<Aircraft[]> {
const { lat, lng } = this.region.center;
const url = `${this.endpoint}/v2/point/${lat}/${lng}/${Math.round(this.region.radiusNm)}`;
try {
const res = await fetch(url);
if (!res.ok) return this.hold();
const body = (await res.json()) as { ac?: RawAircraft[] };
this.held = (body.ac ?? [])
.filter((a) => typeof a.lat === "number" && typeof a.lon === "number")
// See the note on `heading` below: a record with no track is dropped
// rather than zeroed, and dropping it here keeps the map's return type
// honest instead of widening `Aircraft.heading` to admit a null that no
// consumer could do anything sensible with.
.filter((a) => typeof a.track === "number")
// The endpoint takes a radius and is trusted to honour it, but a
// receiver feeding one of these networks hears whatever it hears and
// some deployments serve the lot. Anything outside the region projects
// to a scene coordinate off the board.
.filter((a) => inRegion(this.region, a.lat as number, a.lon as number))
.map((a) => ({
id: a.hex ?? `${a.flight ?? "?"}`,
callsign: a.flight?.trim(),
lat: a.lat as number,
lng: a.lon as number,
// Feed reports feet; the scene works in metres.
altitude: typeof a.alt_baro === "number" ? a.alt_baro * 0.3048 : 3000,
/**
* A missing track is *skipped*, not zeroed.
*
* ADS-B carries position-only records — surface vehicles, TIS-B and
* MLAT-derived targets — with no `track` field at all, and they pass
* every other filter here. Substituting `0` used to be harmless
* because a wrong heading only pointed a symmetrical dart the wrong
* way. It stopped being harmless when aircraft learned to bank: a
* target whose real heading is 200° reported as 0° looks like a 160°
* turn, which pins the roll at its 30° limit and holds it there for as
* long as the target is in the feed — a sustained, full-scale artefact
* produced entirely by invented data.
*
* Dropped in the filter above. An aeroplane this feed will not say
* the heading of is one this layer cannot draw honestly.
*/
heading: a.track as number,
/**
* The velocity, when the feed reported one, and the reason this
* layer's aircraft can fly between snapshots at all.
*
* Guarded on being a positive finite number rather than merely
* present: this feed sends `gs: 0.0` for ground vehicles and parked
* aircraft, and a zero is a fact about a stationary object rather
* than a missing measurement, so both end up absent and both are
* held still by `createFlightLayer`. The track is already known to be
* a number — the filter above dropped every record without one — so
* a speed carried here always has a direction to go with it.
*
* `server/src/flights/adsb.ts` does the same conversion on the same
* fields for the shipped path. Two copies of the arithmetic is one
* more than anybody wants, and the alternative is this class
* importing server code into the browser bundle.
*/
...(typeof a.gs === "number" && Number.isFinite(a.gs) && a.gs > 0
? { groundSpeed: a.gs * KNOTS_TO_MS }
: {}),
...(typeof a.baro_rate === "number" && Number.isFinite(a.baro_rate)
? { verticalRate: a.baro_rate * FPM_TO_MS }
: typeof a.geom_rate === "number" && Number.isFinite(a.geom_rate)
? { verticalRate: a.geom_rate * FPM_TO_MS }
: {}),
...(typeof a.seen_pos === "number" && a.seen_pos >= 0
? { ageSeconds: a.seen_pos }
: {}),
}));
this.heldAt = nowSeconds();
return this.held;
} catch {
// A dead feed must not take the render loop with it.
return this.hold();
}
}
/** The last snapshot, until it is old enough that an empty sky is the truth. */
private hold(): Aircraft[] {
if (nowSeconds() - this.heldAt > ADSB_HOLD_SECONDS) this.held = [];
return this.held;
}
}
interface RawAircraft {
hex?: string;
flight?: string;
lat?: number;
lon?: number;
alt_baro?: number;
track?: number;
/** Ground speed, knots. `0.0` on a ground vehicle or a parked aircraft. */
gs?: number;
/** Barometric climb rate, feet per minute, positive up. */
baro_rate?: number;
/** Geometric climb rate, feet per minute — what a row carries instead. */
geom_rate?: number;
/** Seconds since this row's position was last updated. */
seen_pos?: number;
}
/** The feed's units, converted once. The engine works in metres and seconds. */
const KNOTS_TO_MS = 0.514_444;
const FPM_TO_MS = 0.00508;
// ---- Detail ---------------------------------------------------------------
/**
* One aircraft, described well enough to put on a card somebody clicked.
*
* The demo this project leads with is a signed-out visitor clicking a dart over
* a city they recognise and being told what it is, so this type is written for
* **anon** and carries nothing an account would be needed for. Everything in it
* is either broadcast unencrypted by the aircraft itself — ADS-B is receivable
* with a forty-dollar dongle — or arithmetic on top of that.
*
* There is no route and no operator here, because the open feeds do not carry
* them and inventing them would be the same class of lie `synthetic` exists to
* prevent. The registration and the type **are** here, and used not to be: the
* comment this replaces said the open feeds did not carry those either, and
* that was simply a mistake — `r` and `t` are on every row adsb.lol and
* airplanes.live serve, under the same ODbL as the coordinates, and the server
* was dropping them on the floor. They were the stated reason to want a
* commercial feed, which makes getting them right the cheapest thing in this
* file.
*
* Two fields are about the *provenance* rather than the aeroplane, and they are
* the reason this is a type and not an object literal built in the UI:
* `observed` says whether anybody actually saw this, and `attribution` carries
* whatever the feed asks to be credited with **at the point the data is
* displayed**, which is what an ODbL notice is for. A card is a display. A
* corner label on the other side of the screen is not obviously one.
*/
export interface AircraftDetail {
/** The source's own id. The ICAO address for a live feed; a route name for the simulator. */
id: string;
/** Flight number or tail as broadcast, trimmed, or `null` when the feed said nothing. */
callsign: string | null;
/**
* The transponder's 24-bit ICAO address, lowercase hex, or `null`.
*
* `null` rather than a guess for anything that does not look like one — the
* simulator's ids are route names and a `~`-prefixed id on a real feed is a
* non-ICAO address (TIS-B and MLAT targets carry them), which is genuinely
* not an ICAO24 and must not be presented as one. Somebody can paste this
* into a registry lookup, so a wrong one sends them to another aircraft.
*/
icao24: string | null;
lat: number;
lng: number;
/** Barometric altitude, metres — the unit the wire and the engine both use. */
altitudeM: number;
/** The same altitude in feet, which is the unit aviation is actually read in. */
altitudeFt: number;
/** Degrees clockwise from true north. */
headingDeg: number;
/** The heading as a 16-point compass name, for a card a human reads. */
headingCompass: string;
/**
* The tail number, e.g. `"N68834"`, or `null`.
*
* The paragraph above this type used to say there is no registration and no
* type here because the open feeds do not carry them. **That was wrong**, and
* it was wrong in the expensive direction: it was the stated reason to want a
* commercial feed. Both community feeds have carried `r` and `t` on every row
* all along, under the same ODbL as the position beside them — so this is the
* enrichment that was wanted, obtained legitimately, and an anonymous visitor
* clicking a dart now reads "B739 · N68834" rather than a hex address.
*
* `null` and never a guess. A registration is something somebody pastes into
* a registry lookup, so a wrong one names a different aeroplane — the same
* argument `icao24` makes, about the same kind of identifier.
*/
registration: string | null;
/** ICAO type designator, e.g. `"B739"`, or `null`. Never expanded to a name. */
type: string | null;
/**
* Ground speed in knots, or `null` where the feed did not say.
*
* Knots because that is the unit a speed over the ground is read in, and
* rounded because the tenth of a knot the feed publishes is precision about
* a number that changes while the card is open. `null` for anything not
* moving: a parked aircraft reporting `gs: 0.0` reaches here with no speed at
* all rather than with a zero, and "—" is the honest thing to draw for it.
*/
groundSpeedKt: number | null;
/** Climb rate in feet per minute, positive up, or `null`. Aviation's unit. */
verticalRateFpm: number | null;
/** Nautical miles from the board's centre, or `null` when no centre was given. */
distanceNm: number | null;
/**
* Did somebody observe this, or did this repo invent it?
*
* The same statement `TrafficSource.live()` makes about the whole feed, made
* about one aircraft, and it must travel with the aircraft: a card is read on
* its own, away from any corner label, and a fabricated flight number
* presented in the same frame as a real one is the confusion the `live` flag
* exists to prevent.
*/
observed: boolean;
/** Credit lines owed for this aircraft, to be shown on the card itself. */
attribution: string[];
}
/**
* An ICAO 24-bit address as the feeds write it: six hex digits, lowercase.
*
* Anchored, so `sim-BA286` fails and `~abc123` — the anonymous-address form
* both community feeds emit for targets whose real address is not known — fails
* too, which is the point. See `AircraftDetail.icao24`.
*/
const ICAO24 = /^[0-9a-f]{6}$/;
/** The sixteen names, in the order the compass runs. */
const COMPASS = [
"N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE",
"S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW",
];
/**
* A bearing as a compass point.
*
* Sixteen points rather than eight because the difference between "north-east"
* and "east-north-east" is the difference between two departure corridors, and
* rather than thirty-two because nobody reads "NNE by N" off a card. Negative
* and out-of-range degrees are wrapped rather than refused: a heading is an
* angle and every angle names a direction.
*/
export function compassPoint(degrees: number): string {
if (!Number.isFinite(degrees)) return "—";
const wrapped = ((degrees % 360) + 360) % 360;
return COMPASS[Math.round(wrapped / 22.5) % 16] ?? "N";
}
/** Metres to feet. The wire carries metres; aviation is read in feet. */
const FEET_PER_METRE = 3.280_84;
export interface AircraftDetailOptions {
/**
* The transponder address, when the caller was told one separately.
*
* `HttpFlights` is: `WireAircraft.icao24` is a field on the body and
* `Aircraft` has nowhere to put it, so the adapter keeps the wire record
* beside the position and hands it back here. Absent, the id is tested
* against `ICAO24` — which is right for every feed that keys on the hex, and
* correctly declines for the simulator.
*/
icao24?: string | null;
/**
* The registration and the ICAO type designator, when the caller was told
* them separately.
*
* Here rather than on `Aircraft` for the same reason `icao24` is, and it is
* the same boundary: `Aircraft` is what the *renderer* needs — a position, a
* height and a direction — and a tail number moves no pixels. The adapter
* keeps the wire record beside the position and hands these back when a card
* is asked for. Absent, they are `null`; nothing here is derived from
* anything else, because a type designator inferred from a callsign is a
* guess about a real aeroplane.
*/
registration?: string | null;
type?: string | null;
/** Whether these coordinates were observed. Defaults to `false`: invented until said otherwise. */
observed?: boolean;
/** Credit lines the feed asks for, shown on the card. */
attribution?: readonly string[];
/** Board centre, for the distance readout. Omit and `distanceNm` is `null`. */
from?: Place;
}
/**
* Turn an `Aircraft` into something a panel can render, without the panel
* knowing where aircraft come from.
*
* Pure, total and free of I/O, so the interface layer can call it on a click
* without awaiting anything, and so it can be tested without a network. It
* invents nothing: every field is a restatement, a unit conversion or a `null`.
*/
export function aircraftDetail(
aircraft: Aircraft,
options: AircraftDetailOptions = {},
): AircraftDetail {
const callsign = aircraft.callsign?.trim();
const declared = options.icao24?.trim().toLowerCase();
const fromId = aircraft.id.trim().toLowerCase();
const icao24 =
declared !== undefined && ICAO24.test(declared)
? declared
: ICAO24.test(fromId)
? fromId
: null;
return {
id: aircraft.id,
callsign: callsign === undefined || callsign === "" ? null : callsign,
icao24,
lat: aircraft.lat,
lng: aircraft.lng,
altitudeM: aircraft.altitude,
altitudeFt: Math.round(aircraft.altitude * FEET_PER_METRE),
headingDeg: aircraft.heading,
headingCompass: compassPoint(aircraft.heading),
registration: text(options.registration),
type: text(options.type),
// From the aircraft rather than from the options, because unlike the two
// above it these are numbers the renderer genuinely uses: the layer
// dead-reckons on them, so they are already on `Aircraft` and reading them
// from a second place would be a second chance to disagree.
groundSpeedKt:
typeof aircraft.groundSpeed === "number" && Number.isFinite(aircraft.groundSpeed)
? Math.round(aircraft.groundSpeed / KNOTS_TO_MS)
: null,
verticalRateFpm:
typeof aircraft.verticalRate === "number" && Number.isFinite(aircraft.verticalRate)
? Math.round(aircraft.verticalRate / FPM_TO_MS)
: null,
distanceNm:
options.from === undefined
? null
: Math.round(distanceNm(options.from, { lat: aircraft.lat, lng: aircraft.lng }) * 10) / 10,
observed: options.observed === true,
attribution: [...(options.attribution ?? [])],
};
}
// ---- Rendering ------------------------------------------------------------
export interface FlightLayer {
group: THREE.Group;
/**
* What a pointer can hit, as a **live** array, each entry carrying
* `userData.aircraftId`.
*
* Here rather than on the caller because only this layer knows which object is
* which track: the map from id to mesh is private and the group's child order
* is an artefact of when each aircraft appeared. It is the same shape
* `MarkerLayer.pickables` publishes and it exists for the same reason — a
* pick is resolved from the object that was hit, and something has to say
* what the object stands for.
*
* What a ray meets is a **sphere** around the aeroplane rather than its
* triangles — see `raycastGlyph` — because a seventeen-pixel glyph with
* two-pixel wings is a game of marksmanship rather than an interface, and
* under a fingertip it is not even that. The array itself holds one object per
* *drawn* aircraft, and only while it is drawn: a track kept through a dropped
* refresh stops being clickable at the moment it stops being visible.
*
* `owner-decisions.md` is why this is not gated on anything: an ADS-B
* position is broadcast unencrypted to anybody with a receiver, so the card
* it opens is available to an anonymous visitor and the picking that reaches
* it must be too.
*/
pickables: THREE.Object3D[];
/**
* Hand over a fresh observation. Called on the source's own timer, which is
* once a second for the simulator and once every several seconds for a real
* feed; the motion in between is this layer's problem, not the caller's.
*/
update(aircraft: Aircraft[]): void;
/**
* Move everything to where it should be at this instant.
*
* A pure function of the wall clock and the last two observations, so calling
* it twice in a frame does the same thing as calling it once. That matters:
* the layer drives itself from the trail geometry's `onBeforeRender` — see
* `createFlightLayer` — and a scene that also ticks it explicitly must not end
* up advancing time twice as fast.
*/
tick(): void;
/**
* How far the camera is from **what it is looking at**, in scene units.
*
* The missing input, and the one that turns the glyph's ceiling from a
* constant into a rule. `glyphScale` is handed the distance to the *aircraft*,
* which is the right ruler only when everything in frame is equally far away;
* at a chapter pose the landmark is a few units off and the traffic is a few
* thousand, so the floor fires hard on the aeroplane and not at all on the
* thing beside it. See `GLYPH_FOCUS_HEADROOM`.
*
* A setter and not an `OrbitControls` reference, deliberately. This layer must
* keep working in an office sky, in a chase camera and under a test with no
* controls at all, and a layer that reaches into the camera rig is a layer
* that can only be used by the rig it was written against. The caller already
* has the number: `camera.position.distanceTo(controls.target)`.
*
* Not calling it at all is a supported state and reproduces exactly the
* behaviour this layer had before it existed — the ceiling stays the flat
* `GLYPH_MAX_SCALE`.
*/
setFocusDistance(distance: number): void;
dispose(): void;
}
/**
* How many observations a trail remembers, and how long it may hold one.
*
* Both limits are needed. The count keeps the shared vertex buffer bounded, and
* the age keeps a slow feed from drawing a trail across the entire bay.
*
* These were 20 and 45, which made a trail about twenty seconds long — enough to
* say "this thing is moving" and not enough to say where it came from. At 72
* points the simulator, polled at 1 Hz, draws about seventy seconds of flying:
* a quarter to a half of one of `sample.ts`'s legs, so an arrival trails a
* visible curve down the approach rather than a tick behind it.
*
* The count is what binds for a 1 Hz source; the age binds for a slow one. 240 s
* is four minutes, which at a live feed's 515 s refresh is 1648 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 2530°, and steeper than that
* is a manoeuvre rather than a turn.
*
* The cap is not really here for the aeroplanes, though — it is here for the
* data. A reported heading that jumps forty degrees because a receiver lost a
* target and reacquired it is, at this end of the wire, indistinguishable from
* a genuine turn, and it arrives with the position barely moved, so the
* teleport test in `update` does not catch it — that test is about distance and
* this is a lie about attitude. Without a ceiling, one such sample knife-edges
* an airliner over the city, and a frame of that is worse than the flat turns
* this whole channel exists to replace.
*/
const MAX_BANK = (30 * Math.PI) / 180;
/**
* Nominal true airspeed over g, in seconds. The one constant in the bank.
*
* An aircraft in a coordinated turn holds `tan(bank) = ω·V / g`: the
* horizontal component of lift is the only thing turning it, so the bank
* needed for a given rate of turn rises with how fast the aeroplane is going.
* That relation is what `bankAngle` evaluates, with V fixed at 200 m/s
* (389 kt) rather than measured.
*
* **Fixed rather than measured, deliberately.** The speed is derivable from
* the same pair of samples the turn rate comes from — ground distance over
* `span`, which `climbAngle` already computes half of — but both terms then
* carry a 1/span, so their product carries 1/span², and position noise on a
* live feed would swing the bank about on its own. A constant is wrong by a
* factor the eye cannot read; noise is wrong in a way it can.
*
* 200 m/s is a compromise and knowingly one. `syntheticRoutes` flies its legs
* at eight seconds a nautical mile, which is about 450 kt and is cruise; an
* arrival on final is doing a third of that. Sitting between them draws
* terminal-area turns a little flatter than they fly and high-level ones a
* little steeper — the cheap direction to be wrong in, because the steep case
* saturates against `MAX_BANK` and the flat case still reads as a bank.
*
* V/g works out at 20.4 s, which puts a half-standard-rate turn (1.5 °/s, the
* airline norm at altitude) at 28° and reaches the 30° ceiling at 1.62 °/s.
*/
const SPEED_OVER_G = 200 / 9.80665;
/**
* Time constant for how quickly the bank follows the turn, in seconds.
*
* The roll is not set to the geometric answer, it is eased toward it: each new
* leg closes a fraction `1 - exp(-span / ROLL_SETTLE_SECONDS)` of the gap. A
* first-order lag, and nothing with a second derivative in it, because a
* first-order lag cannot overshoot. The requirement is that a straight leg
* settles to wings-level and *stays* there; a spring-and-damper would sit
* rocking about zero for several seconds after every turn, which is a worse
* artefact than the flat turns it would have been introduced to fix.
*
* Expressed as a time constant rather than as a per-update fraction because
* `span` is not one quantity here: it is a second for the simulator and five
* to fifteen for a live feed — see the repeat check in `update`. A flat "close
* 30% of the gap each time" would be about three seconds of lag on one source
* and forty-five on the other. At 2.5 s a 1 Hz simulator covers 63% of a roll
* in 2.5 s and 90% in 5.8, while a 10 s live refresh takes 98% of it in a
* single step — which is right, because on that source `tick` is already
* spreading the movement across ten seconds of interpolation.
*/
const ROLL_SETTLE_SECONDS = 2.5;
/**
* How long the layer will keep flying an aircraft on its last known velocity.
*
* Dead reckoning is an *interpolation of the near future*, not a simulation. A
* position and a velocity describe where something will be in the next few
* seconds very well and where it will be in ten minutes not at all — an
* airliner turns, descends and lands, and none of that is in the two numbers
* this layer was handed. So the propagation time is clamped: an aircraft coasts
* for a minute and then holds station until somebody tells it something new.
*
* A minute rather than a shorter, tidier number because a minute is what the
* rest of the file already treats as "the feed is gone" — `ADSB_HOLD_SECONDS`
* is the same figure, and both sources hold their last snapshot for exactly
* that long. Reckoning past the point where the snapshot itself would have
* expired would be flying an aeroplane on the strength of data the layer has
* already agreed to stop believing.
*/
const MAX_RECKON_SECONDS = 60;
/**
* How quickly a dead-reckoned track slides onto a fresh observation, in seconds.
*
* **This constant is the whole difference between a fix and a flinch.** The
* reckoner is always a little wrong — the aircraft banked, or the wind changed,
* or the fix that started it was itself a second stale — so every observation
* arrives with the drawn aeroplane a few hundred metres from where the feed
* says it is. Teleporting it there is the artefact this layer has spent its
* whole life removing on other channels: a visible twitch on every aircraft on
* every refresh, five to fifteen seconds apart, forever.
*
* So the error is measured once, at the instant of the observation, and then
* *decayed*: the aeroplane flies the newly-truthful track and carries a
* shrinking offset on top of it. A first-order decay rather than a ramp for the
* same reason `ROLL_SETTLE_SECONDS` is one — it cannot overshoot, and it has no
* end time to be interrupted at, so an observation landing early is not a
* special case.
*
* Three seconds puts 96% of the correction inside a ten-second refresh while
* keeping the closing speed below the aircraft's own: a 400 m error closes at
* 133 m/s against an airliner's 250, so the correction reads as a course
* adjustment rather than as a sideways lurch. Much shorter and it is a twitch
* again; much longer and two aircraft on the same approach never quite agree
* about where the centreline is.
*/
const RECKON_SETTLE_SECONDS = 3;
/**
* The largest error the layer will slide out rather than jump.
*
* Scene units, and a safety valve rather than a tuning knob. A correction is
* only worth easing if the two positions describe the same flight a moment
* apart; a fix that lands two kilometres from the reckoned position is a
* different claim altogether — a receiver reacquiring a target, an id reused,
* a feed skipping a minute — and easing that would drag the aeroplane across
* the county at three hundred knots with its trail attached. Past this the
* offset is simply dropped and the aircraft is where the feed says it is.
*
* 20 units is 1.9 km on the San Francisco board and about 3.3 km on California.
* A ten-second reckon of a turning airliner errs by well under a kilometre, so
* this bites only when something has genuinely gone wrong.
*/
const MAX_RECKON_CORRECTION_UNITS = 20;
/**
* A position and the velocity it is moving with: everything needed to say where
* something will be shortly.
*
* Geographic rather than scene coordinates on purpose. `World.project` is
* linear in latitude and longitude but the two axes have different scales — a
* degree of longitude is shorter than a degree of latitude everywhere but the
* equator, and `lngScale` carries that — so integrating a heading in scene
* space would need the projection undone and redone anyway. Doing it in degrees
* means one `project` at the end and no assumptions about the board.
*/
export interface Reckoning {
lat: number;
lng: number;
/** Metres. */
altitude: number;
/** Degrees clockwise from true north. */
heading: number;
/** Metres per second over the ground. Zero holds station. */
speed: number;
/** Metres per second, positive climbing. */
climb: number;
}
/**
* Where a track will be `seconds` from the state it was in.
*
* Pure, total, and exported so the arithmetic can be tested without a scene: a
* dead-reckoner that turns the wrong way, or converts degrees to metres at the
* wrong latitude, produces a sky that renders perfectly and is wrong by
* kilometres, which is precisely the class of defect this file's comments keep
* recording.
*
* Compass convention throughout: heading 0 is north and increases clockwise, so
* north is `+cos` on latitude and east is `+sin` on longitude. The longitude
* step is divided by the cosine of the latitude, because a degree of longitude
* carries fewer metres the further from the equator it is — get that backwards
* and every eastbound aircraft over California flies at four fifths of its
* reported speed.
*
* Negative time is refused rather than run backwards: every caller is asking
* about the present or the near future, and a negative interval here would mean
* a clock had gone backwards, which is a thing browser clocks do.
*/
export function reckonForward(
state: Reckoning,
seconds: number,
): { lat: number; lng: number; altitude: number } {
const dt = clamp(seconds, 0, MAX_RECKON_SECONDS);
if (!Number.isFinite(dt) || dt === 0 || !Number.isFinite(state.speed)) {
return { lat: state.lat, lng: state.lng, altitude: altitudeAfter(state, seconds) };
}
const distance = state.speed * dt;
const radians = (state.heading * Math.PI) / 180;
const lat = state.lat + (Math.cos(radians) * distance) / METRES_PER_DEGREE_LAT;
// The cosine is taken at the *starting* latitude rather than at the mean of
// the two. A minute of flying moves an airliner about half a degree at most,
// over which the correction differs in the sixth decimal place, and using the
// start keeps this a closed form rather than an iteration.
const metresPerDegreeLng = METRES_PER_DEGREE_LAT * Math.cos((state.lat * Math.PI) / 180);
const lng =
metresPerDegreeLng > 1
? state.lng + (Math.sin(radians) * distance) / metresPerDegreeLng
: state.lng;
return { lat, lng, altitude: altitudeAfter(state, dt) };
}
/**
* The altitude after an interval, with the one floor that matters: nothing
* descends through the ground.
*
* A steady 1,300 ft/min descent reckoned for a full minute puts an aircraft
* 400 m *below* the terrain if it landed in the meantime — which is exactly
* what happens to an arrival that lands while its feed is quiet, and it renders
* as an aeroplane buried in the bay.
*/
function altitudeAfter(state: Reckoning, seconds: number): number {
const dt = clamp(seconds, 0, MAX_RECKON_SECONDS);
if (!Number.isFinite(state.climb) || !Number.isFinite(dt)) return state.altitude;
return Math.max(0, state.altitude + state.climb * dt);
}
/**
* A track's dead-reckoned state, plus the error it is still sliding out of.
*
* `since` is when `state` was true; `offset` is where the aeroplane was drawn
* at `offsetAt` minus where this state says it was, in scene units, and decays
* to nothing over `RECKON_SETTLE_SECONDS`. Keeping the two apart is what makes
* the correction continuous: the *truth* jumps when an observation lands, and
* the *drawing* does not, because the jump is absorbed into an offset that is
* already on its way out.
*/
interface ReckonTrack {
state: Reckoning;
/** Seconds on `nowSeconds`'s monotonic clock. */
since: number;
offset: THREE.Vector3;
offsetAt: number;
}
interface TrailSample {
position: THREE.Vector3;
altitude: number;
/** Compass degrees, as reported. */
heading: number;
/** Seconds on `nowSeconds`'s monotonic clock. */
at: number;
}
interface Track {
mesh: THREE.Mesh;
/**
* Whether the aeroplane is currently in `pickables`.
*
* Tracked rather than derived because the answer changes on a rule the array
* cannot see — a track that has gone stale is still held and still has a mesh,
* and must stop being clickable the moment it stops being drawn. `Raycaster`
* has not consulted `visible` since r119, so an aeroplane hidden by `tick` is
* still a hit until somebody takes it out of the list, and a card raised on an
* aeroplane nobody can see is a card about an aeroplane that is not there.
*/
picking: boolean;
/** Observations, oldest first. The last is where the aircraft is heading. */
samples: TrailSample[];
/** Seconds the current leg should take: the measured gap between the last two. */
span: number;
/** Climb angle of the current leg, radians, positive nose-up. */
pitch: number;
/**
* Bank at the two ends of the current leg, radians, positive right-wing-down.
*
* Two numbers rather than one because the roll is interpolated across the leg
* exactly as the position, the altitude and the heading are. A single value
* would step once per poll, and a step is *more* conspicuous on the roll
* channel than on the others: yaw and position move continuously either side
* of it so the discontinuity is small, whereas a bank that arrives all at once
* is an aircraft snapping onto its wingtip. On a live feed that would be a
* visible flick every five to fifteen seconds, on every aircraft that is
* turning.
*
* `rollFrom` is simply what `rollTo` was on the previous leg, so the two
* always meet and the interpolation is continuous across a poll even though
* the target it is chasing is not.
*/
rollFrom: number;
rollTo: number;
/** Which cached material is on the mesh, so a band change is the only write. */
band: number;
/** Interpolated position, reused rather than reallocated every frame. */
head: THREE.Vector3;
/**
* When this track first went missing from a snapshot, or `0` while it is
* present. See `TRACK_GRACE_SECONDS`.
*/
missingSince: number;
/**
* Missing, and finished moving toward wherever it was last seen — so no
* longer drawn, while its history is still held. See `tick`.
*/
stale: boolean;
/** Altitude at `head`, which is what the aircraft's colour is chosen from. */
headAltitude: number;
/**
* The dead-reckoned state, or `null` for a source that reported no velocity.
*
* The presence of this object is what decides which of the layer's two motion
* models a track gets, and both are needed:
*
* - **`null` — interpolate.** The head is lerped between the last two
* observations and stops when it arrives. This is the original behaviour
* and it is right for anything whose speed is unknown: a ground vehicle,
* a parked airframe, a position-only TIS-B target, a source that predates
* the velocity fields. Guessing a speed for those would be inventing
* motion, which is a worse lie than showing none.
* - **Present — reckon.** The head is integrated forward from the last fix
* along the aircraft's own track at its own speed, every frame, and slides
* onto each new observation instead of snapping to it. This is what makes
* a live feed refreshing every ten seconds look like flying rather than
* like a slideshow.
*/
reckon: ReckonTrack | null;
}
/**
* Aircraft as small airliners, each dragging a fading trail of where it has been.
*
* Rendered at true altitude through the world's vertical exaggeration, so a jet
* on approach sits visibly below one at cruise, and coloured by that altitude so
* the difference survives a camera far enough away that the heights stop being
* separable.
*
* The layer moves things every frame while being told where they are only every
* poll. Positions are interpolated between the last two observations rather than
* extrapolated past the newest one: that costs one interval of lag — a second
* for the simulator — and in exchange an aircraft never overshoots and then
* snaps back, which is what extrapolation does the moment a feed stutters.
*/
export function createFlightLayer(world: World): FlightLayer {
const group = new THREE.Group();
group.name = "flights";
// Mutated in place as tracks appear and expire, so `setPicking` can hold the
// array itself as its target list rather than re-reading it every pointer move.
const pickables: THREE.Object3D[] = [];
const geo = airlinerGeometry();
const materials = new Map<number, THREE.MeshLambertMaterial>();
const tracks = new Map<string, Track>();
const scratch = new THREE.Color();
/**
* The camera the layer was last drawn for, or `null` before the first frame.
*
* The layer has no camera of its own and CONTRACT §1 is why it must not
* acquire one: the camera belongs to the scene's `SceneKit`, and a layer that
* took a second reference would have to be told when the scene swapped. It is
* read off `onBeforeRender` instead, which is handed the camera actually being
* rendered for — so an office looking at the same layer through a different
* camera would size the glyphs for *that* view without anything having to be
* wired up.
*
* Before the first frame there is no answer and the glyphs stay at their
* authored size, which is the size they were before any of this existed.
*/
let viewer: THREE.PerspectiveCamera | null = null;
/**
* The camera's distance to what it is looking at, in scene units, or `null`
* until a caller says. `null` reproduces the behaviour this layer had before
* the setter existed — the ceiling is the flat `GLYPH_MAX_SCALE` — which is
* what an office sky, a chase camera and every test with no controls get.
*/
let focusDistance: number | null = null;
/**
* One material per altitude band, built on demand.
*
* The emissive term is small and deliberate. Aircraft are lit by the same rig
* as the city, and after sunset that rig is a tenth of an intensity — a dart
* of pure diffuse white simply disappears at night, which is the one time of
* day the sky is worth looking at.
*/
function materialFor(band: number): THREE.MeshLambertMaterial {
const existing = materials.get(band);
if (existing) return existing;
const color = scratch.copy(LOW_COLOR).lerp(HIGH_COLOR, band / (COLOR_BANDS - 1)).getHex();
const mat = new THREE.MeshLambertMaterial({
color,
emissive: color,
emissiveIntensity: 0.35,
});
materials.set(band, mat);
return mat;
}
// ---- The trail ----------------------------------------------------------
// One `LineSegments` for every trail in the scene rather than one per
// aircraft: the vertex count is trivial either way, and a single draw call
// with a preallocated buffer avoids allocating and disposing geometry every
// time traffic changes. Per-vertex alpha does the fade, which needs a
// four-component colour attribute — three.js reads the item size and switches
// the shader on it.
const maxVertices = MAX_TRACKS * TRAIL_POINTS * 2;
const trailPositions = new Float32Array(maxVertices * 3);
const trailColors = new Float32Array(maxVertices * 4);
const trailGeo = new THREE.BufferGeometry();
trailGeo.setAttribute("position", new THREE.BufferAttribute(trailPositions, 3));
trailGeo.setAttribute("color", new THREE.BufferAttribute(trailColors, 4));
trailGeo.setDrawRange(0, 0);
const trailMat = new THREE.LineBasicMaterial({
vertexColors: true,
transparent: true,
// Trails cross each other constantly and are the faintest thing in the
// scene; letting them write depth makes the one that happened to draw first
// punch a hole in every one behind it.
depthWrite: false,
});
const trailLine = new THREE.LineSegments(trailGeo, trailMat);
trailLine.name = "flight-trails";
// The buffer is rewritten from scene-space coordinates every frame, so its
// bounding sphere is permanently wrong and culling it would be culling the
// whole layer.
trailLine.frustumCulled = false;
// The layer is handed observations on the source's timer and is otherwise
// never called, so the interpolation hangs off the one thing guaranteed to
// happen every frame: this line being drawn. `tick` is idempotent, so a scene
// that would rather drive the layer itself can call it and nothing here
// double-counts.
// It also carries the camera in, which is the only reason this layer knows how
// far away it is being looked at from — see `viewer`. Like every other write
// `tick` makes, the scale it computes here lands on the *next* frame: world
// matrices were resolved before any `onBeforeRender` ran. That is how this
// layer's position and attitude have always worked, it is one frame at 60 Hz,
// and the alternative is a camera reference this file is not entitled to hold.
trailLine.onBeforeRender = (_renderer, _scene, camera) => {
if ((camera as THREE.PerspectiveCamera).isPerspectiveCamera) {
viewer = camera as THREE.PerspectiveCamera;
}
tick();
};
group.add(trailLine);
// ---- Observations -------------------------------------------------------
function update(aircraft: Aircraft[]) {
const now = nowSeconds();
const seen = new Set<string>();
for (const a of aircraft) {
seen.add(a.id);
const [x, z] = world.project(a.lat, a.lng);
const position = new THREE.Vector3(x, world.metres(a.altitude), z);
const sample: TrailSample = { position, altitude: a.altitude, heading: a.heading, at: now };
let track = tracks.get(a.id);
if (!track) {
const mesh = new THREE.Mesh(geo, materialFor(0));
// Yaw then pitch, because the heading is about the world's vertical and
// the climb angle is about the aircraft's own wing.
mesh.rotation.order = "YXZ";
// The id, on the object, so a raycast hit resolves to an aeroplane
// without this layer having to expose its private track table.
mesh.userData.aircraftId = a.id;
// The pointer aims at the aeroplane and hits a sphere around it. See
// `raycastGlyph`.
mesh.raycast = raycastGlyph;
group.add(mesh);
pickables.push(mesh);
track = {
mesh,
picking: true,
samples: [],
span: MIN_SPAN,
pitch: 0,
// Wings level: a track with one observation has no pair of headings to
// have turned between, and `tick` reads both of these on its very first
// frame, so neither may start undefined.
rollFrom: 0,
rollTo: 0,
band: -1,
head: position.clone(),
headAltitude: a.altitude,
missingSince: 0,
stale: false,
// Filled in by `adoptReckoning` below, on this same observation, if
// the source said how fast the thing is going.
reckon: null,
};
tracks.set(a.id, track);
}
const previous = track.samples[track.samples.length - 1];
/**
* A source repeating itself is not a new observation, and treating it as
* one is what stopped live traffic from ever moving.
*
* `HttpFlights` is polled at 1 Hz and holds a *frozen* snapshot between
* network refreshes, which the server caches for 515 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 (515 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 78 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;
}