GODSIGH/js/layers/satellites.js
type-two 2a51bcad2f fix: markers bled through the globe (far-side dots didn't track rotation)
Every marker used disableDepthTestDistance: Infinity, so points/billboards on
the FAR side of the Earth drew on top of the near hemisphere instead of being
occluded — reading as 'dots stuck, not moving with the globe' when you rotate.
Changed to 50000 (50km) across all layers: the globe now occludes far-side
markers, while you can still zoom right up to one without it clipping into the
surface. Also made the billboard layers (aircraft/military/adsb/radius) explicit
for consistent behavior.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-21 10:19:21 +10:00

282 lines
10 KiB
JavaScript
Raw Permalink 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: 50000,
},
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' };
}