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:
@@ -181,6 +181,95 @@ export function syntheticRoutes(region: SkyRegion, count = 6, seed = 20_617): Si
|
||||
return routes;
|
||||
}
|
||||
|
||||
/**
|
||||
* A source with a dial on it: whatever it was going to draw, plus N invented
|
||||
* aircraft.
|
||||
*
|
||||
* This exists for one control in the godmode panel — "how busy would this look
|
||||
* with three times the traffic" — and the shape it takes is chosen to make that
|
||||
* question answerable without corrupting the answer to any other one.
|
||||
*
|
||||
* **It composes rather than substitutes.** The base source is polled unchanged
|
||||
* and its aircraft are passed through untouched; the fabricated ones are a
|
||||
* second list concatenated onto the end. That is what lets the dial work over a
|
||||
* *live* ADS-B feed as well as over the simulator — the real traffic stays real
|
||||
* and stays complete, and turning the dial back to zero returns exactly the
|
||||
* sky that was there before, because nothing was ever taken away.
|
||||
*
|
||||
* The alternative was to mutate the simulator's route list, and it is worse in
|
||||
* both directions: it does nothing at all when the server is serving its own
|
||||
* plan (`HttpFlights` ignores its fallback in that mode, so the slider would be
|
||||
* inert on every deployment that has an API), and it is destructive when it does
|
||||
* work, because the authored corridors would have to be rebuilt to get back.
|
||||
*
|
||||
* ### On fabricating traffic at all
|
||||
*
|
||||
* The same argument as `weatherOverride` in `main.ts`: a god-only lie about the
|
||||
* inputs, told to see what the renderer does with it. It is deliberately **not**
|
||||
* available to anyone else, and the invented aircraft carry a callsign prefix of
|
||||
* their own so that a screenshot of a busy sky can be told from a screenshot of
|
||||
* a real one. Note what this breaks while it is on — every viewer agreeing about
|
||||
* where the aircraft are, which is the property the server's plan exists to buy.
|
||||
* That is acceptable for a debug dial and would not be for a feature.
|
||||
*/
|
||||
export interface TrafficDial {
|
||||
/** The source to hand `createScene`. Stable for the dial's whole life. */
|
||||
source: FlightSource;
|
||||
/** Fabricate this many additional aircraft. `0` turns the dial off entirely. */
|
||||
setExtra(count: number): void;
|
||||
extra(): number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Callsign prefix for fabricated traffic.
|
||||
*
|
||||
* Distinct from `syntheticRoutes`'s own `SIM`, and it has to be: `sampleRoute`
|
||||
* derives an aircraft's id from its callsign, `createFlightLayer` keys its
|
||||
* tracks on that id, and a deployment with no API is already flying `SIM 1`
|
||||
* through `SIM 6` from the fallback. Reuse the prefix and every fabricated
|
||||
* aircraft would land on an existing track, teleporting it across the board on
|
||||
* alternate polls.
|
||||
*/
|
||||
const FABRICATED_PREFIX = "GOD";
|
||||
|
||||
/** As many as the dial goes to. Past this the sky is soup and the point is made. */
|
||||
export const MAX_EXTRA_TRAFFIC = 400;
|
||||
|
||||
export function withTrafficDial(base: FlightSource, region: SkyRegion): TrafficDial {
|
||||
let extra: SimulatedFlights | null = null;
|
||||
let count = 0;
|
||||
|
||||
return {
|
||||
source: {
|
||||
interval: base.interval,
|
||||
poll(): Aircraft[] | Promise<Aircraft[]> {
|
||||
const theirs = base.poll();
|
||||
if (extra === null) return theirs;
|
||||
const mine = extra.poll();
|
||||
// `poll` is synchronous on every source in this build, but the interface
|
||||
// permits a promise and `HttpFlights` documents its synchrony as a
|
||||
// deliberate property rather than an accident. Handling both here costs
|
||||
// one branch and means the dial cannot be what breaks that.
|
||||
return theirs instanceof Promise ? theirs.then((a) => [...a, ...mine]) : [...theirs, ...mine];
|
||||
},
|
||||
dispose: () => base.dispose?.(),
|
||||
},
|
||||
setExtra(next: number) {
|
||||
count = Math.max(0, Math.min(MAX_EXTRA_TRAFFIC, Math.round(next)));
|
||||
if (count === 0) {
|
||||
extra = null;
|
||||
return;
|
||||
}
|
||||
const routes = syntheticRoutes(region, count).map((route, i) => ({
|
||||
...route,
|
||||
callsign: `${FABRICATED_PREFIX} ${i + 1}`,
|
||||
}));
|
||||
extra = new SimulatedFlights(routes);
|
||||
},
|
||||
extra: () => count,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Traffic that behaves like the real thing without being it: aircraft move
|
||||
* along fixed legs at fixed speeds, looping, with each one offset in phase so
|
||||
|
||||
@@ -0,0 +1,490 @@
|
||||
/**
|
||||
* Satellites over the city — on a dome, because they cannot be anywhere else.
|
||||
*
|
||||
* Every other thing in this scene lives in projected metric space: a building is
|
||||
* where the building is, an aircraft at 10,000 m is at `world.metres(10000)`
|
||||
* above the ground it is over. That rule breaks completely here, and it is worth
|
||||
* being explicit about how badly, because the alternative is a layer that renders
|
||||
* nothing and looks broken.
|
||||
*
|
||||
* Starlink flies at about 550 km. The Bay Area board is ~94 m per scene unit with
|
||||
* a 3.6× vertical exaggeration, so `world.metres(550_000)` is **21,000 scene
|
||||
* units** — against a board 1,000 units across and a camera far plane at 3,000.
|
||||
* The satellite is seven times beyond the horizon of the projection, over ground
|
||||
* two thousand kilometres away that this city pack does not contain. There is no
|
||||
* camera position from which the true placement is both visible and meaningful.
|
||||
*
|
||||
* So this layer draws **what you would see if you looked up**: each satellite at
|
||||
* its real azimuth and elevation from the city centre, on a dome big enough to
|
||||
* sit outside the buildings and inside the far plane. A dot due east at 30° above
|
||||
* the horizon is genuinely due east at 30°. The dome's *radius* is arbitrary and
|
||||
* carries no information; the direction carries all of it.
|
||||
*
|
||||
* That is not a compromise so much as the correct frame for the question. Nobody
|
||||
* looking at a satellite layer wants to know its ECEF coordinates. They want to
|
||||
* know whether one is passing over, where to look, and whether it is lit — and
|
||||
* the last of those is why this bothers with `shadowFraction` rather than drawing
|
||||
* every object the same brightness. A Starlink is visible to the naked eye when
|
||||
* it is in sunlight while the ground below it is dark, which is the whole reason
|
||||
* anybody ever noticed the constellation existed.
|
||||
*
|
||||
* ### Where the propagation happens, and why it is here
|
||||
*
|
||||
* The server sends element sets, not positions (`wire.ts`, `SatellitesBody`), for
|
||||
* the same reason it sends a flight plan rather than aircraft: a TLE is already
|
||||
* the closed form, SGP4 is the function that evaluates it, and every browser
|
||||
* evaluating it agrees. One cacheable fetch every six hours replaces a poll.
|
||||
*
|
||||
* `satellite.js` is the second runtime dependency this package has ever taken,
|
||||
* after three.js, and the bar it had to clear was "would hand-rolling this be
|
||||
* better". SGP4 is a 1980 Fortran model with a specific set of drag and
|
||||
* resonance terms, a published reference implementation, and a well-known list of
|
||||
* ways a re-implementation goes subtly wrong; the library is MIT, is a direct
|
||||
* translation of Vallado's C++, and is the one everybody checks against. Writing
|
||||
* our own would have been a worse copy of it.
|
||||
*/
|
||||
|
||||
import * as THREE from "three";
|
||||
import {
|
||||
eciToEcf,
|
||||
ecfToLookAngles,
|
||||
gstime,
|
||||
jday,
|
||||
propagate,
|
||||
shadowFraction,
|
||||
sunPos,
|
||||
twoline2satrec,
|
||||
type SatRec,
|
||||
} from "satellite.js";
|
||||
import type { City, SatelliteGroup } from "./types.ts";
|
||||
|
||||
/**
|
||||
* One satellite's element set, as the catalogue takes it.
|
||||
*
|
||||
* Structurally `WireSatellite` from `server/wire.ts`, restated rather than
|
||||
* imported for the same reason `SimRoute` is: the server must be able to build
|
||||
* one without three.js becoming one of its dependencies.
|
||||
*/
|
||||
export interface SatelliteElements {
|
||||
noradId: number;
|
||||
name: string;
|
||||
group: SatelliteGroup;
|
||||
line1: string;
|
||||
line2: string;
|
||||
}
|
||||
|
||||
/** Where one satellite is in the observer's sky, right now. */
|
||||
export interface SatelliteFix {
|
||||
noradId: number;
|
||||
name: string;
|
||||
group: SatelliteGroup;
|
||||
/** Radians clockwise from true north. */
|
||||
azimuth: number;
|
||||
/** Radians above the horizon. Only non-negative fixes are ever produced. */
|
||||
elevation: number;
|
||||
/** Observer to satellite, in kilometres. Straight-line, not ground track. */
|
||||
rangeKm: number;
|
||||
/**
|
||||
* How much of the sun's disc the earth is covering, from the satellite's point
|
||||
* of view. `0` is full sunlight, `1` is umbra, and the values in between are
|
||||
* the penumbra — which is where the fade at the end of a Starlink train comes
|
||||
* from, and is the reason this is a fraction rather than a boolean.
|
||||
*/
|
||||
shadow: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Observer height above the ellipsoid, in kilometres.
|
||||
*
|
||||
* Zero. The correction for a city at 50 m matters to a radar and not to a dot on
|
||||
* a dome — it moves a look angle by well under a hundredth of a degree — and
|
||||
* pretending otherwise would mean threading a ground elevation through here for
|
||||
* no visible change.
|
||||
*/
|
||||
const OBSERVER_HEIGHT_KM = 0;
|
||||
|
||||
/**
|
||||
* How much of a frame the rolling sweep may spend propagating.
|
||||
*
|
||||
* SGP4 costs roughly ten microseconds per object, so a six-thousand-object
|
||||
* catalogue is about sixty milliseconds — four frames' worth, in one lump, and
|
||||
* a visible hitch if it happens all at once. Doing it every frame is out of the
|
||||
* question and doing it on a timer just moves the hitch somewhere less
|
||||
* predictable.
|
||||
*
|
||||
* So the sweep is **time-budgeted and rolling**: each call propagates as many
|
||||
* objects as fit in this budget and remembers where it stopped, wrapping around
|
||||
* the catalogue continuously. Two milliseconds is under a seventh of a 60 Hz
|
||||
* frame, and it walks six thousand objects in about half a second.
|
||||
*
|
||||
* The staleness that buys is the thing to check, and it is negligible: half a
|
||||
* second at Starlink's ~0.8° per second of apparent motion overhead is under
|
||||
* half a degree of arc. Nobody can see that. An aircraft interpolated half a
|
||||
* second late would be visibly behind; a satellite is not, because the dome is
|
||||
* angular and the angles barely move.
|
||||
*
|
||||
* (`satellite.js` ships a WASM `BulkPropagator` that would make this a
|
||||
* non-question. It is not used here because it needs a binary loaded at runtime
|
||||
* and a fallback path for when that fails, which is a lot of machinery to buy
|
||||
* back two milliseconds a frame that are already accounted for.)
|
||||
*/
|
||||
const SWEEP_BUDGET_MS = 2;
|
||||
|
||||
/**
|
||||
* The observer's own coordinates, in the radians `ecfToLookAngles` wants.
|
||||
*
|
||||
* Built once. `geodeticToEcf` would recompute the same three numbers on every
|
||||
* call otherwise, several thousand times a second.
|
||||
*/
|
||||
interface Observer {
|
||||
longitude: number;
|
||||
latitude: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A catalogue of element sets, propagated continuously, reporting what is up.
|
||||
*
|
||||
* Deliberately shaped like `SimulatedFlights` and `AdsbFlights` — construct with
|
||||
* data, ask for the current state — but it is **not** a `FlightSource` and does
|
||||
* not implement that interface. A `FlightSource.poll()` returns positions on the
|
||||
* ground; this returns look angles on a dome, and collapsing the two into one
|
||||
* interface would mean a renderer that could not tell which space it was in.
|
||||
*/
|
||||
export class SatelliteCatalogue {
|
||||
private readonly records: { rec: SatRec; meta: SatelliteElements }[] = [];
|
||||
private readonly observer: Observer;
|
||||
|
||||
/** Latest fix per NORAD id. Entries are deleted as they set below the horizon. */
|
||||
private readonly current = new Map<number, SatelliteFix>();
|
||||
|
||||
/** Where the rolling sweep stopped last time. */
|
||||
private cursor = 0;
|
||||
|
||||
/**
|
||||
* How many objects the last full pass found above the horizon, for the panel.
|
||||
* Counted rather than derived from `current.size` so that a partial sweep does
|
||||
* not make the number jump around while it is still walking the catalogue.
|
||||
*/
|
||||
private lastPassVisible = 0;
|
||||
private passVisible = 0;
|
||||
|
||||
constructor(elements: SatelliteElements[], center: City["center"]) {
|
||||
for (const el of elements) {
|
||||
// A TLE this build cannot read is one satellite missing, never a throw:
|
||||
// the catalogue arrives over the network and one malformed line must not
|
||||
// take the layer down.
|
||||
//
|
||||
// `rec.error` is necessary and **not sufficient**, which is worth stating
|
||||
// because the obvious version of this check is wrong. `twoline2satrec`
|
||||
// reads fixed columns with `parseFloat` and does not validate: hand it two
|
||||
// lines of prose that merely start "1 " and "2 " and it returns `error: 0`
|
||||
// with `NaN` in the orbital elements. Those propagate to `NaN` positions,
|
||||
// which become `NaN` look angles, which land in the layer's vertex buffer
|
||||
// — and one `NaN` vertex is enough to make a `Points` draw call render
|
||||
// nothing at all. So the elements are checked for being numbers.
|
||||
const rec = twoline2satrec(el.line1, el.line2);
|
||||
if (rec.error !== 0) continue;
|
||||
if (!Number.isFinite(rec.no) || !Number.isFinite(rec.inclo) || !Number.isFinite(rec.ecco)) {
|
||||
continue;
|
||||
}
|
||||
this.records.push({ rec, meta: el });
|
||||
}
|
||||
|
||||
this.observer = {
|
||||
longitude: (center.lng * Math.PI) / 180,
|
||||
latitude: (center.lat * Math.PI) / 180,
|
||||
height: OBSERVER_HEIGHT_KM,
|
||||
};
|
||||
}
|
||||
|
||||
/** How many element sets this build could actually read. */
|
||||
get size(): number {
|
||||
return this.records.length;
|
||||
}
|
||||
|
||||
/** How many were above the horizon at the end of the last complete pass. */
|
||||
get visibleCount(): number {
|
||||
return this.lastPassVisible;
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance the rolling sweep and return everything currently above the horizon.
|
||||
*
|
||||
* `when` is passed in rather than read from the clock because godmode scrubs
|
||||
* time — the whole panel exists to put the scene at an arbitrary instant, and
|
||||
* a layer that quietly used `new Date()` would be the one thing on screen that
|
||||
* ignored the scrubber. It is also what makes a time-lapse capture possible at
|
||||
* all: `shots/` steps the clock rather than recording it.
|
||||
*/
|
||||
fixes(when: Date): SatelliteFix[] {
|
||||
if (this.records.length === 0) return [];
|
||||
|
||||
const gmst = gstime(when);
|
||||
// The sun moves a degree a day; computing its position once per sweep call
|
||||
// rather than once per satellite is free accuracy-wise and saves a few
|
||||
// thousand redundant evaluations.
|
||||
const sun = sunPos(jday(when));
|
||||
|
||||
const deadline = performance.now() + SWEEP_BUDGET_MS;
|
||||
let stepped = 0;
|
||||
|
||||
// At least one per call, so a machine so slow that `performance.now()` has
|
||||
// already passed the deadline still makes progress instead of freezing the
|
||||
// sky forever.
|
||||
do {
|
||||
const entry = this.records[this.cursor];
|
||||
if (entry !== undefined) {
|
||||
const fix = this.fixOne(entry.rec, entry.meta, when, gmst, sun.rsun);
|
||||
if (fix === null) this.current.delete(entry.meta.noradId);
|
||||
else {
|
||||
this.current.set(entry.meta.noradId, fix);
|
||||
this.passVisible += 1;
|
||||
}
|
||||
}
|
||||
|
||||
this.cursor += 1;
|
||||
if (this.cursor >= this.records.length) {
|
||||
// A pass completed: publish its count and start the next one's tally.
|
||||
this.cursor = 0;
|
||||
this.lastPassVisible = this.passVisible;
|
||||
this.passVisible = 0;
|
||||
}
|
||||
stepped += 1;
|
||||
} while (performance.now() < deadline && stepped < this.records.length);
|
||||
|
||||
return [...this.current.values()];
|
||||
}
|
||||
|
||||
/** One satellite, or `null` if it is below the horizon or will not propagate. */
|
||||
private fixOne(
|
||||
rec: SatRec,
|
||||
meta: SatelliteElements,
|
||||
when: Date,
|
||||
gmst: number,
|
||||
sunEciAU: { x: number; y: number; z: number },
|
||||
): SatelliteFix | null {
|
||||
// `propagate` returns null for a decayed object and for an element set it
|
||||
// cannot carry to this date — both of which are ordinary in a catalogue that
|
||||
// is hours old, and neither of which is this layer's problem.
|
||||
const state = propagate(rec, when);
|
||||
const eci = state?.position;
|
||||
if (eci === undefined || typeof eci === "boolean") return null;
|
||||
|
||||
const look = ecfToLookAngles(this.observer, eciToEcf(eci, gmst));
|
||||
// Below the horizon is the common case by a wide margin — a few hundred of
|
||||
// several thousand objects are up at any instant — so this returns before
|
||||
// the shadow calculation rather than after it.
|
||||
//
|
||||
// `NaN < 0` is false, so this comparison alone would let a degenerate
|
||||
// element set through to the vertex buffer. The constructor screens for that
|
||||
// and this is the belt: an object can also decay or go numerically unstable
|
||||
// partway through a session, long after it was admitted.
|
||||
if (!(look.elevation >= 0)) return null;
|
||||
|
||||
return {
|
||||
noradId: meta.noradId,
|
||||
name: meta.name,
|
||||
group: meta.group,
|
||||
azimuth: look.azimuth,
|
||||
elevation: look.elevation,
|
||||
rangeKm: look.rangeSat,
|
||||
shadow: shadowFraction(sunEciAU, eci),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Rendering ------------------------------------------------------------
|
||||
|
||||
export interface SatelliteLayer {
|
||||
group: THREE.Group;
|
||||
/** Redraw from a set of fixes. Cheap enough to call every frame, and is. */
|
||||
update(fixes: SatelliteFix[]): void;
|
||||
/**
|
||||
* Whether the layer draws at all. The catalogue keeps propagating either way —
|
||||
* see `setVisible` for why that is deliberate rather than wasteful.
|
||||
*/
|
||||
setVisible(visible: boolean): void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The dome's radius, as a fraction of the board's longest side.
|
||||
*
|
||||
* It has to clear the city — a dot inside the buildings would be occluded by
|
||||
* them, which is the one thing that is definitely wrong — and it has to stay
|
||||
* inside the camera's far plane, which `scene.ts` sets at three board spans. At
|
||||
* 1.2 the dome is outside every building and comfortably clear of the far plane
|
||||
* even with the camera pulled all the way back.
|
||||
*/
|
||||
const DOME_RADIUS_FACTOR = 1.2;
|
||||
|
||||
/**
|
||||
* How large a dot is drawn, in scene units, before attenuation.
|
||||
*
|
||||
* Everything on the dome is the same distance away, so `sizeAttenuation` cannot
|
||||
* separate near from far here the way it does for a starfield — it only makes
|
||||
* the whole layer shrink as the camera retreats, which is what keeps the sky
|
||||
* looking like a sky rather than like a fixed-size overlay pasted on the frame.
|
||||
*/
|
||||
const DOT_SIZE_FACTOR = 0.012;
|
||||
|
||||
/** Ceiling on dots, so the buffers are allocated once and never grow. */
|
||||
const MAX_DOTS = 4096;
|
||||
|
||||
/**
|
||||
* Colour per constellation.
|
||||
*
|
||||
* Starlink is the one that gets a colour of its own, for the same reason it gets
|
||||
* its own group in the wire type: it is what people are looking for. The rest are
|
||||
* deliberately close to white — a sky where every object is a different hue is a
|
||||
* chart, not a sky.
|
||||
*/
|
||||
const GROUP_COLORS: Record<SatelliteGroup, THREE.Color> = {
|
||||
starlink: new THREE.Color(0xbfd8ff),
|
||||
comms: new THREE.Color(0xd8e2ee),
|
||||
navigation: new THREE.Color(0xe6e0cf),
|
||||
station: new THREE.Color(0xfff0d0),
|
||||
weather: new THREE.Color(0xd4ecdf),
|
||||
other: new THREE.Color(0xdcdcdc),
|
||||
};
|
||||
|
||||
/**
|
||||
* Below this elevation a dot is faded out entirely.
|
||||
*
|
||||
* Not because the geometry is wrong down there but because it is *useless*: an
|
||||
* object one degree above the horizon is behind the hills, behind the buildings,
|
||||
* and behind more atmosphere than it can be seen through. Fading the last few
|
||||
* degrees also hides the pop that a hard cut-off produces every time something
|
||||
* rises, which on a busy constellation is several times a minute.
|
||||
*/
|
||||
const HORIZON_FADE_DEG = 8;
|
||||
|
||||
/**
|
||||
* Alpha for an object in full shadow, relative to a sunlit one.
|
||||
*
|
||||
* Not zero. A satellite in the earth's shadow is genuinely invisible to the eye,
|
||||
* and drawing nothing would be the physically honest choice — but this layer is
|
||||
* also a map of what is overhead, and a sky that empties itself at local midnight
|
||||
* reads as a broken feed rather than as a correct one. So an eclipsed object is
|
||||
* drawn faintly: present, obviously not lit, and clearly a different thing from
|
||||
* the one crossing above it in sunlight.
|
||||
*/
|
||||
const SHADOW_ALPHA = 0.16;
|
||||
|
||||
export function createSatelliteLayer(boardSpan: number): SatelliteLayer {
|
||||
const group = new THREE.Group();
|
||||
group.name = "satellites";
|
||||
|
||||
const radius = boardSpan * DOME_RADIUS_FACTOR;
|
||||
|
||||
const positions = new Float32Array(MAX_DOTS * 3);
|
||||
const colors = new Float32Array(MAX_DOTS * 4);
|
||||
// Held as locals rather than looked up through `geo.attributes` on every
|
||||
// update: the lookup is a string index into a dictionary typed as possibly
|
||||
// holding nothing, and the alternative to keeping the references is a
|
||||
// non-null assertion on the hot path twice a frame.
|
||||
const positionAttr = new THREE.BufferAttribute(positions, 3);
|
||||
const colorAttr = new THREE.BufferAttribute(colors, 4);
|
||||
const geo = new THREE.BufferGeometry();
|
||||
geo.setAttribute("position", positionAttr);
|
||||
geo.setAttribute("color", colorAttr);
|
||||
geo.setDrawRange(0, 0);
|
||||
|
||||
const material = new THREE.PointsMaterial({
|
||||
size: boardSpan * DOT_SIZE_FACTOR,
|
||||
sizeAttenuation: true,
|
||||
vertexColors: true,
|
||||
transparent: true,
|
||||
// Dots are drawn over the sky and over each other; letting them write depth
|
||||
// makes whichever drew first punch a hole in the ones behind, which on a
|
||||
// dense constellation is most of them.
|
||||
depthWrite: false,
|
||||
// The sky is the darkest thing in the frame at the hour this layer matters,
|
||||
// and additive blending is what makes a lit satellite read as a light source
|
||||
// rather than as a grey sticker.
|
||||
blending: THREE.AdditiveBlending,
|
||||
});
|
||||
|
||||
const points = new THREE.Points(geo, material);
|
||||
points.name = "satellite-dots";
|
||||
// The buffer is rewritten in scene space every update, so its bounding sphere
|
||||
// is permanently stale and culling on it would cull the whole sky.
|
||||
points.frustumCulled = false;
|
||||
group.add(points);
|
||||
|
||||
const scratch = new THREE.Color();
|
||||
|
||||
/**
|
||||
* The dome is centred on the board's origin and not on the camera.
|
||||
*
|
||||
* Centring it on the camera would keep every dot at a constant apparent size
|
||||
* and would be the right call for a true skybox. It is the wrong call here,
|
||||
* because this dome is *anchored to a place*: the look angles were computed for
|
||||
* the city centre, so a dot means "from the middle of this board, look there".
|
||||
* Following the camera would silently turn a measured direction into a
|
||||
* decoration.
|
||||
*/
|
||||
function place(fix: SatelliteFix, into: THREE.Vector3): void {
|
||||
const cosEl = Math.cos(fix.elevation);
|
||||
// Azimuth is clockwise from north, and scene north is −Z with +X east —
|
||||
// which is exactly `sin` on X and `−cos` on Z, with no sign fudge. The same
|
||||
// convention `world.project` uses; see `District.gridAngle` for the other
|
||||
// place this rule is stated.
|
||||
into.set(
|
||||
Math.sin(fix.azimuth) * cosEl * radius,
|
||||
Math.sin(fix.elevation) * radius,
|
||||
-Math.cos(fix.azimuth) * cosEl * radius,
|
||||
);
|
||||
}
|
||||
|
||||
const scratchVec = new THREE.Vector3();
|
||||
|
||||
function update(fixes: SatelliteFix[]): void {
|
||||
let n = 0;
|
||||
for (const fix of fixes) {
|
||||
if (n >= MAX_DOTS) break;
|
||||
|
||||
const elevationDeg = (fix.elevation * 180) / Math.PI;
|
||||
const horizon = Math.min(1, elevationDeg / HORIZON_FADE_DEG);
|
||||
if (horizon <= 0) continue;
|
||||
|
||||
place(fix, scratchVec);
|
||||
positions[n * 3] = scratchVec.x;
|
||||
positions[n * 3 + 1] = scratchVec.y;
|
||||
positions[n * 3 + 2] = scratchVec.z;
|
||||
|
||||
scratch.copy(GROUP_COLORS[fix.group] ?? GROUP_COLORS.other);
|
||||
const lit = 1 - fix.shadow;
|
||||
colors[n * 4] = scratch.r;
|
||||
colors[n * 4 + 1] = scratch.g;
|
||||
colors[n * 4 + 2] = scratch.b;
|
||||
colors[n * 4 + 3] = horizon * (SHADOW_ALPHA + (1 - SHADOW_ALPHA) * lit);
|
||||
n += 1;
|
||||
}
|
||||
|
||||
geo.setDrawRange(0, n);
|
||||
positionAttr.needsUpdate = true;
|
||||
colorAttr.needsUpdate = true;
|
||||
}
|
||||
|
||||
return {
|
||||
group,
|
||||
update,
|
||||
/**
|
||||
* Hiding the layer stops it drawing and does **not** stop the catalogue
|
||||
* propagating, which is the right way round: turning the sky back on should
|
||||
* show where things are now, not resume a sweep from wherever it was
|
||||
* abandoned and then crawl back into agreement with reality over the next
|
||||
* half second. The propagation is two milliseconds a frame; correctness on
|
||||
* re-entry is worth more than reclaiming it.
|
||||
*/
|
||||
setVisible(visible: boolean) {
|
||||
group.visible = visible;
|
||||
},
|
||||
dispose() {
|
||||
geo.dispose();
|
||||
material.dispose();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user