1
0

The sky gets the things above the aeroplanes

Satellites, end to end: CelesTrak element sets behind the same TTL cache
the weather and the flights use, served as TLEs rather than as positions,
and propagated in the browser with SGP4.

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

Two things are worth knowing about the shape of it:

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-06 20:57:14 -07:00
parent 0cc2126e85
commit a229fb2721
23 changed files with 2062 additions and 12 deletions
+118 -2
View File
@@ -19,7 +19,13 @@ import {
type WeatherObservation,
} from "./engine/atmosphere.ts";
import { createScene, type SceneHandle } from "./engine/scene.ts";
import { regionOf, SimulatedFlights } from "./engine/flights.ts";
import {
regionOf,
SimulatedFlights,
withTrafficDial,
type TrafficDial,
} from "./engine/flights.ts";
import { SatelliteCatalogue, type SatelliteElements } from "./engine/satellites.ts";
import type { Pose } from "./engine/scenekit.ts";
import { createStage, deviceProfile } from "./engine/stage.ts";
import { daylightPhase } from "./engine/solar.ts";
@@ -156,6 +162,44 @@ let weatherWatch: WeatherWatch | null = null;
* which is never live and does not need asking.
*/
let cityFlights: TrafficSource | null = null;
/**
* The satellite element sets, fetched once for the page rather than once per city.
*
* Every other feed here is per-city and is torn down on a switch. These are not,
* and the asymmetry is the physics: an aircraft at 10,000 m is visible for tens
* of kilometres and the two boards are six hundred apart, but a satellite at
* 550 km is above the horizon for a circle two thousand kilometres across. The
* same element sets serve both cities and would serve a continent.
*
* **The elements are shared and the catalogue is not.** A `SatelliteCatalogue`
* is built around an observer, and the observer is the city centre: reusing one
* across a city switch would compute the Southland's sky from San Francisco and
* put every look angle several degrees out, with nothing on screen to say so.
* So the expensive, universal half is cached here and the cheap, local half is
* rebuilt per board.
*
* `null` until the first fetch is started, and on the overwhelming majority of
* deployments forever: `TERA_SATELLITES_SOURCE` is off by default. A promise
* rather than a value so that a second city mounted while the first fetch is
* still in the air waits for it instead of starting another.
*/
let satelliteElements: Promise<SatelliteElements[]> | null = null;
/**
* The fabricated-traffic dial for the board on screen, rebuilt with every city.
*
* Held here rather than inside the godmode closure because the panel is mounted
* once and the board is not: a dial captured when the panel opened would keep
* pushing aircraft at a scene that had been disposed two city switches ago.
*/
let trafficDial: TrafficDial | null = null;
/**
* Whether the satellite layer is drawn, remembered across city switches.
*
* A new board builds a new layer, which starts visible, so a god who turned the
* sky off and then changed city would have it come back on — a setting that
* quietly undoes itself is worse than one that is not there.
*/
let satellitesVisible = true;
/**
* The build in progress. Aborting it is what makes a second click on the other
* city cheap: `createScene` drops the heightfield, resolves `null`, and has
@@ -319,6 +363,11 @@ function updateSun() {
const env = observe(active.center.lat, active.center.lng, currentInstant(), currentWeather());
city.setLighting(atmosphere.apply(env));
city.setSolarElevation(env.sun.elevation);
// The override itself, not `currentInstant()`. Handing over a resolved date
// would peg the sky to whatever second this ran in, and this runs about once a
// second — so an unscrubbed sky would advance in visible steps while the
// aircraft beside it moved smoothly. `null` means "read the clock yourself".
city.setSkyInstant(instantOverride);
// The plan view follows the same day the map does. It computes its own
// palette from this one number rather than reading the rig, because a rig is
// a set of three.js lights and the minimap has none.
@@ -406,10 +455,47 @@ async function mountCity(id: string) {
access.can.liveEnvironment && access.feeds?.flights ? tera.flights(region, routes) : null;
cityFlights = traffic;
/**
* Started here and awaited below, so the fetch overlaps the heightfield build
* rather than following it. Gated on the deployment for the same reason the
* traffic is: a box with no satellite source answers with an empty catalogue,
* and asking it once per page load for that is a request nobody needs.
*
* Not gated on the visitor. There is no `can.` check because there is nothing
* to grant — the objects in this catalogue broadcast their positions to
* anybody with a radio, and every element set in it is a US Government work.
*/
if (satelliteElements === null && access.feeds?.satellites) {
satelliteElements = tera.satellites();
}
const elements = (await satelliteElements) ?? [];
// Rebuilt per board: the observer is this city's centre. See the note on
// `satelliteElements` for why only the elements are shared.
const catalogue =
elements.length === 0 ? undefined : new SatelliteCatalogue(elements, entry.city.center);
// The build may have been abandoned while that was in the air.
if (mount.signal.aborted) {
traffic?.dispose();
if (cityFlights === traffic) cityFlights = null;
return;
}
// Wrapped, not replaced: the dial passes the real sky through untouched and
// concatenates fabricated aircraft after it, so it composes with a live ADS-B
// feed as readily as with the simulator. `cityFlights` stays the unwrapped
// source — the corner label asks it whether what is on screen was observed,
// and the answer is about the feed rather than about the dial.
const dial = withTrafficDial(traffic ?? new SimulatedFlights(routes), region);
// Carried across the switch, so a dial somebody set on the last board is still
// set on this one.
dial.setExtra(trafficDial?.extra() ?? 0);
trafficDial = dial;
const handle = await createScene(stage, {
city: entry.city,
markerPalette: palette,
flights: traffic ?? new SimulatedFlights(routes),
flights: dial.source,
...(catalogue ? { satellites: catalogue } : {}),
onMarkerPick: (m) => showDetail(m ? `${m.label}${m.blurb ? `${m.blurb}` : ""}` : null),
signal: mount.signal,
// An abandoned build keeps its worker running for a tick or two after the
@@ -430,6 +516,10 @@ async function mountCity(id: string) {
return;
}
city = handle;
// A new board builds a new layer, and a new layer starts visible. Reapply
// whatever the panel last said, or the setting silently undoes itself on the
// first city switch.
handle.setSatellitesVisible(satellitesVisible);
/**
* The weather, started only now that the board exists.
@@ -1496,6 +1586,32 @@ async function mountGodmode() {
// on screen is one somebody typed.
renderSource();
},
/**
* Both dials read through the module-level handles rather than closing over
* a board, because the panel outlives the city: it is mounted once and every
* later `mountCity` swaps `city` and `trafficDial` underneath it. A closure
* over the board that was current when the panel opened would go on driving
* a disposed scene after the first city switch.
*/
sky: {
onExtraTraffic(count) {
trafficDial?.setExtra(count);
},
onSatellitesVisible(visible) {
satellitesVisible = visible;
city?.setSatellitesVisible(visible);
},
read() {
const counts = city?.satelliteCounts();
return {
extraTraffic: trafficDial?.extra() ?? 0,
trafficIsLive: cityFlights?.live() ?? false,
// `total: 0` is a board with no catalogue, which is not the same as a
// catalogue with nothing above the horizon — the panel says so.
satellites: counts && counts.total > 0 ? counts : null,
};
},
},
});
// The city was built before this chunk arrived, so its pose editor is built