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 a229fb2721 The sky gets the things above the aeroplanes
Satellites, end to end: CelesTrak element sets behind the same TTL cache
the weather and the flights use, served as TLEs rather than as positions,
and propagated in the browser with SGP4.

Sending elements is the same trick `flights/plan.ts` plays and it has a
better excuse here — a TLE *is* the closed form, valid for days either
side of its epoch, so one cacheable fetch every six hours replaces a poll
and every viewer agrees about where everything is.

Two things are worth knowing about the shape of it:

  - There is no region parameter. An aeroplane at 10,000 m is local and
    a satellite at 550 km is above the horizon for a circle two thousand
    kilometres across, so one catalogue serves both boards and the client
    decides what is above its own horizon. Only the observer is per-city,
    which is why `main.ts` shares the elements and rebuilds the catalogue.
  - The layer draws on a dome, because it cannot draw anywhere else.
    `world.metres(550_000)` is 21,000 scene units against a far plane at
    3,000. Azimuth and elevation are real; the radius carries nothing.

Off by default: a clone that started pulling CelesTrak on `npm run dev`
would have volunteered somebody else's bandwidth for its onboarding.

Godmode gets the two dials that point at the sky rather than at the
light — fabricated traffic, which composes with a live ADS-B feed instead
of replacing it, and a switch for the satellite layer with a count beside
it. Both are god-only lies about the inputs, in the manner of the weather
override.

`satellite.js` is the second runtime dependency this package has taken.
Its entry point star-exports an Emscripten build that cannot be shaken
out, so `noWasmPropagator` in the Vite config cuts it: 308 kB of WASM
loader for a bulk propagator nothing calls, against 26 kB for the SGP4
that does the work.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-06 20:57:14 -07:00

858 lines
34 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
* forbid scraping and forbid redistributing their data, so an Apache-2.0 repo
* containing an FR24 client would be publishing instructions for breaking a
* ToS and shipping 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.
*
* `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 { mergeGeometries } from "three/examples/jsm/utils/BufferGeometryUtils.js";
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;
return { id: `sim-${route.callsign}`, callsign: route.callsign, lat, lng, altitude, heading };
}
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.
*
* 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;
constructor(
private readonly endpoint: string,
private readonly region: SkyRegion,
) {}
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")
// 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,
heading: typeof a.track === "number" ? a.track : 0,
}));
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;
}
// ---- Rendering ------------------------------------------------------------
export interface FlightLayer {
group: THREE.Group;
/**
* 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;
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: at
* `AdsbFlights`'s eight-second interval, twenty samples is nearly three minutes
* of flying, which is most of a leg.
*/
const TRAIL_POINTS = 20;
const TRAIL_SECONDS = 45;
/** Ceiling on tracks that get a trail, so the buffer can be allocated once. */
const MAX_TRACKS = 192;
/**
* Opacity at the head of a trail, fading to nothing at the tail. Well under 1
* on purpose: the trail is context for the dart, 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;
const MAX_SPAN = 15;
/**
* 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;
/**
* 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 a dart is drawn at, in radians. */
const MAX_PITCH = 0.42;
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;
/** 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;
/** 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;
/** Altitude at `head`, which is what the dart's colour is chosen from. */
headAltitude: number;
}
/**
* Aircraft as small darts, 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";
const geo = dartGeometry();
const materials = new Map<number, THREE.MeshLambertMaterial>();
const tracks = new Map<string, Track>();
const scratch = new THREE.Color();
/**
* 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.
trailLine.onBeforeRender = () => 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";
group.add(mesh);
track = {
mesh,
samples: [],
span: MIN_SPAN,
pitch: 0,
band: -1,
head: position.clone(),
headAltitude: a.altitude,
};
tracks.set(a.id, track);
}
const previous = track.samples[track.samples.length - 1];
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) {
// 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;
} else {
track.span = span;
track.pitch = climbAngle(world, previous, sample);
}
}
track.samples.push(sample);
trim(track, now);
}
for (const [id, track] of tracks) {
if (seen.has(id)) continue;
group.remove(track.mesh);
tracks.delete(id);
}
tick();
}
/** 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);
track.head.lerpVectors(from.position, to.position, alpha);
track.headAltitude = from.altitude + (to.altitude - from.altitude) * alpha;
track.mesh.position.copy(track.head);
// A heading of 0 is north, and north is -z, so a dart 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;
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 dart.
*/
function rebuildTrails() {
let vertex = 0;
for (const track of tracks.values()) {
const spine = track.samples.length - 1;
if (spine < 1) continue;
const points = spine + 1; // the spine, plus the head
for (let i = 1; 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);
trailGeo.attributes.position!.needsUpdate = true;
trailGeo.attributes.color!.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,
update,
tick,
dispose() {
geo.dispose();
for (const m of materials.values()) m.dispose();
materials.clear();
trailGeo.dispose();
trailMat.dispose();
tracks.clear();
group.clear();
},
};
}
/**
* A dart: a five-sided body with a wing and a tailplane, merged into one
* geometry so an aircraft is one draw call.
*
* The wing is what earns its keep. A bare cone at this scale is a bright speck
* with no orientation, and the whole reason to draw traffic on a city map is
* that it is going somewhere — the crossbar is the only part of the silhouette
* that says which way.
*/
function dartGeometry(): THREE.BufferGeometry {
const body = new THREE.ConeGeometry(0.09, 0.42, 5);
body.rotateX(Math.PI / 2); // nose along +z, so heading is a rotation about Y
const wing = new THREE.BoxGeometry(0.44, 0.016, 0.085);
wing.translate(0, -0.005, -0.02);
const tail = new THREE.BoxGeometry(0.15, 0.014, 0.055);
tail.translate(0, 0.02, -0.165);
const parts = [body, wing, tail];
const merged = mergeGeometries(parts);
for (const part of parts) part.dispose();
if (merged) return merged;
// `mergeGeometries` returns null when the inputs disagree about their
// attributes, which three primitives from the same library cannot — but the
// signature allows it, and a missing aircraft is worse than a plain one.
const fallback = new THREE.ConeGeometry(0.09, 0.42, 5);
fallback.rotateX(Math.PI / 2);
return fallback;
}
/**
* 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);
}
/**
* 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 {
const delta = (((to - from) % 360) + 540) % 360 - 180;
return from + delta * 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;
}