GODSIGH/js/solarsystem.js
type-two 7ccb2214f9 feat: Wave 6 OSINT4ALL sweep — TfL JamCams, InciWeb incidents, Radio Garden
Three new layers from the OSINT4ALL directory triage:

- jamcams (registry): ~800 live London traffic cameras via TfL open API
  (CORS-open, keyless). Click a cam dot -> live snapshot in the InfoBox,
  5-min cache-busted refresh. New collapsed 'Regional - London' category.
- inciweb (registry): named US wildfire incidents from InciWeb RSS. New
  spec.parse hook in the geojson-layer factory lets XML feeds supply their
  own parser; coordinates regex'd out of DMS text in <description>.
- radio (bespoke): ~12k live radio stations from Radio Garden pinned to
  their cities (PointPrimitiveCollection). Click -> station list + live
  audio player (audio streams direct; JSON via new allowlisted serve.py
  proxy/rg/ with 12h/1h caches, SSRF-tested). Keeps playing while you pan.

Deploy note: prod nginx needs a location for /godsigh/proxy/rg/ before
the radio layer works live (mirror of serve.py RG_PATH_RE allowlist).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 13:57:26 +10:00

768 lines
40 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.

// Solar System mode (Wave 4) — "Travel to…". Leaves the Earth OSINT globe and
// renders a heliocentric orrery: the Sun + 8 planets as spheres textured with
// real (CC BY 4.0, self-hosted) imagery, at their TRUE current positions
// (Schlyter series), with orbits. Click a destination to fly there.
//
// Cesium's globe is Earth-at-origin, so the mode HIDES the globe + all Earth
// layers and places the Sun at the scene origin; exiting restores everything.
import { FACTS, MISSIONS, MOONS } from './ssdata.js';
export default function initSolarSystem(ctx) {
const { viewer, Cesium, lib } = ctx;
const scene = viewer.scene;
// ---- astronomy (Schlyter low-precision, heliocentric ecliptic AU) ----
const D2R = Math.PI / 180;
const rev = (x) => x - Math.floor(x / 360) * 360;
const ELEM = {
Mercury: (d) => ({ N: 48.3313 + 3.24587e-5 * d, i: 7.0047 + 5.00e-8 * d, w: 29.1241 + 1.01444e-5 * d, a: 0.387098, e: 0.205635 + 5.59e-10 * d, M: 168.6562 + 4.0923344368 * d }),
Venus: (d) => ({ N: 76.6799 + 2.46590e-5 * d, i: 3.3946 + 2.75e-8 * d, w: 54.8910 + 1.38374e-5 * d, a: 0.723330, e: 0.006773 - 1.302e-9 * d, M: 48.0052 + 1.6021302244 * d }),
Mars: (d) => ({ N: 49.5574 + 2.11081e-5 * d, i: 1.8497 - 1.78e-8 * d, w: 286.5016 + 2.92961e-5 * d, a: 1.523688, e: 0.093405 + 2.516e-9 * d, M: 18.6021 + 0.5240207766 * d }),
Jupiter: (d) => ({ N: 100.4542 + 2.76854e-5 * d, i: 1.3030 - 1.557e-7 * d, w: 273.8777 + 1.64505e-5 * d, a: 5.20256, e: 0.048498 + 4.469e-9 * d, M: 19.8950 + 0.0830853001 * d }),
Saturn: (d) => ({ N: 113.6634 + 2.38980e-5 * d, i: 2.4886 - 1.081e-7 * d, w: 339.3939 + 2.97661e-5 * d, a: 9.55475, e: 0.055546 - 9.499e-9 * d, M: 316.9670 + 0.0334442282 * d }),
Uranus: (d) => ({ N: 74.0005 + 1.3978e-5 * d, i: 0.7733 + 1.9e-8 * d, w: 96.6612 + 3.0565e-5 * d, a: 19.18171 - 1.55e-8 * d, e: 0.047318 + 7.45e-9 * d, M: 142.5905 + 0.011725806 * d }),
Neptune: (d) => ({ N: 131.7806 + 3.0173e-5 * d, i: 1.7700 - 2.55e-7 * d, w: 272.8461 - 6.027e-6 * d, a: 30.05826 + 3.313e-8 * d, e: 0.008606 + 2.15e-9 * d, M: 260.2471 + 0.005995147 * d }),
};
const dayNumber = (t) => Cesium.JulianDate.toDate(t).getTime() / 86400000 - 10956.0;
function helio(name, d) {
const el = ELEM[name](d);
const N = el.N * D2R, i = el.i * D2R, w = el.w * D2R, e = el.e, M = rev(el.M) * D2R;
let E = M + e * Math.sin(M) * (1 + e * Math.cos(M));
for (let k = 0; k < 6; k++) E = E - (E - e * Math.sin(E) - M) / (1 - e * Math.cos(E));
const xv = el.a * (Math.cos(E) - e), yv = el.a * Math.sqrt(1 - e * e) * Math.sin(E);
const v = Math.atan2(yv, xv), r = Math.hypot(xv, yv), u = v + w;
return {
x: r * (Math.cos(N) * Math.cos(u) - Math.sin(N) * Math.sin(u) * Math.cos(i)),
y: r * (Math.sin(N) * Math.cos(u) + Math.cos(N) * Math.sin(u) * Math.cos(i)),
z: r * Math.sin(u) * Math.sin(i),
};
}
function earthHelio(d) {
const w = 282.9404 + 4.70935e-5 * d, e = 0.016709 - 1.151e-9 * d, M = rev(356.0470 + 0.9856002585 * d) * D2R;
let E = M + e * Math.sin(M) * (1 + e * Math.cos(M));
const xv = Math.cos(E) - e, yv = Math.sqrt(1 - e * e) * Math.sin(E);
const v = Math.atan2(yv, xv), r = Math.hypot(xv, yv), lon = v + w * D2R;
return { x: -r * Math.cos(lon), y: -r * Math.sin(lon), z: 0 }; // Earth = (Sun geocentric)
}
// ---- scaling (tuned for a readable orrery, not true scale) ----
const AU_UNIT = 4.0e7; // ~Earth orbit display radius
const distScale = (au) => AU_UNIT * Math.pow(au, 0.72); // gentle outer compression
const BODY_UNIT = 3.4e6; // exaggerated so bodies read at orrery scale
const bodyRadius = (km, isSun) => isSun ? 1.6e7 : BODY_UNIT * Math.pow(km / 6371, 0.4);
function scaledPos(vec) { // ecliptic AU {x,y,z} → scene Cartesian3
const au = Math.hypot(vec.x, vec.y, vec.z) || 1e-6;
const s = distScale(au) / au;
return new Cesium.Cartesian3(vec.x * s, vec.y * s, vec.z * s);
}
// Real 3D spheres textured with self-hosted CC BY 4.0 / NASA imagery. `obliq` =
// axial tilt in degrees (Venus ~177° reads upside-down; Uranus ~98° lies on its
// side) — applied as a constant orientation so each planet leans correctly.
const BODIES = [
{ key: 'sun', name: '☉ Sun', tex: 'textures/2k_sun.jpg', km: 696000, sun: true, color: '#ffcf6b' },
{ key: 'mercury', name: '☿ Mercury', tex: 'textures/hd_mercury.jpg', km: 2439.7, color: '#9a8d80', obliq: 0.03 },
{ key: 'venus', name: '♀ Venus', tex: 'textures/hd_venus.jpg', km: 6051.8, color: '#d8b56a', obliq: 177.4 },
{ key: 'earth', name: '🜨 Earth', tex: 'textures/hd_earth.jpg', km: 6371, color: '#5b8fd0', obliq: 23.44 },
{ key: 'mars', name: '♂ Mars', tex: 'textures/hd_mars.jpg', km: 3389.5, color: '#c1502a', obliq: 25.19 },
{ key: 'jupiter', name: '♃ Jupiter', tex: 'textures/hd_jupiter.jpg', km: 69911, color: '#d9c9a3', obliq: 3.13 },
{ key: 'saturn', name: '♄ Saturn', tex: 'textures/hd_saturn.jpg', km: 58232, color: '#e6dbb0', obliq: 26.73, ring: 'textures/hd_saturn_ring.png' },
{ key: 'uranus', name: '⛢ Uranus', tex: 'textures/hd_uranus.jpg', km: 25362, color: '#9fdce4', obliq: 97.77 },
{ key: 'neptune', name: '♆ Neptune', tex: 'textures/2k_neptune.jpg', km: 24622, color: '#456fe0', obliq: 28.32 },
];
const ds = new Cesium.CustomDataSource('solarsystem');
ds.show = false;
viewer.dataSources.add(ds);
const bodyEntities = {}; // key → primary entity (sun billboard; planets' sphere)
const sphereEntities = {}; // key → textured ellipsoid (frozen position, visible up close)
const dotEntities = {}; // key → colored dot + label (animates along the orbit)
const ringEntities = {}; // key → Saturn ring plane
// NOTE: moons MUST live in this same datasource. Adding a second
// CustomDataSource breaks the planets' static ellipsoid rendering outright
// (DataSourceDisplay shares primitive collections — the same class of bug that
// originally stopped this whole mode from drawing). They also must be BILLBOARDS,
// not ellipsoids: an animated ellipsoid enters Cesium's async geometry pipeline
// and stalls the planets' build, so the planet silently never renders.
// Moons render as PRIMITIVES, not entities. Any extra entity in this datasource
// starves Cesium's async static-geometry build and the textured PLANET silently
// stops rendering (verified: remove the moons and the planet reappears). Primitive
// collections bypass the entity/DataSourceDisplay pipeline entirely — the same
// trick the aircraft layers use — so the planets' geometry batch is untouched.
// They're also only built for the planet you're actually visiting.
const moonBB = new Cesium.BillboardCollection();
const moonLBL = new Cesium.LabelCollection();
const moonPL = new Cesium.PolylineCollection();
moonBB.show = moonLBL.show = moonPL.show = false;
scene.primitives.add(moonBB);
scene.primitives.add(moonLBL);
scene.primitives.add(moonPL);
// A single shaded white sphere, drawn once and tinted per moon via billboard
// colour. Billboards render immediately and — crucially — never enter Cesium's
// async geometry pipeline, so animated moons can't stall the planets' build.
// With sizeInMeters the width/height are WORLD units, so they scale exactly like
// real 3D bodies (true relative size) while always facing the camera.
let moonDiscCanvas = null;
function moonDisc() {
if (moonDiscCanvas) return moonDiscCanvas;
const S = 64, R = S / 2, cv = document.createElement('canvas');
cv.width = cv.height = S;
const g = cv.getContext('2d'), img = g.createImageData(S, S), d = img.data;
const lx = -0.4, ly = 0.45, lz = 0.8; // key light, upper-left-front
for (let y = 0; y < S; y++) for (let x = 0; x < S; x++) {
const nx = (x - R) / R, ny = (R - y) / R, r2 = nx * nx + ny * ny, i = (y * S + x) * 4;
if (r2 > 1) { d[i + 3] = 0; continue; }
const nz = Math.sqrt(1 - r2);
const v = Math.min(255, 255 * (Math.max(0.16, nx * lx + ny * ly + nz * lz) * 0.85 + 0.15));
d[i] = v; d[i + 1] = v; d[i + 2] = v; d[i + 3] = 255;
}
g.putImageData(img, 0, 0);
moonDiscCanvas = cv;
return cv;
}
// A single point on a moon's orbit, in the planet's equatorial plane, offset from
// wherever that planet currently sits.
function orbitPoint(key, md, uAx, vAx, ang) {
const c = posOf(key, simDay);
const p = Cesium.Cartesian3.multiplyByScalar(uAx, md * Math.cos(ang), new Cesium.Cartesian3());
Cesium.Cartesian3.add(p, Cesium.Cartesian3.multiplyByScalar(vAx, md * Math.sin(ang), new Cesium.Cartesian3()), p);
return Cesium.Cartesian3.add(c, p, p);
}
// One full moon-orbit circle in the planet's equatorial plane, around wherever
// that planet currently sits. Plain array ⇒ Cesium treats it as constant.
function traceRing(key, md, uAx, vAx) {
const c = posOf(key, simDay), out = [];
for (let i = 0; i <= 48; i++) {
const a = (i / 48) * 2 * Math.PI;
const p = Cesium.Cartesian3.multiplyByScalar(uAx, md * Math.cos(a), new Cesium.Cartesian3());
Cesium.Cartesian3.add(p, Cesium.Cartesian3.multiplyByScalar(vAx, md * Math.sin(a), new Cesium.Cartesian3()), p);
out.push(Cesium.Cartesian3.add(c, p, p));
}
return out;
}
// Orbital time-lapse: `simDay` is the heliocentric day number driving the dots.
// It advances in real time while `playing` (overview only) so the planets sweep
// their orbits at correct RELATIVE speeds (Mercury fast, Neptune a crawl). The
// textured spheres stay FROZEN — a moving ellipsoid goes onto Cesium's dynamic
// path and loses its image material (blank white), so only the dots move; the
// sphere is snapshotted to the current position the moment you travel to it.
let simDay = 0, playing = false, animTick = null, lastAnimMs = 0;
const DAYS_PER_SEC = 8;
function posOf(key, d) {
if (key === 'sun') return new Cesium.Cartesian3(0, 0, 0);
if (key === 'earth') return scaledPos(earthHelio(d));
return scaledPos(helio(key.charAt(0).toUpperCase() + key.slice(1), d));
}
// Raw heliocentric AU vector (before the display compression) — for mission arcs.
function helioAU(body, d) {
if (body === 'sun') return { x: 0, y: 0, z: 0 };
if (body === 'earth') return earthHelio(d);
return helio(body.charAt(0).toUpperCase() + body.slice(1), d);
}
const dayFromISO = (iso) => new Date(iso + 'T00:00:00Z').getTime() / 86400000 - 10956.0;
// Schematic transfer arc for a mission: for each leg, connect the two real
// heliocentric endpoints (body positions at their dated waypoints) with a smooth
// prograde curve — angle swept the short way round the Sun, radius eased between
// the two orbital distances. Not a true Kepler arc, but date-accurate at every
// waypoint and it reads clearly as an interplanetary transfer.
function missionArc(mission) {
const pts = [];
for (let leg = 0; leg < mission.waypoints.length - 1; leg++) {
const a = mission.waypoints[leg], b = mission.waypoints[leg + 1];
const pa = helioAU(a.body, dayFromISO(a.date)), pb = helioAU(b.body, dayFromISO(b.date));
const r1 = Math.hypot(pa.x, pa.y), th1 = Math.atan2(pa.y, pa.x), z1 = pa.z;
const r2 = Math.hypot(pb.x, pb.y), th2 = Math.atan2(pb.y, pb.x), z2 = pb.z;
let dth = (th2 - th1) % (2 * Math.PI); if (dth < 0) dth += 2 * Math.PI; // prograde CCW
const N = 60;
for (let i = 0; i <= N; i++) {
const t = i / N, s = t * t * (3 - 2 * t);
const rr = r1 + (r2 - r1) * s, th = th1 + dth * t, zz = z1 + (z2 - z1) * s;
pts.push(scaledPos({ x: rr * Math.cos(th), y: rr * Math.sin(th), z: zz }));
}
}
return pts;
}
// Orthographic planet disc from an equirectangular texture, drawn onto a canvas
// used as a BILLBOARD image. Billboards render instantly (no async worker
// geometry), always face the camera, and are unlit — reliable where a 3D
// ellipsoid + hidden globe is not. Still reads as a real 3D planet.
const discCache = {};
function makeDisc(b) {
if (discCache[b.key]) return discCache[b.key];
const S = 256, R = S / 2, cv = document.createElement('canvas'); cv.width = cv.height = S;
const g = cv.getContext('2d');
discCache[b.key] = cv; // return now; fill in once the texture loads
const img = new Image();
img.onload = () => {
const iw = img.width, ih = img.height, src = document.createElement('canvas');
src.width = iw; src.height = ih; const sg = src.getContext('2d'); sg.drawImage(img, 0, 0);
const sd = sg.getImageData(0, 0, iw, ih).data;
const out = g.createImageData(S, S), od = out.data;
const lx = -0.5, ly = 0.55, lz = 0.67; // light from upper-left-front
for (let y = 0; y < S; y++) for (let x = 0; x < S; x++) {
const nx = (x - R) / R, ny = (R - y) / R, r2 = nx * nx + ny * ny, oi = (y * S + x) * 4;
if (r2 > 1) { od[oi + 3] = 0; continue; }
const nz = Math.sqrt(1 - r2);
const lat = Math.asin(ny), lon = Math.atan2(nx, nz);
let u = lon / (2 * Math.PI) + 0.5; u -= Math.floor(u);
const vv = 0.5 - lat / Math.PI;
const si = (Math.min(ih - 1, (vv * ih) | 0) * iw + Math.min(iw - 1, (u * iw) | 0)) * 4;
const shade = b.sun ? 1 : Math.max(0.14, nx * lx + ny * ly + nz * lz) * 0.85 + 0.15;
od[oi] = sd[si] * shade; od[oi + 1] = sd[si + 1] * shade; od[oi + 2] = sd[si + 2] * shade; od[oi + 3] = 255;
}
g.putImageData(out, 0, 0);
// The billboard uploaded a blank canvas at creation time; re-assign the now
// -drawn canvas so Cesium re-uploads the real planet image to the atlas.
if (bodyEntities[b.key]) bodyEntities[b.key].billboard.image = cv;
};
img.src = b.tex;
return cv;
}
// Constant axial tilt about +X (Saturn's matches its ring plane; Uranus rolls on
// its side at 98°). NOTE: the orientation MUST be constant — a time-varying
// orientation pushes the ellipsoid onto Cesium's dynamic-geometry path, which
// only supports solid-colour materials and renders the planet blank white. A
// static orientation keeps the image material; you still orbit planets by hand.
// ---- moon scaling ----
// True relative sizes/distances are unusable here (Phobos would be 0.3% of Mars;
// the Moon would sit 60 planet-radii out, far outside the travel-to framing). So
// compress both: radius by sqrt (keeps ordering, lifts the tiny ones off zero)
// with a visibility floor, and orbit distance by a log map into a band that fits
// the close-up view — pushed outside the rings for Saturn.
const moonRadius = (planetR, moonKm, planetKm) =>
planetR * Math.max(0.035, 0.45 * Math.sqrt(moonKm / planetKm));
function moonDist(planetR, ratio, ringed) {
const lo = Math.log(2.5), hi = Math.log(62);
const t = Math.min(1, Math.max(0, (Math.log(ratio) - lo) / (hi - lo)));
return planetR * (ringed ? 3.4 + 1.6 * t : 1.6 + 2.4 * t);
}
// Display period: sqrt-compressed so Phobos still whips round and Iapetus still
// visibly moves, while preserving who-orbits-faster-than-whom.
const moonPeriodSec = (P) => Math.max(2.0, 3.2 * Math.sqrt(Math.abs(P)));
const X_AXIS = new Cesium.Cartesian3(1, 0, 0);
function tiltQuat(b) {
return Cesium.Quaternion.fromAxisAngle(X_AXIS, (b.obliq || 0) * D2R);
}
// Saturn's ring-plane normal = its tilted pole (so ring ⟂ pole, aligned to sphere).
function ringNormal(obliqDeg) {
const t = obliqDeg * D2R;
return new Cesium.Cartesian3(0, -Math.sin(t), Math.cos(t));
}
function build(d) {
ds.entities.removeAll();
simDay = d;
for (const b of BODIES) {
const r = bodyRadius(b.km, b.sun);
// The Sun is a star: keep it a bright, always-lit billboard disc (a lit
// ellipsoid would show a dark hemisphere). Planets are real 3D spheres.
if (b.sun) {
const ent = ds.entities.add({
name: b.name,
position: new Cesium.Cartesian3(0, 0, 0),
billboard: {
image: makeDisc(b),
scaleByDistance: new Cesium.NearFarScalar(r * 2.5, 2.4, 9e8, 0.12),
disableDepthTestDistance: Number.POSITIVE_INFINITY,
},
label: labelGraphics(b),
});
bodyEntities[b.key] = ent;
continue;
}
// Textured sphere — FROZEN position (constant property keeps the image
// material; snapshotted forward when you travel to it). Only visible up close.
const sphere = ds.entities.add({
name: b.name,
position: new Cesium.ConstantPositionProperty(posOf(b.key, simDay)),
orientation: tiltQuat(b),
ellipsoid: {
radii: new Cesium.Cartesian3(r, r, r),
material: new Cesium.ImageMaterialProperty({ image: b.tex, color: Cesium.Color.WHITE }),
slicePartitions: 64, stackPartitions: 64,
distanceDisplayCondition: new Cesium.DistanceDisplayCondition(0.0, r * 30),
},
});
sphereEntities[b.key] = sphere;
bodyEntities[b.key] = sphere;
// Colored dot + label — ANIMATED: position reads live `simDay`, so it sweeps
// the orbit while playing. Point is a cheap primitive (no image material), so
// a time-varying position is safe here. Vanishes up close (r*30 handoff).
const dot = ds.entities.add({
name: b.name,
position: new Cesium.CallbackProperty(() => posOf(b.key, simDay), false),
point: {
pixelSize: 7,
color: Cesium.Color.fromCssColorString(b.color),
outlineColor: Cesium.Color.fromCssColorString('#05080b'), outlineWidth: 1,
distanceDisplayCondition: new Cesium.DistanceDisplayCondition(r * 30, 9e9),
disableDepthTestDistance: Number.POSITIVE_INFINITY,
},
label: labelGraphics(b),
});
dotEntities[b.key] = dot;
// Saturn's rings: a flat quad in the equatorial plane, textured with a
// top-down ring annulus (transparent centre + corners). Depth-tested so the
// planet occludes the far half of the ring. Frozen with the sphere.
if (b.ring) {
const side = r * 6.6; // hole ≈ planet radius, outer edge ≈ 3.1 radii
ringEntities[b.key] = ds.entities.add({
position: new Cesium.ConstantPositionProperty(posOf(b.key, simDay)),
plane: {
plane: new Cesium.Plane(ringNormal(b.obliq), 0.0),
dimensions: new Cesium.Cartesian2(side, side),
material: new Cesium.ImageMaterialProperty({ image: b.ring, transparent: true }),
distanceDisplayCondition: new Cesium.DistanceDisplayCondition(0.0, r * 30),
},
});
}
// Orbit ring: one full revolution swept by anomaly. Static — the dot rides it.
const pts = [];
for (let a = 0; a <= 360; a += 3) {
const v = b.key === 'earth' ? earthHelioAt(d, a) : helioAt(b.key, d, a);
pts.push(scaledPos(v));
}
ds.entities.add({
polyline: { positions: pts, width: 1.5, arcType: Cesium.ArcType.NONE, material: lib.cz('#6a86c0', 0.6) },
});
}
}
function labelGraphics(b) {
return {
text: b.name, font: '13px "Segoe UI", system-ui, sans-serif',
fillColor: Cesium.Color.WHITE, outlineColor: Cesium.Color.fromCssColorString('#05080b'),
outlineWidth: 3, style: Cesium.LabelStyle.FILL_AND_OUTLINE,
verticalOrigin: Cesium.VerticalOrigin.TOP, pixelOffset: new Cesium.Cartesian2(0, 12),
disableDepthTestDistance: Number.POSITIVE_INFINITY,
};
}
// Orbit sampling: recompute a body's heliocentric position at mean-anomaly `deg`.
function helioAt(key, d, deg) {
const name = key.charAt(0).toUpperCase() + key.slice(1);
const el = ELEM[name](d);
const N = el.N * D2R, i = el.i * D2R, w = el.w * D2R, e = el.e, M = deg * D2R;
let E = M + e * Math.sin(M) * (1 + e * Math.cos(M));
for (let k = 0; k < 6; k++) E = E - (E - e * Math.sin(E) - M) / (1 - e * Math.cos(E));
const xv = el.a * (Math.cos(E) - e), yv = el.a * Math.sqrt(1 - e * e) * Math.sin(E);
const v = Math.atan2(yv, xv), r = Math.hypot(xv, yv), u = v + w;
return { x: r * (Math.cos(N) * Math.cos(u) - Math.sin(N) * Math.sin(u) * Math.cos(i)), y: r * (Math.sin(N) * Math.cos(u) + Math.cos(N) * Math.sin(u) * Math.cos(i)), z: r * Math.sin(u) * Math.sin(i) };
}
function earthHelioAt(d, deg) {
const w = 282.9404 + 4.70935e-5 * d, e = 0.016709 - 1.151e-9 * d, M = deg * D2R;
let E = M + e * Math.sin(M) * (1 + e * Math.cos(M));
const xv = Math.cos(E) - e, yv = Math.sqrt(1 - e * e) * Math.sin(E);
const v = Math.atan2(yv, xv), r = Math.hypot(xv, yv), lon = v + w * D2R;
return { x: -r * Math.cos(lon), y: -r * Math.sin(lon), z: 0 };
}
// ---- mode enter / exit ----
let active = false;
const saved = { dataSources: [], primitives: [], globe: true, atmo: true, sky: true, animate: true, light: null, far: 5e8, near: 0.1, collide: true };
// Cesium's animated camera.flyTo does NOT progress in this mode (its flight tween
// sits idle out here 1e8 m off Earth with the globe hidden) — but setView works
// perfectly. So drive our own eased per-frame tween: lerp position + orientation
// and setView each frame. Guaranteed smooth because setView is reliable.
let flightTick = null;
const V3 = Cesium.Cartesian3;
function flyCamera(destPos, destDir, destUp, duration, onArrive) {
if (flightTick) { flightTick(); flightTick = null; }
scene.camera.lookAtTransform(Cesium.Matrix4.IDENTITY); // clear any planet orbit-lock so setView is world-space
const cam = scene.camera;
const startPos = cam.positionWC.clone();
const startDir = cam.directionWC.clone();
const startUp = cam.upWC.clone();
const t0 = performance.now();
flightTick = scene.preRender.addEventListener(() => {
let u = (performance.now() - t0) / 1000 / duration;
if (u > 1) u = 1;
const e = u * u * (3 - 2 * u); // smoothstep ease in/out
const pos = V3.lerp(startPos, destPos, e, new V3());
let dir = V3.normalize(V3.lerp(startDir, destDir, e, new V3()), new V3());
let up = V3.normalize(V3.lerp(startUp, destUp, e, new V3()), new V3());
const right = V3.normalize(V3.cross(dir, up, new V3()), new V3());
up = V3.normalize(V3.cross(right, dir, new V3()), new V3()); // re-orthonormalize
scene.camera.setView({ destination: pos, orientation: { direction: dir, up } });
if (u >= 1 && flightTick) { flightTick(); flightTick = null; if (onArrive) onArrive(); }
});
}
// Re-anchor drag/scroll to orbit a specific body (not the Sun at the origin).
// lookAtTransform with NO offset preserves the camera's current pose (no snap) and
// just moves the controller's pivot to `pos`; cleared on the next flight
// (flyCamera resets to IDENTITY) or on exit.
function lockOrbit(pos) {
scene.camera.lookAtTransform(Cesium.Transforms.eastNorthUpToFixedFrame(pos));
}
function clearMoons() {
moonBB.removeAll(); moonLBL.removeAll(); moonPL.removeAll();
}
// Build the moon system for one planet, at its current (frozen) position.
function buildMoonsFor(key) {
clearMoons();
const b = BODIES.find((x) => x.key === key);
const moons = MOONS[key];
if (!b || !moons || !moons.length) return;
const r = bodyRadius(b.km, b.sun);
const tilt = (b.obliq || 0) * D2R;
const uAx = new Cesium.Cartesian3(1, 0, 0); // in-plane
const vAx = new Cesium.Cartesian3(0, Math.cos(tilt), Math.sin(tilt)); // ⟂ u, in-plane
const ddc = new Cesium.DistanceDisplayCondition(0.0, r * 30);
moons.forEach((mn, idx) => {
const mr = moonRadius(r, mn.km, b.km);
const md = moonDist(r, mn.a / b.km, !!b.ring);
const phase = idx * 1.7; // fan them around the orbit so they don't line up
const pos = orbitPoint(key, md, uAx, vAx, phase);
const col = Cesium.Color.fromCssColorString(mn.color);
// sizeInMeters ⇒ width/height are WORLD units, so the orbs hold their true
// relative size as you zoom, exactly like real bodies.
moonBB.add({
position: pos, image: moonDisc(), color: col,
sizeInMeters: true, width: mr * 2, height: mr * 2,
distanceDisplayCondition: ddc,
});
moonLBL.add({
position: pos, text: mn.name,
font: '10px "Segoe UI", system-ui, sans-serif',
fillColor: col, outlineColor: Cesium.Color.fromCssColorString('#05080b'),
outlineWidth: 3, style: Cesium.LabelStyle.FILL_AND_OUTLINE,
verticalOrigin: Cesium.VerticalOrigin.TOP,
pixelOffset: new Cesium.Cartesian2(0, 8),
distanceDisplayCondition: new Cesium.DistanceDisplayCondition(0.0, r * 14),
disableDepthTestDistance: Number.POSITIVE_INFINITY,
});
moonPL.add({
positions: traceRing(key, md, uAx, vAx), width: 1,
material: Cesium.Material.fromType('Color', {
color: col.withAlpha(0.22),
}),
distanceDisplayCondition: ddc,
});
});
}
// Snapshot ALL planet spheres+rings forward to the current sim time (constant
// position keeps the image material). Freezing every body — not just the target —
// keeps any sphere that drifts into view aligned with its animated dot.
function freezeAll() {
for (const bb of BODIES) {
if (bb.sun) continue;
const p = posOf(bb.key, simDay);
if (sphereEntities[bb.key]) sphereEntities[bb.key].position = new Cesium.ConstantPositionProperty(p);
if (ringEntities[bb.key]) ringEntities[bb.key].position = new Cesium.ConstantPositionProperty(p);
}
}
// Spheres are the "settled" (paused) representation; while the orrery plays only
// the animated dots show, so a frozen sphere can never appear at a stale spot.
function showSpheres(v) {
for (const bb of BODIES) {
if (bb.sun) continue;
if (sphereEntities[bb.key]) sphereEntities[bb.key].show = v;
if (ringEntities[bb.key]) ringEntities[bb.key].show = v;
}
}
// Orbital time-lapse loop — advances simDay by real elapsed time while playing.
function startAnim() {
if (animTick) return;
lastAnimMs = performance.now();
animTick = scene.preRender.addEventListener(() => {
const now = performance.now();
let dt = (now - lastAnimMs) / 1000; lastAnimMs = now;
if (dt > 0.1) dt = 0.1; // clamp tab-switch / hitch jumps
if (playing) simDay += dt * DAYS_PER_SEC;
});
}
function stopAnim() { if (animTick) { animTick(); animTick = null; } }
const OVERVIEW = {
pos: new Cesium.Cartesian3(1.6e8, -3.4e8, 2.6e8),
dir: Cesium.Cartesian3.normalize(new Cesium.Cartesian3(-1.6e8, 3.4e8, -2.6e8), new Cesium.Cartesian3()),
up: new Cesium.Cartesian3(0, 0, 1),
};
function goOverview() {
hideCard();
clearMoons();
playing = !missionsOn; // resume the orrery (stay paused if browsing missions)
showSpheres(false); // dots-only
flyCamera(OVERVIEW.pos, OVERVIEW.dir, OVERVIEW.up, 2.0);
}
function enter() {
if (active) return;
active = true;
const d = dayNumber(viewer.clock.currentTime);
build(d);
// Hide every Earth layer: entity data sources + billboard primitives.
saved.dataSources = [];
for (let i = 0; i < viewer.dataSources.length; i++) {
const s = viewer.dataSources.get(i);
if (s === ds) continue;
saved.dataSources.push([s, s.show]); s.show = false;
}
// NOTE: do NOT blanket-hide scene.primitives by duck-typing — that also hides
// Cesium's shared DataSourceDisplay collections (the very ones our planets
// render into). The bespoke billboard layers (aircraft/military/adsb/radius)
// sit at Earth-surface coords → a tiny speck near the Sun in the orrery, so
// leaving them visible is harmless.
saved.primitives = [];
saved.globe = scene.globe.show; scene.globe.show = false;
saved.atmo = scene.globe.showGroundAtmosphere; scene.globe.showGroundAtmosphere = false;
saved.sky = scene.skyAtmosphere.show; scene.skyAtmosphere.show = false;
saved.animate = viewer.clock.shouldAnimate; viewer.clock.shouldAnimate = false; // freeze the snapshot
// The scene spans ~1e9 m — well past Cesium's default 5e8 far plane, which
// would clip every body. Widen it (and drop the surface-collision limit so
// you can fly far out); restored on exit.
saved.far = scene.camera.frustum.far; scene.camera.frustum.far = 6e9;
// Push the near plane out too: with a 6e9 far plane, a 0.1 near destroys depth
// precision and the starfield/scene stop drawing. 1e5 is fine at orrery scale.
saved.near = scene.camera.frustum.near; scene.camera.frustum.near = 1e5;
saved.collide = scene.screenSpaceCameraController.enableCollisionDetection;
scene.screenSpaceCameraController.enableCollisionDetection = false;
scene.screenSpaceCameraController.maximumZoomDistance = 6e9;
// Portrait key-light: source sits upper-left-front of the camera, so the lit
// hemisphere of whatever you orbit stays upper-left — every 3D sphere reads
// with a graceful terminator from any angle, and (since it tracks the camera)
// there's no "sun is over there but lit from here" contradiction.
saved.light = scene.light;
// Intensity is deliberately low: the equirect maps are near-albedo-1 in
// places, so anything above ~0.5 clamps the lit hemisphere to pure white
// (no HDR). 0.42 keeps the texture + Saturn's rings rich and readable.
scene.light = new Cesium.DirectionalLight({ direction: scene.camera.directionWC.clone(), intensity: 0.42 });
const _ld = new Cesium.Cartesian3();
headlight = scene.preRender.addEventListener((s) => {
const c = s.camera;
Cesium.Cartesian3.multiplyByScalar(c.rightWC, 0.5, _ld);
Cesium.Cartesian3.add(_ld, Cesium.Cartesian3.multiplyByScalar(c.upWC, -0.55, new Cesium.Cartesian3()), _ld);
Cesium.Cartesian3.add(_ld, Cesium.Cartesian3.multiplyByScalar(c.directionWC, 0.67, new Cesium.Cartesian3()), _ld);
Cesium.Cartesian3.normalize(_ld, scene.light.direction);
});
ds.show = true;
moonBB.show = moonLBL.show = moonPL.show = true;
document.body.classList.add('ss-active'); // CSS hides the OSINT timeline/animation chrome
document.getElementById('hud').style.display = 'none';
panel.style.display = 'block';
document.getElementById('ss-btn').classList.add('active');
// Start the orbital time-lapse (planets sweep their orbits in the overview).
playing = true;
showSpheres(false); // dots-only while animating
startAnim();
// Swoop out from "Sun in your face" (camera starts at the old Earth-surface
// spot, and the Sun now sits where Earth's centre was) to a clean oblique view
// of the whole system.
flyCamera(OVERVIEW.pos, OVERVIEW.dir, OVERVIEW.up, 2.6);
}
let headlight = null;
function exit() {
if (!active) return;
active = false;
closeMissions(false); hideCard(); clearMoons();
if (flightTick) { flightTick(); flightTick = null; } // stop any in-progress swoop
stopAnim(); playing = false;
scene.camera.lookAtTransform(Cesium.Matrix4.IDENTITY); // clear planet orbit-lock before returning to Earth
document.body.classList.remove('ss-active');
ds.show = false;
moonBB.show = moonLBL.show = moonPL.show = false;
for (const [s, show] of saved.dataSources) s.show = show;
for (const [p, show] of saved.primitives) p.show = show;
scene.globe.show = saved.globe;
scene.globe.showGroundAtmosphere = saved.atmo;
scene.skyAtmosphere.show = saved.sky;
viewer.clock.shouldAnimate = saved.animate;
if (headlight) { headlight(); headlight = null; }
if (saved.light) scene.light = saved.light;
scene.camera.frustum.far = saved.far;
if (saved.near != null) scene.camera.frustum.near = saved.near;
scene.screenSpaceCameraController.enableCollisionDetection = saved.collide;
scene.screenSpaceCameraController.maximumZoomDistance = Infinity;
document.getElementById('hud').style.display = '';
panel.style.display = 'none';
document.getElementById('ss-btn').classList.remove('active');
// Instant, reliable return to the OSINT globe (animated flyTo is dead in this
// mode; setView always works). Nadir view over the configured home spot.
scene.camera.setView({
destination: Cesium.Cartesian3.fromDegrees(ctx.CONFIG.camera.lon, ctx.CONFIG.camera.lat, ctx.CONFIG.camera.height),
orientation: { heading: 0, pitch: -Math.PI / 2, roll: 0 },
});
}
function travelTo(key) {
const b = BODIES.find((x) => x.key === key);
if (!b) return;
// Earth is a real destination like the rest (so you can see the Moon); the
// "Return to Earth (OSINT)" button is the way back to the globe.
if (missionsOn) closeMissions(false);
// Pause the orrery, freeze every sphere to the current sim time and reveal them
// (textured, aligned with their dots). The close-up is a constant-position
// sphere → keeps its image material.
playing = false;
freezeAll();
showSpheres(true);
buildMoonsFor(key); // only this planet's moons exist at a time
showBodyCard(key); // infographic while you fly in
const V = Cesium.Cartesian3;
const pos = b.sun ? new V(0, 0, 0) : posOf(key, simDay);
const r = bodyRadius(b.km, b.sun);
const range = (b.sun ? 3.0 : b.ring ? 6.0 : 5.0) * r;
// Deterministic framing in WORLD space (flyTo/flyToBoundingSphere misbehave out
// here). Camera up and to the side → a 3/4 view; Saturn's ring tilt reads well.
const offDir = V.normalize(new V(0.55, -0.25, 0.45), new V());
const dest = V.add(pos, V.multiplyByScalar(offDir, range, new V()), new V());
const dir = V.negate(offDir, new V());
const right = V.normalize(V.cross(dir, new V(0, 0, 1), new V()), new V());
const up = V.normalize(V.cross(right, dir, new V()), new V());
// On arrival, re-anchor drag/scroll to orbit THIS planet (not the whole system).
flyCamera(dest, dir, up, 2.2, b.sun ? null : () => lockOrbit(pos));
}
// ---- infographic card (planet facts + mission info) ----
const card = document.createElement('aside');
card.id = 'ss-card';
card.style.display = 'none';
document.body.appendChild(card);
const MON = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const fmtDate = (iso) => { const [y, m, d] = iso.split('-'); return `${+d} ${MON[+m - 1]} ${y}`; };
function hideCard() { card.style.display = 'none'; }
function fillCard(title, blurb, stats, fact, accent) {
const rows = stats.map(([k, v]) => `<div class="ss-card-row"><span>${k}</span><span>${v}</span></div>`).join('');
card.innerHTML = `<button class="ss-card-x" title="Close">×</button>
<div class="ss-card-title"${accent ? ` style="color:${accent}"` : ''}>${title}</div>
<div class="ss-card-blurb">${blurb}</div>
<div class="ss-card-stats">${rows}</div>
${fact ? `<div class="ss-card-fact">${fact}</div>` : ''}`;
card.querySelector('.ss-card-x').addEventListener('click', hideCard);
card.style.display = 'block';
}
function showBodyCard(key) {
const f = FACTS[key]; if (!f) return hideCard();
const b = BODIES.find((x) => x.key === key);
const mn = MOONS[key] || [];
const stats = mn.length ? [...f.stats, ['Moons shown', mn.map((m) => m.name).join(', ')]] : f.stats;
fillCard(b.name, f.blurb, stats, '💡 ' + f.fact, b.color);
}
function showMissionCard(m) {
const tgt = BODIES.find((b) => b.key === m.target);
fillCard('🛰 ' + m.name, m.blurb, [
['Agency', m.agency], ['Type', m.type],
['Launched', fmtDate(m.waypoints[0].date)],
['Arrived', fmtDate(m.waypoints[m.waypoints.length - 1].date)],
['Destination', tgt ? tgt.name : m.target],
], '', m.color);
}
// ---- space-missions overlay (schematic transfer arcs) ----
let missionsOn = false, selectedMission = null;
const missionArcs = {};
function drawMissions() {
for (const m of MISSIONS) {
missionArcs[m.id] = ds.entities.add({
polyline: { positions: missionArc(m), width: 1.4, arcType: Cesium.ArcType.NONE, material: lib.cz(m.color, 0.4) },
});
}
}
function clearMissions() { for (const id in missionArcs) { ds.entities.remove(missionArcs[id]); delete missionArcs[id]; } }
function selectMission(id) {
selectedMission = id;
for (const mid in missionArcs) {
const mm = MISSIONS.find((x) => x.id === mid), sel = mid === id;
missionArcs[mid].polyline.width = sel ? 3.5 : 1.1;
missionArcs[mid].polyline.material = lib.cz(mm.color, sel ? 0.95 : 0.14);
}
showMissionCard(MISSIONS.find((x) => x.id === id));
missionList.querySelectorAll('.ss-mission').forEach((b) => b.classList.toggle('active', b.dataset.id === id));
}
function openMissions() {
missionsOn = true; missionsBtn.classList.add('active'); missionsWrap.style.display = 'block';
playing = false; showSpheres(false); hideCard(); clearMoons();
clearMissions(); drawMissions();
scene.camera.lookAtTransform(Cesium.Matrix4.IDENTITY);
flyCamera(OVERVIEW.pos, OVERVIEW.dir, OVERVIEW.up, 1.8);
}
function closeMissions(resume) {
missionsOn = false; selectedMission = null;
missionsBtn.classList.remove('active'); missionsWrap.style.display = 'none';
clearMissions(); hideCard();
missionList.querySelectorAll('.ss-mission').forEach((b) => b.classList.remove('active'));
if (resume) playing = true;
}
// ---- UI: enter button + destination panel ----
const btn = document.createElement('button');
btn.id = 'ss-btn';
btn.type = 'button';
btn.textContent = '◉ Solar System';
btn.title = 'Leave Earth and travel the solar system';
btn.addEventListener('click', () => (active ? exit() : enter()));
document.body.appendChild(btn);
const panel = document.createElement('aside');
panel.id = 'ss-panel';
panel.style.display = 'none';
panel.innerHTML = `<div class="ss-title" title="Collapse / expand">TRAVEL TO<span class="ss-collapse">▾</span></div>
<div class="ss-body">
<button class="ss-dest ss-overview">⊙ System overview</button>
<div class="ss-dests"></div>
<button class="ss-dest ss-missions-toggle">🛰 Space missions</button>
<div class="ss-missions-wrap" style="display:none">
<div class="ss-missions-hint">Approx. transfer paths — pick one:</div>
<div class="ss-missions"></div>
</div>
<div class="ss-foot">Positions computed live · planet maps: NASA/JPL &amp; <a href="https://www.solarsystemscope.com/" target="_blank" rel="noopener">Solar System Scope</a> (CC BY 4.0)</div>
</div>`;
panel.querySelector('.ss-title').addEventListener('click', () => panel.classList.toggle('collapsed'));
panel.querySelector('.ss-overview').addEventListener('click', goOverview);
const dests = panel.querySelector('.ss-dests');
for (const b of BODIES) {
const d = document.createElement('button');
d.className = 'ss-dest';
d.textContent = b.name;
d.addEventListener('click', () => travelTo(b.key));
dests.appendChild(d);
}
const missionsBtn = panel.querySelector('.ss-missions-toggle');
const missionsWrap = panel.querySelector('.ss-missions-wrap');
const missionList = panel.querySelector('.ss-missions');
missionsBtn.addEventListener('click', () => (missionsOn ? closeMissions(true) : openMissions()));
for (const m of MISSIONS) {
const tgt = BODIES.find((x) => x.key === m.target);
const b = document.createElement('button');
b.className = 'ss-mission';
b.dataset.id = m.id;
b.innerHTML = `<span class="ss-mission-dot" style="background:${m.color}"></span>` +
`<span class="ss-mission-name">${m.name}</span>` +
`<span class="ss-mission-tgt">${tgt ? tgt.name.split(' ')[0] : ''}</span>`;
b.addEventListener('click', () => selectMission(m.id));
missionList.appendChild(b);
}
const back = document.createElement('button');
back.className = 'ss-dest ss-back';
back.textContent = '↩ Return to Earth (OSINT)';
back.addEventListener('click', exit);
panel.querySelector('.ss-body').appendChild(back);
document.body.appendChild(panel);
return { enter, exit, isActive: () => active };
}