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
+65 -1
View File
@@ -22,6 +22,7 @@
* | ---------------- | --------------- | --------- |
* | `GET /health` | `HealthBody` | no |
* | `GET /flights` | `FlightsBody` | yes |
* | `GET /satellites` | `SatellitesBody` | yes |
* | `GET /weather` | `WeatherBody` | yes |
* | `GET /markers` | `MarkersBody` | yes |
* | `GET /offices/:id` | `OfficeDoc` | public offices only |
@@ -30,9 +31,11 @@
* See CONTRACT.md §5.
*/
import type { Marker } from "../engine/types.ts";
import type { Marker, SatelliteGroup } from "../engine/types.ts";
import type { Office, Presence } from "../interiors/types.ts";
export type { SatelliteGroup };
/** Path prefix every route lives under. Stated here so both sides read it once. */
export type ApiBase = "/api/v1";
@@ -56,6 +59,7 @@ export interface ErrorBody {
export type WeatherSourceId = "none" | "nws" | "metno" | "openmeteo";
export type FlightsSourceId = "sim" | "adsb" | "dump1090";
export type SatellitesSourceId = "none" | "celestrak";
export type MarkersSourceId = "none" | "file";
export type AuthMode = "none" | "sso" | "jwt";
@@ -75,6 +79,7 @@ export interface HealthBody {
sources: {
weather: WeatherSourceId;
flights: FlightsSourceId;
satellites: SatellitesSourceId;
markers: MarkersSourceId;
};
auth: {
@@ -154,6 +159,65 @@ export interface FlightsLiveBody {
export type FlightsBody = FlightsPlanBody | FlightsLiveBody;
// ---- Satellites -----------------------------------------------------------
/**
* One satellite, sent as its **element set** rather than as a position.
*
* The same trick `FlightsPlanBody` plays, for the same reason and with better
* justification: a TLE is already a closed-form description of an orbit, valid
* for days either side of its epoch, and SGP4 is the function that evaluates it.
* Sending positions would mean polling — a satellite crosses the sky in ten
* minutes — and would mean two people looking at the same overhead pass from
* different machines disagreeing about where it is. Sending the elements means
* one cacheable request every few hours and universal agreement, which is
* exactly the property the flight plan exists to buy.
*
* It is also the *honest* shape. CelesTrak publishes element sets; positions are
* something a consumer computes. A server that computed them would be inserting
* itself into a calculation it adds nothing to.
*
* `line1` and `line2` are the two 69-character TLE lines, verbatim. They are
* carried as strings rather than parsed into fields because SGP4 implementations
* take exactly this and every parse in between is a chance to lose a digit.
*/
export interface WireSatellite {
/** NORAD catalogue number, from columns 37 of line 1. Stable for the object's life. */
noradId: number;
name: string;
group: SatelliteGroup;
/** The first TLE line, 69 characters, unmodified. */
line1: string;
/** The second TLE line, 69 characters, unmodified. */
line2: string;
}
/**
* The catalogue this box is serving, and when it last managed to fetch one.
*
* There is no `mode` discriminant here, unlike `FlightsBody`, because there is
* only ever one mode: elements. A box with no satellite source configured serves
* `source: "none"` and an **empty array** rather than a synthetic constellation,
* and that asymmetry with the flight plan is deliberate. An invented aeroplane is
* a plausible aeroplane; an invented Starlink is a lie about a specific object
* with a catalogue number, and somebody standing in a field with a telescope
* would be entitled to be annoyed about it. The sky either has the real thing in
* it or it has nothing.
*/
export interface SatellitesBody {
source: SatellitesSourceId;
/**
* ISO-8601, the last time a fetch **succeeded**. Not the time of this response:
* a body served from a six-hour-old snapshot must say so, because the client
* has no other way to tell a fresh catalogue from a stale one and SGP4 accuracy
* degrades with distance from the element epoch.
*/
fetchedAt: string;
satellites: WireSatellite[];
ttlSeconds: number;
attribution?: string[];
}
// ---- Weather --------------------------------------------------------------
/**