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>
282 lines
10 KiB
JavaScript
282 lines
10 KiB
JavaScript
// 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' };
|
||
}
|