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>
635 lines
33 KiB
JavaScript
635 lines
33 KiB
JavaScript
// 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 & <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 };
|
||
}
|