GODSIGH/js/layers/satellites.js
jing 6c7b945848 phases 2-5: satellite, aircraft, ship, infra & event layers
- satellites: Celestrak TLE -> SGP4 -> time-dynamic entities w/ glowing orbit
  paths; per-source cap + reordering so COSMOS/ISS aren't starved by the 72-sat
  Gaofen fleet (now ~97 sats, diverse); chunked propagation, orbital InfoBox
- aircraft: global OpenSky ADS-B -> BillboardCollection primitive (6444 planes
  verified), altitude-banded, quota/scrub/visibility-gated polling, click overlay
- ships: [DEMO] fleet through Hormuz incl. toll-route carrier, dark-vessel AIS
  gap, Fujairah idle cluster; optional aisstream.io live path
- infra: choke-point rings, Petroline + Habshan-Fujairah pipelines, 9 facilities
- events: [DEMO] time-anchored pulsing markers that appear as the slider crosses
- authored + adversarially hardened via parallel agent workflow

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 12:25:51 +10:00

232 lines
8.0 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');
// --- 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>`;
return 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,
});
}
run().catch((err) => {
console.error('[godsigh] satellites layer failed:', err);
ui.setStatus('satellites', `error: ${err.message || err}`, 'err');
});
return { id: 'satellites' };
}