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
+73
View File
@@ -32,6 +32,11 @@ import { createBlocks, createLandmarks } from "./blocks.ts";
import { createNightLights, type NightLights } from "./nightlights.ts";
import { createFlightLayer, type FlightLayer } from "./flights.ts";
import { createMarkerLayer, type MarkerLayer } from "./markers.ts";
import {
createSatelliteLayer,
type SatelliteCatalogue,
type SatelliteLayer,
} from "./satellites.ts";
import { createSceneKit, type Pose } from "./scenekit.ts";
import type { Stage, StageScene } from "./stage.ts";
import { createBridges, createRoads } from "./structures.ts";
@@ -51,6 +56,15 @@ export interface SceneOptions {
city: City;
markerPalette?: MarkerPalette;
flights?: FlightSource;
/**
* Element sets to propagate, if this deployment has any.
*
* A catalogue rather than a source, and the asymmetry with `flights` is the
* point: a `FlightSource` is polled because there is no closed form for where
* aircraft are, and a `SatelliteCatalogue` is *evaluated* because a TLE is
* exactly that closed form. Nothing here is ever fetched on a timer.
*/
satellites?: SatelliteCatalogue;
/** Fires on hover/click of a marker head. */
onMarkerPick?: (marker: Marker | null) => void;
/**
@@ -97,6 +111,27 @@ export interface SceneHandle {
* elevation, so the number has to arrive separately.
*/
setSolarElevation(degrees: number): void;
/**
* Freeze the satellite sky at an instant, or pass `null` to follow the wall
* clock. Exactly the shape of `main.ts`'s own time override, deliberately.
*
* Separate from `setSolarElevation` even though both follow the same clock,
* because they need different things from it: the night-lights want a scalar
* the caller has already worked out, and SGP4 wants the date itself. Handing
* the layer an elevation would mean it could not propagate, and handing the
* lights a `Date` would mean two modules computing the sun.
*
* No-op on a deployment with no catalogue.
*/
setSkyInstant(when: Date | null): void;
/** Draw the satellite layer, or do not. No-op with no catalogue. */
setSatellitesVisible(visible: boolean): void;
/**
* How many objects were above the horizon at the end of the last complete
* propagation pass, and how many element sets this build could read at all.
* Both zero without a catalogue. For the godmode readout; nothing renders it.
*/
satelliteCounts(): { visible: number; total: number };
flyTo(chapterId: string): void;
current(): string;
onChapterChange(fn: (id: string) => void): void;
@@ -198,6 +233,29 @@ export async function createScene(
scene.add(flightLayer.group);
}
let satelliteLayer: SatelliteLayer | null = null;
if (options.satellites) {
satelliteLayer = createSatelliteLayer(boardSpan);
scene.add(satelliteLayer.group);
}
/**
* The instant the sky is drawn for, or `null` for the wall clock.
*
* Satellites are the one layer whose content is a function of *absolute* time
* rather than of elapsed time, so `tick(dt)` cannot serve them: godmode scrubs
* the clock to an arbitrary date and the sky has to follow it there.
*
* It holds the **override** rather than a resolved `Date`, which is the same
* shape `main.ts` keeps its own clock in and is load-bearing here. A resolved
* instant would have to be pushed in on a timer, and the only timer available
* is `updateSun`'s — which runs about once a second, so the sky would advance
* in one-second jumps while everything around it moved smoothly. Holding the
* override means an unscrubbed scene reads the clock afresh every frame and a
* scrubbed one is frozen exactly where it was put.
*/
let skyOverride: Date | null = null;
// ---- Chapters -----------------------------------------------------------
const chapterById = Object.fromEntries(city.chapters.map((c) => [c.id, c]));
@@ -261,10 +319,17 @@ export async function createScene(
void Promise.resolve(options.flights.poll()).then((ac) => flightLayer?.update(ac));
}
}
// Every frame and on no timer of its own. The catalogue's sweep is
// time-budgeted internally — see `SWEEP_BUDGET_MS` — so calling it more
// often makes it walk the catalogue sooner, never makes it cost more.
if (options.satellites && satelliteLayer) {
satelliteLayer.update(options.satellites.fixes(skyOverride ?? new Date()));
}
},
dispose() {
options.flights?.dispose?.();
flightLayer?.dispose();
satelliteLayer?.dispose();
nightLights.dispose();
markerLayer.dispose();
kit.dispose();
@@ -286,6 +351,14 @@ export async function createScene(
stageScene,
setLighting: (state) => kit.applyLighting(state),
setSolarElevation: (degrees) => nightLights.setSolarElevation(degrees),
setSkyInstant: (when) => {
skyOverride = when;
},
setSatellitesVisible: (visible) => satelliteLayer?.setVisible(visible),
satelliteCounts: () => ({
visible: options.satellites?.visibleCount ?? 0,
total: options.satellites?.size ?? 0,
}),
flyTo,
current: () => currentChapter,
onChapterChange(fn) {