/** * 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, marginNm = 15): SkyRegion { const { minLat, maxLat, minLng, maxLng } = city.bounds; const corners: Place[] = [ { lat: minLat, lng: minLng }, { lat: minLat, lng: maxLng }, { lat: maxLat, lng: minLng }, { lat: maxLat, lng: maxLng }, ]; let radiusNm = 0; for (const corner of corners) radiusNm = Math.max(radiusNm, distanceNm(city.center, corner)); return { center: city.center, radiusNm: Math.round(radiusNm + marginNm) }; } /** Whether a position is in the region, with optional slack in nautical miles. */ export function inRegion(region: SkyRegion, lat: number, lng: number, slackNm = 0): boolean { return distanceNm(region.center, { lat, lng }) <= region.radiusNm + slackNm; } /** * Plausible traffic for a region nobody has authored routes for. * * `adapters/sample.ts` has hand-placed corridors for the two cities in this * build and they are much better than this: real arrivals come down the real * approach, and that is most of what makes a sky read as *this* city's sky * rather than as motion. What follows is what a third city gets on the day it * is added and before anybody has done that work — chords across the region at * airliner altitudes, deterministic from the seed so that two viewers agree * about where everything is. * * The alternative floor was an empty sky, and an empty sky over a city is not * read as "no traffic today", it is read as a broken layer. Every leg here is * inside the region by construction, which is the one property the previous * arrangement could not offer: the constant it used was San Francisco. */ export function syntheticRoutes(region: SkyRegion, count = 6, seed = 20_617): SimRoute[] { const rand = seededRandom(seed); const degPerNm = 1 / NM_PER_DEGREE; // Longitude degrees are shorter than latitude degrees everywhere but the // equator, so an east–west offset in nautical miles is more of them. const lngPerNm = degPerNm / Math.cos((region.center.lat * Math.PI) / 180); const routes: SimRoute[] = []; for (let i = 0; i < count; i++) { const bearing = rand() * Math.PI * 2; // Push the chord off the centre so the legs are not six spokes through // downtown. ±60% of the radius crosses the board at a spread of depths. const offset = (rand() * 1.2 - 0.6) * region.radiusNm; const half = Math.sqrt(Math.max(region.radiusNm ** 2 - offset ** 2, 1)); const alongE = Math.sin(bearing); const alongN = Math.cos(bearing); const from = { lat: region.center.lat + (-alongN * half - alongE * offset) * degPerNm, lng: region.center.lng + (-alongE * half + alongN * offset) * lngPerNm, }; const to = { lat: region.center.lat + (alongN * half - alongE * offset) * degPerNm, lng: region.center.lng + (alongE * half + alongN * offset) * lngPerNm, }; // A third arriving, a third departing, a third crossing high. A board where // everything is at cruise has no altitude ramp to read and no reason for // the colour band in `createFlightLayer` to exist. const kind = i % 3; const fromAlt = kind === 0 ? 3400 : kind === 1 ? 500 : 8600 + rand() * 1800; const toAlt = kind === 0 ? 450 : kind === 1 ? 6200 : fromAlt + 400; // Eight seconds a nautical mile is about 450 knots, which is an airliner. const duration = Math.round(half * 2 * 8); routes.push({ callsign: `SIM ${i + 1}`, from: [from.lat, from.lng], to: [to.lat, to.lng], fromAlt: Math.round(fromAlt), toAlt: Math.round(toAlt), duration, }); } return routes; } /** * A source with a dial on it: whatever it was going to draw, plus N invented * aircraft. * * This exists for one control in the godmode panel — "how busy would this look * with three times the traffic" — and the shape it takes is chosen to make that * question answerable without corrupting the answer to any other one. * * **It composes rather than substitutes.** The base source is polled unchanged * and its aircraft are passed through untouched; the fabricated ones are a * second list concatenated onto the end. That is what lets the dial work over a * *live* ADS-B feed as well as over the simulator — the real traffic stays real * and stays complete, and turning the dial back to zero returns exactly the * sky that was there before, because nothing was ever taken away. * * The alternative was to mutate the simulator's route list, and it is worse in * both directions: it does nothing at all when the server is serving its own * plan (`HttpFlights` ignores its fallback in that mode, so the slider would be * inert on every deployment that has an API), and it is destructive when it does * work, because the authored corridors would have to be rebuilt to get back. * * ### On fabricating traffic at all * * The same argument as `weatherOverride` in `main.ts`: a god-only lie about the * inputs, told to see what the renderer does with it. It is deliberately **not** * available to anyone else, and the invented aircraft carry a callsign prefix of * their own so that a screenshot of a busy sky can be told from a screenshot of * a real one. Note what this breaks while it is on — every viewer agreeing about * where the aircraft are, which is the property the server's plan exists to buy. * That is acceptable for a debug dial and would not be for a feature. */ export interface TrafficDial { /** The source to hand `createScene`. Stable for the dial's whole life. */ source: FlightSource; /** Fabricate this many additional aircraft. `0` turns the dial off entirely. */ setExtra(count: number): void; extra(): number; } /** * Callsign prefix for fabricated traffic. * * Distinct from `syntheticRoutes`'s own `SIM`, and it has to be: `sampleRoute` * derives an aircraft's id from its callsign, `createFlightLayer` keys its * tracks on that id, and a deployment with no API is already flying `SIM 1` * through `SIM 6` from the fallback. Reuse the prefix and every fabricated * aircraft would land on an existing track, teleporting it across the board on * alternate polls. */ const FABRICATED_PREFIX = "GOD"; /** As many as the dial goes to. Past this the sky is soup and the point is made. */ export const MAX_EXTRA_TRAFFIC = 400; export function withTrafficDial(base: FlightSource, region: SkyRegion): TrafficDial { let extra: SimulatedFlights | null = null; let count = 0; return { source: { interval: base.interval, poll(): Aircraft[] | Promise { const theirs = base.poll(); if (extra === null) return theirs; const mine = extra.poll(); // `poll` is synchronous on every source in this build, but the interface // permits a promise and `HttpFlights` documents its synchrony as a // deliberate property rather than an accident. Handling both here costs // one branch and means the dial cannot be what breaks that. return theirs instanceof Promise ? theirs.then((a) => [...a, ...mine]) : [...theirs, ...mine]; }, dispose: () => base.dispose?.(), }, setExtra(next: number) { count = Math.max(0, Math.min(MAX_EXTRA_TRAFFIC, Math.round(next))); if (count === 0) { extra = null; return; } const routes = syntheticRoutes(region, count).map((route, i) => ({ ...route, callsign: `${FABRICATED_PREFIX} ${i + 1}`, })); extra = new SimulatedFlights(routes); }, extra: () => count, }; } /** * Traffic that behaves like the real thing without being it: aircraft move * along fixed legs at fixed speeds, looping, with each one offset in phase so * the sky is never empty and never synchronised. */ export class SimulatedFlights implements FlightSource { readonly interval = 1; private readonly routes: SimRoute[]; private readonly phase: number[]; private t = 0; private last = 0; constructor(routes: SimRoute[], seed = 4711) { this.routes = routes; const rand = seededRandom(seed); this.phase = routes.map(() => rand()); this.last = nowSeconds(); } poll(): Aircraft[] { const now = nowSeconds(); this.t += Math.min(now - this.last, 5); this.last = now; return this.routes.map((route, i) => sampleRoute(route, this.t / route.duration + (this.phase[i] ?? 0))); } } /** * One aircraft's state at a fraction of the way along its leg. `p` wraps, so * anything can be handed in and 1.4 means the same as 0.4. * * Split out of `SimulatedFlights.poll` because the HTTP adapter needs exactly * this and cannot reuse the class to get it: `SimulatedFlights` runs on a * monotonic clock that starts when it is constructed, whereas the wire's * `FlightsPlanBody` anchors every route to a fixed epoch so that two browsers * agree about where the aircraft are. Same arithmetic, different origin — and * two copies of the arithmetic would drift. */ export function sampleRoute(route: SimRoute, p: number): Aircraft { const t = ((p % 1) + 1) % 1; const lat = route.from[0] + (route.to[0] - route.from[0]) * t; const lng = route.from[1] + (route.to[1] - route.from[1]) * t; // Ease the altitude so departures climb steeply and level off. const ease = 1 - (1 - t) ** 2; const altitude = route.fromAlt + (route.toAlt - route.fromAlt) * ease; const heading = (Math.atan2(route.to[1] - route.from[1], route.to[0] - route.from[0]) * 180) / Math.PI; 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 { 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(); const tracks = new Map(); 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(); 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; }