diff --git a/css/style.css b/css/style.css index 9855599..cb3bd1e 100644 --- a/css/style.css +++ b/css/style.css @@ -290,7 +290,17 @@ html, body { font: 12px ui-monospace, Menlo, monospace; color: var(--text); } -#ss-panel .ss-title { font-weight: 700; letter-spacing: 1px; color: var(--accent); font-size: 11px; margin-bottom: 10px; } +#ss-panel .ss-title { + font-weight: 700; letter-spacing: 1px; color: var(--accent); font-size: 11px; + margin-bottom: 10px; cursor: pointer; user-select: none; + display: flex; align-items: center; justify-content: space-between; +} +#ss-panel .ss-collapse { font-size: 9px; transition: transform 0.15s; } +#ss-panel.collapsed .ss-collapse { transform: rotate(-90deg); } +#ss-panel.collapsed { width: auto; } +#ss-panel.collapsed .ss-title { margin-bottom: 0; } +#ss-panel.collapsed .ss-body { display: none; } +#ss-panel .ss-overview { color: var(--accent); border-color: rgba(53,224,255,0.3); margin-bottom: 8px; } #ss-panel .ss-dests { display: flex; flex-direction: column; gap: 4px; } #ss-panel .ss-dest { text-align: left; @@ -307,3 +317,64 @@ html, body { #ss-panel .ss-back { margin-top: 8px; color: var(--dim); } #ss-panel .ss-foot { margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--border); color: var(--dim); font-size: 9.5px; line-height: 1.4; } #ss-panel .ss-foot a { color: var(--dim); } + +/* Solar System mode hides the Earth/OSINT clock + timeline chrome for a clean view. */ +body.ss-active .cesium-viewer-timelineContainer, +body.ss-active .cesium-viewer-animationContainer, +body.ss-active #live-chip { display: none !important; } + +/* ---- Space missions list (in the Travel-to panel) ---- */ +#ss-panel .ss-missions-toggle { margin-top: 8px; } +#ss-panel .ss-missions-toggle.active { color: #04121a; background: var(--accent); border-color: var(--accent); font-weight: 700; } +#ss-panel .ss-missions-hint { color: var(--dim); font-size: 9px; margin: 6px 2px 4px; } +#ss-panel .ss-missions { display: flex; flex-direction: column; gap: 2px; max-height: 220px; overflow-y: auto; } +#ss-panel .ss-mission { + display: flex; align-items: center; gap: 7px; + padding: 5px 8px; font: inherit; font-size: 11px; text-align: left; + color: var(--text); background: rgba(255, 255, 255, 0.02); + border: 1px solid transparent; border-radius: 6px; cursor: pointer; transition: all 0.12s; +} +#ss-panel .ss-mission:hover { background: rgba(255, 255, 255, 0.06); } +#ss-panel .ss-mission.active { background: rgba(53, 224, 255, 0.12); border-color: var(--accent); } +#ss-panel .ss-mission-dot { width: 8px; height: 8px; border-radius: 50%; flex: none; box-shadow: 0 0 5px currentColor; } +#ss-panel .ss-mission-name { flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +#ss-panel .ss-mission-tgt { color: var(--dim); font-size: 12px; } + +/* ---- Infographic card (planet facts + mission info) ---- */ +#ss-card { + position: absolute; + top: 14px; + right: 14px; + width: 268px; + max-height: calc(100vh - 28px); + overflow-y: auto; + z-index: 13; + padding: 16px; + background: var(--panel-solid); + border: 1px solid var(--border); + border-radius: 12px; + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + font: 12px ui-monospace, Menlo, monospace; + color: var(--text); + box-shadow: 0 8px 40px rgba(0, 0, 0, 0.5); +} +/* Sit below the Solar System button, which shares the top-right corner. */ +body.ss-active #ss-card { top: 62px; } +#ss-card .ss-card-x { + position: absolute; top: 10px; right: 12px; width: 20px; height: 20px; + padding: 0; font: 16px/1 ui-monospace, monospace; color: var(--dim); + background: none; border: none; cursor: pointer; +} +#ss-card .ss-card-x:hover { color: var(--text); } +#ss-card .ss-card-title { font-size: 16px; font-weight: 700; color: #fff; margin: 0 20px 8px 0; letter-spacing: 0.5px; } +#ss-card .ss-card-blurb { color: var(--text); font-size: 11px; line-height: 1.5; margin-bottom: 12px; } +#ss-card .ss-card-stats { display: flex; flex-direction: column; gap: 3px; margin-bottom: 12px; } +#ss-card .ss-card-row { display: flex; justify-content: space-between; gap: 10px; padding: 3px 0; border-bottom: 1px solid rgba(255, 255, 255, 0.05); } +#ss-card .ss-card-row span:first-child { color: var(--dim); } +#ss-card .ss-card-row span:last-child { color: var(--text); text-align: right; } +#ss-card .ss-card-fact { + font-size: 10.5px; line-height: 1.45; color: var(--accent); + background: rgba(53, 224, 255, 0.08); border-left: 2px solid var(--accent); + padding: 8px 10px; border-radius: 0 6px 6px 0; +} diff --git a/js/solarsystem.js b/js/solarsystem.js index 9519649..6301e85 100644 --- a/js/solarsystem.js +++ b/js/solarsystem.js @@ -6,6 +6,8 @@ // 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; @@ -73,13 +75,55 @@ export default function initSolarSystem(ctx) { const ds = new Cesium.CustomDataSource('solarsystem'); ds.show = false; viewer.dataSources.add(ds); - const bodyEntities = {}; // key → entity (for travel-to framing) + 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 @@ -135,8 +179,8 @@ export default function initSolarSystem(ctx) { function build(d) { ds.entities.removeAll(); + simDay = d; for (const b of BODIES) { - const pos = posOf(b.key, d); const r = bodyRadius(b.km, b.sun); // The Sun is a star: keep it a bright, always-lit billboard disc (a lit @@ -144,7 +188,7 @@ export default function initSolarSystem(ctx) { if (b.sun) { const ent = ds.entities.add({ name: b.name, - position: pos, + position: new Cesium.Cartesian3(0, 0, 0), billboard: { image: makeDisc(b), scaleByDistance: new Cesium.NearFarScalar(r * 2.5, 2.4, 9e8, 0.12), @@ -156,21 +200,28 @@ export default function initSolarSystem(ctx) { continue; } - const ent = ds.entities.add({ + // 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: pos, + 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, - // Only render the real sphere once you're close enough for it to read as a - // textured planet. Far away it minifies to a grey ball — hide it and show - // the colored dot instead. Clean handoff at r*30. distanceDisplayCondition: new Cesium.DistanceDisplayCondition(0.0, r * 30), }, - // Colored dot + label are the orrery-overview representation; they vanish as - // you approach (r*30) and the textured 3D planet takes over. + }); + 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), @@ -180,29 +231,25 @@ export default function initSolarSystem(ctx) { }, label: labelGraphics(b), }); - bodyEntities[b.key] = ent; + 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 — the real Saturn silhouette. + // 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 - ds.entities.add({ - position: pos, + 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 }), - // Match the sphere's visibility window (r*30). Beyond it the ring both - // minifies to an ugly opaque grey card AND would float without a planet, - // so hide it — at that range Saturn is just its labelled dot. distanceDisplayCondition: new Cesium.DistanceDisplayCondition(0.0, r * 30), }, }); } - // Orbit: trace one full revolution by sweeping the anomaly. - // arcType NONE = straight segments in space (not draped on an ellipsoid). + // 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); @@ -251,8 +298,9 @@ export default function initSolarSystem(ctx) { // and setView each frame. Guaranteed smooth because setView is reliable. let flightTick = null; const V3 = Cesium.Cartesian3; - function flyCamera(destPos, destDir, destUp, duration) { + 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(); @@ -268,10 +316,64 @@ export default function initSolarSystem(ctx) { 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 (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; @@ -325,26 +427,31 @@ export default function initSolarSystem(ctx) { }); 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( - new Cesium.Cartesian3(1.6e8, -3.4e8, 2.6e8), - Cesium.Cartesian3.normalize(new Cesium.Cartesian3(-1.6e8, 3.4e8, -2.6e8), new Cesium.Cartesian3()), - new Cesium.Cartesian3(0, 0, 1), - 2.6, - ); + 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; @@ -376,21 +483,96 @@ export default function initSolarSystem(ctx) { exit(); return; } - // Deterministic framing in WORLD space. flyTo(entity)/flyToBoundingSphere both - // misbehave out here (the ENU frame they offset within is degenerate 2e8 m off - // Earth), so compute the destination + orientation directly. Camera sits up and - // to the side of the ecliptic → a 3/4 view that shows Saturn's ring tilt. - const ent = bodyEntities[key]; - const pos = ent.position.getValue(viewer.clock.currentTime); + 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; - const V = Cesium.Cartesian3; + // 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()); - flyCamera(dest, dir, up, 2.2); + // 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]) => `