The sky gets the things above the aeroplanes
Satellites, end to end: CelesTrak element sets behind the same TTL cache
the weather and the flights use, served as TLEs rather than as positions,
and propagated in the browser with SGP4.
Sending elements is the same trick `flights/plan.ts` plays and it has a
better excuse here — a TLE *is* the closed form, valid for days either
side of its epoch, so one cacheable fetch every six hours replaces a poll
and every viewer agrees about where everything is.
Two things are worth knowing about the shape of it:
- There is no region parameter. An aeroplane at 10,000 m is local and
a satellite at 550 km is above the horizon for a circle two thousand
kilometres across, so one catalogue serves both boards and the client
decides what is above its own horizon. Only the observer is per-city,
which is why `main.ts` shares the elements and rebuilds the catalogue.
- The layer draws on a dome, because it cannot draw anywhere else.
`world.metres(550_000)` is 21,000 scene units against a far plane at
3,000. Azimuth and elevation are real; the radius carries nothing.
Off by default: a clone that started pulling CelesTrak on `npm run dev`
would have volunteered somebody else's bandwidth for its onboarding.
Godmode gets the two dials that point at the sky rather than at the
light — fabricated traffic, which composes with a live ADS-B feed instead
of replacing it, and a switch for the satellite layer with a count beside
it. Both are god-only lies about the inputs, in the manner of the weather
override.
`satellite.js` is the second runtime dependency this package has taken.
Its entry point star-exports an Emscripten build that cannot be shaken
out, so `noWasmPropagator` in the Vite config cuts it: 308 kB of WASM
loader for a bulk propagator nothing calls, against 26 kB for the SGP4
that does the work.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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)}`);
|
||||
|
||||
Reference in New Issue
Block a user