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
+7
View File
@@ -12,6 +12,7 @@
"server"
],
"dependencies": {
"satellite.js": "^7.1.0",
"three": "^0.182.0"
},
"devDependencies": {
@@ -1747,6 +1748,12 @@
"node": ">=10"
}
},
"node_modules/satellite.js": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/satellite.js/-/satellite.js-7.1.0.tgz",
"integrity": "sha512-U6nRml9Nb7dV9LJPiMNPyna7U7ry+1nXYkOeCEG7K/YbojQQLHHmcjPisp03VNY+HLcbQHqbt7t4t1vQMaKCLQ==",
"license": "MIT"
},
"node_modules/secure-json-parse": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz",
+1
View File
@@ -16,6 +16,7 @@
"preview": "vite preview"
},
"dependencies": {
"satellite.js": "^7.1.0",
"three": "^0.182.0"
},
"devDependencies": {
+1 -1
View File
@@ -182,7 +182,7 @@ function report(body) {
console.log(` service: ${body.service} ${body.version}`);
console.log(
` sources: weather=${body.sources?.weather} flights=${body.sources?.flights} ` +
`markers=${body.sources?.markers}`,
`satellites=${body.sources?.satellites} markers=${body.sources?.markers}`,
);
console.log(` auth: ${body.auth?.mode}`);
console.log(` degraded: none`);
+2
View File
@@ -20,6 +20,7 @@ import { registerHealth } from "./routes/health.ts";
import { registerMarkers } from "./routes/markers.ts";
import { registerOffices } from "./routes/offices.ts";
import { registerPresence } from "./routes/presence.ts";
import { registerSatellites } from "./routes/satellites.ts";
import { registerSession } from "./routes/session.ts";
import { registerWeather } from "./routes/weather.ts";
import { createServices } from "./services.ts";
@@ -45,6 +46,7 @@ export function buildApp(config: Config = loadConfig()): FastifyInstance {
const services = createServices(config, app.log);
registerHealth(app, services);
registerFlights(app, services);
registerSatellites(app, services);
registerWeather(app, services);
registerMarkers(app, services);
registerOffices(app, services);
+51
View File
@@ -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. */
+30 -5
View File
@@ -26,23 +26,50 @@ const DEFAULT_TIMEOUT_MS = 6000;
*/
const MAX_BODY_BYTES = 4 * 1024 * 1024;
export interface GetJsonOptions {
export interface GetTextOptions {
headers?: Record<string, string>;
timeoutMs?: number;
/** Body-size ceiling in bytes. Defaults to `MAX_BODY_BYTES`. */
maxBytes?: number;
/** Sent as `Accept` when set. Omitted entirely when not. */
accept?: string;
}
export type GetJsonOptions = Omit<GetTextOptions, "accept">;
/**
* `null` on any failure at all transport, status, an oversized body, or one
* that will not parse. The caller decides what a missing answer means; nothing
* here does.
*/
export async function getJson<T>(url: string, opts: GetJsonOptions = {}): Promise<T | null> {
const text = await getText(url, { accept: "application/json", ...opts });
if (text === null) return null;
try {
return JSON.parse(text) as T;
} catch {
return null;
}
}
/**
* The same bounded fetch, stopping one step earlier.
*
* Not every upstream speaks JSON. CelesTrak serves element sets as fixed-width
* text, and the choice was between this and a second copy of the size-bounding
* that `getJson` already had which is the copy that gets a fix applied to one
* of it. So the ceiling, the timeout, the streaming cancel and the
* failure-is-`null` contract live here once, and `getJson` is this plus a parse.
*
* `accept` defaults to nothing rather than to `text/plain`, because a server
* that content-negotiates should be free to send its own idea of the right type;
* `getJson` passes the header it actually needs.
*/
export async function getText(url: string, opts: GetTextOptions = {}): Promise<string | null> {
const maxBytes = opts.maxBytes ?? MAX_BODY_BYTES;
try {
const res = await fetch(url, {
headers: { accept: "application/json", ...opts.headers },
headers: { ...(opts.accept === undefined ? {} : { accept: opts.accept }), ...opts.headers },
signal: AbortSignal.timeout(opts.timeoutMs ?? DEFAULT_TIMEOUT_MS),
});
if (!res.ok) return null;
@@ -59,9 +86,7 @@ export async function getJson<T>(url: string, opts: GetJsonOptions = {}): Promis
return null;
}
const text = await readBounded(res, maxBytes);
if (text === null) return null;
return JSON.parse(text) as T;
return await readBounded(res, maxBytes);
} catch {
return null;
}
+1
View File
@@ -48,6 +48,7 @@ export function registerHealth(app: FastifyInstance, services: Services): void {
sources: {
weather: config.weather.source,
flights: config.flights.source,
satellites: config.satellites.source,
markers: config.markers.source,
},
auth: {
+27
View File
@@ -0,0 +1,27 @@
/**
* `GET /api/v1/satellites` the catalogue, for everybody, everywhere.
*
* The one route here that takes no query at all, and the absence is the design
* rather than an omission. `/flights` and `/weather` both resolve a region
* because their answers are local; a satellite catalogue is not. See the note at
* the top of `satellites/index.ts` for the reasoning, and note the consequence:
* with no parameter there is no key space, so none of the amplification concerns
* that shape `regions.ts` apply. There is exactly one body and it is the same one
* for every caller.
*
* Publicly cacheable, for a TTL measured in hours. Orbital elements for objects
* broadcasting their position to anyone with a radio are not personal data, and
* this body does not vary by who asked.
*/
import type { FastifyInstance } from "fastify";
import { publicCache } from "../cache.ts";
import type { Services } from "../services.ts";
export function registerSatellites(app: FastifyInstance, services: Services): void {
app.get("/api/v1/satellites", async (req, reply) => {
const body = await services.satellites.current();
publicCache(req, reply, body.ttlSeconds);
return body;
});
}
+264
View File
@@ -0,0 +1,264 @@
/**
* celestrak.org the general perturbations element sets.
*
* CelesTrak is Dr T.S. Kelso's, it has been running since 1985, and it is the
* canonical public mirror of the US Space Force's catalogue. The data are US
* Government works and are not copyrightable; the *service* is one person's
* bandwidth bill, which is the constraint that shapes everything below.
*
* ### The rate limit, and the one URL that gets around it honestly
*
* CelesTrak asks callers not to fetch more often than the data changes element
* sets are regenerated a few times a day and enforces it. A caller who polls
* `gp.php?GROUP=starlink` on a short interval gets a 403, or worse gets a 200
* whose body is the sentence "has not updated since", which parses as zero
* satellites and looks exactly like an empty sky.
*
* There are two answers and this module uses both:
*
* 1. **The supplemental feed for Starlink.** SpaceX publishes its own ephemeris,
* CelesTrak republishes it at `supplemental/sup-gp.php?FILE=starlink`, and
* that endpoint is not under the same ceiling as the primary group which is
* the point of it existing. It is also *better data*: supplemental elements
* come from the operator rather than from radar, so a satellite that
* manoeuvred last night is in the right place this morning.
* 2. **A six-hour TTL**, which is longer than it looks like it needs to be and is
* still four fetches a day against a source that regenerates a handful of
* times. `upstream.ts` rule 3 does the rest: a failed fetch stamps the clock,
* so a 403 costs one request every six hours rather than one per viewer.
*
* The body that says "has not updated since" is caught explicitly rather than
* left to the parser, because rule 3 only helps if a soft failure is recognised
* as a failure. A 200 that yields no satellites and a 403 must reach the cache
* as the same thing, or the last good catalogue gets overwritten with nothing.
*
* ### What this deliberately does not do
*
* No key, no account, no `space-track.org`. Space-Track has the fuller catalogue
* and requires credentials and a login flow whose terms restrict redistribution
* which would put a licence argument between this repo and a stranger who cloned
* it, and the acceptance test for the whole server is that a stranger with no
* keys gets something that works. See CONTRACT.md §5.1.
*
* And no User-Agent games. The identifying agent from `http.ts` goes out here
* exactly as it does to NWS. A source that would rather not serve us is entitled
* to that, and the response to a rate limit is a longer TTL, never a costume.
*/
import { getText, userAgent } from "../http.ts";
import type { SatelliteGroup, WireSatellite } from "../../../src/server/wire.ts";
export interface CelestrakLog {
warn(msg: string): void;
}
/**
* A parsed catalogue and the instant it was read.
*
* The timestamp is the *fetch* and not the element epoch, because a body can mix
* element sets from several days and the client's question is "how old is the
* freshest thing this server knows", which the fetch answers and the epochs do
* not.
*/
export interface SatelliteSnapshot {
/** Epoch milliseconds. */
fetchedAt: number;
satellites: WireSatellite[];
}
/**
* The groups fetched, in the order they are merged.
*
* This is a short list where Osiris-style aggregators run forty, and the length
* is the design. Every entry is one more request against a volunteer's server
* every six hours, and this layer draws satellites over a city the debris
* fields and the cubesat catalogue would add eleven thousand objects nobody can
* see and nobody asked about, at the cost of a slower boot and a fatter cache.
*
* Order matters only for de-duplication: a NORAD id seen twice keeps the **first**
* set, so the supplemental Starlink feed must come before anything that might
* also carry a Starlink. See `merge`.
*/
const GROUPS: { url: string; group: SatelliteGroup }[] = [
// Supplemental first, and first for a reason — see the note above and `merge`.
{
url: "https://celestrak.org/NORAD/elements/supplemental/sup-gp.php?FILE=starlink&FORMAT=tle",
group: "starlink",
},
{ url: group("stations"), group: "station" },
{ url: group("gps-ops"), group: "navigation" },
{ url: group("galileo"), group: "navigation" },
{ url: group("oneweb"), group: "comms" },
{ url: group("iridium-NEXT"), group: "comms" },
{ url: group("weather"), group: "weather" },
// The naked-eye catalogue: the hundred-odd objects bright enough to be worth
// drawing for somebody who might go outside and look for one.
{ url: group("visual"), group: "other" },
];
function group(name: string): string {
return `https://celestrak.org/NORAD/elements/gp.php?GROUP=${name}&FORMAT=tle`;
}
/**
* The ceiling on what is kept, after merging.
*
* Starlink alone is past eight thousand objects and the supplemental feed serves
* all of them. Every one costs a `SatRec` in every browser that opens the map,
* and SGP4 is not free the client propagates the whole set on a timer. Six
* thousand is comfortably more than a city view can draw legibly and comfortably
* under what a laptop can propagate at 4 Hz, and going over it truncates rather
* than failing, because a partial sky beats no sky.
*
* The truncation is not silent: `createSatellitesService` logs the drop.
*/
export const MAX_SATELLITES = 6000;
/**
* TLE text is at most a few megabytes; the default 4 MB ceiling in `http.ts` is
* the right order but the Starlink supplemental feed alone runs to roughly
* 1.6 MB and the merged set is larger, so this is stated rather than inherited.
*/
const MAX_BODY_BYTES = 8 * 1024 * 1024;
/**
* Longer than the six-second default because these are big text bodies from one
* modestly-resourced server, and a timeout here is not an outage it is one
* group missing from a merge that still returns the other seven.
*/
const TIMEOUT_MS = 20_000;
/**
* The whole catalogue, or `null` if **nothing at all** answered.
*
* A partial answer is a success: seven groups out of eight is a sky, and
* returning `null` for it would discard seven good fetches and serve the
* previous snapshot instead. Only a total failure is a failure, which is the
* same rule `upstream.ts` applies one level up.
*/
export async function fetchCatalogue(
contact: string,
log: CelestrakLog,
): Promise<SatelliteSnapshot | null> {
const headers = { "user-agent": userAgent(contact) };
// Concurrently, because they are independent and eight sequential 20-second
// timeouts is a two-and-a-half-minute worst case on a route somebody is
// waiting on. Eight parallel requests every six hours is not a flood.
const fetched = await Promise.all(
GROUPS.map(async (spec) => {
const text = await getText(spec.url, {
headers,
timeoutMs: TIMEOUT_MS,
maxBytes: MAX_BODY_BYTES,
});
if (text === null) {
log.warn(`satellites:celestrak: ${spec.url} did not answer`);
return [];
}
// The decision is the parse, not a guess about the body — see `whyEmpty`.
const parsed = parseTle(text, spec.group);
if (parsed.length === 0) {
log.warn(`satellites:celestrak: ${spec.url} ${whyEmpty(text)}`);
return [];
}
return parsed;
}),
);
const satellites = merge(fetched);
if (satellites.length === 0) return null;
return { fetchedAt: Date.now(), satellites };
}
/**
* Why a 200 yielded no element sets, for the log line and for nothing else.
*
* The *decision* is made by the parse: a body that produces no element sets did
* not answer, whatever it contained. That is the honest test and it needs no
* heuristics an earlier version of this guessed from the body length instead,
* with a 200-byte floor, and a group holding one satellite is about 160 bytes.
* The guard rejected legitimate answers, which is exactly the failure mode it
* was written to prevent, aimed the other way.
*
* What the body *said* still matters to a human, though: "CelesTrak is telling
* us to slow down" calls for a longer TTL and "the group is genuinely empty"
* calls for editing `GROUPS`, and one warning line that distinguishes them saves
* an operator the trip.
*/
function whyEmpty(text: string): string {
if (text.includes("has not updated since")) {
return "was refused for asking too often (CelesTrak answers this with a 200); " +
"raise TERA_SATELLITES_TTL";
}
if (text.trim() === "") return "answered with an empty body";
return "answered with something that holds no element sets";
}
/**
* Three-line TLE text into element sets.
*
* The format is fixed-width and unforgiving, and the parse leans on that: a line
* starting `1 ` followed by a line starting `2 ` is an element set, and whatever
* came immediately before it is the name. That is enough to handle both the
* three-line form CelesTrak serves and the bare two-line form some mirrors do,
* without a mode flag.
*
* Anything that does not fit is skipped rather than throwing. This function is
* reading somebody else's file over the network; the one thing it must not do is
* take the server down because a line was short.
*/
export function parseTle(text: string, group: SatelliteGroup): WireSatellite[] {
const lines = text.split("\n").map((line) => line.trimEnd());
const out: WireSatellite[] = [];
for (let i = 0; i < lines.length - 1; i += 1) {
const line1 = lines[i] ?? "";
const line2 = lines[i + 1] ?? "";
if (!line1.startsWith("1 ") || !line2.startsWith("2 ")) continue;
const noradId = Number.parseInt(line1.slice(2, 7).trim(), 10);
if (!Number.isInteger(noradId) || noradId <= 0) continue;
// The name is the preceding line when there is one that is not itself an
// element line. A catalogue number is a poor name but it is a true one, and
// it beats dropping the object.
const previous = i > 0 ? (lines[i - 1] ?? "").trim() : "";
const named = previous !== "" && !previous.startsWith("1 ") && !previous.startsWith("2 ");
// Some feeds prefix the name line with "0 ", per the original TLE spec.
const name = named ? previous.replace(/^0\s+/, "") : `NORAD ${noradId}`;
out.push({ noradId, name, group, line1, line2 });
// The element lines are consumed as a pair; the loop's own increment covers
// the second, so one extra step here lands on the line after it.
i += 1;
}
return out;
}
/**
* One list, first set per NORAD id wins, capped at `MAX_SATELLITES`.
*
* First-wins is what makes the group ordering in `GROUPS` load-bearing: a
* Starlink appearing in both the supplemental feed and, say, `last-30-days`
* should keep the operator's ephemeris and the `starlink` display bucket, and it
* does so purely because the supplemental feed is fetched first. Last-wins would
* silently re-bucket half the constellation as `other` and nobody would notice
* until the colours looked wrong.
*/
function merge(groups: WireSatellite[][]): WireSatellite[] {
const seen = new Set<number>();
const out: WireSatellite[] = [];
for (const list of groups) {
for (const sat of list) {
if (seen.has(sat.noradId)) continue;
seen.add(sat.noradId);
out.push(sat);
if (out.length >= MAX_SATELLITES) return out;
}
}
return out;
}
+114
View File
@@ -0,0 +1,114 @@
/**
* Which satellites this box serves which is either all of them or none.
*
* The shortest service in the server, and the shape is worth noticing because of
* what it *lacks*. There is no region parameter. Flights and weather are both
* keyed per region because an aeroplane at 3,000 m over Oakland is not visible
* from Long Beach and a rain shower is not either; a satellite at 550 km is
* above the horizon for a circle roughly two thousand kilometres across, and the
* two cities this repo ships are six hundred apart. One catalogue covers both,
* and it would cover a continent.
*
* So the cache has exactly one key, the client is handed the whole catalogue, and
* **the client decides what is above its own horizon** which it must do anyway,
* continuously, because the answer changes every few seconds as things move. A
* server that filtered by region would be doing a worse version of a calculation
* the client cannot avoid doing, and would break the property that makes this
* cacheable at all: one body, every viewer, no variation.
*
* ### Six hours, and why that is not laziness
*
* `flights/index.ts` clamps its live TTL between 5 and 15 seconds. This one
* defaults to six hours and is floored at one, and both numbers are chosen the
* same way: how often does the upstream change, and what does asking cost?
*
* CelesTrak regenerates element sets a handful of times a day. A TLE stays
* accurate to within a few kilometres for days either side of its epoch that
* is the entire design of the format. Fetching it every minute would produce
* identical bytes, at the expense of one person's bandwidth, and would get this
* box rate-limited into serving an empty sky. The floor exists because
* `TERA_SATELLITES_TTL=0` reads like "as fresh as possible" and means "one
* multi-megabyte fetch of eight groups per inbound request", which is the same
* trap `TERA_FLIGHTS_TTL=0` used to be.
*/
import { fetchCatalogue, MAX_SATELLITES, type SatelliteSnapshot } from "./celestrak.ts";
import { createUpstream } from "../upstream.ts";
import type { Config } from "../config.ts";
import type { SatellitesBody } from "../../../src/server/wire.ts";
export interface SatellitesService {
current(): Promise<SatellitesBody>;
}
export interface SatellitesLog {
warn(msg: string): void;
}
/** See the note above. One catalogue, one key. */
const CATALOGUE_KEY = "catalogue";
/**
* An hour is already twelve times more often than the data changes. Below that,
* a caller is spending somebody else's bandwidth to receive the same bytes.
*/
const MIN_TTL_SECONDS = 3600;
/**
* The body a box with no satellite source serves: real and empty, never invented.
* See the note on `SatellitesBody` in `wire.ts` for why there is no synthetic
* constellation to fall back to the way there is a synthetic sky.
*
* `fetchedAt` is the Unix epoch rather than "now", because "now" would be a
* claim that this catalogue was fetched a moment ago. It was never fetched.
*/
function emptyBody(ttlSeconds: number): SatellitesBody {
return {
source: "none",
fetchedAt: new Date(0).toISOString(),
satellites: [],
ttlSeconds,
};
}
export function createSatellitesService(config: Config, log: SatellitesLog): SatellitesService {
const { source, contact, ttlSeconds } = config.satellites;
const ttl = Math.max(MIN_TTL_SECONDS, ttlSeconds);
const upstream = createUpstream<SatelliteSnapshot>({
label: "satellites:celestrak",
ttlSeconds: ttl,
log,
});
// Once, not per response: a truncated catalogue is a property of the
// deployment and the feed, and logging it on every request would turn one
// useful sentence into a wall of the same sentence.
let warnedTruncated = false;
return {
async current(): Promise<SatellitesBody> {
if (source === "none") return emptyBody(ttl);
const snapshot = await upstream.get(CATALOGUE_KEY, () => fetchCatalogue(contact, log));
if (snapshot === null) return emptyBody(ttl);
if (snapshot.satellites.length >= MAX_SATELLITES && !warnedTruncated) {
warnedTruncated = true;
log.warn(
`satellites:celestrak: the merged catalogue hit the ${MAX_SATELLITES}-object ` +
`ceiling and was truncated; later groups in celestrak.ts are missing from ` +
`the served sky.`,
);
}
return {
source,
fetchedAt: new Date(snapshot.fetchedAt).toISOString(),
satellites: snapshot.satellites,
ttlSeconds: ttl,
attribution: ["Orbital element sets from CelesTrak"],
};
},
};
}
+3
View File
@@ -12,6 +12,7 @@ import { createFlightsService, type FlightsService } from "./flights/index.ts";
import { createMarkerStore, type MarkerStore } from "./markers/store.ts";
import { createOfficeStore, type OfficeStore } from "./offices/store.ts";
import { createPresenceStore, type PresenceStore } from "./presence/store.ts";
import { createSatellitesService, type SatellitesService } from "./satellites/index.ts";
import { createWeatherService, type WeatherService } from "./weather/index.ts";
import type { Config } from "./config.ts";
@@ -19,6 +20,7 @@ export interface Services {
config: Config;
weather: WeatherService;
flights: FlightsService;
satellites: SatellitesService;
markers: MarkerStore;
offices: OfficeStore;
presence: PresenceStore;
@@ -37,6 +39,7 @@ export function createServices(config: Config, log: ServiceLog): Services {
config,
weather: createWeatherService(config, log),
flights: createFlightsService(config, log),
satellites: createSatellitesService(config, log),
markers: createMarkerStore(config, log),
offices: createOfficeStore(config.offices.dir),
presence: createPresenceStore(config.presence.dir),
+284
View File
@@ -0,0 +1,284 @@
/**
* The catalogue, and the three ways it is allowed to be wrong.
*
* CelesTrak is one person's bandwidth and it rate-limits, so as with the ADS-B
* tests most of this file is about **how often this server calls out** rather
* than about what comes back. The difference is the failure mode being guarded
* against: adsb.lol answers a caller who is asking too often with a status code,
* and CelesTrak answers with **200 and a sentence**. A soft failure that parses
* as an empty catalogue would overwrite the last good sky with nothing and look
* like a clear night so a group that yields no element sets is treated as a
* group that did not answer, and that is worth a test of its own.
*
* The last group of tests is about the shape of the answer, and specifically
* about `merge` keeping the *first* element set per NORAD id. That ordering is
* what puts the operator's supplemental Starlink ephemeris ahead of a radar one
* and keeps the display bucket right; reverse it and half the constellation
* quietly re-colours itself.
*/
import assert from "node:assert/strict";
import { after, beforeEach, describe, it } from "node:test";
import { buildApp } from "../app.ts";
import { loadConfig } from "../config.ts";
import { parseTle } from "../satellites/celestrak.ts";
import type { SatellitesBody } from "../../../src/server/wire.ts";
const realFetch = globalThis.fetch;
let calls: string[] = [];
/** Every User-Agent this server actually put on the wire, in order. */
let agents: string[] = [];
let feedIsUp = true;
/** Served instead of a real TLE body, for the soft-failure tests. */
let feedOverride: string | undefined = undefined;
/**
* A plausible three-line element set. The numbers are ISS's published TLE
* checksummed shape rather than a hand-mangled one, because `parseTle` reads
* fixed columns and a shortened line would exercise a path no real feed takes.
*/
const ISS = [
"ISS (ZARYA)",
"1 25544U 98067A 26037.51782528 -.00002182 00000-0 -11606-4 0 2927",
"2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537",
].join("\n");
const STARLINK = [
"STARLINK-1007",
"1 44713U 19074A 26037.54166667 .00002182 00000-0 16538-3 0 9995",
"2 44713 53.0538 156.7285 0001367 86.7419 273.3728 15.06391223 12345",
].join("\n");
function feed(url: string): string | undefined {
if (!feedIsUp) return undefined;
if (feedOverride !== undefined) return feedOverride;
if (url.includes("FILE=starlink")) return STARLINK;
if (url.includes("GROUP=stations")) return ISS;
// Every other configured group answers with nothing usable, which is a
// partial-merge case and must still produce a sky.
return "";
}
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
const url = String(input);
calls.push(url);
agents.push(String((init?.headers as Record<string, string> | undefined)?.["user-agent"] ?? ""));
const body = feed(url);
if (body === undefined) return new Response("nope", { status: 503 });
return new Response(body, { status: 200, headers: { "content-type": "text/plain" } });
}) as unknown as typeof globalThis.fetch;
after(() => {
globalThis.fetch = realFetch;
});
beforeEach(() => {
calls = [];
agents = [];
feedIsUp = true;
feedOverride = undefined;
});
function appWith(env: Record<string, string>) {
const config = loadConfig(env);
config.logLevel = "silent";
return buildApp(config);
}
const celestrakEnv = { TERA_SATELLITES_SOURCE: "celestrak" };
async function get(app: ReturnType<typeof buildApp>): Promise<SatellitesBody> {
const res = await app.inject({ method: "GET", url: "/api/v1/satellites" });
assert.equal(res.statusCode, 200);
return res.json() as SatellitesBody;
}
describe("a box with no satellite source", () => {
it("serves an empty catalogue rather than an invented one", async () => {
const app = appWith({});
after(() => app.close());
const body = await get(app);
assert.equal(body.source, "none");
assert.deepEqual(body.satellites, []);
});
it("makes no outbound request at all", async () => {
const app = appWith({});
after(() => app.close());
await get(app);
await get(app);
assert.deepEqual(calls, []);
});
});
describe("the CelesTrak source", () => {
it("takes the supplemental Starlink feed, not the rate-limited group", async () => {
const app = appWith(celestrakEnv);
after(() => app.close());
await get(app);
const starlink = calls.filter((url) => url.includes("starlink"));
assert.equal(starlink.length, 1);
assert.match(starlink[0] ?? "", /supplemental\/sup-gp\.php\?FILE=starlink/);
// The primary group is the one that 403s under polling. It must not be here.
assert.equal(
calls.some((url) => url.includes("GROUP=starlink")),
false,
);
});
it("identifies the operator, rather than disguising the caller", async () => {
const app = appWith({ ...celestrakEnv, TERA_SATELLITES_CONTACT: "ops@example.com" });
after(() => app.close());
await get(app);
assert.ok(agents.length > 0, "nothing was fetched");
for (const agent of agents) {
assert.match(agent, /^tera-api /);
assert.match(agent, /ops@example\.com/);
}
});
it("falls back to the weather contact rather than sending an empty one", async () => {
const config = loadConfig({ ...celestrakEnv, TERA_WEATHER_CONTACT: "ops@example.com" });
assert.equal(config.satellites.contact, "ops@example.com");
});
it("asks once per TTL however many callers turn up", async () => {
const app = appWith(celestrakEnv);
after(() => app.close());
await Promise.all([get(app), get(app), get(app), get(app)]);
// One pass over the group list, not four.
const starlink = calls.filter((url) => url.includes("starlink"));
assert.equal(starlink.length, 1);
});
it("floors the TTL at an hour, whatever the environment asked for", async () => {
const config = loadConfig({ ...celestrakEnv, TERA_SATELLITES_TTL: "0" });
const app = appWith({ ...celestrakEnv, TERA_SATELLITES_TTL: "0" });
after(() => app.close());
// The config keeps what was asked; the service is what enforces the floor,
// so the assertion belongs on the body the client is handed.
assert.equal(config.satellites.ttlSeconds, 0);
const body = await get(app);
assert.ok(body.ttlSeconds >= 3600, `ttlSeconds was ${body.ttlSeconds}`);
});
it("serves a partial merge rather than nothing when a group goes quiet", async () => {
const app = appWith(celestrakEnv);
after(() => app.close());
// Six of the eight groups answer with an empty body in the fixture above.
const body = await get(app);
assert.equal(body.source, "celestrak");
assert.equal(body.satellites.length, 2);
});
it("reports the fetch time, so a stale catalogue can be recognised as one", async () => {
const app = appWith(celestrakEnv);
after(() => app.close());
const body = await get(app);
assert.notEqual(body.fetchedAt, new Date(0).toISOString());
assert.ok(Date.now() - Date.parse(body.fetchedAt) < 60_000);
});
it("credits CelesTrak", async () => {
const app = appWith(celestrakEnv);
after(() => app.close());
const body = await get(app);
assert.match(body.attribution?.[0] ?? "", /CelesTrak/);
});
});
/**
* The failure this file exists for.
*
* A 200 carrying "has not updated since" is CelesTrak's way of saying no. Parsed
* naively it is an empty catalogue, and an empty catalogue cached for six hours
* is a sky that stays dark until somebody restarts the box.
*/
describe("a rate-limited fetch", () => {
it("is treated as a failure, not as an empty sky", async () => {
const app = appWith(celestrakEnv);
after(() => app.close());
// Warm the cache with a good answer first.
const good = await get(app);
assert.equal(good.satellites.length, 2);
feedOverride =
"The requested group has not updated since the last request. Please try again later.";
// Force the TTL to have elapsed by standing up a second app against the same
// fixture — a fresh service, so this is really "what does one bad fetch do".
const cold = appWith(celestrakEnv);
after(() => cold.close());
const body = await get(cold);
// Nothing was ever cached in this instance, so the honest answer is the
// empty one — but it must be reported as `source: none`-shaped emptiness
// rather than as a successful catalogue of zero satellites.
assert.equal(body.satellites.length, 0);
assert.equal(body.fetchedAt, new Date(0).toISOString());
});
it("does not overwrite the last good catalogue", async () => {
const app = appWith({ ...celestrakEnv, TERA_SATELLITES_TTL: "3600" });
after(() => app.close());
const good = await get(app);
assert.equal(good.satellites.length, 2);
feedOverride = "has not updated since";
const again = await get(app);
assert.equal(again.satellites.length, 2, "the last good sky must survive a refused fetch");
});
});
describe("parsing element sets", () => {
it("reads the three-line form CelesTrak serves", () => {
const parsed = parseTle(ISS, "station");
assert.equal(parsed.length, 1);
assert.equal(parsed[0]?.noradId, 25544);
assert.equal(parsed[0]?.name, "ISS (ZARYA)");
assert.equal(parsed[0]?.group, "station");
});
it("carries the two lines through unmodified, because SGP4 reads columns", () => {
const parsed = parseTle(ISS, "station");
const lines = ISS.split("\n");
assert.equal(parsed[0]?.line1, lines[1]);
assert.equal(parsed[0]?.line2, lines[2]);
});
it("names an object by its catalogue number when the feed gave no name line", () => {
const bare = ISS.split("\n").slice(1).join("\n");
const parsed = parseTle(bare, "other");
assert.equal(parsed.length, 1);
assert.equal(parsed[0]?.name, "NORAD 25544");
});
it("strips the leading zero some feeds put on the name line", () => {
const parsed = parseTle(`0 ISS (ZARYA)\n${ISS.split("\n").slice(1).join("\n")}`, "station");
assert.equal(parsed[0]?.name, "ISS (ZARYA)");
});
it("skips what it cannot read instead of throwing", () => {
const parsed = parseTle("nonsense\nmore nonsense\n\n", "other");
assert.deepEqual(parsed, []);
});
it("reads every set in a multi-object body", () => {
const parsed = parseTle(`${ISS}\n${STARLINK}`, "other");
assert.equal(parsed.length, 2);
assert.deepEqual(
parsed.map((s) => s.noradId),
[25544, 44713],
);
});
});
+13 -2
View File
@@ -116,7 +116,7 @@ export interface Capabilities {
}
/**
* Which of the three feeds this deployment has actually wired.
* Which of the feeds this deployment has actually wired.
*
* A capability says what a *visitor* may have; this says what the *server* has,
* and the app needs both before it opens a socket. `can.liveData` is true for
@@ -135,6 +135,12 @@ export interface Capabilities {
export interface Feeds {
weather: boolean;
flights: boolean;
/**
* A satellite catalogue. Off on almost every box, including this repo's own
* default see `loadSatellites` in the server's `config.ts` for why a clone
* does not start pulling CelesTrak the moment it boots.
*/
satellites: boolean;
markers: boolean;
}
@@ -305,7 +311,12 @@ function access(
function feedsFrom(raw: unknown): Feeds {
const sources = (typeof raw === "object" && raw !== null ? raw : {}) as Record<string, unknown>;
const wired = (key: string) => typeof sources[key] === "string" && sources[key] !== "none";
return { weather: wired("weather"), flights: wired("flights"), markers: wired("markers") };
return {
weather: wired("weather"),
flights: wired("flights"),
satellites: wired("satellites"),
markers: wired("markers"),
};
}
/**
+31
View File
@@ -42,6 +42,7 @@ import {
type SimRoute,
type SkyRegion,
} from "../engine/flights.ts";
import type { SatelliteElements } from "../engine/satellites.ts";
import type { Aircraft, FlightSource, Marker, MarkerPalette } from "../engine/types.ts";
import { seededRandom } from "../engine/world.ts";
import type {
@@ -51,6 +52,7 @@ import type {
MarkersBody,
OfficeDoc,
PresenceBody,
SatellitesBody,
WeatherBody,
} from "../server/wire.ts";
import { SAMPLE_MARKERS, SAMPLE_PALETTE } from "./sample.ts";
@@ -223,6 +225,13 @@ export interface TeraClient {
* should pass them; `sampleRoutesFor` in `sample.ts` has them.
*/
flights(region: SkyRegion, fallbackRoutes?: SimRoute[]): TrafficSource;
/**
* Every element set this deployment serves, once. `[]` when it serves none,
* which is the default and is not an error.
*
* Not per-region and not watched see the implementation for both reasons.
*/
satellites(options?: { signal?: AbortSignal }): Promise<SatelliteElements[]>;
/**
* One office pack. `null` for anything the server will not serve including
* a private one, which answers 404 rather than 403 so the endpoint cannot be
@@ -361,6 +370,28 @@ export function createTeraClient(options: TeraApiOptions = {}): TeraClient {
return new HttpFlights(get, region, fallbackRoutes ?? syntheticRoutes(region));
},
/**
* The satellite catalogue, once.
*
* The only feed here with no watcher, no back-off ladder and no fallback,
* and all three absences are the same fact: element sets are good for days
* and the server caches them for hours, so there is nothing to poll for. One
* fetch per page load is not a compromise, it is the whole requirement.
*
* No sample constellation underneath it either, unlike `markers` and
* `flights`. An invented aeroplane is a plausible aeroplane; an invented
* Starlink is a false claim about a numbered object somebody could go
* outside and fail to find. `[]` is the honest answer and it renders as an
* empty sky, which is what a box with no satellite source actually has.
*/
async satellites(opts: { signal?: AbortSignal } = {}): Promise<SatelliteElements[]> {
const body = await get<SatellitesBody>("/satellites", {
...(opts.signal ? { signal: opts.signal } : {}),
});
if (!body || !Array.isArray(body.satellites)) return [];
return body.satellites;
},
office: (id) => get<OfficeDoc>(`/offices/${encodeURIComponent(id)}`),
/**
+89
View File
@@ -181,6 +181,95 @@ export function syntheticRoutes(region: SkyRegion, count = 6, seed = 20_617): Si
return routes;
}
/**
* A source with a dial on it: whatever it was going to draw, plus N invented
* aircraft.
*
* This exists for one control in the godmode panel "how busy would this look
* with three times the traffic" and the shape it takes is chosen to make that
* question answerable without corrupting the answer to any other one.
*
* **It composes rather than substitutes.** The base source is polled unchanged
* and its aircraft are passed through untouched; the fabricated ones are a
* second list concatenated onto the end. That is what lets the dial work over a
* *live* ADS-B feed as well as over the simulator the real traffic stays real
* and stays complete, and turning the dial back to zero returns exactly the
* sky that was there before, because nothing was ever taken away.
*
* The alternative was to mutate the simulator's route list, and it is worse in
* both directions: it does nothing at all when the server is serving its own
* plan (`HttpFlights` ignores its fallback in that mode, so the slider would be
* inert on every deployment that has an API), and it is destructive when it does
* work, because the authored corridors would have to be rebuilt to get back.
*
* ### On fabricating traffic at all
*
* The same argument as `weatherOverride` in `main.ts`: a god-only lie about the
* inputs, told to see what the renderer does with it. It is deliberately **not**
* available to anyone else, and the invented aircraft carry a callsign prefix of
* their own so that a screenshot of a busy sky can be told from a screenshot of
* a real one. Note what this breaks while it is on every viewer agreeing about
* where the aircraft are, which is the property the server's plan exists to buy.
* That is acceptable for a debug dial and would not be for a feature.
*/
export interface TrafficDial {
/** The source to hand `createScene`. Stable for the dial's whole life. */
source: FlightSource;
/** Fabricate this many additional aircraft. `0` turns the dial off entirely. */
setExtra(count: number): void;
extra(): number;
}
/**
* Callsign prefix for fabricated traffic.
*
* Distinct from `syntheticRoutes`'s own `SIM`, and it has to be: `sampleRoute`
* derives an aircraft's id from its callsign, `createFlightLayer` keys its
* tracks on that id, and a deployment with no API is already flying `SIM 1`
* through `SIM 6` from the fallback. Reuse the prefix and every fabricated
* aircraft would land on an existing track, teleporting it across the board on
* alternate polls.
*/
const FABRICATED_PREFIX = "GOD";
/** As many as the dial goes to. Past this the sky is soup and the point is made. */
export const MAX_EXTRA_TRAFFIC = 400;
export function withTrafficDial(base: FlightSource, region: SkyRegion): TrafficDial {
let extra: SimulatedFlights | null = null;
let count = 0;
return {
source: {
interval: base.interval,
poll(): Aircraft[] | Promise<Aircraft[]> {
const theirs = base.poll();
if (extra === null) return theirs;
const mine = extra.poll();
// `poll` is synchronous on every source in this build, but the interface
// permits a promise and `HttpFlights` documents its synchrony as a
// deliberate property rather than an accident. Handling both here costs
// one branch and means the dial cannot be what breaks that.
return theirs instanceof Promise ? theirs.then((a) => [...a, ...mine]) : [...theirs, ...mine];
},
dispose: () => base.dispose?.(),
},
setExtra(next: number) {
count = Math.max(0, Math.min(MAX_EXTRA_TRAFFIC, Math.round(next)));
if (count === 0) {
extra = null;
return;
}
const routes = syntheticRoutes(region, count).map((route, i) => ({
...route,
callsign: `${FABRICATED_PREFIX} ${i + 1}`,
}));
extra = new SimulatedFlights(routes);
},
extra: () => count,
};
}
/**
* 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
+490
View File
@@ -0,0 +1,490 @@
/**
* Satellites over the city on a dome, because they cannot be anywhere else.
*
* Every other thing in this scene lives in projected metric space: a building is
* where the building is, an aircraft at 10,000 m is at `world.metres(10000)`
* above the ground it is over. That rule breaks completely here, and it is worth
* being explicit about how badly, because the alternative is a layer that renders
* nothing and looks broken.
*
* Starlink flies at about 550 km. The Bay Area board is ~94 m per scene unit with
* a 3.6× vertical exaggeration, so `world.metres(550_000)` is **21,000 scene
* units** against a board 1,000 units across and a camera far plane at 3,000.
* The satellite is seven times beyond the horizon of the projection, over ground
* two thousand kilometres away that this city pack does not contain. There is no
* camera position from which the true placement is both visible and meaningful.
*
* So this layer draws **what you would see if you looked up**: each satellite at
* its real azimuth and elevation from the city centre, on a dome big enough to
* sit outside the buildings and inside the far plane. A dot due east at 30° above
* the horizon is genuinely due east at 30°. The dome's *radius* is arbitrary and
* carries no information; the direction carries all of it.
*
* That is not a compromise so much as the correct frame for the question. Nobody
* looking at a satellite layer wants to know its ECEF coordinates. They want to
* know whether one is passing over, where to look, and whether it is lit and
* the last of those is why this bothers with `shadowFraction` rather than drawing
* every object the same brightness. A Starlink is visible to the naked eye when
* it is in sunlight while the ground below it is dark, which is the whole reason
* anybody ever noticed the constellation existed.
*
* ### Where the propagation happens, and why it is here
*
* The server sends element sets, not positions (`wire.ts`, `SatellitesBody`), for
* the same reason it sends a flight plan rather than aircraft: a TLE is already
* the closed form, SGP4 is the function that evaluates it, and every browser
* evaluating it agrees. One cacheable fetch every six hours replaces a poll.
*
* `satellite.js` is the second runtime dependency this package has ever taken,
* after three.js, and the bar it had to clear was "would hand-rolling this be
* better". SGP4 is a 1980 Fortran model with a specific set of drag and
* resonance terms, a published reference implementation, and a well-known list of
* ways a re-implementation goes subtly wrong; the library is MIT, is a direct
* translation of Vallado's C++, and is the one everybody checks against. Writing
* our own would have been a worse copy of it.
*/
import * as THREE from "three";
import {
eciToEcf,
ecfToLookAngles,
gstime,
jday,
propagate,
shadowFraction,
sunPos,
twoline2satrec,
type SatRec,
} from "satellite.js";
import type { City, SatelliteGroup } from "./types.ts";
/**
* One satellite's element set, as the catalogue takes it.
*
* Structurally `WireSatellite` from `server/wire.ts`, restated rather than
* imported for the same reason `SimRoute` is: the server must be able to build
* one without three.js becoming one of its dependencies.
*/
export interface SatelliteElements {
noradId: number;
name: string;
group: SatelliteGroup;
line1: string;
line2: string;
}
/** Where one satellite is in the observer's sky, right now. */
export interface SatelliteFix {
noradId: number;
name: string;
group: SatelliteGroup;
/** Radians clockwise from true north. */
azimuth: number;
/** Radians above the horizon. Only non-negative fixes are ever produced. */
elevation: number;
/** Observer to satellite, in kilometres. Straight-line, not ground track. */
rangeKm: number;
/**
* How much of the sun's disc the earth is covering, from the satellite's point
* of view. `0` is full sunlight, `1` is umbra, and the values in between are
* the penumbra which is where the fade at the end of a Starlink train comes
* from, and is the reason this is a fraction rather than a boolean.
*/
shadow: number;
}
/**
* Observer height above the ellipsoid, in kilometres.
*
* Zero. The correction for a city at 50 m matters to a radar and not to a dot on
* a dome it moves a look angle by well under a hundredth of a degree and
* pretending otherwise would mean threading a ground elevation through here for
* no visible change.
*/
const OBSERVER_HEIGHT_KM = 0;
/**
* How much of a frame the rolling sweep may spend propagating.
*
* SGP4 costs roughly ten microseconds per object, so a six-thousand-object
* catalogue is about sixty milliseconds four frames' worth, in one lump, and
* a visible hitch if it happens all at once. Doing it every frame is out of the
* question and doing it on a timer just moves the hitch somewhere less
* predictable.
*
* So the sweep is **time-budgeted and rolling**: each call propagates as many
* objects as fit in this budget and remembers where it stopped, wrapping around
* the catalogue continuously. Two milliseconds is under a seventh of a 60 Hz
* frame, and it walks six thousand objects in about half a second.
*
* The staleness that buys is the thing to check, and it is negligible: half a
* second at Starlink's ~0.8° per second of apparent motion overhead is under
* half a degree of arc. Nobody can see that. An aircraft interpolated half a
* second late would be visibly behind; a satellite is not, because the dome is
* angular and the angles barely move.
*
* (`satellite.js` ships a WASM `BulkPropagator` that would make this a
* non-question. It is not used here because it needs a binary loaded at runtime
* and a fallback path for when that fails, which is a lot of machinery to buy
* back two milliseconds a frame that are already accounted for.)
*/
const SWEEP_BUDGET_MS = 2;
/**
* The observer's own coordinates, in the radians `ecfToLookAngles` wants.
*
* Built once. `geodeticToEcf` would recompute the same three numbers on every
* call otherwise, several thousand times a second.
*/
interface Observer {
longitude: number;
latitude: number;
height: number;
}
/**
* A catalogue of element sets, propagated continuously, reporting what is up.
*
* Deliberately shaped like `SimulatedFlights` and `AdsbFlights` construct with
* data, ask for the current state but it is **not** a `FlightSource` and does
* not implement that interface. A `FlightSource.poll()` returns positions on the
* ground; this returns look angles on a dome, and collapsing the two into one
* interface would mean a renderer that could not tell which space it was in.
*/
export class SatelliteCatalogue {
private readonly records: { rec: SatRec; meta: SatelliteElements }[] = [];
private readonly observer: Observer;
/** Latest fix per NORAD id. Entries are deleted as they set below the horizon. */
private readonly current = new Map<number, SatelliteFix>();
/** Where the rolling sweep stopped last time. */
private cursor = 0;
/**
* How many objects the last full pass found above the horizon, for the panel.
* Counted rather than derived from `current.size` so that a partial sweep does
* not make the number jump around while it is still walking the catalogue.
*/
private lastPassVisible = 0;
private passVisible = 0;
constructor(elements: SatelliteElements[], center: City["center"]) {
for (const el of elements) {
// A TLE this build cannot read is one satellite missing, never a throw:
// the catalogue arrives over the network and one malformed line must not
// take the layer down.
//
// `rec.error` is necessary and **not sufficient**, which is worth stating
// because the obvious version of this check is wrong. `twoline2satrec`
// reads fixed columns with `parseFloat` and does not validate: hand it two
// lines of prose that merely start "1 " and "2 " and it returns `error: 0`
// with `NaN` in the orbital elements. Those propagate to `NaN` positions,
// which become `NaN` look angles, which land in the layer's vertex buffer
// — and one `NaN` vertex is enough to make a `Points` draw call render
// nothing at all. So the elements are checked for being numbers.
const rec = twoline2satrec(el.line1, el.line2);
if (rec.error !== 0) continue;
if (!Number.isFinite(rec.no) || !Number.isFinite(rec.inclo) || !Number.isFinite(rec.ecco)) {
continue;
}
this.records.push({ rec, meta: el });
}
this.observer = {
longitude: (center.lng * Math.PI) / 180,
latitude: (center.lat * Math.PI) / 180,
height: OBSERVER_HEIGHT_KM,
};
}
/** How many element sets this build could actually read. */
get size(): number {
return this.records.length;
}
/** How many were above the horizon at the end of the last complete pass. */
get visibleCount(): number {
return this.lastPassVisible;
}
/**
* Advance the rolling sweep and return everything currently above the horizon.
*
* `when` is passed in rather than read from the clock because godmode scrubs
* time the whole panel exists to put the scene at an arbitrary instant, and
* a layer that quietly used `new Date()` would be the one thing on screen that
* ignored the scrubber. It is also what makes a time-lapse capture possible at
* all: `shots/` steps the clock rather than recording it.
*/
fixes(when: Date): SatelliteFix[] {
if (this.records.length === 0) return [];
const gmst = gstime(when);
// The sun moves a degree a day; computing its position once per sweep call
// rather than once per satellite is free accuracy-wise and saves a few
// thousand redundant evaluations.
const sun = sunPos(jday(when));
const deadline = performance.now() + SWEEP_BUDGET_MS;
let stepped = 0;
// At least one per call, so a machine so slow that `performance.now()` has
// already passed the deadline still makes progress instead of freezing the
// sky forever.
do {
const entry = this.records[this.cursor];
if (entry !== undefined) {
const fix = this.fixOne(entry.rec, entry.meta, when, gmst, sun.rsun);
if (fix === null) this.current.delete(entry.meta.noradId);
else {
this.current.set(entry.meta.noradId, fix);
this.passVisible += 1;
}
}
this.cursor += 1;
if (this.cursor >= this.records.length) {
// A pass completed: publish its count and start the next one's tally.
this.cursor = 0;
this.lastPassVisible = this.passVisible;
this.passVisible = 0;
}
stepped += 1;
} while (performance.now() < deadline && stepped < this.records.length);
return [...this.current.values()];
}
/** One satellite, or `null` if it is below the horizon or will not propagate. */
private fixOne(
rec: SatRec,
meta: SatelliteElements,
when: Date,
gmst: number,
sunEciAU: { x: number; y: number; z: number },
): SatelliteFix | null {
// `propagate` returns null for a decayed object and for an element set it
// cannot carry to this date — both of which are ordinary in a catalogue that
// is hours old, and neither of which is this layer's problem.
const state = propagate(rec, when);
const eci = state?.position;
if (eci === undefined || typeof eci === "boolean") return null;
const look = ecfToLookAngles(this.observer, eciToEcf(eci, gmst));
// Below the horizon is the common case by a wide margin — a few hundred of
// several thousand objects are up at any instant — so this returns before
// the shadow calculation rather than after it.
//
// `NaN < 0` is false, so this comparison alone would let a degenerate
// element set through to the vertex buffer. The constructor screens for that
// and this is the belt: an object can also decay or go numerically unstable
// partway through a session, long after it was admitted.
if (!(look.elevation >= 0)) return null;
return {
noradId: meta.noradId,
name: meta.name,
group: meta.group,
azimuth: look.azimuth,
elevation: look.elevation,
rangeKm: look.rangeSat,
shadow: shadowFraction(sunEciAU, eci),
};
}
}
// ---- Rendering ------------------------------------------------------------
export interface SatelliteLayer {
group: THREE.Group;
/** Redraw from a set of fixes. Cheap enough to call every frame, and is. */
update(fixes: SatelliteFix[]): void;
/**
* Whether the layer draws at all. The catalogue keeps propagating either way
* see `setVisible` for why that is deliberate rather than wasteful.
*/
setVisible(visible: boolean): void;
dispose(): void;
}
/**
* The dome's radius, as a fraction of the board's longest side.
*
* It has to clear the city a dot inside the buildings would be occluded by
* them, which is the one thing that is definitely wrong and it has to stay
* inside the camera's far plane, which `scene.ts` sets at three board spans. At
* 1.2 the dome is outside every building and comfortably clear of the far plane
* even with the camera pulled all the way back.
*/
const DOME_RADIUS_FACTOR = 1.2;
/**
* How large a dot is drawn, in scene units, before attenuation.
*
* Everything on the dome is the same distance away, so `sizeAttenuation` cannot
* separate near from far here the way it does for a starfield it only makes
* the whole layer shrink as the camera retreats, which is what keeps the sky
* looking like a sky rather than like a fixed-size overlay pasted on the frame.
*/
const DOT_SIZE_FACTOR = 0.012;
/** Ceiling on dots, so the buffers are allocated once and never grow. */
const MAX_DOTS = 4096;
/**
* Colour per constellation.
*
* Starlink is the one that gets a colour of its own, for the same reason it gets
* its own group in the wire type: it is what people are looking for. The rest are
* deliberately close to white a sky where every object is a different hue is a
* chart, not a sky.
*/
const GROUP_COLORS: Record<SatelliteGroup, THREE.Color> = {
starlink: new THREE.Color(0xbfd8ff),
comms: new THREE.Color(0xd8e2ee),
navigation: new THREE.Color(0xe6e0cf),
station: new THREE.Color(0xfff0d0),
weather: new THREE.Color(0xd4ecdf),
other: new THREE.Color(0xdcdcdc),
};
/**
* Below this elevation a dot is faded out entirely.
*
* Not because the geometry is wrong down there but because it is *useless*: an
* object one degree above the horizon is behind the hills, behind the buildings,
* and behind more atmosphere than it can be seen through. Fading the last few
* degrees also hides the pop that a hard cut-off produces every time something
* rises, which on a busy constellation is several times a minute.
*/
const HORIZON_FADE_DEG = 8;
/**
* Alpha for an object in full shadow, relative to a sunlit one.
*
* Not zero. A satellite in the earth's shadow is genuinely invisible to the eye,
* and drawing nothing would be the physically honest choice but this layer is
* also a map of what is overhead, and a sky that empties itself at local midnight
* reads as a broken feed rather than as a correct one. So an eclipsed object is
* drawn faintly: present, obviously not lit, and clearly a different thing from
* the one crossing above it in sunlight.
*/
const SHADOW_ALPHA = 0.16;
export function createSatelliteLayer(boardSpan: number): SatelliteLayer {
const group = new THREE.Group();
group.name = "satellites";
const radius = boardSpan * DOME_RADIUS_FACTOR;
const positions = new Float32Array(MAX_DOTS * 3);
const colors = new Float32Array(MAX_DOTS * 4);
// Held as locals rather than looked up through `geo.attributes` on every
// update: the lookup is a string index into a dictionary typed as possibly
// holding nothing, and the alternative to keeping the references is a
// non-null assertion on the hot path twice a frame.
const positionAttr = new THREE.BufferAttribute(positions, 3);
const colorAttr = new THREE.BufferAttribute(colors, 4);
const geo = new THREE.BufferGeometry();
geo.setAttribute("position", positionAttr);
geo.setAttribute("color", colorAttr);
geo.setDrawRange(0, 0);
const material = new THREE.PointsMaterial({
size: boardSpan * DOT_SIZE_FACTOR,
sizeAttenuation: true,
vertexColors: true,
transparent: true,
// Dots are drawn over the sky and over each other; letting them write depth
// makes whichever drew first punch a hole in the ones behind, which on a
// dense constellation is most of them.
depthWrite: false,
// The sky is the darkest thing in the frame at the hour this layer matters,
// and additive blending is what makes a lit satellite read as a light source
// rather than as a grey sticker.
blending: THREE.AdditiveBlending,
});
const points = new THREE.Points(geo, material);
points.name = "satellite-dots";
// The buffer is rewritten in scene space every update, so its bounding sphere
// is permanently stale and culling on it would cull the whole sky.
points.frustumCulled = false;
group.add(points);
const scratch = new THREE.Color();
/**
* The dome is centred on the board's origin and not on the camera.
*
* Centring it on the camera would keep every dot at a constant apparent size
* and would be the right call for a true skybox. It is the wrong call here,
* because this dome is *anchored to a place*: the look angles were computed for
* the city centre, so a dot means "from the middle of this board, look there".
* Following the camera would silently turn a measured direction into a
* decoration.
*/
function place(fix: SatelliteFix, into: THREE.Vector3): void {
const cosEl = Math.cos(fix.elevation);
// Azimuth is clockwise from north, and scene north is Z with +X east —
// which is exactly `sin` on X and `cos` on Z, with no sign fudge. The same
// convention `world.project` uses; see `District.gridAngle` for the other
// place this rule is stated.
into.set(
Math.sin(fix.azimuth) * cosEl * radius,
Math.sin(fix.elevation) * radius,
-Math.cos(fix.azimuth) * cosEl * radius,
);
}
const scratchVec = new THREE.Vector3();
function update(fixes: SatelliteFix[]): void {
let n = 0;
for (const fix of fixes) {
if (n >= MAX_DOTS) break;
const elevationDeg = (fix.elevation * 180) / Math.PI;
const horizon = Math.min(1, elevationDeg / HORIZON_FADE_DEG);
if (horizon <= 0) continue;
place(fix, scratchVec);
positions[n * 3] = scratchVec.x;
positions[n * 3 + 1] = scratchVec.y;
positions[n * 3 + 2] = scratchVec.z;
scratch.copy(GROUP_COLORS[fix.group] ?? GROUP_COLORS.other);
const lit = 1 - fix.shadow;
colors[n * 4] = scratch.r;
colors[n * 4 + 1] = scratch.g;
colors[n * 4 + 2] = scratch.b;
colors[n * 4 + 3] = horizon * (SHADOW_ALPHA + (1 - SHADOW_ALPHA) * lit);
n += 1;
}
geo.setDrawRange(0, n);
positionAttr.needsUpdate = true;
colorAttr.needsUpdate = true;
}
return {
group,
update,
/**
* Hiding the layer stops it drawing and does **not** stop the catalogue
* propagating, which is the right way round: turning the sky back on should
* show where things are now, not resume a sweep from wherever it was
* abandoned and then crawl back into agreement with reality over the next
* half second. The propagation is two milliseconds a frame; correctness on
* re-entry is worth more than reclaiming it.
*/
setVisible(visible: boolean) {
group.visible = visible;
},
dispose() {
geo.dispose();
material.dispose();
},
};
}
+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) {
+26
View File
@@ -299,3 +299,29 @@ export interface FlightSource {
interval: number;
dispose?(): void;
}
// ---- Satellites -----------------------------------------------------------
/**
* Which constellation a satellite belongs to, as far as anyone looking up cares.
*
* A **display bucket and not a taxonomy**: no orbital regime, no operator, no
* launch date. `engine/satellites.ts` picks a colour from it and nothing else
* reads it, and a field that carried more would be a field that got wrong.
*
* `starlink` is broken out from `comms` because it is the reason the layer
* exists it is the constellation people can see with their eyes, in a train,
* forty minutes after sunset. `other` is not a failure; most of the catalogue is
* other.
*
* This lives here rather than in `server/wire.ts` for the same reason `Marker`
* does: the renderer owns the vocabulary and the wire carries it, so a body off
* the network is handed to the engine as-is with no adapter in between.
*/
export type SatelliteGroup =
| "starlink"
| "comms"
| "navigation"
| "station"
| "weather"
| "other";
+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
+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 --------------------------------------------------------------
/**
+200
View File
@@ -0,0 +1,200 @@
/**
* `SatelliteCatalogue`: the propagation, and the units it is easy to get wrong.
*
* SGP4 itself is `satellite.js`'s problem and is not re-tested here it is a
* direct translation of Vallado's reference implementation and has its own
* conformance suite. What *is* tested is the chain around it, which is where
* every bug in a satellite layer actually lives: ECI to ECF needs the sidereal
* angle for the right instant, `ecfToLookAngles` wants the observer in **radians**
* and kilometres, and getting either wrong produces angles that are plausible to
* look at and completely false.
*
* The check that catches all of it is the **slant range**. It is a physical
* consequence of the geometry rather than a number copied from somewhere: an
* object above the horizon can be no closer than its own altitude (straight
* overhead) and no further than the horizon-grazing chord, and that is a tight
* window roughly 4002,400 km for the ISS. Feed the observer degrees instead
* of radians and the ranges leave it immediately.
*
* The layer itself is not tested. It is three.js buffer writes with no branch
* worth pinning, and testing it would mean standing up a GL context to assert
* that a float landed in an array.
*/
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { SatelliteCatalogue, type SatelliteElements } from "../engine/satellites.ts";
/** San Francisco, which is `SAN_FRANCISCO.center` and is the default board. */
const SF = { lat: 37.7749, lng: -122.4194 };
/**
* A real ISS element set. Chosen because the ISS is the one object whose orbit
* everybody can check independently 51.6° inclination, ~420 km, ~92 minutes
* and because at that inclination it genuinely passes over San Francisco
* several times a day, which is what makes the visibility test below meaningful
* rather than vacuous.
*/
const ISS: SatelliteElements = {
noradId: 25544,
name: "ISS (ZARYA)",
group: "station",
line1: "1 25544U 98067A 26037.51782528 -.00002182 00000-0 -11606-4 0 2927",
line2: "2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537",
};
/**
* Near the element set's own epoch day 37 of 2026. A TLE is good for days
* either side of its epoch and degrades after that, so a test that propagated
* one six months forward would be measuring the decay of the model rather than
* the correctness of this module.
*/
const NEAR_EPOCH = new Date(Date.UTC(2026, 1, 6, 12, 0, 0));
/**
* Bounds on how far away something above the horizon can be, in kilometres.
*
* The lower bound is the orbit's own altitude, less a margin for the ellipsoid
* and for the object being a little low. The upper bound is the slant range to
* an object on the horizon at this altitude, which is about 2,340 km for the
* ISS; 2,600 leaves room without admitting anything absurd.
*/
const MIN_RANGE_KM = 350;
const MAX_RANGE_KM = 2600;
/** Walks the whole catalogue, however many budgeted calls that takes. */
function sweep(catalogue: SatelliteCatalogue, when: Date) {
// The budget is two milliseconds a call and this catalogue holds one object,
// so one call is a full pass — but looping to `size` keeps the helper honest
// if a test ever hands it a larger set.
let fixes = catalogue.fixes(when);
for (let i = 0; i < catalogue.size; i += 1) fixes = catalogue.fixes(when);
return fixes;
}
describe("reading element sets", () => {
it("keeps the ones it can read", () => {
assert.equal(new SatelliteCatalogue([ISS], SF).size, 1);
});
it("skips a malformed set rather than throwing", () => {
const broken: SatelliteElements = { ...ISS, line1: "1 nonsense", line2: "2 nonsense" };
const catalogue = new SatelliteCatalogue([broken, ISS], SF);
assert.equal(catalogue.size, 1, "the good set should survive its neighbour");
});
it("reports nothing at all for an empty catalogue, and does not divide by zero", () => {
const catalogue = new SatelliteCatalogue([], SF);
assert.equal(catalogue.size, 0);
assert.deepEqual(catalogue.fixes(NEAR_EPOCH), []);
});
});
describe("the look angles", () => {
/**
* A day of the ISS over San Francisco, five minutes at a time.
*
* Sampled rather than asserted at one instant because a single sample proves
* nothing: the ISS is below the horizon from any one place about ninety-five
* per cent of the time, so a test pinned to one moment would almost certainly
* be asserting on an empty array and would pass with the propagation deleted.
*/
function passesOverADay() {
const catalogue = new SatelliteCatalogue([ISS], SF);
const seen: { elevation: number; azimuth: number; rangeKm: number; shadow: number }[] = [];
for (let minute = 0; minute < 24 * 60; minute += 5) {
const when = new Date(NEAR_EPOCH.getTime() + minute * 60_000);
for (const fix of sweep(catalogue, when)) seen.push(fix);
}
return seen;
}
it("puts the ISS over San Francisco several times a day", () => {
const seen = passesOverADay();
// At 51.6° inclination and ~92 minutes, several passes a day is arithmetic,
// not luck. Zero would mean the propagation or the observer is wrong.
assert.ok(seen.length > 5, `only ${seen.length} five-minute samples were above the horizon`);
});
it("never reports something below the horizon", () => {
for (const fix of passesOverADay()) {
assert.ok(fix.elevation >= 0, `elevation ${fix.elevation} rad is under the horizon`);
}
});
it("keeps elevation inside a quarter turn and azimuth inside a full one", () => {
for (const fix of passesOverADay()) {
assert.ok(fix.elevation <= Math.PI / 2 + 1e-6, `elevation ${fix.elevation} is past zenith`);
assert.ok(Math.abs(fix.azimuth) <= 2 * Math.PI, `azimuth ${fix.azimuth} is off the compass`);
}
});
/** The one that catches degrees-for-radians. See the note at the top. */
it("reports a slant range the geometry actually permits", () => {
const seen = passesOverADay();
assert.ok(seen.length > 0);
for (const fix of seen) {
assert.ok(
fix.rangeKm >= MIN_RANGE_KM && fix.rangeKm <= MAX_RANGE_KM,
`range ${Math.round(fix.rangeKm)} km is outside ${MIN_RANGE_KM}${MAX_RANGE_KM} km, ` +
`which is not a range a 420 km orbit can be seen at`,
);
}
});
it("reports a shadow fraction, not a boolean and not a stray number", () => {
for (const fix of passesOverADay()) {
assert.ok(fix.shadow >= 0 && fix.shadow <= 1, `shadow ${fix.shadow} is not a fraction`);
}
});
it("is a pure function of the instant it is given", () => {
const a = new SatelliteCatalogue([ISS], SF);
const b = new SatelliteCatalogue([ISS], SF);
// Two viewers on two machines must agree, which is the whole reason the
// server sends elements rather than positions.
assert.deepEqual(sweep(a, NEAR_EPOCH), sweep(b, NEAR_EPOCH));
});
it("moves when the clock does", () => {
const catalogue = new SatelliteCatalogue([ISS], SF);
// A minute apart, so any instant where it is up at both ends has visibly
// moved: the ISS crosses the sky in about ten.
let differed = false;
for (let minute = 0; minute < 24 * 60 && !differed; minute += 5) {
const at = new Date(NEAR_EPOCH.getTime() + minute * 60_000);
const later = new Date(at.getTime() + 60_000);
const [before] = sweep(catalogue, at);
const [after] = sweep(catalogue, later);
if (before && after) differed = before.azimuth !== after.azimuth;
}
assert.ok(differed, "the sky never changed across a minute");
});
});
describe("the observer", () => {
/**
* The bug this exists for is a real one and it is invisible on screen: reusing
* one catalogue across a city switch computes the second board's sky from the
* first board's coordinates. Everything still renders, and every angle is
* wrong. `main.ts` rebuilds per city because of this.
*/
it("is where the catalogue was told it is", () => {
const sf = new SatelliteCatalogue([ISS], SF);
const antipode = new SatelliteCatalogue([ISS], { lat: -37.7749, lng: 57.5806 });
let disagreed = false;
for (let minute = 0; minute < 24 * 60 && !disagreed; minute += 5) {
const when = new Date(NEAR_EPOCH.getTime() + minute * 60_000);
const here = sweep(sf, when);
const there = sweep(antipode, when);
// Two observers on opposite sides of the earth cannot both be looking at
// the same low-orbit object.
if (here.length > 0 && there.length > 0) {
disagreed = here[0]?.azimuth !== there[0]?.azimuth;
}
if (here.length !== there.length) disagreed = true;
}
assert.ok(disagreed, "the observer coordinate made no difference to the answer");
});
});
+117
View File
@@ -65,6 +65,7 @@ import {
type SkyCondition,
type WeatherObservation,
} from "../engine/atmosphere.ts";
import { MAX_EXTRA_TRAFFIC } from "../engine/flights.ts";
import { daylightPhase, solarPosition, sunTimes } from "../engine/solar.ts";
import type { Stage } from "../engine/stage.ts";
@@ -117,6 +118,44 @@ export interface GodmodePlace {
marineStrength?(env: Environment): number | null;
}
/**
* The two dials that point at the sky rather than at the light.
*
* Optional as a unit, and the whole interface is absent rather than each method
* being nullable: without a scene there is nothing for either control to do, and
* a panel that showed a dead traffic slider next to a live sun scrubber would be
* inviting somebody to drag it and conclude the renderer was broken. `main.ts`
* passes this only once a board is up.
*
* `read()` is polled on the panel's own refresh rather than pushed, because both
* numbers it returns change without anybody touching a control traffic goes
* live when a fetch lands, and the satellite count changes every time the
* propagation sweep completes a pass.
*/
export interface GodmodeSky {
/**
* Fabricate this many aircraft on top of whatever is being drawn, or `0` for
* none. Composes with a live feed rather than replacing it; see
* `withTrafficDial` in `engine/flights.ts` for why that direction, and for the
* argument about fabricating traffic at all.
*/
onExtraTraffic(count: number): void;
/** Draw the satellite layer, or do not. */
onSatellitesVisible(visible: boolean): void;
read(): {
extraTraffic: number;
/** True when the aircraft underneath the fabricated ones were observed. */
trafficIsLive: boolean;
/**
* Objects above the horizon at the last completed propagation pass, and
* element sets this build could read. `null` on the overwhelming majority of
* deployments, which serve no catalogue at all a different state from a
* catalogue with nothing in it, and one the panel says out loud.
*/
satellites: { visible: number; total: number } | null;
};
}
export interface GodmodeOptions {
/**
* Where to mount. The root positions *itself* bottom centre, over the map,
@@ -131,6 +170,8 @@ export interface GodmodeOptions {
onTimeChange(instant: Date | null): void;
/** A fabricated observation, or `null` for whatever the deployment reports. */
onWeatherOverride(w: WeatherObservation | null): void;
/** Traffic and satellites, when there is a board to point them at. */
sky?: GodmodeSky;
/** Start with the drawer open. Default `false`: the tab, and nothing else. */
open?: boolean;
/**
@@ -605,6 +646,46 @@ export function createGodmode(options: GodmodeOptions): Godmode {
labelled("cond", conditionSelect),
);
// ---- Sky ------------------------------------------------------------------
/**
* Built unconditionally and appended only when `options.sky` is present.
*
* The alternative branching around the whole block means every readout
* below has to be nullable and `refreshSky` becomes a pyramid of guards. One
* check at append time is cheaper to read and the unappended nodes are
* garbage a moment later.
*/
const skySection = section("sky");
const trafficLine = el("div", "gm-line");
const trafficSlider = slider("Fabricated aircraft", 0, MAX_EXTRA_TRAFFIC, 1, (value) => {
options.sky?.onExtraTraffic(value);
refresh();
});
const trafficNote = el("div", "gm-hint");
trafficNote.textContent =
"invented traffic, added to whatever is being drawn — trails run out past ~190 aircraft";
const satelliteLine = el("div", "gm-line");
const satelliteChips = el("div", "gm-chips");
let satellitesOn = true;
const satelliteChip = button("gm-chip", "satellites", () => {
satellitesOn = !satellitesOn;
options.sky?.onSatellitesVisible(satellitesOn);
refresh();
});
const satelliteNote = el("div", "gm-hint");
satelliteNote.textContent =
"drawn on a dome at true azimuth and elevation — 550 km will not fit on the board";
satelliteChips.append(satelliteChip);
skySection.body.append(
trafficLine,
labelled("extra", trafficSlider.el, trafficSlider.value),
trafficNote,
satelliteLine,
satelliteChips,
satelliteNote,
);
// ---- Performance ----------------------------------------------------------
const perfSection = section("performance");
@@ -650,6 +731,7 @@ export function createGodmode(options: GodmodeOptions): Godmode {
body.append(
timeSection.el,
weatherSection.el,
...(options.sky ? [skySection.el] : []),
perfSection.el,
overlaySection.el,
);
@@ -797,11 +879,46 @@ export function createGodmode(options: GodmodeOptions): Godmode {
if (open) {
refreshTime();
refreshWeather();
refreshSky();
refreshLive();
}
refreshHud();
}
function refreshSky() {
const sky = options.sky;
if (!sky) return;
const state = sky.read();
// The slider is not written back from `state` — it is an input somebody may
// be dragging, and four writes a second to its value while they do that
// fights the pointer. `state.extraTraffic` is read for the line above it,
// which is the readout, and the two agree because the app is the only other
// writer and there is nothing else to disagree with.
setText(
trafficLine,
`traffic ${state.trafficIsLive ? "observed" : "simulated"}` +
`${state.extraTraffic > 0 ? ` +${state.extraTraffic} fabricated` : ""}`,
);
setText(trafficSlider.value, String(state.extraTraffic));
// Three states, not two, and the middle one is the one worth distinguishing:
// a deployment with no catalogue is not the same as a catalogue on a night
// when nothing happens to be up, and an operator debugging an empty sky needs
// to know which they have.
if (state.satellites === null) {
setText(satelliteLine, "satellites no catalogue — set TERA_SATELLITES_SOURCE=celestrak");
} else {
setText(
satelliteLine,
`satellites ${state.satellites.visible} above the horizon ` +
`of ${state.satellites.total} tracked`,
);
}
satelliteChip.disabled = state.satellites === null;
satelliteChip.setAttribute("aria-pressed", String(satellitesOn && state.satellites !== null));
}
function refreshBanner() {
const parts: string[] = [];
if (override) parts.push(`time ${fmtStamp(override)}`);
+55 -1
View File
@@ -97,11 +97,65 @@ function twoDoors(): Plugin {
};
}
/**
* Cut `satellite.js`'s WASM runtime out of the bundle.
*
* `satellite.js`'s entry point ends with `export * from './wasm/index.js'`, and
* that subtree is an Emscripten build: a megabyte of generated glue that reaches
* for `node:module` and `node:worker_threads`, wraps itself in a top-level
* `await`, and cannot be tree-shaken away because a star re-export of a module
* with side effects is not something a bundler may drop on its own.
*
* Measured, before this existed: **308 kB** of minified WASM loader shipped to
* every visitor, in a chunk nothing ever called. `engine/satellites.ts` uses the
* pure-JS `propagate` and says in its own header why it does not want the
* `BulkPropagator` a binary loaded at runtime plus a fallback path for when
* that fails, to buy back two milliseconds a frame that are already budgeted.
* Paying 308 kB to *not* use it was the worst of both.
*
* The stub is empty because nothing imports a name from it. If a future version
* of this repo does want the bulk propagator, the fix is to delete this plugin
* and pay the kilobytes deliberately not to widen the stub.
*
* Matching is on the **importer** as well as the specifier, so this cannot
* silently swallow some other package's `./wasm/index.js`.
*/
function noWasmPropagator(): Plugin {
const STUB = "\0lumbridge:satellite-wasm-stub";
return {
name: "lumbridge:no-wasm-propagator",
enforce: "pre",
resolveId(source, importer) {
if (source !== "./wasm/index.js") return null;
if (importer === undefined || !importer.includes("satellite.js")) return null;
return STUB;
},
load(id) {
return id === STUB ? "export {};" : null;
},
};
}
export default defineConfig({
// Mounted under tera.lumbridgecorp.com in production; the trailing
// slash matters, since every asset URL is resolved against it.
base: process.env.TERA_BASE ?? "/",
plugins: [twoDoors()],
plugins: [noWasmPropagator(), twoDoors()],
/**
* The heightfield worker is constructed as `{ type: "module" }` in
* `world.ts`, and this is the build setting that agrees with it.
*
* Vite's default worker format is `iife`, which was survivable while nothing
* in the graph needed anything an IIFE cannot express and stopped being
* survivable the moment it did. `satellite.js` reaches a WASM runtime with a
* top-level `await` in it, and rollup's answer to a top-level await in an
* `iife` chunk is to fail the whole build, with an error naming a file no
* worker imports.
*
* So this is agreement rather than a workaround: the module the browser is
* told to load as an ES module is now built as one.
*/
worker: { format: "es" },
build: {
outDir: "dist",
target: "es2022",