feat: SFO, LAX, both bridges, a road that reads as a road, and aeroplanes that move
**The aeroplanes were stuck because the wire could not describe motion.** `WireAircraft` carried position, altitude and heading and nothing else, so the client could only interpolate between the last two observations: every aircraft replayed a segment it had already flown, arrived at the newest known point, and sat still until the next poll landed five to fifteen seconds later. The feed had the missing numbers the whole time and the server threw them away. Sampled live from `api.adsb.lol/v2/point` while writing this — `gs` ground speed, `track`, `baro_rate`, plus `r` registration and `t` type designator. They are on the wire now in SI, aircraft dead-reckon along their own track and correct toward the truth when a fix lands, and the click card an anonymous visitor gets says "B739 · N68834". That last part is the enrichment FR24 was wanted for, obtained from an ODbL feed we may actually republish. **SFO and LAX exist.** A new `engine/airports.ts` composes an airport from runways, taxiways, aprons and terminal masses, with markings drawn on a canvas rather than modelled; the pattern of the runways is what the eye recognises from altitude, long before any building does. SFO is the two crossing pairs on the bay fill; LAX is the four parallels either side of the terminal horseshoe, plus the Southland fields under the traffic that actually flies there. **The Golden Gate and the Bay Bridge are those bridges.** One kit in `engine/bridges.ts`, because a suspension bridge is a repeated tower, a catenary main cable, a series of hangers and a deck — so both are configurations rather than two private implementations. The Bay Bridge carries the real 2013 topology: two suspension towers west of Yerba Buena, one east, then the piered causeway. The freeway stopped being a wireframe overlay and became a road, with shoulders, a median, and lane markings as texture. **And the board got faster while all of that landed.** California went from 728,744 triangles and 562 draw calls to 391,169 and 371 — headroom from 2.8% to 47.8%. The Bay Area board is 506,550 triangles lighter than before this work. Two things paid for it: - `transmission: 0.08` on the aircraft cockpit glass. three.js runs a full transmission backdrop pass whenever any rendered material has transmission above zero, re-drawing the entire opaque scene into a second target every frame — so the city was rendering terrain, every block and every freeway piece TWICE. Measured by patching only that number in a copy of the built bundle: 703,267 tris / 562 draws with it, 398,608 / 371 without. The material was already `transparent: true, opacity: 0.86`, so it was buying nothing. - Flatness-adaptive terrain LOD, which collapses runs of lattice cells wherever the height and colour agree with the quad replacing them. The coastline is provably untouched — a patch collapses only when every point is on land and agrees about `park` — and a test asserts the drawn footprint matches the cell-by-cell area to 1e-6. `createTerrain` got *faster*: the vertices it stops emitting cost more than the flatness scan costs to run. **The budget now watches the boards this was built on.** There was no `bay-area` or `socal` cell — so SFO, LAX and both bridges all landed in frames nothing measured, which is how a cap you do not have looks from the inside. Both are in the matrix now with caps set from measurement, and the rationale lives in the harness because JSON cannot hold a comment. Two known defects ship with this, both recorded in TODO.md rather than hidden: - `bay-area.desktop` drops about one frame in twenty (p50 16.7, p95 33.3). It is desktop-only and not fill rate — mobile runs the same 2.26 M triangles at a comparable pixel count and holds 16.7 flat — which points at the 2048 shadow map desktop uses against handheld's 1024. Measured at the commit before this work with the same harness: identical p95 33.3. Pre-existing, and invisible until the cell existed. - The aeroplane glyph is still about 1.5x the Golden Gate's main span at chapter zoom, down from 2.5x. `GLYPH_MAX_SCALE` is 52 because the raw scale at the far end of the California orbit is 51.0 at a 60-degree field of view, and 26 — tried first — put the glyph at 0.0123 of the frame against the 0.012 where the wings stop resolving. The real fix is to clamp against the camera's focus distance rather than the aircraft's, which is a signature change. Tests 1020 -> 1137. Typecheck, build, eight budget cells, no-binaries, provenance, zero-config boot, dependency licences, arena source hashes and the UI smoke across two viewports and two access tiers all pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+572
-9
@@ -320,7 +320,64 @@ export function sampleRoute(route: SimRoute, p: number): Aircraft {
|
||||
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 };
|
||||
/**
|
||||
* 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 {
|
||||
@@ -432,6 +489,34 @@ export class AdsbFlights implements FlightSource {
|
||||
* 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;
|
||||
@@ -455,8 +540,20 @@ interface RawAircraft {
|
||||
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 ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
@@ -466,11 +563,17 @@ interface RawAircraft {
|
||||
* 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,
|
||||
* no registration and no operator here, because the open feeds do not carry
|
||||
* 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. `owner-decisions.md` reserves those for an openly-licensed registry
|
||||
* we have not wired.
|
||||
* 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:
|
||||
@@ -504,6 +607,36 @@ export interface AircraftDetail {
|
||||
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;
|
||||
/**
|
||||
@@ -564,6 +697,20 @@ export interface AircraftDetailOptions {
|
||||
* 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. */
|
||||
@@ -604,6 +751,20 @@ export function aircraftDetail(
|
||||
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
|
||||
@@ -790,6 +951,49 @@ const JUMP_UNITS_PER_SECOND = 8;
|
||||
*/
|
||||
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;
|
||||
|
||||
/**
|
||||
* The radius of the sphere a pointer actually has to hit, in glyph lengths.
|
||||
*
|
||||
@@ -901,6 +1105,169 @@ const SPEED_OVER_G = 200 / 9.80665;
|
||||
*/
|
||||
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;
|
||||
@@ -963,6 +1330,25 @@ interface Track {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1119,6 +1505,9 @@ export function createFlightLayer(world: World): FlightLayer {
|
||||
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);
|
||||
}
|
||||
@@ -1159,6 +1548,18 @@ export function createFlightLayer(world: World): FlightLayer {
|
||||
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
|
||||
@@ -1174,6 +1575,7 @@ export function createFlightLayer(world: World): FlightLayer {
|
||||
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
|
||||
@@ -1201,6 +1603,7 @@ export function createFlightLayer(world: World): FlightLayer {
|
||||
|
||||
track.samples.push(sample);
|
||||
trim(track, now);
|
||||
adoptReckoning(track, a, now, jumped);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1234,6 +1637,137 @@ export function createFlightLayer(world: World): FlightLayer {
|
||||
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.
|
||||
*
|
||||
@@ -1318,8 +1852,23 @@ export function createFlightLayer(world: World): FlightLayer {
|
||||
setPickable(track, !track.stale);
|
||||
if (track.stale) continue;
|
||||
|
||||
track.head.lerpVectors(from.position, to.position, alpha);
|
||||
track.headAltitude = from.altitude + (to.altitude - from.altitude) * alpha;
|
||||
/**
|
||||
* 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);
|
||||
/**
|
||||
@@ -1407,7 +1956,20 @@ export function createFlightLayer(world: World): FlightLayer {
|
||||
// a genuinely new arrival — `tracks` is walked in insertion order, and the
|
||||
// ghosts are the oldest entries in it.
|
||||
if (track.stale) continue;
|
||||
const spine = track.samples.length - 1;
|
||||
/**
|
||||
* 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
|
||||
@@ -1532,7 +2094,8 @@ export function glyphScale(distance: number, fovDegrees: number): number {
|
||||
if (!Number.isFinite(distance) || !Number.isFinite(fovDegrees)) return 1;
|
||||
if (distance <= 0 || fovDegrees <= 0 || fovDegrees >= 180) return 1;
|
||||
const frustumHeight = 2 * distance * Math.tan((fovDegrees * Math.PI) / 360);
|
||||
return Math.max(1, (GLYPH_MIN_SCREEN_FRACTION * frustumHeight) / AIRLINER_LENGTH);
|
||||
const legible = (GLYPH_MIN_SCREEN_FRACTION * frustumHeight) / AIRLINER_LENGTH;
|
||||
return Math.min(GLYPH_MAX_SCALE, Math.max(1, legible));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user