GODSIGH/js/layers/satellites.js
jing 2e061b2cdf wave2 phase 5: polish pack (military, ground tracks, URL state, screenshots)
1. Military callsigns (aircraft.js): callsigns matching CONFIG.aircraft.
   militaryPrefixes tint red regardless of altitude band and bump larger. New
   "Military filter" HUD row (default off) shows only military aircraft when on;
   status "N military of M". Pick overlay shows Military/Civil class.
2. Satellite ground track (satellites.js): selecting a satellite draws its
   sub-satellite path (height 0, ±half orbit around now) as a dashed polyline in
   the sat's colour; deselect hides it; one track at a time. Recomputed on
   demand from the satrec (no upfront precompute of 97 tracks).
3. Shareable URL state (main.js + ui.js): camera / mode / layer toggles / clock
   offset serialize to location.hash (replaceState, 1 s cadence, only on change)
   and are restored on boot; malformed hashes ignored silently.
4. Screenshot endpoint (serve.py): dev-only POST snap?name= writes a base64 PNG
   body to docs/<name>.png (name sanitized to [a-z0-9-], can't escape docs/).
   Captured docs/screenshot-data.png + screenshot-photo.png (render() called
   synchronously before toDataURL — the preserveDrawingBuffer pitfall).

README rewritten for Wave 2: quakes/fires layer rows (marked REAL), replay,
shared cache, shareable links, ground tracks, military, screenshots, updated
architecture + roadmap.

Verified in browser: 11 military of 6062 + filter; ground track 180 pts on
select / hidden on deselect; URL round-trips camera+mode+layers+clock; both
screenshots written as valid 1280x720 PNGs; subpath audit clean.

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

279 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;
}
});
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
const nowJd = Cesium.JulianDate.now();
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' };
}