GODSIGH/js/layers/satellites.js
jing 403884e480 wave2: adversarial-review fixes (9 confirmed findings)
Ran a 7-reviewer × per-finding-verifier workflow over the Wave 2 code; 9 of 13
raw findings survived adversarial verification. Fixes:

- quakes.js: refresh now UPDATES revised quakes, not just dedupes — a signature
  (mag|place|depth|time) per id detects USGS revisions and rebuilds that entity,
  so a quake revised M4.4→M5.2 no longer stays visually understated (SPEC2 §2
  "update, don't duplicate").
- aircraft.js: (a) poll() drops its result if the clock scrubbed off-live mid-
  fetch, so late live data never paints over the replay view; (b) re-enabling the
  aircraft layer while scrubbed now restores the replayed frame instead of
  leaving it hidden; (c) transient (non-404) history errors schedule a bounded
  retry so a paused clock doesn't wedge on an error frame.
- satellites.js: ground track centers on the VIEWER clock (not wall-clock now)
  so it stays under a scrubbed satellite; and it hides when the Satellites layer
  is toggled off (no stray dashed line).
- main.js: malformed camera hash with blank tokens (#c=,,,,) is now rejected
  instead of applying a bogus (0,0,0) camera — falls back to the default view.
- serve.py: history t-parse catches OverflowError (t=inf/1e999 → 400, not a
  crashed handler); stale-on-error now also covers upstream 5xx, not just 429.

Verified: t=inf/-inf/1e999/nan → 400; malformed hash → default Gulf camera;
ground track 427 km from the scrubbed dot (was ~2 orbits off) and hidden on
layer-off; aircraft re-enable during replay restores 6062 billboards; zero
console errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 17:16:24 +10:00

282 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Satellites layer (SPEC §6.2): SGP4-propagated EO/recon satellites as
// time-dynamic Cesium entities with orbit paths. Fetches TLEs from Celestrak
// via the same-origin proxy; one failed source never sinks the layer.
export default function create(ctx) {
const { viewer, CONFIG, lib, ui, Cesium, start, stop } = ctx;
const satellite = window.satellite;
const ds = new Cesium.CustomDataSource('satellites');
viewer.dataSources.add(ds);
// Ordered token → color lookup (first match wins).
const COLOR_TOKENS = ['GAOFEN', 'COSMOS', 'WORLDVIEW', 'LEGION', 'CAPELLA',
'PLEIADES', 'SENTINEL', 'ISS', 'CSS'];
// Live toggle state — also applied to entities created later during the
// chunked propagation loop, so toggling off mid-load stays honored.
let showSats = true;
let showPaths = true;
ui.addLayer('satellites', 'Satellites', true, (on) => {
showSats = on;
for (const e of ds.entities.values) {
if (e.point) e.point.show = on;
if (e.label) e.label.show = on;
}
if (!on) groundTrack.show = false; // don't leave a stray track when sats are hidden
});
ui.addLayer('sat-paths', 'Orbit paths', true, (on) => {
showPaths = on;
for (const e of ds.entities.values) {
if (e.path) e.path.show = on;
}
});
ui.setStatus('satellites', 'loading TLEs…', 'warn');
ui.setStatus('sat-paths', '', 'ok');
// ---- ground track on selection (Wave 2 polish) ----
// One reusable dashed polyline showing the selected sat's sub-satellite path
// (height 0) over ±half an orbit around now. Recomputed on demand from the
// satrec so we don't precompute 97 tracks up front.
const satMeta = new Map(); // entity.id → { satrec, hex, periodSec }
const groundTrack = ds.entities.add({
id: 'sat-ground-track',
show: false,
polyline: {
positions: [],
width: 1.5,
material: new Cesium.PolylineDashMaterialProperty({ color: Cesium.Color.WHITE }),
},
});
function groundTrackPositions(satrec, periodSec) {
const positions = [];
const half = periodSec / 2;
const step = Math.max(20, periodSec / 180); // ~one orbit in ~180 samples
// Center on the VIEWER clock, not wall-clock now, so a scrubbed timeline's
// track still passes under the (time-dynamic) satellite dot.
const nowJd = viewer.clock.currentTime.clone();
for (let dt = -half; dt <= half; dt += step) {
const date = Cesium.JulianDate.toDate(
Cesium.JulianDate.addSeconds(nowJd, dt, new Cesium.JulianDate()));
let pv;
try { pv = satellite.propagate(satrec, date); } catch { pv = null; }
if (!pv || !pv.position) continue;
const geo = satellite.eciToGeodetic(pv.position, satellite.gstime(date));
if (!isFinite(geo.longitude) || !isFinite(geo.latitude)) continue;
// Cartesian3 positions → the 3D polyline hugs the globe, no antimeridian streak.
positions.push(Cesium.Cartesian3.fromRadians(geo.longitude, geo.latitude, 0));
}
return positions;
}
// selectedEntityChanged fires with undefined on deselect — handle it.
viewer.selectedEntityChanged.addEventListener((sel) => {
const meta = sel && satMeta.get(sel.id);
if (!meta) { groundTrack.show = false; return; }
groundTrack.polyline.positions = groundTrackPositions(meta.satrec, meta.periodSec);
groundTrack.polyline.material =
new Cesium.PolylineDashMaterialProperty({ color: lib.cz(meta.hex) });
groundTrack.show = true;
});
// --- Parse raw TLE text into {name, l1, l2} triplets. ---
function parseTLE(text) {
const lines = text.split(/\r?\n/).map((s) => s.replace(/\s+$/, '')).filter((s) => s.length);
// Celestrak "NAME=" queries can return "No GP data found" as plain text:
// a valid TLE stream's first data line (line1) starts with '1'.
const out = [];
for (let i = 0; i + 2 < lines.length; i += 3) {
const name = lines[i];
const l1 = lines[i + 1];
const l2 = lines[i + 2];
if (!l1 || !l2 || l1[0] !== '1' || l2[0] !== '2') return out.length ? out : [];
out.push({ name: name.trim(), l1, l2 });
}
return out;
}
function passesSource(name, source) {
if (source.keepOnly) return source.keepOnly.includes(name);
if (source.filter) {
const up = name.toUpperCase();
return source.filter.some((tok) => up.includes(tok.toUpperCase()));
}
return true;
}
function colorForName(name) {
const up = name.toUpperCase();
for (const tok of COLOR_TOKENS) {
if (up.includes(tok)) return CONFIG.colors[tok] || CONFIG.colors.satDefault;
}
return CONFIG.colors.satDefault;
}
const yieldTick = () => new Promise((r) => setTimeout(r, 0));
async function run() {
const sources = CONFIG.satellites.sources;
const settled = await Promise.allSettled(
sources.map((s) => fetch(`${CONFIG.proxy.celestrak}?${s.q}`).then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.text();
}))
);
// Collect candidate sats, deduped by NORAD id, tagged with their source.
const seen = new Set();
const candidates = [];
let failedSources = 0;
settled.forEach((r, i) => {
const source = sources[i];
if (r.status === 'rejected') { failedSources++; return; }
const triplets = parseTLE(r.value);
let srcCount = 0;
for (const t of triplets) {
if (candidates.length >= CONFIG.satellites.cap) break;
if (source.max && srcCount >= source.max) break;
if (!passesSource(t.name, source)) continue;
let satrec;
try { satrec = satellite.twoline2satrec(t.l1, t.l2); } catch { continue; }
if (!satrec || satrec.error) continue;
const id = satrec.satnum;
if (seen.has(id)) continue;
seen.add(id);
candidates.push({ name: t.name, satrec, q: source.q });
srcCount++;
}
});
if (!candidates.length) {
const msg = failedSources ? `no data (${failedSources}/${sources.length} sources failed)` : 'no satellites';
ui.setStatus('satellites', msg, 'err');
return;
}
// --- Chunked SGP4 propagation so first paint isn't blocked. ---
const stepSec = CONFIG.satellites.sampleStepSec;
let built = 0;
const perSource = new Map(); // q → count
for (let i = 0; i < candidates.length; i += 10) {
const chunk = candidates.slice(i, i + 10);
for (const c of chunk) {
const entity = buildEntity(c, stepSec);
if (entity) {
ds.entities.add(entity);
built++;
perSource.set(c.q, (perSource.get(c.q) || 0) + 1);
}
}
ui.setStatus('satellites', `propagating ${Math.min(i + 10, candidates.length)}/${candidates.length}`, 'warn');
await yieldTick();
}
if (!built) {
ui.setStatus('satellites', 'all TLEs failed propagation', 'err');
return;
}
const parts = [...perSource.entries()].map(([q, n]) => `${n}×${q.split('&')[0]}`);
const summary = `${built} sats` + (failedSources ? ` (${failedSources} src failed)` : '');
ui.setStatus('satellites', summary, failedSources ? 'warn' : 'ok');
ui.setStatus('sat-paths', parts.join(', '), 'ok');
}
function buildEntity(c, stepSec) {
const { name, satrec, q } = c;
const posProp = new Cesium.SampledPositionProperty();
let valid = 0;
let t = start.clone();
while (Cesium.JulianDate.lessThanOrEquals(t, stop)) {
const date = Cesium.JulianDate.toDate(t);
let pv;
try { pv = satellite.propagate(satrec, date); } catch { pv = null; }
if (pv && pv.position) {
const p = pv.position;
if (isFinite(p.x) && isFinite(p.y) && isFinite(p.z)) {
const gmst = satellite.gstime(date);
const geo = satellite.eciToGeodetic(p, gmst);
if (isFinite(geo.longitude) && isFinite(geo.latitude) && isFinite(geo.height)) {
posProp.addSample(t.clone(), Cesium.Cartesian3.fromRadians(
geo.longitude, geo.latitude, geo.height * 1000));
valid++;
}
}
}
t = Cesium.JulianDate.addSeconds(t, stepSec, new Cesium.JulianDate());
}
if (valid < 50) return null;
posProp.setInterpolationOptions({
interpolationDegree: 5,
interpolationAlgorithm: Cesium.LagrangePolynomialApproximation,
});
const hex = colorForName(name);
const color = lib.cz(hex);
const periodSec = (2 * Math.PI / satrec.no) * 60; // satrec.no is rad/min
// Orbital element derivations for the InfoBox.
const incDeg = Cesium.Math.toDegrees(satrec.inclo);
const nRadS = satrec.no / 60; // rad/s
const a = Math.pow(398600.4418 / (nRadS * nRadS), 1 / 3); // km
const e = satrec.ecco;
const apogee = a * (1 + e) - 6378.137;
const perigee = a * (1 - e) - 6378.137;
const description =
`<table class="cesium-infoBox-defaultTable"><tbody>` +
`<tr><th>NORAD</th><td>${satrec.satnum}</td></tr>` +
`<tr><th>Source</th><td>${q}</td></tr>` +
`<tr><th>Period</th><td>${(periodSec / 60).toFixed(1)} min</td></tr>` +
`<tr><th>Inclination</th><td>${incDeg.toFixed(2)}°</td></tr>` +
`<tr><th>Apogee</th><td>${apogee.toFixed(0)} km</td></tr>` +
`<tr><th>Perigee</th><td>${perigee.toFixed(0)} km</td></tr>` +
`</tbody></table>`;
const entity = new Cesium.Entity({
name,
position: posProp,
point: {
pixelSize: 6,
color,
show: showSats,
},
label: {
text: name,
show: showSats,
font: '10px ui-monospace',
pixelOffset: new Cesium.Cartesian2(0, -12),
translucencyByDistance: new Cesium.NearFarScalar(1.5e6, 1.0, 1.5e7, 0.0),
disableDepthTestDistance: Number.POSITIVE_INFINITY,
},
path: {
width: 1,
show: showPaths,
resolution: 120,
leadTime: periodSec / 2,
trailTime: periodSec / 2,
material: new Cesium.PolylineGlowMaterialProperty({
glowPower: 0.2,
color: color.withAlpha(0.35),
}),
},
description,
});
// Stash what the ground-track overlay needs to recompute on selection.
satMeta.set(entity.id, { satrec, hex, periodSec });
return entity;
}
run().catch((err) => {
console.error('[godsigh] satellites layer failed:', err);
ui.setStatus('satellites', `error: ${err.message || err}`, 'err');
});
return { id: 'satellites' };
}