mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 07:42:23 +00:00
Move satellites every frame between SGP4 samples
Satellite points were propagated once a second and held still in between, so while the world clock plays faster than real time they jumped: at 1 min/s, about 45 px once a second at globe scale. Each SGP4 call now keeps a sample of the satellite's inertial position, velocity, gravity and its rate of change (src/layers/satellites/motion.js). Every frame each shown point is reckoned from its sample to the world time and turned into the Earth-fixed frame by the sidereal angle, so points glide at any play speed. Core satellites still take a fresh sample every second, and at once when the world time is 30 s from it (fast play, a seek), which keeps every point within about 10 m of SGP4. Starlink extras take their first sample as they load and are resampled round-robin, more per frame the faster time plays (up to 2,000 a frame); a point more than two minutes from its sample waits for the next one. Hidden points are moved when shown again, and the tracked satellite keeps its per-frame path. Measured at globe scale: at 1 min/s points move on every frame, 0.4 px a frame and at most 1.4 px (before: once a second, 45 px). With 11,880 satellites (Starlink on) the scene holds 60 fps, frames 16.7 ms median and 19.7 ms p95; reckoning costs 0.02 ms a frame for 1,342 points.
This commit is contained in:
1 parent
e4708a815d
commit
e31de29ccc
11 files changed
+466
-60
No files matched your search
@@ -4940,8 +4940,8 @@ down | auth-failed` (plus the unchanged `missing-key`/`unsupported`) with
|
||||
> Cesium entity carries no label graphic.
|
||||
> - **Phase 5 ISS and tracked-satellite labels:** The ISS path and large red
|
||||
> point remain native, while persistent `ISS` text is a one-entry moving
|
||||
> ambient-label host source. Its getter reads the already-propagated point
|
||||
> cache, preserving the 1 Hz fleet epoch and eliminating the former second ISS
|
||||
> ambient-label host source. Its getter reads the point's own position, which
|
||||
> moves every frame, eliminating the former second ISS
|
||||
> propagation. Satellite tracking continues to publish exactly one protected
|
||||
> tracked-lane card through `gevLabelModel` and `_trackedDisplayCached`; the
|
||||
> tracked entity is point-only. Tracking ISS suppresses the ambient entry and
|
||||
@@ -7002,7 +7002,7 @@ silently demoting every later lookup for the session.
|
||||
`activeCameraCardEnabled` presentation option can publish the retained
|
||||
protected-card path. Disable tears the tier down completely.
|
||||
Coverage/viewshed semantics unchanged.
|
||||
- **Satellites**: orbit rings rotate about Earth's Z by ΔGMST every ~1s (exact inertial→ECEF compensation; no SGP4 re-runs); the tracked satellite propagates per frame with one shared epoch for dot/label/camera. Verified: ISS holds <1km perpendicular to its ring while tracked. Pass prediction (`src/data/satellitePass.js`; `getNextSatellitePass`, `getNextIssPass`) scans at 20s, bisects rise/set to ~0.2s, fits the peak with a parabola, and marks a pass visible when the satellite is outside a cylindrical Earth shadow while the observer's Sun is at or below −6°. `next_iss_pass` preserves its geometric-pass ordering and appends visibility metadata. General `next_satellite_pass` accepts a loaded NORAD ID or unambiguous name, searches 24 hours and optionally requires estimated visibility. These estimates do not account for weather or satellite brightness.
|
||||
- **Satellites**: every point moves every frame. Each satellite's SGP4 sample (position, velocity, gravity and its rate of change; `src/layers/satellites/motion.js`) is reckoned forward or back to the world time and turned into the Earth-fixed frame, so points glide at any play speed instead of stepping once a second. Core satellites take a fresh sample every second, or as soon as the world time is 30 s from their sample (fast play, a seek), which keeps every point within about 10 m of SGP4; Starlink extras are sampled round-robin, more per frame the faster time plays, and a point more than two minutes from its sample waits for the next one. Hidden points are not moved until shown. Orbit rings rotate about Earth's Z by ΔGMST every ~1s (exact inertial→ECEF compensation; no SGP4 re-runs); the tracked satellite propagates per frame with one shared epoch for dot/label/camera. Verified: ISS holds <1km perpendicular to its ring while tracked. Pass prediction (`src/data/satellitePass.js`; `getNextSatellitePass`, `getNextIssPass`) scans at 20s, bisects rise/set to ~0.2s, fits the peak with a parabola, and marks a pass visible when the satellite is outside a cylindrical Earth shadow while the observer's Sun is at or below −6°. `next_iss_pass` preserves its geometric-pass ordering and appends visibility metadata. General `next_satellite_pass` accepts a loaded NORAD ID or unambiguous name, searches 24 hours and optionally requires estimated visibility. These estimates do not account for weather or satellite brightness.
|
||||
- **Space Missions (30d)**: recent launches render as bounded shared-host, horizon-occluded mission markers using Launch Library 2 v2.3 detailed records. Enabling the layer selects the unified right-side Context panel's Space Missions mode and enables the required satellite layer. Before applying its temporary dense/hidden Satellite mode, Space Missions snapshots the complete standalone Satellite parameter set and exact enabled-layer set. Disabling Space Missions from either Context or the left Data Layers rail restores those parameters and the exact prior enabled state, so a Satellite layer that was already on stays on while a mode-owned dependency returns off; enabling Satellites by itself remains independent. Selecting a mission isolates its launch-to-orbit transfer and dashed satellite orbit, fills that same panel with navigation/details, and animates a small phase-colored marker along the exact displayed Cartesian samples. Marker hit testing drill-picks through photorealistic tiles so the depth-test-free tactical dot remains reliably selectable; its text is non-interactive shared-host presentation. The selected pad is the camera's zoom pivot: the overview remains centered on its launch site, wheel zoom approaches that site instead of drifting elsewhere, and camera pitch progressively changes from global nadir to an oblique close 3D view. Its protected shared-host callout remains visible and expands to include both mission and launch-site names. `FOCUS` flies directly to a 12 km oblique frame around the selected launch site and retains the same anchored zoom/orbit behavior. `REPLAY ASCENT` resets the selected marker at the pad, frames it from an oblique third-person angle, and follows it through the mission-specific compressed ascent directly into one orbital lap at the default `1×` rate. A live `0.25×`–`4×` slider changes ascent and orbit playback speed; adjusting it mid-replay preserves the current path position and historical mission timestamp. Re-entry/recovery cannot be inserted into replay. At orbit insertion the camera smoothly pulls back over the first fifth of the orbital replay and pitches to a globe-scale nadir view while continuing to target the moving replay point. Replay Cancel, mission navigation, deselection, layer disable, and data refresh all release camera ownership. Reconstructed paths are one continuous 128-sample geodetic curve: horizontal departure begins near zero while altitude rises quickly, then the climb progressively bends toward insertion without the former hard 120 km corner. Unmatched orbit fallbacks are smooth planar inclined rings rather than longitude/latitude ground-track curves. The panel lists disclosed payload names, types, operators/manufacturers, mass, multiplicity, and destination when supplied; an empty LL2 payload collection is shown as `CLASSIFIED / MULTI-PAYLOAD`. Launcher, spacecraft, and recoverable payload stages appear in a compact stage table with serial/flight/reuse details, recovery outcome/type, destination, and final coordinates when those records exist; an empty recovery collection omits the section. Stage recoveries with confirmed coordinates use those coordinates; return-to-launch-site records use the pad; downrange-only records receive an explicitly labeled estimated endpoint along the ascent azimuth. Available endpoints render as static 2 px dashed descent/recovery paths with a fixed final-position dot and an estimated atmospheric-interface segment when applicable. The ascent is geodetically densified above the ellipsoid toward the orbit's nearest insertion point, then rendered with `ArcType.NONE`, so it neither cuts through Earth nor separates from the marker. Because LL2 does not normally supply continuous ascent telemetry, pad-to-insertion paths without upstream trajectory samples are labeled `RECONSTRUCTED ESTIMATE` / `ASCENT ESTIMATE`; only supplied trajectory samples receive the replay wording. Selected-orbit framing fits the complete ring, rear-side linework uses normal scene depth occlusion, and current distance, speed, and callout data come only from a launch-year-validated satellite match. Both the loaded-catalog and active-TLE lookups read the launch year from the TLE international designator through one parser (`src/sources/tle.js`); a blank or malformed designator is an unknown year, which skips the year check rather than rejecting the match. Speed is the magnitude of the SGP4 inertial velocity vector at the same propagation epoch as position, displayed in km/s with km/h available as hover detail. Unavailable operator, site, launch-time, orbit, current-altitude, and speed values omit their detail rows instead of reserving panel space with placeholders. Newly launched payloads absent from the core operational groups use CelesTrak's cached active TLE feed as a lookup-only fallback; weak constellation-name matches are rejected. The replay callout maps compressed animation progress onto Launch Library's mission-relative timeline,Line truncated
|
||||
When no mission is selected, the Context panel presents a scrollable newest-first roster of every launch in the rolling window, including the smaller 5 px operator-colored marker, provider, and launch date. Hovering or keyboard-focusing a row shows four compact cyan corner brackets on both that roster row and its corresponding globe dot, rotates the globe at the current zoom to center it, and gives its label declutter priority without selecting it; the globe label remains unbracketed. Selecting a roster row invokes the same mission isolation and full-orbit framing as clicking its globe marker. The replay vehicle is one screen-space SVG/CSS HUD overlay rather than separate Cesium billboard, label, and reticle graphics. It is hidden during ordinary Focus and manual close views, where the standard selected launch-site label remains visible, and exists only while ascent replay is active. Its fixed pixel scale is shared by ascent and insertion, so camera range never resizes the rocket on screen before the phase boundary. Generic Launch Library pad names are reduced to their identifying suffix, and replay timestamps use a cyan state title over unprefixed white UTC date/time values. `REPLAY ASCENT` holds the unframed cyan/white rocket at the pad for a real-time `T−10` countdown, transitions through `LIFTOFF`, and attaches six tapered cyan/white ellipse waves directly below it from liftoff through insertion to convey thrust without adding scene geometry. While replay is active, the single start button is replaced by compact Play, Pause, and Cancel icon controls. Pause freezes countdown or mission time, vehicle/stage positions, camera target, labels, and thrust-wave animation; Play resumes from that exact frame, and Cancel releases replay camera ownership. The rocket and thrust group rotates from the path's live screen-space tangent, so its nose follows the visible ascent curve while the adjacent text remains upright. The camera begins as a close oblique launch chase, then smoothly widens between roughly 120 and 420 km vehicle altitude into a higher oblique context view that keeps the moving rocket targeted while exposing the ascent bend and orbit connection. At insertion the rocket/thrust glyph is replaced by the fixed-size cyan orbit dot, which the camera follows through the existing globe-scale orbit pullback. The chase camera limits per-frame yaw changes across heading wraps so it cannot abruptly cross in front of the vehicle and make ascent read in reverse; the replay-speed slider affects mission playback but not countdown duration.
|
||||
Mission world text has no native `LabelGraphics`: overview launch markers publish at most 48 ambient candidates for a 24-winner budget, while selecting a mission clears that overview source and publishes its launch-site callout, stage re-entry annotations, live/estimated payload-position readout, and orbit annotation as protected selected-lane entries. The source retains the exact former strings and colors. Static anchors reuse the Cartesian values used to build their mission geometry; the moving payload entry reads the layer's per-frame live-position cache; catalog-backed orbit annotation positions are cached in the same one-second matrix update that realigns the orbit primitive. Keyhole edge fade, horizon culling, final collision placement, and UI exclusion are owned by the shared host. Deselect restores the bounded overview, and refresh, disable, and destroy replace or clear both mission sources.
|
||||
@@ -8197,7 +8197,7 @@ The Space Missions roster prioritizes data-rich records using available mission,
|
||||
When no live catalog track is available, the mission view marks the approximate orbital ring as `PROJECTED ORBIT` in purple and renders the ascent-to-insertion transfer in green; catalog-backed satellite orbits remain cyan.
|
||||
|
||||
The replay vehicle is a smaller solid cyan silhouette without the former orange flame; its initial pad anchor uses photoreal terrain, globe height, or launch elevation fallback so it remains above the surface during tile loading. Replay begins at a close launch-complex range so pad detail remains visible before the camera widens into the ascent context view. Its initial camera heading is perpendicular to the ascent/orbit direction for a profile view, then eases into tangent tracking. Small screen-space reprojection changes are damped for the animated marker on both ascent and orbit, while large camera or phase changes snap to the authoritative path position.
|
||||
Space Missions keeps the Satellite layer available for catalog/TLE matching but suppresses its standalone fleet points and orbit rings. While those visuals are hidden, their per-frame dense propagation, one-second core point-buffer rewrite, and one-second orbit-matrix rotation are suspended; selected mission telemetry continues to propagate independently. The selected mission's live or estimated satellite marker uses fractional wall-clock time, so it moves continuously rather than creating a once-per-second position discontinuity and one-frame photoreal globe LOD pulse.
|
||||
Space Missions keeps the Satellite layer available for catalog/TLE matching but suppresses its standalone fleet points and orbit rings. While those visuals are hidden, their sampling, per-frame point moves, and one-second orbit-matrix rotation are suspended; selected mission telemetry continues to propagate independently. The selected mission's live or estimated satellite marker uses fractional wall-clock time, so it moves continuously rather than creating a once-per-second position discontinuity and one-frame photoreal globe LOD pulse.
|
||||
|
||||
During ascent replay, the camera, Cesium callbacks, and HTML vehicle overlay share one replay sample per rendered frame. The tracked overlay is projected directly from that shared position instead of applying a second screen-space lag filter, preventing the vehicle and globe from repeatedly advancing and snapping back.
|
||||
|
||||
|
||||
@@ -456,6 +456,7 @@
|
||||
"src/layers/satellites/ingestion.js",
|
||||
"src/layers/satellites/index.js",
|
||||
"src/layers/satellites/orbits.js",
|
||||
"src/layers/satellites/motion.js",
|
||||
"src/layers/launches/lifecycle.js",
|
||||
"src/layers/launches/controls.js",
|
||||
"src/layers/launches/panel.js",
|
||||
@@ -483,6 +484,8 @@
|
||||
"src/data/rocketLaunches.js",
|
||||
"src/layers/satellites/source.test.mjs",
|
||||
"src/layers/satellites/stale.test.mjs",
|
||||
"src/layers/satellites/motion.test.mjs",
|
||||
"src/layers/satellites/reckon.test.mjs",
|
||||
"src/layers/launches/source.test.mjs",
|
||||
"src/layers/launches/ingestion.test.mjs",
|
||||
"src/layers/launches/failedEntry.test.mjs",
|
||||
|
||||
@@ -1854,6 +1854,7 @@
|
||||
"src/layers/satellites/interaction.js",
|
||||
"src/layers/satellites/labels.js",
|
||||
"src/layers/satellites/lifecycle.js",
|
||||
"src/layers/satellites/motion.js",
|
||||
"src/layers/satellites/orbits.js",
|
||||
"src/layers/satellites/records.js",
|
||||
"src/layers/satellites/policy.js",
|
||||
@@ -2972,6 +2973,7 @@
|
||||
"src/layers/satellites/interaction.js",
|
||||
"src/layers/satellites/labels.js",
|
||||
"src/layers/satellites/lifecycle.js",
|
||||
"src/layers/satellites/motion.js",
|
||||
"src/layers/satellites/orbits.js",
|
||||
"src/sources/tle.js",
|
||||
"src/layers/satellites/records.js",
|
||||
@@ -4159,6 +4161,7 @@
|
||||
"src/layers/satellites/interaction.js",
|
||||
"src/layers/satellites/labels.js",
|
||||
"src/layers/satellites/lifecycle.js",
|
||||
"src/layers/satellites/motion.js",
|
||||
"src/layers/satellites/orbits.js",
|
||||
"src/sources/tle.js",
|
||||
"src/layers/satellites/records.js",
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { twoline2satrec } from 'satellite.js';
|
||||
import { gstime, twoline2satrec } from 'satellite.js';
|
||||
import { noradIdFromSatnum, tleLineLaunchYear } from '../../sources/tle.js';
|
||||
import { reckonEarthFixed, sampleOrbit } from './motion.js';
|
||||
import {
|
||||
DENSE_REFRESH_FRAMES,
|
||||
DENSE_GROUP_PATH,
|
||||
DENSE_SAMPLES_PER_FRAME_MAX,
|
||||
POINT_STYLES,
|
||||
DENSE_CREATE_CHUNK,
|
||||
ORBIT_SAMPLE_HORIZON_MS,
|
||||
} from './policy.js';
|
||||
|
||||
export function createCatalog({ state: layerState, services, parts, source }) {
|
||||
@@ -18,37 +21,42 @@ export function createCatalog({ state: layerState, services, parts, source }) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-propagate a small per-frame slice of the dense extras (round-robin).
|
||||
* Budget: the full dense set completes one pass every ~DENSE_REFRESH_FRAMES
|
||||
* frames (~5s at 60fps ≈ 1/5 of the core cadence), so per-frame cost stays
|
||||
* ~35 propagations (~0.1 ms) even with 10K+ Starlink sats — spreading the
|
||||
* work per frame avoids the once-per-second spike a tick-sized chunk
|
||||
* (~2K props ≈ 4ms) would cause.
|
||||
* Sample a small per-frame slice of the dense extras (round-robin); every
|
||||
* point is then reckoned from its sample each frame (rendering.js
|
||||
* _reckonPoints). At normal speed the full dense set is sampled once every
|
||||
* ~DENSE_REFRESH_FRAMES frames (~5s at 60fps), so per-frame cost stays ~35
|
||||
* propagations (~0.1 ms) even with 10K+ Starlink sats — spreading the work
|
||||
* per frame avoids the once-per-second spike a tick-sized chunk (~2K props
|
||||
* ≈ 4ms) would cause. While time plays fast, or after a seek, the slice
|
||||
* grows so each sample stays within ORBIT_SAMPLE_HORIZON_MS of the world
|
||||
* time, up to DENSE_SAMPLES_PER_FRAME_MAX a frame.
|
||||
* @param {number} worldStepMs How far the world time moved since the last
|
||||
* frame (NaN on the first).
|
||||
*/
|
||||
|
||||
function _propagateDenseChunk() {
|
||||
if (layerState._denseIds.length === 0) return;
|
||||
const perFrame = Math.max(
|
||||
1,
|
||||
Math.ceil(layerState._denseIds.length / DENSE_REFRESH_FRAMES),
|
||||
function _propagateDenseChunk(worldStepMs) {
|
||||
const count = layerState._denseIds.length;
|
||||
if (count === 0) return;
|
||||
const share = Math.max(
|
||||
1 / DENSE_REFRESH_FRAMES,
|
||||
Number.isFinite(worldStepMs)
|
||||
? Math.abs(worldStepMs) / ORBIT_SAMPLE_HORIZON_MS
|
||||
: 1,
|
||||
);
|
||||
const now = new Date(layerState.now());
|
||||
const perFrame = Math.min(
|
||||
count,
|
||||
DENSE_SAMPLES_PER_FRAME_MAX,
|
||||
Math.max(1, Math.ceil(count * share)),
|
||||
);
|
||||
const nowMs = layerState.now();
|
||||
for (let i = 0; i < perFrame; i++) {
|
||||
if (layerState._denseCursor >= layerState._denseIds.length)
|
||||
layerState._denseCursor = 0;
|
||||
if (layerState._denseCursor >= count) layerState._denseCursor = 0;
|
||||
const noradId = layerState._denseIds[layerState._denseCursor++];
|
||||
if (noradId === layerState._trackedNorad) continue; // per-frame tracked path owns it
|
||||
const sat = layerState._catalog.get(noradId);
|
||||
const point = layerState._points.get(noradId);
|
||||
if (!sat || !point) continue;
|
||||
const pos = parts.orbits.propagatePosition(sat.satrec, now);
|
||||
if (pos) {
|
||||
point.position = Cesium.Cartesian3.fromDegrees(
|
||||
pos.longitude,
|
||||
pos.latitude,
|
||||
pos.altitude,
|
||||
);
|
||||
}
|
||||
if (!sat) continue;
|
||||
const sample = sampleOrbit(sat.satrec, nowMs, sat.orbitSample);
|
||||
if (sample) sat.orbitSample = sample;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,7 +105,8 @@ export function createCatalog({ state: layerState, services, parts, source }) {
|
||||
|
||||
const entries = parts.orbits.parseTLE(text);
|
||||
const style = POINT_STYLES.dense;
|
||||
const now = new Date(layerState.now());
|
||||
const nowMs = layerState.now();
|
||||
const gmst = gstime(new Date(nowMs));
|
||||
let added = 0;
|
||||
|
||||
for (let start = 0; start < entries.length; start += DENSE_CREATE_CHUNK) {
|
||||
@@ -116,8 +125,9 @@ export function createCatalog({ state: layerState, services, parts, source }) {
|
||||
if (!satrec || satrec.error !== 0) continue;
|
||||
const noradId = noradIdFromSatnum(satrec.satnum);
|
||||
if (layerState._catalog.has(noradId)) continue; // core catalog keeps priority
|
||||
const pos = parts.orbits.propagatePosition(satrec, now);
|
||||
if (!pos) continue;
|
||||
// Its first sample places it now and moves it from then on.
|
||||
const orbitSample = sampleOrbit(satrec, nowMs);
|
||||
if (!orbitSample) continue;
|
||||
|
||||
layerState._catalog.set(noradId, {
|
||||
name: entry.name,
|
||||
@@ -131,12 +141,14 @@ export function createCatalog({ state: layerState, services, parts, source }) {
|
||||
ucs: null,
|
||||
satcat: null,
|
||||
sensor: null,
|
||||
orbitSample,
|
||||
});
|
||||
const point = layerState._pointCollection.add({
|
||||
position: Cesium.Cartesian3.fromDegrees(
|
||||
pos.longitude,
|
||||
pos.latitude,
|
||||
pos.altitude,
|
||||
position: reckonEarthFixed(
|
||||
orbitSample,
|
||||
nowMs,
|
||||
gmst,
|
||||
new Cesium.Cartesian3(),
|
||||
),
|
||||
pixelSize: style.pixelSize,
|
||||
color: style.color,
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { propagate } from 'satellite.js';
|
||||
|
||||
/** The Earth's gravitational parameter, km³/s², as SGP4's WGS72 constants. */
|
||||
const MU_KM3_S2 = 398600.8;
|
||||
|
||||
/** Floats in one orbit sample: its time, then position, velocity,
|
||||
* acceleration and jerk. */
|
||||
export const ORBIT_SAMPLE_LENGTH = 13;
|
||||
|
||||
/**
|
||||
* One SGP4 state of a satellite for dead reckoning: its inertial (TEME)
|
||||
* position and velocity at `atMs`, with the two-body acceleration and its
|
||||
* rate of change (jerk) there.
|
||||
* Layout: [atMs, rx, ry, rz (km), vx, vy, vz (km/s), ax, ay, az (km/s²),
|
||||
* jx, jy, jz (km/s³)].
|
||||
* @param {object} satrec
|
||||
* @param {number} atMs Epoch milliseconds.
|
||||
* @param {Float64Array} [out]
|
||||
* @returns {Float64Array|null} Null when SGP4 has no state for that time.
|
||||
*/
|
||||
export function sampleOrbit(
|
||||
satrec,
|
||||
atMs,
|
||||
out = new Float64Array(ORBIT_SAMPLE_LENGTH),
|
||||
) {
|
||||
let state;
|
||||
try {
|
||||
state = propagate(satrec, new Date(atMs));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const p = state?.position;
|
||||
const v = state?.velocity;
|
||||
if (!p || typeof p === 'boolean' || !v || typeof v === 'boolean') return null;
|
||||
const r = Math.hypot(p.x, p.y, p.z);
|
||||
if (!(r > 0) || !Number.isFinite(v.x + v.y + v.z)) return null;
|
||||
const k = -MU_KM3_S2 / (r * r * r);
|
||||
// d/dt of -μ r/|r|³: -μ (v/|r|³ - 3 (r·v) r/|r|⁵).
|
||||
const radial = (3 * (p.x * v.x + p.y * v.y + p.z * v.z)) / (r * r);
|
||||
out[0] = atMs;
|
||||
out[1] = p.x;
|
||||
out[2] = p.y;
|
||||
out[3] = p.z;
|
||||
out[4] = v.x;
|
||||
out[5] = v.y;
|
||||
out[6] = v.z;
|
||||
out[7] = k * p.x;
|
||||
out[8] = k * p.y;
|
||||
out[9] = k * p.z;
|
||||
out[10] = k * (v.x - radial * p.x);
|
||||
out[11] = k * (v.y - radial * p.y);
|
||||
out[12] = k * (v.z - radial * p.z);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a satellite is at `atMs`, reckoned from its sample along the inertial
|
||||
* path (position, velocity, gravity and its change) and turned into the
|
||||
* Earth-fixed frame by the Greenwich sidereal angle at that time. Over half a
|
||||
* minute of world time the reckoned point stays within metres of SGP4.
|
||||
* @param {Float64Array} sample From sampleOrbit.
|
||||
* @param {number} atMs Epoch milliseconds.
|
||||
* @param {number} gmst Greenwich mean sidereal angle at `atMs`, radians.
|
||||
* @param {{x: number, y: number, z: number}} out Receives Earth-fixed metres.
|
||||
* @returns {{x: number, y: number, z: number}} `out`.
|
||||
*/
|
||||
export function reckonEarthFixed(sample, atMs, gmst, out) {
|
||||
const dt = (atMs - sample[0]) / 1000;
|
||||
const half = 0.5 * dt * dt;
|
||||
const sixth = (half * dt) / 3;
|
||||
const x = sample[1] + sample[4] * dt + sample[7] * half + sample[10] * sixth;
|
||||
const y = sample[2] + sample[5] * dt + sample[8] * half + sample[11] * sixth;
|
||||
const z = sample[3] + sample[6] * dt + sample[9] * half + sample[12] * sixth;
|
||||
const cos = Math.cos(gmst);
|
||||
const sin = Math.sin(gmst);
|
||||
out.x = (x * cos + y * sin) * 1000;
|
||||
out.y = (-x * sin + y * cos) * 1000;
|
||||
out.z = z * 1000;
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
// src/layers/satellites/motion.test.mjs — a satellite's point reckoned from
|
||||
// one SGP4 sample stays on SGP4's track between samples.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { gstime, twoline2satrec } from 'satellite.js';
|
||||
import { createOrbits } from './orbits.js';
|
||||
import { reckonEarthFixed, sampleOrbit } from './motion.js';
|
||||
|
||||
const ISS = twoline2satrec(
|
||||
'1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927',
|
||||
'2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537',
|
||||
);
|
||||
const AT_MS = Date.UTC(2008, 8, 20, 12, 30, 0);
|
||||
const orbits = createOrbits({ state: {}, services: {}, parts: {} });
|
||||
|
||||
/** Where the layer has always drawn a satellite: SGP4, through geodetic. */
|
||||
function drawnBySgp4(atMs) {
|
||||
const pos = orbits.propagatePosition(ISS, new Date(atMs));
|
||||
return Cesium.Cartesian3.fromDegrees(
|
||||
pos.longitude,
|
||||
pos.latitude,
|
||||
pos.altitude,
|
||||
);
|
||||
}
|
||||
|
||||
function reckoned(sample, atMs) {
|
||||
return reckonEarthFixed(
|
||||
sample,
|
||||
atMs,
|
||||
gstime(new Date(atMs)),
|
||||
new Cesium.Cartesian3(),
|
||||
);
|
||||
}
|
||||
|
||||
test('at its own time the reckoned point is where SGP4 draws it', () => {
|
||||
const sample = sampleOrbit(ISS, AT_MS);
|
||||
const gap = Cesium.Cartesian3.distance(
|
||||
reckoned(sample, AT_MS),
|
||||
drawnBySgp4(AT_MS),
|
||||
);
|
||||
assert.ok(gap < 1, `${gap} m`);
|
||||
});
|
||||
|
||||
test('between samples the point keeps to the SGP4 track', () => {
|
||||
const sample = sampleOrbit(ISS, AT_MS);
|
||||
for (const [seconds, withinM] of [
|
||||
[0.016, 1],
|
||||
[1, 2],
|
||||
[10, 2],
|
||||
[30, 10],
|
||||
[60, 50],
|
||||
]) {
|
||||
const atMs = AT_MS + seconds * 1000;
|
||||
const gap = Cesium.Cartesian3.distance(
|
||||
reckoned(sample, atMs),
|
||||
drawnBySgp4(atMs),
|
||||
);
|
||||
assert.ok(gap < withinM, `${seconds} s: ${gap} m`);
|
||||
}
|
||||
});
|
||||
|
||||
test('the point moves every frame, at orbital speed', () => {
|
||||
const sample = sampleOrbit(ISS, AT_MS);
|
||||
const a = reckoned(sample, AT_MS + 500);
|
||||
const b = reckoned(sample, AT_MS + 516);
|
||||
const speed = Cesium.Cartesian3.distance(a, b) / 0.016;
|
||||
// ISS ground-relative speed: about 7.2–7.7 km/s.
|
||||
assert.ok(speed > 7000 && speed < 8000, `${speed} m/s`);
|
||||
});
|
||||
|
||||
test('reckoning backwards in time works as forwards', () => {
|
||||
const sample = sampleOrbit(ISS, AT_MS);
|
||||
const atMs = AT_MS - 30_000;
|
||||
const gap = Cesium.Cartesian3.distance(
|
||||
reckoned(sample, atMs),
|
||||
drawnBySgp4(atMs),
|
||||
);
|
||||
assert.ok(gap < 10, `${gap} m`);
|
||||
});
|
||||
|
||||
test('an element set SGP4 cannot propagate has no sample', () => {
|
||||
const decayed = { ...ISS, error: 0 };
|
||||
decayed.ecco = 2; // not an orbit
|
||||
assert.equal(sampleOrbit(decayed, AT_MS), null);
|
||||
});
|
||||
@@ -31,7 +31,16 @@ export const ISS_OVERLAY_SOURCE_OPTIONS = Object.freeze({
|
||||
export const ORBIT_PATH_STEPS = 180;
|
||||
// points per orbital path
|
||||
|
||||
/** How often every core satellite takes a fresh SGP4 sample (wall ms). */
|
||||
export const POSITION_UPDATE_MS = 1000;
|
||||
/**
|
||||
* Between SGP4 samples every point is reckoned each frame from its last one
|
||||
* (motion.js). A sample this far from the world time is taken again, so a
|
||||
* point is never more than about 10 m off SGP4 however fast time plays.
|
||||
*/
|
||||
export const ORBIT_SAMPLE_HORIZON_MS = 30_000;
|
||||
/** Beyond this from its sample a point is left where it is until sampled again. */
|
||||
export const ORBIT_RECKON_LIMIT_MS = 120_000;
|
||||
/** How far either side of now SGP4 on current element sets stays meaningful. */
|
||||
export const SATELLITE_COVERAGE_MS = 14 * 24 * 3_600_000;
|
||||
|
||||
@@ -41,7 +50,6 @@ export const SATELLITE_COVERAGE_MS = 14 * 24 * 3_600_000;
|
||||
* drifts a few degrees a day.
|
||||
*/
|
||||
export const ORBIT_REBAKE_MS = 30 * 60_000;
|
||||
// re-propagate every 1s (SGP4 is smooth at this rate)
|
||||
|
||||
export const RING_ROTATION_MS = 1000;
|
||||
// re-align baked orbit rings to current GMST every 1s
|
||||
@@ -80,6 +88,14 @@ export const DENSE_GROUP_PATH = 'starlink';
|
||||
export const DENSE_REFRESH_FRAMES = 300;
|
||||
// full dense pass spread over ~300 frames (~5s @ 60fps)
|
||||
|
||||
/**
|
||||
* The most dense extras sampled in one frame while time plays fast (~4 ms).
|
||||
* Past about 10 minutes of world time a second the full dense set no longer
|
||||
* fits within ORBIT_RECKON_LIMIT_MS, and its points move only as they are
|
||||
* sampled.
|
||||
*/
|
||||
export const DENSE_SAMPLES_PER_FRAME_MAX = 2000;
|
||||
|
||||
export const DENSE_CREATE_CHUNK = 1500;
|
||||
// satrec builds per macro-task while loading
|
||||
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
// src/layers/satellites/reckon.test.mjs — every frame each shown point moves
|
||||
// to where its satellite is at the world time; samples are taken again when
|
||||
// time has moved far from them, and dense extras are sampled faster the
|
||||
// faster time plays.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import * as Cesium from 'cesium';
|
||||
import { twoline2satrec } from 'satellite.js';
|
||||
import { createCatalog } from './catalog.js';
|
||||
import { createOrbits } from './orbits.js';
|
||||
import { createRendering } from './rendering.js';
|
||||
import { sampleOrbit } from './motion.js';
|
||||
import {
|
||||
DENSE_REFRESH_FRAMES,
|
||||
DENSE_SAMPLES_PER_FRAME_MAX,
|
||||
ORBIT_RECKON_LIMIT_MS,
|
||||
ORBIT_SAMPLE_HORIZON_MS,
|
||||
} from './policy.js';
|
||||
|
||||
const ISS = twoline2satrec(
|
||||
'1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927',
|
||||
'2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537',
|
||||
);
|
||||
const AT_MS = Date.UTC(2008, 8, 20, 12, 30, 0);
|
||||
const orbits = createOrbits({ state: {}, services: {}, parts: {} });
|
||||
|
||||
/** A point primitive that counts position writes. */
|
||||
function point(show = true) {
|
||||
let position = new Cesium.Cartesian3();
|
||||
return {
|
||||
show,
|
||||
writes: 0,
|
||||
get position() {
|
||||
return position;
|
||||
},
|
||||
set position(value) {
|
||||
this.writes++;
|
||||
position = Cesium.Cartesian3.clone(value);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function scene(entries, { tracked = null } = {}) {
|
||||
const state = {
|
||||
_points: new Map(),
|
||||
_catalog: new Map(),
|
||||
_trackedNorad: tracked,
|
||||
_denseIds: [],
|
||||
_denseCursor: 0,
|
||||
};
|
||||
for (const [id, entry] of Object.entries(entries)) {
|
||||
state._points.set(id, entry.point);
|
||||
state._catalog.set(id, { satrec: ISS, group: 'core', ...entry.sat });
|
||||
}
|
||||
const rendering = createRendering({
|
||||
state,
|
||||
services: { focus: {} },
|
||||
parts: {},
|
||||
});
|
||||
return { state, rendering };
|
||||
}
|
||||
|
||||
function sgp4At(atMs) {
|
||||
const pos = orbits.propagatePosition(ISS, new Date(atMs));
|
||||
return Cesium.Cartesian3.fromDegrees(
|
||||
pos.longitude,
|
||||
pos.latitude,
|
||||
pos.altitude,
|
||||
);
|
||||
}
|
||||
|
||||
test('a shown point moves to its satellite at the world time; hidden and tracked ones do not', () => {
|
||||
const shown = point();
|
||||
const hidden = point(false);
|
||||
const tracked = point();
|
||||
const { rendering } = scene(
|
||||
{
|
||||
shown: { point: shown },
|
||||
hidden: { point: hidden },
|
||||
tracked: { point: tracked },
|
||||
},
|
||||
{ tracked: 'tracked' },
|
||||
);
|
||||
rendering._reckonPoints(AT_MS);
|
||||
assert.ok(Cesium.Cartesian3.distance(shown.position, sgp4At(AT_MS)) < 1);
|
||||
assert.equal(hidden.writes, 0);
|
||||
assert.equal(tracked.writes, 0);
|
||||
// The same time again (the clock paused): nothing is written.
|
||||
rendering._reckonPoints(AT_MS);
|
||||
assert.equal(shown.writes, 1);
|
||||
// Shown while paused, a hidden point moves at once.
|
||||
hidden.show = true;
|
||||
rendering._reckonPoints(AT_MS);
|
||||
assert.equal(hidden.writes, 1);
|
||||
});
|
||||
|
||||
test('between samples the point keeps to the track; far from its sample, a core satellite is sampled again', () => {
|
||||
const p = point();
|
||||
const { state, rendering } = scene({ iss: { point: p } });
|
||||
rendering._reckonPoints(AT_MS);
|
||||
const sample = state._catalog.get('iss').orbitSample;
|
||||
assert.equal(sample[0], AT_MS);
|
||||
// Within the horizon the same sample moves it on.
|
||||
const soon = AT_MS + ORBIT_SAMPLE_HORIZON_MS / 2;
|
||||
rendering._reckonPoints(soon);
|
||||
assert.equal(state._catalog.get('iss').orbitSample[0], AT_MS);
|
||||
assert.ok(Cesium.Cartesian3.distance(p.position, sgp4At(soon)) < 5);
|
||||
// A seek an hour back samples it again there.
|
||||
const back = AT_MS - 3_600_000;
|
||||
rendering._reckonPoints(back);
|
||||
assert.equal(state._catalog.get('iss').orbitSample[0], back);
|
||||
assert.ok(Cesium.Cartesian3.distance(p.position, sgp4At(back)) < 1);
|
||||
});
|
||||
|
||||
test('a dense extra is left for its round-robin, and stays put once too far from its sample', () => {
|
||||
const p = point();
|
||||
const { rendering } = scene({
|
||||
starlink: {
|
||||
point: p,
|
||||
sat: { group: 'dense', orbitSample: sampleOrbit(ISS, AT_MS) },
|
||||
},
|
||||
});
|
||||
rendering._reckonPoints(AT_MS + ORBIT_SAMPLE_HORIZON_MS * 2);
|
||||
assert.equal(p.writes, 1, 'reckoned from its own sample');
|
||||
rendering._reckonPoints(AT_MS + ORBIT_RECKON_LIMIT_MS + 1);
|
||||
assert.equal(p.writes, 1, 'too far: left where it is');
|
||||
});
|
||||
|
||||
test('the dense slice grows with how fast the world time moves, up to its cap', () => {
|
||||
const sampled = (count, worldStepMs) => {
|
||||
const state = {
|
||||
_denseIds: [],
|
||||
_denseCursor: 0,
|
||||
_catalog: new Map(),
|
||||
_trackedNorad: null,
|
||||
now: () => AT_MS,
|
||||
};
|
||||
for (let i = 0; i < count; i++) {
|
||||
state._denseIds.push(String(i));
|
||||
state._catalog.set(String(i), { satrec: ISS, group: 'dense' });
|
||||
}
|
||||
createCatalog({
|
||||
state,
|
||||
services: {},
|
||||
parts: {},
|
||||
source: {},
|
||||
})._propagateDenseChunk(worldStepMs);
|
||||
return [...state._catalog.values()].filter((sat) => sat.orbitSample).length;
|
||||
};
|
||||
// Real time: the set over DENSE_REFRESH_FRAMES frames.
|
||||
assert.equal(sampled(3000, 16), Math.ceil(3000 / DENSE_REFRESH_FRAMES));
|
||||
// A tenth of the horizon a frame: a tenth of the set.
|
||||
assert.equal(sampled(3000, ORBIT_SAMPLE_HORIZON_MS / 10), 300);
|
||||
// A seek, or the first frame: as many as the cap allows.
|
||||
assert.equal(sampled(3000, Number.NaN), DENSE_SAMPLES_PER_FRAME_MAX);
|
||||
assert.equal(sampled(1000, -ORBIT_SAMPLE_HORIZON_MS * 5), 1000);
|
||||
});
|
||||
@@ -1,12 +1,18 @@
|
||||
import * as Cesium from 'cesium';
|
||||
import { gstime } from 'satellite.js';
|
||||
import { reckonEarthFixed, sampleOrbit } from './motion.js';
|
||||
import {
|
||||
ISS_NORAD,
|
||||
ORBIT_REBAKE_MS,
|
||||
ORBIT_RECKON_LIMIT_MS,
|
||||
ORBIT_SAMPLE_HORIZON_MS,
|
||||
POSITION_UPDATE_MS,
|
||||
RING_ROTATION_MS,
|
||||
} from './policy.js';
|
||||
|
||||
/** Scratch for a point's reckoned position. */
|
||||
const _reckoned = new Cesium.Cartesian3();
|
||||
|
||||
export function createRendering({
|
||||
state: layerState,
|
||||
services,
|
||||
@@ -158,40 +164,73 @@ export function createRendering({
|
||||
}
|
||||
|
||||
/**
|
||||
* Propagate all CORE satellite positions and update point primitives.
|
||||
* (About 1,300 core satellites: ~840 measured at ≈ 1.6 ms/pass, so ≈ 2.5 ms,
|
||||
* fine at the 1s/200ms cadence.) Dense extras are
|
||||
* excluded: they refresh on the round-robin budget in _propagateDenseChunk.
|
||||
* Take a fresh SGP4 sample of every CORE satellite (motion.js); the points
|
||||
* move from these each frame in _reckonPoints. (About 1,300 core
|
||||
* satellites: ~840 measured at ≈ 1.6 ms/pass, so ≈ 2.5 ms, fine at the
|
||||
* 1s/200ms cadence.) Dense extras are excluded: they are sampled on the
|
||||
* round-robin budget in _propagateDenseChunk.
|
||||
* @returns {number} How many satellites were sampled.
|
||||
*/
|
||||
|
||||
function _propagateAll() {
|
||||
const now = new Date(layerState.now());
|
||||
const nowMs = layerState.now();
|
||||
let updated = 0;
|
||||
|
||||
for (const [noradId, sat] of layerState._catalog) {
|
||||
for (const sat of layerState._catalog.values()) {
|
||||
if (sat.group === 'dense') continue;
|
||||
const pos = parts.orbits.propagatePosition(sat.satrec, now);
|
||||
if (!pos) continue;
|
||||
|
||||
const cartesian = Cesium.Cartesian3.fromDegrees(
|
||||
pos.longitude,
|
||||
pos.latitude,
|
||||
pos.altitude,
|
||||
);
|
||||
const point = layerState._points.get(noradId);
|
||||
if (point) {
|
||||
point.position = cartesian;
|
||||
updated++;
|
||||
}
|
||||
if (_sample(sat, nowMs)) updated++;
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sample one satellite's orbit at `atMs` into its reused sample.
|
||||
* @returns {boolean} Whether SGP4 had a state for that time.
|
||||
*/
|
||||
|
||||
function _sample(sat, atMs) {
|
||||
const sample = sampleOrbit(sat.satrec, atMs, sat.orbitSample);
|
||||
if (!sample) return false;
|
||||
sat.orbitSample = sample;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move every shown point to where its satellite is at the world time,
|
||||
* reckoned from its last sample, so points glide every frame between
|
||||
* samples instead of stepping once a second. A core sample more than
|
||||
* ORBIT_SAMPLE_HORIZON_MS from the world time (time playing fast, or a
|
||||
* seek) is taken again first; dense extras are resampled by their own
|
||||
* round-robin. A hidden point is moved when it is shown again, and the
|
||||
* tracked satellite is placed by its per-frame path.
|
||||
* @param {number} nowMs World time, epoch ms.
|
||||
*/
|
||||
|
||||
function _reckonPoints(nowMs) {
|
||||
const gmst = gstime(new Date(nowMs));
|
||||
for (const [noradId, point] of layerState._points) {
|
||||
if (!point.show || noradId === layerState._trackedNorad) continue;
|
||||
const sat = layerState._catalog.get(noradId);
|
||||
if (!sat || sat.reckonedAtMs === nowMs) continue;
|
||||
let sample = sat.orbitSample;
|
||||
if (
|
||||
sat.group !== 'dense' &&
|
||||
(!sample || Math.abs(nowMs - sample[0]) > ORBIT_SAMPLE_HORIZON_MS)
|
||||
)
|
||||
sample = _sample(sat, nowMs) ? sat.orbitSample : null;
|
||||
if (!sample || Math.abs(nowMs - sample[0]) > ORBIT_RECKON_LIMIT_MS)
|
||||
continue;
|
||||
point.position = reckonEarthFixed(sample, nowMs, gmst, _reckoned); // setter clones
|
||||
sat.reckonedAtMs = nowMs;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared scene.preRender tick (single definition for init + enable):
|
||||
* - core fleet propagation at 200ms-tracked / 1s-idle cadence,
|
||||
* - dense extras on a per-frame round-robin budget,
|
||||
* - core fleet SGP4 samples at 200ms-tracked / 1s-idle cadence,
|
||||
* - dense extras sampled on a per-frame round-robin budget,
|
||||
* - every point reckoned from its sample each frame (motion.js),
|
||||
* - tracked satellite's point primitive per frame (WS-D2),
|
||||
* - orbit ring GMST re-alignment every ~1s (WS-D1).
|
||||
*/
|
||||
@@ -213,7 +252,12 @@ export function createRendering({
|
||||
layerState._lastPropagation = now;
|
||||
}
|
||||
|
||||
if (layerState._params.showPoints) parts.catalog._propagateDenseChunk();
|
||||
if (layerState._params.showPoints) {
|
||||
const worldMs = layerState.now();
|
||||
parts.catalog._propagateDenseChunk(worldMs - layerState._lastReckonMs);
|
||||
layerState._lastReckonMs = worldMs;
|
||||
_reckonPoints(worldMs);
|
||||
}
|
||||
|
||||
// Keep the tracked dot on the per-frame epoch shared with label + camera —
|
||||
// runs after _propagateAll so the per-frame sample wins over the 200ms one.
|
||||
@@ -339,6 +383,7 @@ export function createRendering({
|
||||
_updateOrbitPathRotations,
|
||||
_hideOrbitPath,
|
||||
_propagateAll,
|
||||
_reckonPoints,
|
||||
_preRenderTick,
|
||||
_updatePointFocus,
|
||||
applySatellitePointFocusDeemphasis,
|
||||
|
||||
@@ -64,6 +64,10 @@ export function createState({ services }) {
|
||||
|
||||
state._lastPropagation = 0;
|
||||
|
||||
// The world time of the last frame that reckoned the points (motion.js),
|
||||
// NaN before the first.
|
||||
state._lastReckonMs = NaN;
|
||||
|
||||
state._lastRingRotation = 0;
|
||||
|
||||
state._lastFocusUpdate = 0;
|
||||
|
||||
@@ -429,7 +429,7 @@ export function createTracking({ state: layerState, services, parts, source }) {
|
||||
|
||||
// Tracked entity position propagates per evaluation through the per-frame
|
||||
// cache (WS-D2) — dot, host readout, and camera share one SGP4 epoch per frame.
|
||||
// Falls back to the point primitive's 1s-throttled position if SGP4 fails.
|
||||
// Falls back to the point primitive's reckoned position if SGP4 fails.
|
||||
const positionProperty = new Cesium.CallbackProperty(() => {
|
||||
const pos = _getTrackedFramePosition();
|
||||
return pos ? layerState._trackedFrameCartesian : point.position;
|
||||
|
||||
Reference in new issue
Block a user