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:
@@ -24,6 +24,7 @@ import type {
|
||||
AuthMode,
|
||||
FlightsSourceId,
|
||||
MarkersSourceId,
|
||||
SatellitesSourceId,
|
||||
WeatherSourceId,
|
||||
} from "../../src/server/wire.ts";
|
||||
|
||||
@@ -48,6 +49,17 @@ export interface FlightsConfig {
|
||||
ttlSeconds: number;
|
||||
}
|
||||
|
||||
export interface SatellitesConfig {
|
||||
source: SatellitesSourceId;
|
||||
/**
|
||||
* Sent as the User-Agent. Not required — CelesTrak does not demand one the way
|
||||
* NWS does — but sending it is the same courtesy, and it is what lets them
|
||||
* mail an operator who is hammering them instead of just blocking the address.
|
||||
*/
|
||||
contact: string;
|
||||
ttlSeconds: number;
|
||||
}
|
||||
|
||||
export interface MarkersConfig {
|
||||
source: MarkersSourceId;
|
||||
/** Path to the JSON snapshot written by the sync oneshot. */
|
||||
@@ -146,6 +158,7 @@ export interface Config {
|
||||
publicMaxAge: number;
|
||||
weather: WeatherConfig;
|
||||
flights: FlightsConfig;
|
||||
satellites: SatellitesConfig;
|
||||
markers: MarkersConfig;
|
||||
offices: { dir: string };
|
||||
/**
|
||||
@@ -166,6 +179,7 @@ export function loadConfig(env: Env = process.env): Config {
|
||||
|
||||
const weather = loadWeather(env, degraded);
|
||||
const flights = loadFlights(env, degraded);
|
||||
const satellites = loadSatellites(env, degraded);
|
||||
const auth = loadAuth(env, degraded);
|
||||
// After auth, because a marker feed with nobody able to sign in is worth a
|
||||
// sentence and the sentence is only true once `mode` has finished demoting.
|
||||
@@ -193,6 +207,7 @@ export function loadConfig(env: Env = process.env): Config {
|
||||
publicMaxAge: num(env, "TERA_PUBLIC_MAX_AGE", 60, degraded),
|
||||
weather,
|
||||
flights,
|
||||
satellites,
|
||||
markers,
|
||||
offices: { dir: str(env, "TERA_OFFICES_DIR", "") },
|
||||
presence: { dir: str(env, "TERA_PRESENCE_DIR", "") },
|
||||
@@ -305,6 +320,42 @@ function radius(asked: number, degraded: string[]): number {
|
||||
return clamped;
|
||||
}
|
||||
|
||||
const SATELLITE_SOURCES: SatellitesSourceId[] = ["none", "celestrak"];
|
||||
|
||||
/**
|
||||
* Off by default, which is the opposite of the flight plan and is the right way
|
||||
* round for this one.
|
||||
*
|
||||
* The simulated sky costs nothing and touches no network, so it can be the
|
||||
* default. A satellite catalogue is somebody else's multi-megabyte file, fetched
|
||||
* from a service run by one person, and a repo whose every clone starts pulling
|
||||
* it the moment `npm run dev` finishes is a repo that has volunteered CelesTrak
|
||||
* to host its onboarding. An operator who wants the layer says so.
|
||||
*
|
||||
* The zero-config boot check (`scripts/check-zero-config-boot.mjs`) depends on
|
||||
* this too: a box with no environment at all must make no outbound requests.
|
||||
*/
|
||||
function loadSatellites(env: Env, degraded: string[]): SatellitesConfig {
|
||||
const asked = str(env, "TERA_SATELLITES_SOURCE", "none");
|
||||
let source = oneOf(asked, SATELLITE_SOURCES);
|
||||
if (source === null) {
|
||||
degraded.push(
|
||||
`TERA_SATELLITES_SOURCE="${asked}" is not one of ${SATELLITE_SOURCES.join(", ")}; ` +
|
||||
`serving no satellites.`,
|
||||
);
|
||||
source = "none";
|
||||
}
|
||||
|
||||
return {
|
||||
source,
|
||||
// Falls back to the weather contact, because it is the same operator and the
|
||||
// same courtesy, and making somebody type their email into two variables to
|
||||
// get one User-Agent is a way of ensuring one of them stays empty.
|
||||
contact: str(env, "TERA_SATELLITES_CONTACT", str(env, "TERA_WEATHER_CONTACT", "")),
|
||||
ttlSeconds: num(env, "TERA_SATELLITES_TTL", 21_600, degraded),
|
||||
};
|
||||
}
|
||||
|
||||
const MARKER_SOURCES: MarkersSourceId[] = ["none", "file"];
|
||||
|
||||
/** CONTRACT.md §8. Adding to this list is a licence decision, not a config tweak. */
|
||||
|
||||
Reference in New Issue
Block a user