diff --git a/package-lock.json b/package-lock.json index 19f2184..3ed6550 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 94d92b1..e66dda9 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "preview": "vite preview" }, "dependencies": { + "satellite.js": "^7.1.0", "three": "^0.182.0" }, "devDependencies": { diff --git a/scripts/check-zero-config-boot.mjs b/scripts/check-zero-config-boot.mjs index cfcfbd3..7ad60dc 100644 --- a/scripts/check-zero-config-boot.mjs +++ b/scripts/check-zero-config-boot.mjs @@ -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`); diff --git a/server/src/app.ts b/server/src/app.ts index 9663e2c..d1b1d47 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -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); diff --git a/server/src/config.ts b/server/src/config.ts index b76b7e1..1a7618a 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -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. */ diff --git a/server/src/http.ts b/server/src/http.ts index 5117d55..93aa61f 100644 --- a/server/src/http.ts +++ b/server/src/http.ts @@ -26,23 +26,50 @@ const DEFAULT_TIMEOUT_MS = 6000; */ const MAX_BODY_BYTES = 4 * 1024 * 1024; -export interface GetJsonOptions { +export interface GetTextOptions { headers?: Record; 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; + /** * `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(url: string, opts: GetJsonOptions = {}): Promise { + 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 { 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(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; } diff --git a/server/src/routes/health.ts b/server/src/routes/health.ts index d39e673..035f15c 100644 --- a/server/src/routes/health.ts +++ b/server/src/routes/health.ts @@ -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: { diff --git a/server/src/routes/satellites.ts b/server/src/routes/satellites.ts new file mode 100644 index 0000000..98b2fb9 --- /dev/null +++ b/server/src/routes/satellites.ts @@ -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; + }); +} diff --git a/server/src/satellites/celestrak.ts b/server/src/satellites/celestrak.ts new file mode 100644 index 0000000..3510c86 --- /dev/null +++ b/server/src/satellites/celestrak.ts @@ -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 { + 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(); + 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; +} diff --git a/server/src/satellites/index.ts b/server/src/satellites/index.ts new file mode 100644 index 0000000..73f3977 --- /dev/null +++ b/server/src/satellites/index.ts @@ -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; +} + +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({ + 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 { + 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"], + }; + }, + }; +} diff --git a/server/src/services.ts b/server/src/services.ts index be2d0a4..9866c37 100644 --- a/server/src/services.ts +++ b/server/src/services.ts @@ -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), diff --git a/server/src/test/satellites.test.ts b/server/src/test/satellites.test.ts new file mode 100644 index 0000000..87d7a57 --- /dev/null +++ b/server/src/test/satellites.test.ts @@ -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 | 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) { + const config = loadConfig(env); + config.logLevel = "silent"; + return buildApp(config); +} + +const celestrakEnv = { TERA_SATELLITES_SOURCE: "celestrak" }; + +async function get(app: ReturnType): Promise { + 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], + ); + }); +}); diff --git a/src/access.ts b/src/access.ts index 7e85a05..924547c 100644 --- a/src/access.ts +++ b/src/access.ts @@ -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; 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"), + }; } /** diff --git a/src/adapters/http.ts b/src/adapters/http.ts index c69abfa..654b771 100644 --- a/src/adapters/http.ts +++ b/src/adapters/http.ts @@ -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; /** * 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 { + const body = await get("/satellites", { + ...(opts.signal ? { signal: opts.signal } : {}), + }); + if (!body || !Array.isArray(body.satellites)) return []; + return body.satellites; + }, + office: (id) => get(`/offices/${encodeURIComponent(id)}`), /** diff --git a/src/engine/flights.ts b/src/engine/flights.ts index 5dd9bb6..49375c9 100644 --- a/src/engine/flights.ts +++ b/src/engine/flights.ts @@ -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 { + 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 diff --git a/src/engine/satellites.ts b/src/engine/satellites.ts new file mode 100644 index 0000000..66a1981 --- /dev/null +++ b/src/engine/satellites.ts @@ -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(); + + /** 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 = { + 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(); + }, + }; +} diff --git a/src/engine/scene.ts b/src/engine/scene.ts index 6e0b637..44200fc 100644 --- a/src/engine/scene.ts +++ b/src/engine/scene.ts @@ -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) { diff --git a/src/engine/types.ts b/src/engine/types.ts index d650f2c..926da13 100644 --- a/src/engine/types.ts +++ b/src/engine/types.ts @@ -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"; diff --git a/src/main.ts b/src/main.ts index 32fae96..133f713 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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 | 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 diff --git a/src/server/wire.ts b/src/server/wire.ts index 314ba0c..240a8c4 100644 --- a/src/server/wire.ts +++ b/src/server/wire.ts @@ -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 3–7 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 -------------------------------------------------------------- /** diff --git a/src/test/satellites.test.ts b/src/test/satellites.test.ts new file mode 100644 index 0000000..12c2120 --- /dev/null +++ b/src/test/satellites.test.ts @@ -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 400–2,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"); + }); +}); diff --git a/src/tools/godmode.ts b/src/tools/godmode.ts index f2e9e3c..602773f 100644 --- a/src/tools/godmode.ts +++ b/src/tools/godmode.ts @@ -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)}`); diff --git a/vite.config.ts b/vite.config.ts index 38f4ac6..34c6097 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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",