// 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 = `
| NORAD | ${satrec.satnum} |
|---|---|
| Source | ${q} |
| Period | ${(periodSec / 60).toFixed(1)} min |
| Inclination | ${incDeg.toFixed(2)}° |
| Apogee | ${apogee.toFixed(0)} km |
| Perigee | ${perigee.toFixed(0)} km |