Real weather, real aircraft, a heightfield off the main thread, and instruments
Three things that were built and never connected, connected.
**The weather was already there.** `observe()` has always taken a
`WeatherObservation` and `main.ts` has always passed null, so the cloud,
precipitation, visibility and marine-layer paths in atmosphere.ts had never run
outside a test. The server already shipped NWS, met.no and Open-Meteo, all
configured off. What was actually missing was that a single TERA_ORIGIN_LAT/LNG
served one metro and lied to the other — so weather and traffic are per-region
now, derived from the city's own bounds, and the Bay Area gets its fog while
Long Beach gets its own sky. The route takes ?city= or a validated ?lat=&lng=
and refuses to become an open geocoding proxy for the planet.
**The heightfield moved to a Worker.** 2.3 s of blocked main thread at boot, and
another ~950 ms of point-in-polygon on top of it: the park mask is filled in the
worker now, and block placement samples four corners and only runs the exact
test on a cell that straddles an edge — 8 buildings differ out of 185,036.
createScene is async and takes a Stage as a consequence, and there is a
main-thread fallback because "clone it and it works" has no exception clause.
**Spaces is a chunk you fetch when you reach for the door**, not one everybody
downloads. Same for the godmode tools. The entry chunk is 722 kB rather than
772; three.js is most of what is left and splitting it is a different job.
**Godmode is an instrument panel now** rather than one slider: the date and the
season, not just the hour, so the Meeus moon and the sun's seasonal arc become
visible instead of merely correct; a weather override that says on screen when
it is lying; a frame-time and draw-call readout; and a pose editor that emits a
paste-ready Chapter block, which is the thing that makes adding New York cheap.
Two blockers the review caught:
- Every city switch leaked 8 GPU textures — one of them a 2048x2048 shadow map
— and ~10.5 shader programs, and deleteTexture had never been called once in
the app's lifetime. The renderer was being built per scene; it belongs to the
canvas, for the life of the page.
- An upstream fetch that threw rather than returning null skipped the cache
stamp, so the TTL — the only rate limit on outbound calls — collapsed to one
upstream request per inbound request, and the caller got a 500.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+190
-7
@@ -16,7 +16,7 @@
|
||||
|
||||
import * as THREE from "three";
|
||||
import { mergeGeometries } from "three/examples/jsm/utils/BufferGeometryUtils.js";
|
||||
import type { Aircraft, FlightSource } from "./types.ts";
|
||||
import type { Aircraft, City, FlightSource } from "./types.ts";
|
||||
import { seededRandom, type World } from "./world.ts";
|
||||
|
||||
/** A route the simulator flies: great-circle-ish, with a climb or descent. */
|
||||
@@ -31,6 +31,156 @@ export interface SimRoute {
|
||||
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 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -86,6 +236,18 @@ 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.
|
||||
*
|
||||
@@ -93,23 +255,36 @@ function nowSeconds(): number {
|
||||
* 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 radiusNm = 25,
|
||||
private readonly center: { lat: number; lng: number } = { lat: 37.77, lng: -122.42 },
|
||||
private readonly region: SkyRegion,
|
||||
) {}
|
||||
|
||||
async poll(): Promise<Aircraft[]> {
|
||||
const url = `${this.endpoint}/v2/point/${this.center.lat}/${this.center.lng}/${this.radiusNm}`;
|
||||
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 [];
|
||||
if (!res.ok) return this.hold();
|
||||
const body = (await res.json()) as { ac?: RawAircraft[] };
|
||||
return (body.ac ?? [])
|
||||
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(),
|
||||
@@ -119,11 +294,19 @@ export class AdsbFlights implements FlightSource {
|
||||
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 [];
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user