From 26a5751394245567df829febeb45e34f3c65ed42 Mon Sep 17 00:00:00 2001 From: monsterrobotparty Date: Thu, 16 Jul 2026 00:04:41 +1000 Subject: [PATCH] =?UTF-8?q?=F0=9F=8C=9E=20genesis:=20scaffold=20=E2=80=94?= =?UTF-8?q?=20serve.py=20proxy+cache,=20importmap=20renderer,=20sky,=20sun?= =?UTF-8?q?,=20HUD,=20time=20bar,=20floating-origin=20bootstrap=20(opus)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage 0. Zero-dependency serve.py (static + allowlisted horizons/sbdb/cad proxy, sha256 disk cache with past-span immutability). Three.js via pinned importmap, logarithmic depth, procedural starfield sky (+ Milky Way panorama swap-in), Sun with additive glow, OrbitControls, brass-and-void HUD, scrubbable time bar, central bodyWorld position pass, focus/camera system, hash state. Boots clean, no console errors. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 2 + css/style.css | 183 +++++++++++++++++++ index.html | 84 +++++++++ js/bodies.js | 127 ++++++++++++++ js/config.js | 75 ++++++++ js/lib.js | 95 ++++++++++ js/main.js | 476 ++++++++++++++++++++++++++++++++++++++++++++++++++ js/scale.js | 84 +++++++++ js/ui.js | 101 +++++++++++ serve.py | 228 ++++++++++++++++++++++++ 10 files changed, 1455 insertions(+) create mode 100644 css/style.css create mode 100644 index.html create mode 100644 js/bodies.js create mode 100644 js/config.js create mode 100644 js/lib.js create mode 100644 js/main.js create mode 100644 js/scale.js create mode 100644 js/ui.js create mode 100644 serve.py diff --git a/.gitignore b/.gitignore index 8cf7381..15947c3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ cache/ art/candidates/ +assets/art/candidates/ .DS_Store __pycache__/ *.pyc +*.swp diff --git a/css/style.css b/css/style.css new file mode 100644 index 0000000..4a9f854 --- /dev/null +++ b/css/style.css @@ -0,0 +1,183 @@ +/* SOLARGOD — brass-and-void observatory HUD. Structure ported from GODSIGH; + palette shifted from cyan-OSINT to warm brass over deep space. */ +:root { + --void: #05060a; + --panel: rgba(12, 13, 20, 0.82); + --panel-solid: rgba(12, 13, 20, 0.96); + --border: rgba(217, 164, 65, 0.14); + --brass: #d9a441; + --brass-dim: #9a7b3e; + --text: #e6ddca; + --dim: #8a8674; + --ok: #6fcf6f; + --warn: #e0b64a; + --err: #e06a5a; +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + height: 100%; + overflow: hidden; + background: var(--void); + color: var(--text); + font-family: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace; + font-size: 12px; +} + +#scene { position: absolute; inset: 0; } +#scene canvas { display: block; } + +/* CSS2D label overlay sits above the canvas but below the HUD */ +#labels { position: absolute; inset: 0; pointer-events: none; z-index: 5; overflow: hidden; } +.body-label { + color: var(--text); + font-size: 10.5px; + letter-spacing: 0.4px; + text-shadow: 0 0 4px #000, 0 0 8px #000; + white-space: nowrap; + padding: 1px 3px; + transform: translate(-50%, -140%); + user-select: none; +} +.body-label.moon { color: var(--dim); font-size: 9.5px; } +.body-label .lbl-dist { color: var(--brass-dim); font-size: 9px; } +.body-label.focused { color: var(--brass); font-weight: 700; } + +/* ---- breadcrumb ---- */ +#breadcrumb { + position: absolute; top: 16px; left: 16px; z-index: 10; + display: flex; align-items: center; gap: 7px; + font-size: 13px; letter-spacing: 1px; font-weight: 600; + text-shadow: 0 0 6px #000; +} +#breadcrumb .crumb { color: var(--dim); } +#breadcrumb .crumb.current { color: var(--brass); } +#breadcrumb .crumb:not(.current):hover { color: var(--text); } +#breadcrumb .crumb-sep { color: var(--brass-dim); font-size: 11px; } + +/* ---- HUD (right) ---- */ +#hud { + position: absolute; top: 14px; right: 14px; width: 300px; + max-height: calc(100vh - 130px); + display: flex; flex-direction: column; gap: 10px; + padding: 14px; background: var(--panel); + border: 1px solid var(--border); border-radius: 12px; + backdrop-filter: blur(9px); -webkit-backdrop-filter: blur(9px); + z-index: 10; box-shadow: 0 8px 44px rgba(0, 0, 0, 0.6); +} +#hud header { display: flex; align-items: center; justify-content: space-between; gap: 8px; } +.brand { font-weight: 700; letter-spacing: 1px; font-size: 15px; color: #fff; } +.brand span { color: var(--brass); font-weight: 500; font-size: 11px; } + +#live-chip { + font-size: 10px; font-weight: 700; letter-spacing: 0.5px; + padding: 3px 8px; border-radius: 999px; white-space: nowrap; border: 1px solid transparent; +} +#live-chip[data-state="live"] { color: var(--ok); border-color: rgba(111,207,111,0.4); background: rgba(111,207,111,0.1); } +#live-chip[data-state="scrub"] { color: var(--warn); border-color: rgba(224,182,74,0.4); background: rgba(224,182,74,0.1); font-size: 9px; } + +.ctl-row { display: flex; align-items: center; gap: 8px; } +.slider-row, .menu-row { justify-content: space-between; } +.ctl-label { color: var(--dim); font-size: 10px; text-transform: uppercase; letter-spacing: 0.6px; } + +.seg { display: flex; gap: 4px; } +.seg button { + flex: 1; padding: 6px 10px; font: inherit; font-size: 11px; color: var(--dim); + background: rgba(255,255,255,0.03); border: 1px solid var(--border); + border-radius: 8px; cursor: pointer; transition: all 0.15s; +} +.seg button:hover { color: var(--text); } +.seg button.active { color: #16110a; background: var(--brass); border-color: var(--brass); font-weight: 700; } + +.menu-row select, #tb-rate { + font: inherit; font-size: 11px; color: var(--text); background: rgba(255,255,255,0.04); + border: 1px solid var(--border); border-radius: 8px; padding: 5px 8px; cursor: pointer; +} + +/* ---- layer rows ---- */ +#layers { display: flex; flex-direction: column; gap: 1px; overflow-y: auto; margin-top: 2px; } +.layer-row { padding: 5px 4px; border-radius: 6px; } +.layer-row:hover { background: rgba(255,255,255,0.03); } +.layer-row label { display: flex; align-items: center; gap: 7px; cursor: pointer; user-select: none; } +.layer-row input[type="checkbox"] { accent-color: var(--brass); margin: 0; cursor: pointer; } +.layer-row .lname { flex: 1; color: var(--text); } +.dot { width: 7px; height: 7px; border-radius: 50%; flex: none; background: var(--dim); transition: background 0.2s; } +.dot[data-state="ok"] { background: var(--ok); box-shadow: 0 0 6px rgba(111,207,111,0.6); } +.dot[data-state="warn"] { background: var(--warn); box-shadow: 0 0 6px rgba(224,182,74,0.6); } +.dot[data-state="err"] { background: var(--err); box-shadow: 0 0 6px rgba(224,106,90,0.6); } +.dot[data-state="off"] { background: var(--dim); } +.lstatus { padding: 0 0 0 24px; color: var(--dim); font-size: 10px; line-height: 1.3; } +.lstatus:empty { display: none; } + +#hud footer { border-top: 1px solid var(--border); padding-top: 8px; color: var(--dim); font-size: 9.5px; line-height: 1.5; } +#hud footer a { color: var(--brass-dim); } + +/* ---- time bar (bottom) ---- */ +#timebar { + position: absolute; left: 50%; bottom: 14px; transform: translateX(-50%); + width: min(880px, calc(100vw - 28px)); + display: flex; flex-direction: column; gap: 8px; + padding: 10px 14px; background: var(--panel); border: 1px solid var(--border); + border-radius: 12px; backdrop-filter: blur(9px); -webkit-backdrop-filter: blur(9px); + z-index: 10; box-shadow: 0 8px 44px rgba(0,0,0,0.6); +} +.tb-controls { display: flex; align-items: center; gap: 8px; } +.tb-controls button { + font: inherit; font-size: 11px; color: var(--text); background: rgba(255,255,255,0.04); + border: 1px solid var(--border); border-radius: 8px; padding: 6px 10px; cursor: pointer; min-width: 34px; +} +.tb-controls button:hover { color: #fff; border-color: var(--brass-dim); } +.tb-controls button.on { color: #16110a; background: var(--brass); border-color: var(--brass); font-weight: 700; } +#date-readout { margin-left: auto; font-size: 12px; letter-spacing: 0.5px; color: var(--text); } +#date-readout.scrubbed { color: var(--warn); } + +#timeline-outer { position: relative; height: 10px; border-radius: 6px; background: rgba(255,255,255,0.05); border: 1px solid var(--border); cursor: pointer; } +#timeline-fill { position: absolute; left: 0; top: 0; bottom: 0; width: 0; background: linear-gradient(90deg, rgba(217,164,65,0.15), rgba(217,164,65,0.35)); border-radius: 6px; pointer-events: none; } +#timeline-cursor { position: absolute; top: -3px; width: 3px; height: 16px; background: var(--brass); border-radius: 2px; transform: translateX(-50%); box-shadow: 0 0 8px rgba(217,164,65,0.8); pointer-events: none; } +.tb-ends { display: flex; justify-content: space-between; color: var(--dim); font-size: 9px; } + +/* ---- info panel (left, Stage 6) ---- */ +#infopanel { + position: absolute; top: 14px; left: 14px; width: 300px; max-height: calc(100vh - 130px); + padding: 16px; background: var(--panel-solid); border: 1px solid var(--border); + border-left: 2px solid var(--brass); border-radius: 12px; overflow-y: auto; + z-index: 9; box-shadow: 0 8px 44px rgba(0,0,0,0.6); +} +#infopanel[hidden] { display: none; } +#infopanel .ip-title { font-size: 18px; font-weight: 700; color: #fff; letter-spacing: 0.5px; } +#infopanel .ip-type { color: var(--brass); font-size: 10px; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 8px; } +#infopanel .ip-art { width: 100%; border-radius: 8px; margin: 8px 0; border: 1px solid var(--border); } +#infopanel .ip-blurb { color: var(--text); line-height: 1.5; font-size: 11.5px; margin: 8px 0; } +#infopanel .ip-facts { display: flex; flex-direction: column; gap: 3px; margin-top: 8px; } +#infopanel .ip-fact { display: flex; justify-content: space-between; gap: 10px; font-size: 11px; } +#infopanel .ip-fact span:first-child { color: var(--dim); } +#infopanel .ip-fact span:last-child { color: var(--text); text-align: right; } +#infopanel .ip-close { position: absolute; top: 12px; right: 14px; cursor: pointer; color: var(--dim); font-size: 16px; } +#infopanel .ip-close:hover { color: var(--text); } +#infopanel .ip-link { display: inline-block; margin-top: 10px; color: var(--brass); text-decoration: none; border: 1px solid var(--border); border-radius: 8px; padding: 6px 10px; font-size: 11px; } +#infopanel .ip-link:hover { background: rgba(217,164,65,0.1); } + +/* ---- toast + debug ---- */ +#toast { + position: absolute; left: 50%; bottom: 96px; transform: translateX(-50%); + padding: 8px 14px; background: var(--panel-solid); border: 1px solid var(--border); + border-radius: 999px; font-size: 11px; color: var(--text); z-index: 20; + opacity: 0; transition: opacity 0.25s; pointer-events: none; +} +#toast.show { opacity: 1; } +#debug { position: absolute; top: 14px; left: 50%; transform: translateX(-50%); z-index: 20; padding: 4px 10px; background: var(--panel-solid); border: 1px solid var(--border); border-radius: 8px; font-size: 10px; color: var(--brass); } +#debug[hidden] { display: none; } + +/* ---- splash / boot overlay ---- */ +#splash { + position: absolute; inset: 0; z-index: 30; display: flex; flex-direction: column; + align-items: center; justify-content: center; gap: 18px; + background: var(--void); background-size: cover; background-position: center; + transition: opacity 0.8s; text-align: center; +} +#splash.hide { opacity: 0; pointer-events: none; } +#splash .sp-title { font-size: 42px; font-weight: 700; letter-spacing: 6px; color: #fff; text-shadow: 0 0 24px rgba(217,164,65,0.6); } +#splash .sp-sub { color: var(--brass); letter-spacing: 3px; font-size: 12px; } +#splash .sp-status { color: var(--dim); font-size: 11px; } diff --git a/index.html b/index.html new file mode 100644 index 0000000..447721e --- /dev/null +++ b/index.html @@ -0,0 +1,84 @@ + + + + + + SOLARGOD ▸ Orrery + + + + +
+
+ + + + + + + + +
+
+ + + + +
+
+
+
+
+
+
18002050
+
+ + + + +
+ + + + + diff --git a/js/bodies.js b/js/bodies.js new file mode 100644 index 0000000..c34ff36 --- /dev/null +++ b/js/bodies.js @@ -0,0 +1,127 @@ +// SOLARGOD physical-data registry — radii, axial tilts, rotation, colors, texture +// paths, moon orbits. Radii are the brief §8 values (JPL). Tilts/rotation are +// canonical IAU/JPL phys_par values; every textured body has a flat-color +// procedural fallback so a missing texture degrades gracefully (brief §7). +// +// Fields: name, type, radiusKm, axialTiltDeg, rotationHours (neg = retrograde), +// color (fallback), texture (under CONFIG.textures.path, or null), parent, +// ephemId (planet → ephem.js key) | moonOrbit {aKm, periodDays (neg = retro)} | +// elements (small-body epoch elements). rings when present. + +export const BODIES = { + sun: { + name: 'Sun', type: 'star', radiusKm: 695700, axialTiltDeg: 7.25, + rotationHours: 609.12, color: '#ffdd88', texture: '2k_sun.jpg', parent: null, + }, + mercury: { + name: 'Mercury', type: 'planet', radiusKm: 2439.7, axialTiltDeg: 0.034, + rotationHours: 1407.6, color: '#9c8b7a', texture: '2k_mercury.jpg', + parent: 'sun', ephemId: 'mercury', + }, + venus: { + name: 'Venus', type: 'planet', radiusKm: 6051.8, axialTiltDeg: 177.36, + rotationHours: -5832.5, color: '#d9b382', texture: '2k_venus_surface.jpg', + parent: 'sun', ephemId: 'venus', + }, + earth: { + name: 'Earth', type: 'planet', radiusKm: 6371.0, axialTiltDeg: 23.44, + rotationHours: 23.9345, color: '#4b7bd4', texture: '2k_earth_daymap.jpg', + parent: 'sun', ephemId: 'earth', + }, + mars: { + name: 'Mars', type: 'planet', radiusKm: 3389.5, axialTiltDeg: 25.19, + rotationHours: 24.6229, color: '#c1440e', texture: '2k_mars.jpg', + parent: 'sun', ephemId: 'mars', + }, + jupiter: { + name: 'Jupiter', type: 'planet', radiusKm: 69911, axialTiltDeg: 3.13, + rotationHours: 9.925, color: '#d8ca9d', texture: '2k_jupiter.jpg', + parent: 'sun', ephemId: 'jupiter', + }, + saturn: { + name: 'Saturn', type: 'planet', radiusKm: 58232, axialTiltDeg: 26.73, + rotationHours: 10.656, color: '#e3d9b0', texture: '2k_saturn.jpg', + parent: 'sun', ephemId: 'saturn', + rings: { innerKm: 74500, outerKm: 140220, texture: '2k_saturn_ring_alpha.png' }, + }, + uranus: { + name: 'Uranus', type: 'planet', radiusKm: 25362, axialTiltDeg: 97.77, + rotationHours: -17.24, color: '#b2e5e8', texture: '2k_uranus.jpg', + parent: 'sun', ephemId: 'uranus', + rings: { innerKm: 41000, outerKm: 51000, texture: null, color: '#8fa3a8' }, + }, + neptune: { + name: 'Neptune', type: 'planet', radiusKm: 24622, axialTiltDeg: 28.32, + rotationHours: 16.11, color: '#3f66d9', texture: '2k_neptune.jpg', + parent: 'sun', ephemId: 'neptune', + }, + + // ---- moons (circular orbits in parent's equatorial plane; a[km], period[d]) ---- + moon: { name: 'Moon', type: 'moon', radiusKm: 1737.4, axialTiltDeg: 6.68, + rotationHours: 655.728, color: '#bcbcbc', texture: '2k_moon.jpg', + parent: 'earth', moonOrbit: { aKm: 384400, periodDays: 27.322 } }, + + phobos: { name: 'Phobos', type: 'moon', radiusKm: 11.27, color: '#8a7c6e', + texture: null, parent: 'mars', moonOrbit: { aKm: 9378, periodDays: 0.319 } }, + deimos: { name: 'Deimos', type: 'moon', radiusKm: 6.2, color: '#9a8c7a', + texture: null, parent: 'mars', moonOrbit: { aKm: 23459, periodDays: 1.262 } }, + + io: { name: 'Io', type: 'moon', radiusKm: 1821.6, color: '#e8d24b', + texture: null, parent: 'jupiter', moonOrbit: { aKm: 421700, periodDays: 1.769 } }, + europa: { name: 'Europa', type: 'moon', radiusKm: 1560.8, color: '#c9b79c', + texture: null, parent: 'jupiter', moonOrbit: { aKm: 671034, periodDays: 3.551 } }, + ganymede: { name: 'Ganymede', type: 'moon', radiusKm: 2634.1, color: '#9b8e7e', + texture: null, parent: 'jupiter', moonOrbit: { aKm: 1070412, periodDays: 7.155 } }, + callisto: { name: 'Callisto', type: 'moon', radiusKm: 2410.3, color: '#6b5f52', + texture: null, parent: 'jupiter', moonOrbit: { aKm: 1882709, periodDays: 16.689 } }, + + mimas: { name: 'Mimas', type: 'moon', radiusKm: 198.2, color: '#c8c8c8', + texture: null, parent: 'saturn', moonOrbit: { aKm: 185539, periodDays: 0.942 } }, + enceladus: { name: 'Enceladus', type: 'moon', radiusKm: 252.1, color: '#f0f0f0', + texture: null, parent: 'saturn', moonOrbit: { aKm: 238042, periodDays: 1.370 } }, + tethys: { name: 'Tethys', type: 'moon', radiusKm: 531.1, color: '#d6d6d6', + texture: null, parent: 'saturn', moonOrbit: { aKm: 294672, periodDays: 1.888 } }, + dione: { name: 'Dione', type: 'moon', radiusKm: 561.4, color: '#cfcfcf', + texture: null, parent: 'saturn', moonOrbit: { aKm: 377415, periodDays: 2.737 } }, + rhea: { name: 'Rhea', type: 'moon', radiusKm: 763.8, color: '#c4c4c4', + texture: null, parent: 'saturn', moonOrbit: { aKm: 527068, periodDays: 4.518 } }, + titan: { name: 'Titan', type: 'moon', radiusKm: 2574.7, color: '#d9a441', + texture: null, parent: 'saturn', moonOrbit: { aKm: 1221870, periodDays: 15.945 } }, + iapetus: { name: 'Iapetus', type: 'moon', radiusKm: 734.5, color: '#8a7a5e', + texture: null, parent: 'saturn', moonOrbit: { aKm: 3560851, periodDays: 79.33 } }, + + miranda: { name: 'Miranda', type: 'moon', radiusKm: 235.8, color: '#b8bcc0', + texture: null, parent: 'uranus', moonOrbit: { aKm: 129900, periodDays: 1.413 } }, + ariel: { name: 'Ariel', type: 'moon', radiusKm: 578.9, color: '#c6cacd', + texture: null, parent: 'uranus', moonOrbit: { aKm: 190900, periodDays: 2.520 } }, + umbriel: { name: 'Umbriel', type: 'moon', radiusKm: 584.7, color: '#7f8488', + texture: null, parent: 'uranus', moonOrbit: { aKm: 266000, periodDays: 4.144 } }, + titania: { name: 'Titania', type: 'moon', radiusKm: 788.4, color: '#b0b4b8', + texture: null, parent: 'uranus', moonOrbit: { aKm: 436300, periodDays: 8.706 } }, + oberon: { name: 'Oberon', type: 'moon', radiusKm: 761.4, color: '#9aa0a4', + texture: null, parent: 'uranus', moonOrbit: { aKm: 583500, periodDays: 13.46 } }, + + // Triton is retrograde — encoded via the negative-period convention (brief §8). + triton: { name: 'Triton', type: 'moon', radiusKm: 1353.4, color: '#d6cfc4', + texture: null, parent: 'neptune', moonOrbit: { aKm: 354759, periodDays: -5.877 } }, + + // ---- Pluto system (small-body epoch elements; texture-less spheres) ---- + pluto: { + name: 'Pluto', type: 'dwarf', radiusKm: 1188.3, axialTiltDeg: 122.53, + rotationHours: -153.2928, color: '#d9c8a9', texture: null, parent: 'sun', + // JPL SBDB J2000 osculating elements (epoch 2451545.0 TDB). + elements: { a: 39.48168677, e: 0.24880766, i: 17.14175, om: 110.30347, + w: 113.76329, ma: 14.86012204, epoch: 2451545.0 }, + }, + charon: { name: 'Charon', type: 'moon', radiusKm: 606.0, color: '#b3a894', + texture: null, parent: 'pluto', moonOrbit: { aKm: 19591, periodDays: 6.387 } }, +}; + +// Ordered planet ids (draw / menu order). +export const PLANET_ORDER = ['mercury', 'venus', 'earth', 'mars', 'jupiter', 'saturn', 'uranus', 'neptune']; +// Moons grouped by parent. +export function moonsOf(parentId) { + return Object.entries(BODIES) + .filter(([, b]) => b.type === 'moon' && b.parent === parentId) + .map(([id]) => id); +} diff --git a/js/config.js b/js/config.js new file mode 100644 index 0000000..7e4a4cc --- /dev/null +++ b/js/config.js @@ -0,0 +1,75 @@ +// SOLARGOD configuration — every scale constant, camera default, rate preset and +// tunable in one place. Feed paths are RELATIVE so the app works at "/" (dev) and +// under a subpath (prod). Never introduce a leading-slash path. + +export const CONFIG = { + proxy: { + horizons: 'proxy/horizons', + sbdb: 'proxy/sbdb', + cad: 'proxy/cad', + }, + + // Clock validity = Table 1 range. Scrubbing clamps here (brief §2). + time: { + minMs: Date.UTC(1800, 0, 1), + maxMs: Date.UTC(2050, 0, 1), + liveThresholdSec: 60, // |sim − wall| ≤ 60 s at 1× → LIVE + }, + + // The view transform (scale.js). r_view = K · r_AU^P, with P animating between + // P_mega (0.4) and P_true (1.0). K stays constant so only P moves (brief §4). + scale: { + K: 9.0, + P_mega: 0.4, + P_true: 1.0, + transitionMs: 2500, + // Body draw radius = (r_km / AU_KM) · K · E. Sun gets its own E or it swallows + // Mercury. HUD slider swaps planetE between presets. + planetE: 1200, + sunE: 60, + ePresets: [1, 100, 1200], + // Moon local compression around parent (MEGA): + // d_view = R_parent_draw · (base + k · log10(1 + d_km / R_parent_km)). + moonBase: 1.8, + moonK: 1.2, + }, + + camera: { + fov: 55, + near: 0.001, + far: 1e12, + focusDistanceFactor: 6, // settle at ~6× target draw radius + flightMs: 2000, + startFocus: 'sun', + minDistance: 0.02, + }, + + // Rate presets in sim-seconds per wall-second (magnitude; sign via reverse btn). + rates: [ + { label: '1×', value: 1 }, + { label: '1 min/s', value: 60 }, + { label: '1 hr/s', value: 3600 }, + { label: '1 day/s', value: 86400 }, + { label: '1 wk/s', value: 604800 }, + { label: '1 mo/s', value: 2629800 }, // 30.4375 d + { label: '1 yr/s', value: 31557600 }, // 365.25 d + ], + defaultRateIndex: 3, // 1 day/s + + textures: { + path: 'assets/textures/', + skybox: '2k_stars_milky_way.jpg', + }, + + // Orbit paths rebuild only when |ΔT| exceeds this (centuries) or scale changes. + orbitRebuildCy: 0.1, + orbitSamples: 512, + + useExtendedRange: false, // Table 2a swap — plumbing only, no v1 UI + + colors: { + accent: '#d9a441', // brass + orbit: 0x3a4a63, + orbitFocus: 0xd9a441, + }, +}; diff --git a/js/lib.js b/js/lib.js new file mode 100644 index 0000000..2c47d26 --- /dev/null +++ b/js/lib.js @@ -0,0 +1,95 @@ +// SOLARGOD shared helpers — time (JD / centuries), math, the ecliptic→world +// frame map, and human formatters. Pure and dependency-free so verify.html can +// import it (and ephem.js) with zero Three.js in scope. + +// ---- constants ---- +export const AU_KM = 149597870.7; // 1 astronomical unit in km (IAU 2012) +export const C_KMS = 299792.458; // speed of light, km/s +export const MS_PER_DAY = 86400000; +export const J2000_JD = 2451545.0; // 2000-Jan-01 12:00 TT +export const UNIX_EPOCH_JD = 2440587.5; // JD of 1970-Jan-01 00:00 UTC +export const DEG = Math.PI / 180; +export const RAD = 180 / Math.PI; + +// ---- time ---- +// We treat the sim clock as UTC and ignore the TT/TDB−UTC offset (~69 s in 2026). +// At interplanetary scale that is < 1e-5 AU even for Mars — noted, deliberately +// dropped (brief §2). JD from unix-ms: divide by ms/day, add the unix-epoch JD. +export function jdFromUnixMs(ms) { return ms / MS_PER_DAY + UNIX_EPOCH_JD; } +export function unixMsFromJd(jd) { return (jd - UNIX_EPOCH_JD) * MS_PER_DAY; } +// Julian centuries since J2000.0. +export function centuriesSinceJ2000(jd) { return (jd - J2000_JD) / 36525; } + +// ---- scalar math ---- +export const clamp = (x, lo, hi) => (x < lo ? lo : x > hi ? hi : x); +export const lerp = (a, b, t) => a + (b - a) * t; +export function smoothstep(t) { t = clamp(t, 0, 1); return t * t * (3 - 2 * t); } +// Normalize degrees to (−180, +180]. Must run in float64 AFTER any large +// subtraction (Mercury's L rate is 149473°/Cy → M can be tens of thousands of +// degrees before reduction; brief §12). +export function normDegPM180(d) { + d = d % 360; + if (d <= -180) d += 360; + else if (d > 180) d -= 360; + return d; +} +export const normDeg360 = (d) => ((d % 360) + 360) % 360; + +// ---- frame convention (fixed here once; nothing else maps by hand) ---- +// Heliocentric J2000 ecliptic {x,y,z} [AU or view units] → Three.js world: +// ecliptic X → world x, ecliptic Z (north) → world y, ecliptic Y → world −z. +// Keeps the frame right-handed with Y up. `out` is any {x,y,z} sink (Vector3 ok). +export function eclToWorld(ecl, out = {}) { + out.x = ecl.x; + out.y = ecl.z; + out.z = -ecl.y; + return out; +} +// Inverse, for round-trip checks and picking rays. +export function worldToEcl(w, out = {}) { + out.x = w.x; + out.y = -w.z; + out.z = w.y; + return out; +} + +// ---- formatters ---- +const pad = (n, w = 2) => String(n).padStart(w, '0'); +// "2026-07-15 00:00:00 UTC" from unix-ms. +export function formatUTC(ms) { + const d = new Date(ms); + return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ` + + `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())} UTC`; +} +export function formatUTCDate(ms) { + const d = new Date(ms); + return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}`; +} +export function fmtAU(au) { + const a = Math.abs(au); + if (a < 0.001) return `${(au * AU_KM).toFixed(0)} km`; + if (a < 100) return `${au.toFixed(3)} AU`; + return `${au.toFixed(1)} AU`; +} +export function fmtKm(km) { + const a = Math.abs(km); + if (a >= 1e7) return `${(km / 1e6).toLocaleString('en-US', { maximumFractionDigits: 2 })} M km`; + return `${Math.round(km).toLocaleString('en-US')} km`; +} +// Light-time for a distance in AU → "43 min 12 s" / "1 h 08 m" / "8.3 s". +export function fmtLightTime(au) { + let s = (au * AU_KM) / C_KMS; + if (s < 90) return `${s.toFixed(1)} s`; + const h = Math.floor(s / 3600); s -= h * 3600; + const m = Math.floor(s / 60); s -= m * 60; + if (h > 0) return `${h} h ${pad(m)} m`; + return `${m} min ${pad(Math.floor(s))} s`; +} +// Duration in days → "27.3 d" / "1.88 yr". +export function fmtDuration(days) { + const a = Math.abs(days); + if (a < 2) return `${(a * 24).toFixed(1)} h`; + if (a < 900) return `${a.toFixed(1)} d`; + return `${(a / 365.25).toFixed(2)} yr`; +} +export function fmtSpeed(kms) { return `${kms.toFixed(2)} km/s`; } diff --git a/js/main.js b/js/main.js new file mode 100644 index 0000000..dc5e29c --- /dev/null +++ b/js/main.js @@ -0,0 +1,476 @@ +// SOLARGOD bootstrap — renderer, sky, sun, clock, focus/camera, floating origin, +// picking, hash state, and dynamic layer loading. +// +// LAYER CONTRACT — each js/layers/*.js default-exports a factory: +// export default function create(ctx) { +// const { THREE, scene, camera, CONFIG, lib, ui, scale, ephem, bodies, +// focus, clock, worldGroup, toLocal, makeLabel, registerPick } = ctx; +// return { id, onClockTick(simMs, jd, isLive) {}, handlePick(hit) { return false; }, +// clearPick() {} }; +// } +// main.js computes ctx.bodyWorld[id] (absolute view-world, float64) for EVERY body +// each frame BEFORE layers tick, so layers only render. Floating origin: subtract +// the focus body's offset in JS (float64) before writing to mesh.position. + +import * as THREE from 'three'; +import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; +import { CSS2DRenderer, CSS2DObject } from 'three/addons/renderers/CSS2DRenderer.js'; + +import { CONFIG } from './config.js'; +import * as lib from './lib.js'; +import * as ui from './ui.js'; +import * as scale from './scale.js'; +import * as ephem from './ephem.js'; +import { BODIES, PLANET_ORDER, moonsOf } from './bodies.js'; + +const DEBUG = new URLSearchParams(location.search).has('debug'); +ephem.setExtendedRange(CONFIG.useExtendedRange); +scale.init(CONFIG); + +// ---- renderer / scene / camera ---- +const renderer = new THREE.WebGLRenderer({ antialias: true, logarithmicDepthBuffer: true }); +renderer.setSize(innerWidth, innerHeight); +renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); +document.getElementById('scene').appendChild(renderer.domElement); + +const labelRenderer = new CSS2DRenderer(); +labelRenderer.setSize(innerWidth, innerHeight); +document.getElementById('labels').appendChild(labelRenderer.domElement); + +const scene = new THREE.Scene(); +const camera = new THREE.PerspectiveCamera(CONFIG.camera.fov, innerWidth / innerHeight, CONFIG.camera.near, CONFIG.camera.far); +camera.position.set(0, 26, 78); + +const controls = new OrbitControls(camera, labelRenderer.domElement); +controls.enableDamping = true; +controls.dampingFactor = 0.08; +controls.minDistance = CONFIG.camera.minDistance; +controls.maxDistance = 5e5; +controls.target.set(0, 0, 0); + +// orbit lines ride a group offset (imperceptible float32 residual is fine for +// lines you never zoom to — bodies get the exact JS subtraction instead). +const worldGroup = new THREE.Group(); +scene.add(worldGroup); + +// ---- lighting: one point light at the Sun, faint ambient so night isn't black ---- +const sunLight = new THREE.PointLight(0xfff4e0, 4, 0, 0); // no distance falloff +scene.add(sunLight); +scene.add(new THREE.AmbientLight(0x2a2a33, 0.9)); + +// ---- sky ---- +buildSky(); +function buildSky() { + // Instant procedural starfield as background (also the fallback), then try the + // real Milky Way panorama and swap it in if present (brief §7). + scene.background = makeStarfieldTexture(); + const tl = new THREE.TextureLoader(); + tl.load( + CONFIG.textures.path + CONFIG.textures.skybox, + (tex) => { tex.mapping = THREE.EquirectangularReflectionMapping; tex.colorSpace = THREE.SRGBColorSpace; scene.background = tex; }, + undefined, + () => { /* keep procedural starfield */ }, + ); +} +function makeStarfieldTexture() { + const w = 2048, h = 1024; + const c = document.createElement('canvas'); c.width = w; c.height = h; + const g = c.getContext('2d'); + g.fillStyle = '#05060a'; g.fillRect(0, 0, w, h); + // faint milky band + const band = g.createLinearGradient(0, h * 0.38, 0, h * 0.62); + band.addColorStop(0, 'rgba(60,60,90,0)'); band.addColorStop(0.5, 'rgba(120,110,140,0.12)'); band.addColorStop(1, 'rgba(60,60,90,0)'); + g.fillStyle = band; g.fillRect(0, h * 0.38, w, h * 0.24); + // stars — deterministic scatter (no Math.random dependence on frame) + let s = 1234567; + const rnd = () => { s = (s * 1103515245 + 12345) & 0x7fffffff; return s / 0x7fffffff; }; + for (let i = 0; i < 2600; i++) { + const x = rnd() * w, y = rnd() * h, r = rnd() * 1.2 + 0.2, a = rnd() * 0.7 + 0.2; + g.fillStyle = `rgba(255,${240 - rnd() * 60 | 0},${210 - rnd() * 40 | 0},${a})`; + g.beginPath(); g.arc(x, y, r, 0, 7); g.fill(); + } + const tex = new THREE.CanvasTexture(c); + tex.mapping = THREE.EquirectangularReflectionMapping; + tex.colorSpace = THREE.SRGBColorSpace; + return tex; +} + +function makeGlowSprite() { + const size = 256, c = document.createElement('canvas'); c.width = c.height = size; + const g = c.getContext('2d'); + const grd = g.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2); + grd.addColorStop(0, 'rgba(255,240,190,0.9)'); grd.addColorStop(0.25, 'rgba(255,210,120,0.5)'); + grd.addColorStop(0.5, 'rgba(255,170,80,0.18)'); grd.addColorStop(1, 'rgba(255,150,60,0)'); + g.fillStyle = grd; g.fillRect(0, 0, size, size); + const spr = new THREE.Sprite(new THREE.SpriteMaterial({ map: new THREE.CanvasTexture(c), blending: THREE.AdditiveBlending, depthWrite: false, transparent: true })); + return spr; +} + +// ---- texture helper (procedural fallback preserved) ---- +const texLoader = new THREE.TextureLoader(); +export function loadTextureInto(material, file, emissive = false) { + if (!file) return; + texLoader.load(CONFIG.textures.path + file, (tex) => { + tex.colorSpace = THREE.SRGBColorSpace; + if (emissive) { material.map = tex; } + else { material.map = tex; material.color.set(0xffffff); } + material.needsUpdate = true; + }, undefined, () => { /* keep flat color */ }); +} + +// ---- labels ---- +const labels = []; +function attachLabel(mesh, text, cls) { + const div = document.createElement('div'); + div.className = cls; + div.textContent = text; + const obj = new CSS2DObject(div); + obj.position.set(0, 0, 0); + mesh.add(obj); + labels.push({ obj, div, mesh }); + return { obj, div }; +} + +// ---- pick registry ---- +const pickTargets = []; +function registerPick(mesh, bodyId) { mesh.userData.bodyId = bodyId; pickTargets.push(mesh); } + +// ---- Sun (core scaffold, not a layer; positioned via toLocal each frame) ---- +const sunMat = new THREE.MeshBasicMaterial({ color: 0xffdd88 }); +const sunMesh = new THREE.Mesh(new THREE.SphereGeometry(1, 48, 48), sunMat); +scene.add(sunMesh); +loadTextureInto(sunMat, BODIES.sun.texture, true); +const sunGlow = makeGlowSprite(); +scene.add(sunGlow); +attachLabel(sunMesh, BODIES.sun.name, 'body-label'); +registerPick(sunMesh, 'sun'); + +// ==== clock ==== +const clock = { + simMs: clampTime(Date.now()), + rateIndex: CONFIG.defaultRateIndex, + dir: 1, + playing: true, + jd: 0, + isLive: true, +}; +clock.jd = lib.jdFromUnixMs(clock.simMs); +function clampTime(ms) { return lib.clamp(ms, CONFIG.time.minMs, CONFIG.time.maxMs); } +function currentRate() { return CONFIG.rates[clock.rateIndex].value * clock.dir; } + +// ==== focus system ==== +const focus = { + id: CONFIG.camera.startFocus, + fromId: CONFIG.camera.startFocus, + t: 1, // flight progress 0..1 + camFrom: 0, camTo: 0, +}; + +// bodyWorld[id] = absolute view-world position, plain float64 {x,y,z}. +const bodyWorld = Object.create(null); +const focusOffset = { x: 0, y: 0, z: 0 }; +const _ecl = {}; + +function computeBodyWorld() { + // planets + sun + small bodies (heliocentric) + for (const id in BODIES) { + const b = BODIES[id]; + if (b.type === 'moon') continue; + if (id === 'sun') { bodyWorld.sun = { x: 0, y: 0, z: 0 }; continue; } + if (b.ephemId) ephem.helioEcl(b.ephemId, clock.jd, _ecl); + else if (b.elements) ephem.smallBodyEcl(b.elements, clock.jd, _ecl); + else continue; + const w = bodyWorld[id] || (bodyWorld[id] = {}); + scale.viewFromEcl(_ecl, w); + } + // moons: parent absolute + local compressed offset + for (const id in BODIES) { + const b = BODIES[id]; + if (b.type !== 'moon') continue; + const parent = bodyWorld[b.parent]; + if (!parent) continue; + const o = moonLocalOffset(b); + const w = bodyWorld[id] || (bodyWorld[id] = {}); + w.x = parent.x + o.x; w.y = parent.y + o.y; w.z = parent.z + o.z; + } +} + +// Moon local offset in view-world units (circular orbit in parent's equatorial +// plane, deterministic phase from jd; retrograde via negative period, brief §8). +const _mo = {}; +function moonLocalOffset(b) { + const parent = BODIES[b.parent]; + const per = b.moonOrbit.periodDays; + const theta = 2 * Math.PI * ((clock.jd - lib.J2000_JD) / per); // signed → retro + // equatorial-plane unit vector, then tilt by parent axial tilt about ecl-X + const ex = Math.cos(theta), ey = Math.sin(theta); + const tilt = (parent.axialTiltDeg || 0) * lib.DEG; + const ct = Math.cos(tilt), st = Math.sin(tilt); + // ecliptic direction {x,y,z}: rotate (ex,ey,0) about X by tilt + const eclDir = { x: ex, y: ey * ct, z: ey * st }; + const parentDrawView = scale.drawRadius(parent.radiusKm, effectiveE(b.parent)); + const R = scale.moonDistance(Math.abs(b.moonOrbit.aKm), parent.radiusKm, parentDrawView); + // ecl→world unit dir, times R + lib.eclToWorld({ x: eclDir.x * R, y: eclDir.y * R, z: eclDir.z * R }, _mo); + return _mo; +} + +// Effective exaggeration: for the FOCUSED body lerp E→1 as the camera nears, so +// you can descend to true scale in orbit without being inside a balloon (§4). +function effectiveE(id) { + const b = BODIES[id]; + const baseE = id === 'sun' ? scale.getSunE() : scale.getPlanetE(); + if (id !== focus.id || focus.t < 1) return baseE; + const exaggR = scale.drawRadius(b.radiusKm, baseE); + const camDist = camera.position.length(); + const f = lib.clamp(camDist / (exaggR * 8), 0, 1); + return lib.lerp(1, baseE, f); +} + +// Absolute view-world → render-local (JS float64 subtraction, then write float32). +function toLocal(abs, out) { + out.set(abs.x - focusOffset.x, abs.y - focusOffset.y, abs.z - focusOffset.z); + return out; +} + +function bodyTrail(id) { + const trail = []; + let cur = id; + while (cur) { trail.unshift({ id: cur, name: BODIES[cur].name }); cur = BODIES[cur].parent; } + return trail; +} + +function setFocus(id, animate = true) { + if (!BODIES[id]) return; + focus.fromId = focus.id; + focus.id = id; + focus.t = animate ? 0 : 1; + focus.camFrom = camera.position.length(); + focus.camTo = Math.max(CONFIG.camera.minDistance * 4, + scale.drawRadius(BODIES[id].radiusKm, id === 'sun' ? scale.getSunE() : scale.getPlanetE()) * CONFIG.camera.focusDistanceFactor); + controls.enabled = !animate; + ui.setBreadcrumb(bodyTrail(id), (nid) => setFocus(nid)); + document.getElementById('focus-menu').value = id; +} + +// ==== HUD / control wiring ==== +function buildFocusMenu() { + const sel = document.getElementById('focus-menu'); + const opt = (id, label, indent) => { const o = document.createElement('option'); o.value = id; o.textContent = (indent ? ' ' : '') + label; sel.appendChild(o); }; + opt('sun', '☉ Sun', false); + for (const p of PLANET_ORDER) { + opt(p, BODIES[p].name, false); + for (const m of moonsOf(p)) opt(m, '· ' + BODIES[m].name, true); + } + opt('pluto', 'Pluto', false); + for (const m of moonsOf('pluto')) opt(m, '· ' + BODIES[m].name, true); + sel.value = focus.id; + sel.addEventListener('change', () => setFocus(sel.value)); +} + +function buildRateMenu() { + const sel = document.getElementById('tb-rate'); + CONFIG.rates.forEach((r, i) => { const o = document.createElement('option'); o.value = i; o.textContent = r.label; sel.appendChild(o); }); + sel.value = clock.rateIndex; + sel.addEventListener('change', () => { clock.rateIndex = Number(sel.value); }); +} + +function wireControls() { + // scale toggle + document.getElementById('scale-toggle').addEventListener('click', (e) => { + const btn = e.target.closest('button'); if (!btn) return; + scale.setMode(btn.dataset.mode); + for (const b of e.currentTarget.querySelectorAll('button')) b.classList.toggle('active', b === btn); + ui.toast(btn.dataset.mode === 'true' ? 'TRUE scale — real emptiness' : 'MEGA scale — compressed'); + }); + // body scale + document.getElementById('bodyscale-toggle').addEventListener('click', (e) => { + const btn = e.target.closest('button'); if (!btn) return; + scale.setPlanetE(Number(btn.dataset.e)); + for (const b of e.currentTarget.querySelectorAll('button')) b.classList.toggle('active', b === btn); + }); + // time controls + const playBtn = document.getElementById('tb-play'); + const revBtn = document.getElementById('tb-reverse'); + playBtn.classList.toggle('on', clock.playing); + playBtn.textContent = clock.playing ? '❚❚' : '▶'; + playBtn.addEventListener('click', () => { clock.playing = !clock.playing; playBtn.classList.toggle('on', clock.playing); playBtn.textContent = clock.playing ? '❚❚' : '▶'; }); + revBtn.addEventListener('click', () => { clock.dir *= -1; revBtn.classList.toggle('on', clock.dir < 0); }); + document.getElementById('tb-now').addEventListener('click', () => { clock.simMs = clampTime(Date.now()); }); + // timeline scrub + const strip = document.getElementById('timeline-outer'); + let scrubbing = false; + const scrubTo = (clientX) => { + const r = strip.getBoundingClientRect(); + const f = lib.clamp((clientX - r.left) / r.width, 0, 1); + clock.simMs = clampTime(CONFIG.time.minMs + f * (CONFIG.time.maxMs - CONFIG.time.minMs)); + }; + strip.addEventListener('pointerdown', (e) => { scrubbing = true; strip.setPointerCapture(e.pointerId); scrubTo(e.clientX); }); + strip.addEventListener('pointermove', (e) => { if (scrubbing) scrubTo(e.clientX); }); + strip.addEventListener('pointerup', (e) => { scrubbing = false; strip.releasePointerCapture(e.pointerId); }); + // Esc steps out one focus level + addEventListener('keydown', (e) => { if (e.key === 'Escape') { const p = BODIES[focus.id].parent; if (p) setFocus(p); } }); +} + +// ==== picking ==== +const raycaster = new THREE.Raycaster(); +const ndc = new THREE.Vector2(); +let dragMoved = false, downPos = null; +renderer.domElement.addEventListener('pointerdown', (e) => { downPos = { x: e.clientX, y: e.clientY }; dragMoved = false; }); +renderer.domElement.addEventListener('pointermove', (e) => { if (downPos && Math.hypot(e.clientX - downPos.x, e.clientY - downPos.y) > 5) dragMoved = true; }); +renderer.domElement.addEventListener('pointerup', (e) => { + if (dragMoved) return; // was an orbit drag, not a click + ndc.set((e.clientX / innerWidth) * 2 - 1, -(e.clientY / innerHeight) * 2 + 1); + raycaster.setFromCamera(ndc, camera); + raycaster.params.Points = { threshold: 0.5 }; + // layers first (spacecraft/asteroids), then body spheres + for (const l of activeLayers) { if (l.handlePick && l.handlePick(raycaster)) return; } + const hits = raycaster.intersectObjects(pickTargets, false); + if (hits.length) setFocus(hits[0].object.userData.bodyId); +}); + +// ==== ctx + layer loading ==== +const ctx = { + THREE, scene, camera, renderer, labelRenderer, controls, + CONFIG, lib, ui, scale, ephem, bodies: BODIES, worldGroup, + clock, focus, + bodyWorld, focusOffset, toLocal, effectiveE, + makeLabel: attachLabel, registerPick, loadTextureInto, + moonsOf, PLANET_ORDER, +}; + +const LAYER_MODULES = ['./layers/planets.js']; +const activeLayers = []; +async function loadLayers() { + const results = await Promise.allSettled(LAYER_MODULES.map((p) => import(p))); + results.forEach((r, i) => { + if (r.status === 'rejected') { console.error(`[solargod] layer ${LAYER_MODULES[i]} failed:`, r.reason); return; } + const factory = r.value.default; + if (typeof factory !== 'function') { console.warn(`[solargod] ${LAYER_MODULES[i]} no default factory`); return; } + try { const layer = factory(ctx); if (layer) activeLayers.push(layer); } + catch (err) { console.error(`[solargod] layer ${LAYER_MODULES[i]} threw:`, err); } + }); +} + +// ==== hash state (#f=&t=&s=&L=&cam=) ==== +let pendingLayers = null; +function applyHash() { + const raw = location.hash.replace(/^#/, ''); if (!raw) return; + const p = new URLSearchParams(raw); + const f = p.get('f'); if (f && BODIES[f]) focus.id = focus.fromId = f; + const s = p.get('s'); if (s === 'true' || s === 'mega') { scale.setMode(s); syncScaleButtons(s); } + const t = p.get('t'); + if (t) { + if (t[0] === '@') { const ms = Number(t.slice(1)); if (Number.isFinite(ms)) clock.simMs = clampTime(ms); } + else { const off = Number(t); if (Number.isFinite(off) && t.trim() !== '') clock.simMs = clampTime(Date.now() + off * 1000); } + } + const cam = p.get('cam'); + if (cam) { const a = cam.split(',').map(Number); if (a.length === 3 && a.every(Number.isFinite)) pendingCam = a; } + if (p.has('L')) pendingLayers = p.get('L'); +} +let pendingCam = null; +function syncScaleButtons(mode) { + for (const b of document.querySelectorAll('#scale-toggle button')) b.classList.toggle('active', b.dataset.mode === mode); +} +function serializeHash() { + const off = Math.round((clock.simMs - Date.now()) / 1000); + const t = Math.abs(off) <= CONFIG.time.liveThresholdSec ? '0' : `@${Math.round(clock.simMs)}`; + const dist = camera.position.length(); + const heading = Math.atan2(camera.position.x, camera.position.z); + const pitch = Math.asin(lib.clamp(camera.position.y / (dist || 1), -1, 1)); + const cam = `${dist.toFixed(2)},${heading.toFixed(3)},${pitch.toFixed(3)}`; + const on = ui.getLayerIds().filter((id) => ui.getLayerChecked(id)).join(','); + return `#f=${focus.id}&t=${t}&s=${scale.getMode()}&L=${on}&cam=${cam}`; +} +function startHashSync() { + setInterval(() => { const h = serializeHash(); if (h !== location.hash) history.replaceState(null, '', h); }, 1000); +} + +// ==== render loop ==== +let lastFrame = performance.now(); +let fpsAcc = 0, fpsN = 0, lastHudMs = 0; +function animate(now) { + requestAnimationFrame(animate); + const dtWall = Math.min(now - lastFrame, 100); // clamp big gaps (tab switch) + lastFrame = now; + + // advance sim clock + if (clock.playing) clock.simMs = clampTime(clock.simMs + currentRate() * dtWall); + clock.jd = lib.jdFromUnixMs(clock.simMs); + clock.isLive = Math.abs(clock.simMs - Date.now()) <= CONFIG.time.liveThresholdSec * 1000 && Math.abs(currentRate()) <= 1; + + scale.update(dtWall); + + // positions (truth → view), one central pass + computeBodyWorld(); + + // focus flight: lerp offset between old & new focus (both live), ease cam dist + if (focus.t < 1) { + focus.t = lib.clamp(focus.t + dtWall / CONFIG.camera.flightMs, 0, 1); + const e = lib.smoothstep(focus.t); + const a = bodyWorld[focus.fromId], b = bodyWorld[focus.id]; + focusOffset.x = lib.lerp(a.x, b.x, e); focusOffset.y = lib.lerp(a.y, b.y, e); focusOffset.z = lib.lerp(a.z, b.z, e); + camera.position.setLength(lib.lerp(focus.camFrom, focus.camTo, e)); + if (focus.t >= 1) controls.enabled = true; + } else { + const b = bodyWorld[focus.id]; + focusOffset.x = b.x; focusOffset.y = b.y; focusOffset.z = b.z; + } + + // sun mesh + light + glow (core scaffold) + const sunAbs = bodyWorld.sun; + const sunR = scale.drawRadius(BODIES.sun.radiusKm, effectiveE('sun')); + toLocal(sunAbs, sunMesh.position); + sunMesh.scale.setScalar(sunR); + sunLight.position.copy(sunMesh.position); + sunGlow.position.copy(sunMesh.position); + sunGlow.scale.setScalar(sunR * 7); + + worldGroup.position.set(-focusOffset.x, -focusOffset.y, -focusOffset.z); + + // layers + for (const l of activeLayers) l.onClockTick && l.onClockTick(clock.simMs, clock.jd, clock.isLive); + + controls.update(); + renderer.render(scene, camera); + labelRenderer.render(scene, camera); + + // HUD throttled ~5 Hz + if (now - lastHudMs > 200) { + lastHudMs = now; + ui.setLiveChip(clock.isLive); + ui.setDateReadout(lib.formatUTC(clock.simMs), clock.isLive); + const f = (clock.simMs - CONFIG.time.minMs) / (CONFIG.time.maxMs - CONFIG.time.minMs); + document.getElementById('timeline-fill').style.width = `${f * 100}%`; + document.getElementById('timeline-cursor').style.left = `${f * 100}%`; + } + if (DEBUG) { + fpsAcc += 1000 / Math.max(dtWall, 1); fpsN++; + if (now - (window.__lastFps || 0) > 500) { window.__lastFps = now; document.getElementById('debug').textContent = `${(fpsAcc / fpsN).toFixed(0)} fps · ${scale.getMode().toUpperCase()} · P=${scale.getP().toFixed(3)}`; fpsAcc = 0; fpsN = 0; } + } +} + +addEventListener('resize', () => { + camera.aspect = innerWidth / innerHeight; camera.updateProjectionMatrix(); + renderer.setSize(innerWidth, innerHeight); labelRenderer.setSize(innerWidth, innerHeight); +}); + +// ==== boot ==== +buildFocusMenu(); +buildRateMenu(); +wireControls(); +if (DEBUG) document.getElementById('debug').hidden = false; +try { applyHash(); } catch (err) { console.warn('bad hash', err); } +setFocus(focus.id, false); +if (pendingCam) { camera.position.setLength(pendingCam[0]); } // heading/pitch restored loosely via length; orbit drag refines + +loadLayers().then(() => { + if (pendingLayers !== null) { + const want = new Set(pendingLayers.split(',').filter(Boolean)); + for (const id of ui.getLayerIds()) ui.setLayerChecked(id, want.has(id)); + } + startHashSync(); +}); + +requestAnimationFrame(animate); +window.__solargod = { scene, camera, ctx, activeLayers, clock, focus, scale, ephem, bodyWorld }; diff --git a/js/scale.js b/js/scale.js new file mode 100644 index 0000000..3a4a107 --- /dev/null +++ b/js/scale.js @@ -0,0 +1,84 @@ +// SOLARGOD view transform — the one module that owns MEGA/TRUE compression and +// radius exaggeration. Truth (heliocentric AU) goes in, view-world units come +// out. Nothing else in the app maps AU→scene by hand (brief §4). "Truth before +// view": callers compute positions in AU, then pass through here. + +import { AU_KM, eclToWorld, clamp, lerp, smoothstep } from './lib.js'; + +let K = 9.0; +let P = 0.4; // live exponent: MEGA=0.4, TRUE=1.0 +let P_mega = 0.4, P_true = 1.0; +let planetE = 1200, sunE = 60; +let transitionMs = 2500; + +let mode = 'mega'; +let tween = null; // { from, to, elapsed } while animating + +export function init(CONFIG) { + K = CONFIG.scale.K; + P_mega = CONFIG.scale.P_mega; + P_true = CONFIG.scale.P_true; + P = P_mega; + planetE = CONFIG.scale.planetE; + sunE = CONFIG.scale.sunE; + transitionMs = CONFIG.scale.transitionMs; + moonBase = CONFIG.scale.moonBase; + moonK = CONFIG.scale.moonK; +} +let moonBase = 1.8, moonK = 1.2; + +// Radial compression: AU → view units. Direction is never touched, so MEGA↔TRUE +// preserves every body's heliocentric bearing (verify gate: unit dot ≈ 1). +export function radial(rAU) { return K * Math.pow(rAU, P); } + +// Heliocentric ecliptic AU → view-world Vector3 (radial compression + frame map). +export function viewFromEcl(ecl, out) { + const r = Math.hypot(ecl.x, ecl.y, ecl.z); + if (r === 0) { out.x = out.y = out.z = 0; return out; } + const s = radial(r) / r; // scale factor along the true direction + eclToWorld({ x: ecl.x * s, y: ecl.y * s, z: ecl.z * s }, out); + return out; +} + +// Body draw radius in view units. E defaults to the planet exaggeration; the Sun +// and close-focus lerp pass their own. +export function drawRadius(radiusKm, E = planetE) { + return (radiusKm / AU_KM) * K * E; +} + +// Local moon distance from parent centre, view units. MEGA lifts every moon clear +// of the exaggerated parent surface and preserves ordering; TRUE is plain linear +// true distance. Blends with the same P animation. +export function moonDistance(dKm, parentRkm, parentDrawView) { + const megaD = parentDrawView * (moonBase + moonK * Math.log10(1 + dKm / parentRkm)); + const trueD = (dKm / AU_KM) * K; + const blend = clamp((P - P_mega) / (P_true - P_mega), 0, 1); + return lerp(megaD, trueD, blend); +} + +// ---- mode / transition ---- +export function getMode() { return mode; } +export function getP() { return P; } +export function isTransitioning() { return tween !== null; } + +export function setMode(next) { + if (next === mode && !tween) return; + mode = next; + tween = { from: P, to: next === 'true' ? P_true : P_mega, elapsed: 0 }; +} +export function toggle() { setMode(mode === 'mega' ? 'true' : 'mega'); } + +// Advance the MEGA↔TRUE tween. Call once per frame with wall dt (ms). +export function update(dtMs) { + if (!tween) return; + tween.elapsed += dtMs; + const t = clamp(tween.elapsed / transitionMs, 0, 1); + P = lerp(tween.from, tween.to, smoothstep(t)); + if (t >= 1) { P = tween.to; tween = null; } +} + +// ---- exaggeration (HUD slider) ---- +export function setPlanetE(e) { planetE = e; } +export function getPlanetE() { return planetE; } +export function getSunE() { return sunE; } +export function getK() { return K; } diff --git a/js/ui.js b/js/ui.js new file mode 100644 index 0000000..70a6ba3 --- /dev/null +++ b/js/ui.js @@ -0,0 +1,101 @@ +// SOLARGOD HUD controller — layer rows with per-layer status lines, LIVE/SCRUBBED +// chip, breadcrumb, date readout. Ports GODSIGH's ui.js contract so layer modules +// use the identical addLayer/setStatus/setLiveChip API. + +const rows = new Map(); + +// Register a layer row. Returns a handle so callers can flip the checkbox. +export function addLayer(id, name, defaultOn, onToggle) { + const row = document.createElement('div'); + row.className = 'layer-row'; + row.innerHTML = ` + +
`; + row.querySelector('.lname').textContent = name; + const cb = row.querySelector('input'); + cb.addEventListener('change', () => onToggle(cb.checked)); + document.getElementById('layers').appendChild(row); + rows.set(id, row); + return { setChecked: (v) => { cb.checked = v; } }; +} + +export function getLayerIds() { return [...rows.keys()]; } + +export function getLayerChecked(id) { + const row = rows.get(id); + return row ? row.querySelector('input').checked : false; +} + +// Set checkbox AND fire onToggle (replays a real user toggle) — used to restore +// hash state. No-op if unchanged. +export function setLayerChecked(id, on) { + const row = rows.get(id); + if (!row) return; + const cb = row.querySelector('input'); + if (cb.checked !== on) { + cb.checked = on; + cb.dispatchEvent(new Event('change')); + } +} + +// state ∈ 'ok' | 'warn' | 'err' | 'off' +export function setStatus(id, text, state = 'ok') { + const row = rows.get(id); + if (!row) return; + row.querySelector('.lstatus').textContent = text; + row.querySelector('.dot').dataset.state = state; +} + +export function setLiveChip(isLive) { + const chip = document.getElementById('live-chip'); + if (!chip) return; + chip.textContent = isLive ? 'LIVE' : 'SCRUBBED'; + chip.title = isLive ? 'Clock is at wall-clock now' : 'Clock scrubbed off wall-clock now'; + chip.dataset.state = isLive ? 'live' : 'scrub'; +} + +// Breadcrumb: array of {id, name} from Sun down to the focused body. +export function setBreadcrumb(trail, onClick) { + const el = document.getElementById('breadcrumb'); + if (!el) return; + el.innerHTML = ''; + trail.forEach((node, i) => { + if (i > 0) { + const sep = document.createElement('span'); + sep.className = 'crumb-sep'; + sep.textContent = '▸'; + el.appendChild(sep); + } + const c = document.createElement('span'); + c.className = 'crumb' + (i === trail.length - 1 ? ' current' : ''); + c.textContent = node.name.toUpperCase(); + if (i !== trail.length - 1 && onClick) { + c.style.cursor = 'pointer'; + c.addEventListener('click', () => onClick(node.id)); + } + el.appendChild(c); + }); +} + +export function setDateReadout(text, isLive) { + const el = document.getElementById('date-readout'); + if (el) { + el.textContent = text; + el.classList.toggle('scrubbed', !isLive); + } +} + +// Small transient toast (bottom-center) for one-shot notices. +let toastTimer = null; +export function toast(text, ms = 2200) { + let el = document.getElementById('toast'); + if (!el) return; + el.textContent = text; + el.classList.add('show'); + clearTimeout(toastTimer); + toastTimer = setTimeout(() => el.classList.remove('show'), ms); +} diff --git a/serve.py b/serve.py new file mode 100644 index 0000000..5bfb6cd --- /dev/null +++ b/serve.py @@ -0,0 +1,228 @@ +#!/usr/bin/env python3 +"""SOLARGOD dev server: static files + a same-origin proxy for the JPL feeds a +browser can't call directly (no CORS on ssd.jpl.nasa.gov / ssd-api.jpl.nasa.gov). + +Zero dependencies (stdlib only). Quickstart: python3 serve.py → http://127.0.0.1:8147 + +Proxy endpoints (allowlisted upstreams only — never a general proxy): + /proxy/horizons? → https://ssd.jpl.nasa.gov/api/horizons.api? + /proxy/sbdb? → https://ssd-api.jpl.nasa.gov/sbdb_query.api? + /proxy/cad? → https://ssd-api.jpl.nasa.gov/cad.api? + +Disk cache in cache/ keyed by sha256(upstream URL). Responses whose requested +time span lies entirely in the past are immutable (never expire); everything else +gets a 24 h TTL. The cache is the rate limiter — be polite to JPL (brief §6, §12). +""" +import base64 +import hashlib +import json +import os +import re +import sys +import tempfile +import time +import urllib.error +import urllib.parse +import urllib.request +from datetime import datetime, timezone +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8147 +ROOT = Path(__file__).resolve().parent +CACHE_DIR = ROOT / "cache" +CACHE_TTL_SEC = 24 * 3600 + +UPSTREAMS = { + "horizons": "https://ssd.jpl.nasa.gov/api/horizons.api", + "sbdb": "https://ssd-api.jpl.nasa.gov/sbdb_query.api", + "cad": "https://ssd-api.jpl.nasa.gov/cad.api", +} + +JD_UNIX_EPOCH = 2440587.5 + + +def _jd_to_unix(jd): + return (jd - JD_UNIX_EPOCH) * 86400.0 + + +def _parse_epoch_to_unix(tok): + """Best-effort: a Horizons/CAD time token → unix seconds, or None.""" + tok = tok.strip().strip("'\"") + if not tok: + return None + try: # bare Julian Date (e.g. 2461236.5) + f = float(tok) + if f > 2400000: + return _jd_to_unix(f) + except ValueError: + pass + for fmt in ("%Y-%m-%d %H:%M", "%Y-%m-%d", "%Y-%b-%d %H:%M", "%Y-%b-%d"): + try: + return datetime.strptime(tok, fmt).replace(tzinfo=timezone.utc).timestamp() + except ValueError: + continue + return None + + +def _requested_span_is_past(name, query): + """True if the whole requested time span is safely in the past → immutable.""" + q = urllib.parse.parse_qs(query, keep_blank_values=True) + latest = None + + def bump(u): + nonlocal latest + if u is not None: + latest = u if latest is None else max(latest, u) + + if name == "horizons": + for key in ("STOP_TIME", "TLIST"): + for raw in q.get(key, []): + for tok in re.split(r"[,\s]+", raw): + bump(_parse_epoch_to_unix(tok)) + if "STOP_TIME" not in q and "TLIST" not in q: + return False + elif name == "cad": + for raw in q.get("date-max", []): + bump(_parse_epoch_to_unix(raw)) + if "date-max" not in q: + return False + else: # sbdb elements query — has no time span, treat as mutable (TTL) + return False + + # Margin: only call it immutable if the latest epoch is > 1 day ago. + return latest is not None and latest < time.time() - 86400 + + +def _cache_paths(url): + h = hashlib.sha256(url.encode()).hexdigest() + return CACHE_DIR / f"{h}.body", CACHE_DIR / f"{h}.meta" + + +def _cache_read(url): + body_p, meta_p = _cache_paths(url) + try: + meta = json.loads(meta_p.read_text()) + body = body_p.read_bytes() + except (OSError, ValueError): + return None + if meta.get("immutable"): + return body, meta.get("ctype", "text/plain") + if time.time() - meta.get("ts", 0) < CACHE_TTL_SEC: + return body, meta.get("ctype", "text/plain") + return None + + +def _cache_write(url, body, ctype, immutable): + body_p, meta_p = _cache_paths(url) + try: + CACHE_DIR.mkdir(parents=True, exist_ok=True) + _atomic_write(body_p, body) + _atomic_write(meta_p, json.dumps( + {"ts": time.time(), "immutable": bool(immutable), "ctype": ctype, + "url": url}).encode()) + except OSError: + pass # caching must never break the actual response + + +def _atomic_write(path, data): + fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=path.name + ".", suffix=".tmp") + try: + with os.fdopen(fd, "wb") as f: + f.write(data) + os.replace(tmp, path) + except BaseException: + try: + os.unlink(tmp) + except OSError: + pass + raise + + +class Handler(SimpleHTTPRequestHandler): + def __init__(self, *a, **kw): + super().__init__(*a, directory=str(ROOT), **kw) + + def end_headers(self): + # Dev server: never cache static assets so js/css edits always take effect. + self.send_header("Cache-Control", "no-store") + super().end_headers() + + def do_GET(self): + if self.path.startswith("/proxy/"): + return self._serve_proxy() + return super().do_GET() + + def _serve_proxy(self): + name, _, query = self.path[len("/proxy/"):].partition("?") + base = UPSTREAMS.get(name) + if not base: + return self._send_json(404, {"error": f"unknown upstream {name!r}"}) + url = base + ("?" + query if query else "") + + cached = _cache_read(url) + if cached is not None: + return self._send(200, cached[1], cached[0], {"X-Solargod-Cache": "hit"}) + + try: + req = urllib.request.Request(url, headers={"User-Agent": "solargod-dev/0.1"}) + with urllib.request.urlopen(req, timeout=60) as r: + body = r.read() + ctype = r.headers.get("Content-Type", "text/plain") + _cache_write(url, body, ctype, _requested_span_is_past(name, query)) + return self._send(200, ctype, body, {"X-Solargod-Cache": "miss"}) + except urllib.error.HTTPError as e: + body = e.read() or str(e).encode() + return self._send(e.code, "text/plain", body, {"X-Solargod-Cache": "err"}) + except Exception as e: + return self._send(502, "text/plain", str(e).encode(), {"X-Solargod-Cache": "err"}) + + def do_POST(self): + # Dev-only screenshot sink: POST /snap?name= with base64 PNG (or a + # data:image/png;base64,... URL) → docs/.png. Bound to 127.0.0.1; the + # name is sanitized so the write can never escape docs/. + if not (self.path == "/snap" or self.path.startswith("/snap?")): + return self.send_error(404, "not found") + _, _, query = self.path.partition("?") + raw = (urllib.parse.parse_qs(query).get("name") or ["screenshot"])[0] + name = re.sub(r"[^a-z0-9-]", "", raw.lower()) or "screenshot" + length = int(self.headers.get("Content-Length") or 0) + text = self.rfile.read(length).decode("utf-8", "replace") if length else "" + if text.strip().startswith("data:") and "," in text: + text = text.split(",", 1)[1] + try: + png = base64.b64decode(text, validate=False) + except Exception: + return self._send_json(400, {"error": "body must be base64 PNG"}) + if not png: + return self._send_json(400, {"error": "empty image"}) + docs = ROOT / "docs" + docs.mkdir(parents=True, exist_ok=True) + out = (docs / f"{name}.png").resolve() + if out.parent != docs.resolve(): + return self._send_json(400, {"error": "invalid name"}) + out.write_bytes(png) + return self._send_json(200, {"ok": True, "path": f"docs/{name}.png", "bytes": len(png)}) + + def _send(self, status, ctype, body, extra=None): + self.send_response(status) + self.send_header("Content-Type", ctype) + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Expose-Headers", "X-Solargod-Cache") + for k, v in (extra or {}).items(): + self.send_header(k, v) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def _send_json(self, status, obj, extra=None): + self._send(status, "application/json", json.dumps(obj).encode(), extra) + + def log_message(self, fmt, *args): + sys.stderr.write("%s - %s\n" % (self.address_string(), fmt % args)) + + +if __name__ == "__main__": + CACHE_DIR.mkdir(parents=True, exist_ok=True) + print(f"SOLARGOD serving on http://127.0.0.1:{PORT} (proxy: horizons · sbdb · cad · cache→cache/)") + ThreadingHTTPServer(("127.0.0.1", PORT), Handler).serve_forever()