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:
+110
-1
@@ -21,7 +21,27 @@ import { getJson, userAgent } from "../http.ts";
|
||||
import { isOpenAdsbUrl } from "./licence.ts";
|
||||
import type { WireAircraft } from "../../../src/server/wire.ts";
|
||||
|
||||
/** The shared dump1090/readsb aircraft record, as both feeds emit it. */
|
||||
/**
|
||||
* The shared dump1090/readsb aircraft record, as both feeds emit it.
|
||||
*
|
||||
* Declared field by field rather than as an index signature, and the list grew
|
||||
* because the fields that were missing from it were the ones the map needed
|
||||
* most. For a long time this read hex/flight/lat/lon/alt_baro/track, which is
|
||||
* enough to put a dart somewhere and not enough to make it fly: a client handed
|
||||
* positions alone can only interpolate between the last two it was sent, so
|
||||
* every aeroplane arrived at the newest known point and stopped dead until the
|
||||
* next snapshot. The velocity was in every row of the feed the whole time and
|
||||
* this file threw it away.
|
||||
*
|
||||
* Sampled live from `api.adsb.lol/v2/point` while writing this, so the names and
|
||||
* the units are observed rather than remembered:
|
||||
*
|
||||
* ```json
|
||||
* { "hex": "a923cd", "flight": "UAL505 ", "r": "N68834", "t": "B739",
|
||||
* "gs": 249.2, "track": 357.7, "baro_rate": 1344, "alt_baro": 4950,
|
||||
* "seen_pos": 0.183 }
|
||||
* ```
|
||||
*/
|
||||
interface RawAircraft {
|
||||
hex?: string;
|
||||
flight?: string;
|
||||
@@ -29,6 +49,54 @@ interface RawAircraft {
|
||||
lon?: number;
|
||||
alt_baro?: number | string;
|
||||
track?: number;
|
||||
/** Ground speed in knots. `0.0` on a parked aircraft or a ground vehicle. */
|
||||
gs?: number;
|
||||
/** Barometric climb rate, feet per minute, positive up. */
|
||||
baro_rate?: number;
|
||||
/** Geometric climb rate, feet per minute. Present when `baro_rate` is not. */
|
||||
geom_rate?: number;
|
||||
/** Registration — the tail number, e.g. `"N68834"`. */
|
||||
r?: string;
|
||||
/** ICAO type designator, e.g. `"B739"`. */
|
||||
t?: string;
|
||||
/** Seconds since this row's *position* was last updated. */
|
||||
seen_pos?: number;
|
||||
}
|
||||
|
||||
/** One knot in metres per second. The wire carries SI; the feed does not. */
|
||||
const KNOT_MS = 0.514_444;
|
||||
/** One foot per minute in metres per second. */
|
||||
const FPM_MS = 0.00508;
|
||||
|
||||
/**
|
||||
* A finite number, or `undefined` — so an optional wire field is either a
|
||||
* measurement or absent, and never `NaN` dressed as one.
|
||||
*
|
||||
* Every velocity below goes through this. A feed that sends `"gs": null` for a
|
||||
* target it has a position but no velocity for is normal traffic, not an error,
|
||||
* and the honest thing to do with it is to say nothing: a client that
|
||||
* dead-reckons a null speed as zero draws a parked airliner at 10,000 feet, and
|
||||
* one that reads it as `NaN` moves the aircraft to nowhere at all.
|
||||
*/
|
||||
function finite(value: number | undefined): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* A short printable code from the feed — a registration or a type designator —
|
||||
* or `undefined`.
|
||||
*
|
||||
* Trimmed, because the feeds pad `flight` and are not consistent about the
|
||||
* others, and length-capped because these end up on a card in a browser and the
|
||||
* row is somebody else's data. Nothing is invented and nothing is expanded: the
|
||||
* designator is published as `B739` and this repo does not ship a table that
|
||||
* turns it into "Boeing 737-900", because a table like that is one more thing
|
||||
* that can be wrong about a real aeroplane.
|
||||
*/
|
||||
function code(value: string | undefined, max: number): string | undefined {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const trimmed = value.trim();
|
||||
return trimmed === "" || trimmed.length > max ? undefined : trimmed;
|
||||
}
|
||||
|
||||
interface AircraftEnvelope {
|
||||
@@ -200,6 +268,16 @@ function normalise(
|
||||
const id = a.hex ?? callsign;
|
||||
if (id === undefined || id === "") continue;
|
||||
const address = icao24(a.hex);
|
||||
const speed = finite(a.gs);
|
||||
// `baro_rate` is what the airframe's altimeter says and `alt_baro` is the
|
||||
// altitude beside it; `geom_rate` is the GNSS answer and is what a row
|
||||
// carries when the barometric one is unavailable. Either is a climb.
|
||||
const climb = finite(a.baro_rate) ?? finite(a.geom_rate);
|
||||
const age = finite(a.seen_pos);
|
||||
// Eight characters covers every civil registration in use; four is the
|
||||
// width of an ICAO type designator, and the feed emits nothing longer.
|
||||
const registration = code(a.r, 12);
|
||||
const kind = code(a.t, 8);
|
||||
aircraft.push({
|
||||
id,
|
||||
callsign: callsign === "" ? undefined : callsign,
|
||||
@@ -216,6 +294,37 @@ function normalise(
|
||||
// "ground" for anything that is not flying. The wire carries metres.
|
||||
altitude: typeof a.alt_baro === "number" ? a.alt_baro * 0.3048 : 0,
|
||||
heading: typeof a.track === "number" ? a.track : 0,
|
||||
/**
|
||||
* The velocity, and the conditions under which it is carried at all.
|
||||
*
|
||||
* **Both halves of the gate matter.** A speed is only sent when the feed
|
||||
* reported a positive one *and* reported a track to go with it, because
|
||||
* the consumer of these two numbers is a dead-reckoner and the pair is
|
||||
* what it integrates. `heading` above falls back to `0` for a row with no
|
||||
* track — which is harmless for a symmetrical glyph that is not moving,
|
||||
* and is a claim that a ground vehicle is taxiing due north at thirty
|
||||
* knots the moment anything advances it. Ground vehicles and parked
|
||||
* aircraft report `gs: 0.0` with a null track and are exactly this case.
|
||||
*
|
||||
* So: no track, no speed. `engine/flights.ts` holds such a track
|
||||
* motionless rather than flying it along an invented heading, which is
|
||||
* the right answer for something that is genuinely parked.
|
||||
*/
|
||||
...(speed === undefined || speed <= 0 || typeof a.track !== "number"
|
||||
? {}
|
||||
: { groundSpeed: speed * KNOT_MS }),
|
||||
// Barometric first because it is what the altitude above is, so a climb
|
||||
// drawn from this rate is consistent with the height it is drawn at.
|
||||
// Geometric is a few percent different in real air and identical here.
|
||||
...(climb === undefined ? {} : { verticalRate: climb * FPM_MS }),
|
||||
// How stale the position already was when the feed answered. Small on a
|
||||
// healthy feed and carried anyway: it is the client's only way to know
|
||||
// what instant these coordinates describe. See `WireAircraft.ageSeconds`.
|
||||
...(age === undefined || age < 0 ? {} : { ageSeconds: age }),
|
||||
// ODbL, off the same row as the position, and publishable on the same
|
||||
// terms: this is the enrichment a commercial feed was once wanted for.
|
||||
...(registration === undefined ? {} : { registration }),
|
||||
...(kind === undefined ? {} : { type: kind }),
|
||||
});
|
||||
}
|
||||
return { aircraft, observedAt: observedAtMs(body.now) };
|
||||
|
||||
Reference in New Issue
Block a user