GODSIGH/js/solarsystem.js
type-two 92c6e0ebc3 feat: Solar System — orbit planets, live orrery, info cards + mission trajectories
Orbit-lock: traveling to a planet re-anchors drag/scroll to orbit THAT body
(lookAtTransform), not the whole system. Cleared on the next flight / exit.

Animated orrery: planets sweep their orbits at correct relative speeds in the
overview (simDay advances in real time). Since a moving textured ellipsoid loses
its image material, each planet is split into an animated dot + a frozen textured
sphere (revealed + snapshotted forward on travel). New 'System overview' button.

Camera flights: enter/travel/overview run a manual eased setView tween (Cesium's
flyTo is dead in this mode).

Infographic cards: ssdata.js FACTS — a stat card + fun fact per body (not Earth),
auto-shown on travel and mission-select.

Space missions: ssdata.js MISSIONS — 11 real probes with date-accurate launch/
flyby/arrival waypoints, drawn as schematic heliocentric transfer arcs (multi-leg
grand tours included). Toggle + list; select highlights the path + shows a card.

Chrome: hides the OSINT clock/timeline/live-chip in SS mode; Travel-to panel is
now collapsible.

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

635 lines
33 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.

// 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 } 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
// 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.
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));
}
// 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();
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;
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();
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;
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;
if (key === 'earth') { // Earth = home; drop back to the OSINT globe
exit();
return;
}
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);
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);
fillCard(b.name, f.blurb, f.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();
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 };
}