🌞 genesis: scaffold — serve.py proxy+cache, importmap renderer, sky, sun, HUD, time bar, floating-origin bootstrap (opus)

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 <noreply@anthropic.com>
This commit is contained in:
monsterrobotparty 2026-07-16 00:04:41 +10:00
parent 4b146bef68
commit 26a5751394
10 changed files with 1455 additions and 0 deletions

2
.gitignore vendored
View File

@ -1,5 +1,7 @@
cache/
art/candidates/
assets/art/candidates/
.DS_Store
__pycache__/
*.pyc
*.swp

183
css/style.css Normal file
View File

@ -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; }

84
index.html Normal file
View File

@ -0,0 +1,84 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>SOLARGOD ▸ Orrery</title>
<link rel="stylesheet" href="css/style.css" />
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/"
}
}
</script>
</head>
<body>
<div id="scene"></div>
<div id="labels"></div>
<!-- breadcrumb (top-left) -->
<div id="breadcrumb"></div>
<!-- HUD (right) -->
<aside id="hud">
<header>
<div class="brand">SOLARGOD <span>▸ ORRERY</span></div>
<div id="live-chip" data-state="live">LIVE</div>
</header>
<div class="ctl-row">
<div class="seg" id="scale-toggle">
<button data-mode="mega" class="active" type="button">MEGA</button>
<button data-mode="true" type="button">TRUE</button>
</div>
</div>
<div class="ctl-row slider-row">
<label class="ctl-label">Body scale</label>
<div class="seg" id="bodyscale-toggle">
<button data-e="1" type="button">1×</button>
<button data-e="100" type="button">100×</button>
<button data-e="1200" class="active" type="button">1200×</button>
</div>
</div>
<div class="menu-row">
<label class="ctl-label">Focus</label>
<select id="focus-menu"></select>
</div>
<div id="layers"></div>
<footer>
<div>Positions: JPL SSD approximate elements · live feeds: JPL Horizons / SBDB / CAD</div>
<div id="credits-textures">Textures: <a href="https://www.solarsystemscope.com/textures/" target="_blank" rel="noopener">Solar System Scope</a> (CC&nbsp;BY&nbsp;4.0)</div>
</footer>
</aside>
<!-- time bar (bottom) -->
<div id="timebar">
<div class="tb-controls">
<button id="tb-now" type="button" title="Jump to wall-clock now">NOW</button>
<button id="tb-reverse" type="button" title="Reverse"></button>
<button id="tb-play" type="button" title="Play / pause">❚❚</button>
<select id="tb-rate" title="Time rate"></select>
<div id="date-readout"></div>
</div>
<div id="timeline-outer" title="Scrub 1800 → 2050">
<div id="timeline-fill"></div>
<div id="timeline-cursor"></div>
</div>
<div class="tb-ends"><span>1800</span><span>2050</span></div>
</div>
<!-- info panel (left, Stage 6) -->
<aside id="infopanel" hidden></aside>
<div id="toast"></div>
<div id="debug" hidden></div>
<script type="module" src="js/main.js"></script>
</body>
</html>

127
js/bodies.js Normal file
View File

@ -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);
}

75
js/config.js Normal file
View File

@ -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,
},
};

95
js/lib.js Normal file
View File

@ -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/TDBUTC 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`; }

476
js/main.js Normal file
View File

@ -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 };

84
js/scale.js Normal file
View File

@ -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; }

101
js/ui.js Normal file
View File

@ -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 = `
<label>
<input type="checkbox" ${defaultOn ? 'checked' : ''} />
<span class="dot" data-state="${defaultOn ? 'ok' : 'off'}"></span>
<span class="lname"></span>
</label>
<div class="lstatus"></div>`;
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);
}

228
serve.py Normal file
View File

@ -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?<q> https://ssd.jpl.nasa.gov/api/horizons.api?<q>
/proxy/sbdb?<q> https://ssd-api.jpl.nasa.gov/sbdb_query.api?<q>
/proxy/cad?<q> https://ssd-api.jpl.nasa.gov/cad.api?<q>
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=<n> with base64 PNG (or a
# data:image/png;base64,... URL) → docs/<n>.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()