Compare commits

...

15 Commits

Author SHA1 Message Date
monsterrobotparty
6391273ac5 📡 review: NEO status line says 'schematic ring' — real data, honest placement, in the UI not just the README (fable)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 08:58:37 +10:00
monsterrobotparty
4360269ad3 🔧 review: fix 8 findings from adversarial self-review (opus)
Multi-agent adversarial review (verified findings) — the top finding (dead orbit
drag) fable had already fixed in 3e36a0c; these are the rest, composing on top of
fable's 228caf2/3e36a0c:

- scale.js viewFromEcl: allocation-free + alias-safe (read x/y/z into locals before
  writing out) — it ran per body per frame and allocated an object each call (§11).
- planets.js + comets.js: refill orbit paths on any P change, not just
  isTransitioning() — the MEGA↔TRUE tween's FINAL frame (P settled, tween nulled)
  was skipped, leaving body/orbit desync (§12).
- almanac.js: a moon's heliocentric position adds its true orbital offset, so the
  Moon's "From Earth" is its real ~384,400 km / 1.3 s light, not 0.
- asteroids.js: guard refresh() against null geometry when toggled on before the
  SBDB load completes (was a TypeError).
- serve.py: CAD close-approach responses are never cached immutable — the database
  keeps growing for past dates as new NEOs are discovered (only Horizons ephemeris
  is truly immutable).

Re-verified: 13/13 gates green; orbit zoom dollies 82→73 finite; Moon From Earth
0.003 AU; layer toggles throw nothing; no console errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 08:43:40 +10:00
monsterrobotparty
3e36a0c16a 🖱 fix: orbit drag was dead — OrbitControls bound to the pointer-events:none label overlay; rebind to the WebGL canvas (verified by dispatched pointer drag: heading 0→-0.93 rad) (fable)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 01:19:09 +10:00
monsterrobotparty
228caf2270 🔭 review: hash restores full camera pose; tick-loop hygiene (fable)
- pendingCam now restores heading+pitch (inverse of serializeHash spherical),
  not just distance — pasted URLs reproduce the exact view (Stage 2 gate)
- bodyWorld.sun allocated once, not per frame (§11 zero-alloc tick rule)
- spacecraft/comet label DOM writes throttled ≤5 Hz (§11); geometry still per-frame

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 01:16:24 +10:00
monsterrobotparty
4352394a56 merge stage/6-almanac → main: almanac + textures + art, gate green (opus) 2026-07-16 00:56:41 +10:00
monsterrobotparty
1f9664a6f9 📖 almanac: knowing — info panel, live facts, grimoire art, real textures, README (opus)
Stage 6. almanac.js: on-focus grimoire card — physical fact sheet, hand-written
prose (grimoire voice), MODELBEAST portrait, and LIVE computed facts free from the
ephemeris: distance from Sun & Earth, sunlight age, vis-viva orbital speed, day &
year length. Earth easter egg → GODSIGH world view (:8137). Real Solar System Scope
2k textures (CC BY, 6.1 MB) now paint every world + the Milky Way skybox; ephem
gains semiMajor() for vis-viva. Generated grimoire portraits (FLUX via MODELBEAST,
10 bodies, seed logged in art/PROMPTS.md) are the AESTHETIC layer only — real
worlds get real maps (truth boundary). README with layer table + quickstart +
verify; docs/hero.jpg. Also lands the Stage-3 planets label fix (obj.visible).
Gate: every clickable body has a card; quickstart works from a fresh clone.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 00:56:41 +10:00
monsterrobotparty
bed15a496c merge stage/5-swarm → main: asteroids + comets + neos, gate green (opus) 2026-07-16 00:41:23 +10:00
monsterrobotparty
b37eb88670 ☄ swarm: small bodies — main belt, comets, NEO close approaches (opus)
Stage 5. asteroids.js: 1500-strong main-belt sample from SBDB (sb-class=MBA),
propagated by the shared Kepler core, one Points cloud refreshed ≤10 Hz, off by
default. comets.js: 1P/2P/67P/96P from SBDB (elliptical only) with full drawn
orbits, marker + anti-sunward tail scaled 1/r², on by default. neos.js: CAD ±30d
close-approach radar around Earth, colored by miss distance, clickable, lunar-
distance labels, off by default. All fail-soft. verify.html gate: belt view-radii
12.1–13.9 ∈ (Mars 10.7, Jupiter 17.4); Halley aphelion 35.3 AU > Neptune. Also
serialized verify's live Horizons checks (burst-throttle fix) → 13/13 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 00:41:23 +10:00
monsterrobotparty
5b672f009c merge stage/4-probes → main: spacecraft layer, gate green (opus) 2026-07-16 00:32:44 +10:00
monsterrobotparty
6084acad0f 🛰 probes: the live layer — 8 spacecraft via Horizons, trails + light-time (opus)
Stage 4. spacecraft.js: Voyager 1/2, New Horizons, JWST, Parker, Juno, Lucy,
Psyche. Fetches VECTORS (heliocentric ecliptic AU) over a multi-year span through
serve.py's cache, Catmull-Rom interpolated per frame; glowing diamond marker +
fading trail + label with live distance from Sun and one-way light-time from
Earth. Fail-soft per craft. Robustness: sequential fetch (Horizons throttles
bursts), serve.py skips caching non-$$SOE (error) responses, and span
auto-recovery clamps START/STOP to a craft's SPK coverage from Horizons' own
"prior to / after" hints (Psyche pre-launch, Juno past coverage) → 8/8 tracked.
Gate: Voyager 1 @2026-07-15 = (-32.07,-136.21,98.55) Δ0.002 AU; JWST Δ0.011 AU
from Earth. verify.html gates 10-11 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 00:32:44 +10:00
monsterrobotparty
fedf05d8a2 merge stage/3-retinue → main: moons + rings, gate green (opus) 2026-07-16 00:19:28 +10:00
monsterrobotparty
f2477513c0 🌙 retinue: worlds become places — moons, rings, tilts, spin, sun-lit terminator (opus)
Stage 3. moons.js: 21 moons as lit spheres with local per-parent compression
(clear the exaggerated parent, ordering preserved), retrograde via negative
period. rings.js: Saturn textured annulus (true 74500–140220 km scaled with
parent) + Uranus thin schematic ring, tilted with axial tilt, DoubleSide. Moons
draw at the parent's exaggeration so Io/Jupiter ≈ 0.026 ratio holds. Label
visibility gated via CSS2DObject.visible (CSS2DRenderer clobbers div.style.display
every frame — fixed). Gate: Galilean laps 1.00:0.50:0.25 (1:2:4), Triton
retrograde (Δθ<0), moons ordered io<europa<ganymede<callisto.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 00:19:28 +10:00
monsterrobotparty
fc573f98eb merge stage/0-genesis → main: scaffold + kepler engine, all gates green (opus) 2026-07-16 00:05:08 +10:00
monsterrobotparty
c4b572b8bb 🪐 kepler: planets on rails — Standish ephemeris engine, orbit paths, verify gates (opus)
Stage 1. ephem.js: JPL SSD approximate elements (Table 1 + 2a/2b plumbing),
Newton Kepler solver, reusable eclFromElements core (planets + small bodies),
orbitPath sampler. planets.js: 8 planets + Pluto as textured exaggerated spheres
on MEGA-compressed orbits with labels, rebuild-on-drift. verify.html: 9 gates all
green IN BROWSER incl. live Horizons via proxy — Mars @2026-07-15 Δ3.3e-4 AU,
Jupiter Δ2.9e-3, Earth Δ3.9e-5; Mars @J2000 vs live Δ4.8e-5; Jupiter @1900 Δ2.0e-3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 00:04:41 +10:00
monsterrobotparty
26a5751394 🌞 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>
2026-07-16 00:04:41 +10:00
46 changed files with 2842 additions and 19 deletions

2
.gitignore vendored
View File

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

107
README.md
View File

@ -1,29 +1,98 @@
# SOLARGOD ▸ Solar System View
# SOLARGOD ▸ Orrery
A browser-based **compressed-scale 3D solar system explorer** — real ephemerides,
scrubbable time across centuries, jump-to-orbit navigation, and live layers for
deep-space probes, asteroids, and comets. Sibling of
[GODSIGH](../GODSIGH/README.md), which watches Earth; SOLARGOD watches everything
else. Everything runs client-side from free, public, keyless data.
A standalone, browser-based 3D solar-system explorer — the interplanetary sibling
of [GODSIGH](../GODSIGH), which watches Earth. Float above a compressed-scale
("MEGA") solar system with every world visible at once, moving on **real JPL
ephemerides**; scrub a time bar across centuries; fly the camera into orbit around
any planet, its moons circling; toggle **TRUE scale** and watch the system animate
out into its real, humbling emptiness. Live layers plot where Voyager 1, JWST and
Parker Solar Probe are *right now*, plus real asteroids, comets and Earth
close-approaches. Everything is client-side, free, and keyless.
**Status: pre-build.** The full spec lives in [SOLARGOD_BRIEF.md](SOLARGOD_BRIEF.md).
![SOLARGOD](docs/hero.jpg)
## Quickstart (once built)
## Quickstart
```sh
python3 serve.py
python3 serve.py # stdlib only — no npm, no build step
# open http://127.0.0.1:8147
```
## Planned layers
`serve.py` statically serves the app and proxies the JPL feeds a browser can't call
directly (Horizons / SBDB / CAD — no CORS), with a disk cache in `cache/`. Three.js
loads from a pinned CDN importmap. The deterministic core (Sun, planets, moons,
orbits, time travel) runs with **zero network calls** — positions are computed
locally from Keplerian elements; network layers are additive and fail soft.
| Layer | Source | Notes |
|---|---|---|
| **Planets** | JPL Keplerian elements (Standish) | computed locally, no network — scrub 18002050 |
| **Moons & rings** | JPL SSD satellite elements | 21 major moons, Saturn/Uranus rings |
| **Spacecraft** | JPL Horizons | Voyagers, New Horizons, JWST, Parker — where they are *now* |
| **Asteroids** | JPL SBDB | real main-belt sample + NEO close approaches (CNEOS CAD) |
| **Comets** | JPL SBDB | Halley & friends, full orbits, 1/r² tails |
## Verify
MEGA mode compresses distances so the whole system is one readable view;
TRUE mode animates it back out to its real, humbling emptiness.
Open **http://127.0.0.1:8147/verify.html** — an in-browser gate runner that checks
the ephemeris against **JPL Horizons ground truth** (hard-coded checkpoints + live
queries through the proxy). All 13 gates should read PASS, e.g.:
| Gate | Result |
|------|--------|
| Mars / Jupiter / Earth @ 2026-07-15 (heliocentric ecliptic AU) | Δ ≤ 3.3e-4 / 2.9e-3 / 3.9e-5 AU |
| Kepler solver converges at e=0.97 · orbitPath closes · frame round-trips | exact |
| MEGA→TRUE preserves direction (unit dot ≈ 1) | 1.000000000000 |
| Mars @ J2000 · Jupiter @ 1900 vs **live** Horizons | Δ 4.8e-5 / 2.0e-3 AU |
| Main belt between Mars & Jupiter · Halley aphelion beyond Neptune | pass |
| Voyager 1 @ 2026-07-15 = (32.07, 136.21, +98.55) AU · JWST hugs Earth | Δ 0.002 / 0.011 AU |
## Layers
| Layer | Source | Default | Notes |
|-------|--------|:------:|-------|
| **Planets & orbits** | JPL SSD approximate elements (Standish) | on | 8 planets + Pluto, textured, MEGA-compressed orbit paths |
| **Body labels** | — | on | names; the focused body's children also get distance labels |
| **Moons** | JPL satellite data | on | 21 moons, per-parent local compression, retrograde Triton |
| **Rings** | true km, scaled to parent | on | Saturn (textured annulus) · Uranus (schematic) |
| **Spacecraft** | JPL **Horizons** (proxy) | on | Voyager 1/2, New Horizons, JWST, Parker, Juno, Lucy, Psyche — live distance + one-way light-time |
| **Main belt** | JPL **SBDB** (proxy) | off | ~1500 main-belt asteroids, shared Kepler core |
| **Comets** | JPL **SBDB** (proxy) | on | 1P/Halley, 2P/Encke, 67P, 96P — full orbits + anti-sunward 1/r² tail |
| **Close approaches** | JPL **CAD** (proxy) | off | ±30-day NEO radar around Earth, miss distance in lunar distances |
## Controls
- **Focus** — click any body (or use the Focus menu) → the camera flies into orbit
around it; breadcrumb `SOL ▸ JUPITER ▸ EUROPA`; `Esc` steps out one level.
- **MEGA / TRUE** — toggle the scale mode; the transition animates over ~2.5 s.
- **Body scale** — 1× / 100× / 1200× radius exaggeration.
- **Time bar** — NOW, play/pause, reverse, rate presets (`1× · 1 min/s … 1 yr/s`),
and a scrubbable 18002050 timeline. A **LIVE / SCRUBBED** chip mirrors GODSIGH.
- **Info panel** — appears on focus: a grimoire card with a physical fact sheet,
hand-written prose, a stylized portrait, and **live computed** facts (distance
from Sun & Earth, sunlight age, orbital speed, day & year length). Focusing Earth
reveals a link to the GODSIGH world view.
- **Shareable URLs** — the view serializes to the hash
(`#f=<body>&t=<time>&s=<mega|true>&L=<layers>&cam=…`); paste one to reproduce a view.
- **`?debug=1`** — frame-rate / scale readout.
## Architecture
No build step, no framework, plain ES modules. One module owns the truth→view
transform (`scale.js`); every position is computed in real heliocentric J2000
ecliptic AU first (`ephem.js`) then compressed. A **floating origin** subtracts the
focused body's position in float64 before writing to the scene graph, so a focused
planet never jitters at close zoom.
```
index.html · serve.py · verify.html
css/style.css
js/ config lib ephem bodies scale ui main
js/layers/ planets moons rings spacecraft asteroids comets neos almanac
assets/textures/ (CC BY) · assets/art/grimoire/ (generated)
```
The full execution spec is [SOLARGOD_BRIEF.md](SOLARGOD_BRIEF.md).
## Credits
- **Positions** — [JPL SSD](https://ssd.jpl.nasa.gov/planets/approx_pos.html)
approximate elements; live feeds via **JPL Horizons / SBDB / CAD**.
- **Textures** — [Solar System Scope](https://www.solarsystemscope.com/textures/),
CC BY 4.0 (see `assets/textures/CREDITS.md`).
- **Grimoire art** — generated locally with MODELBEAST / FLUX (see
`assets/art/PROMPTS.md`); the *aesthetic* layer only — real worlds get real data
textures.
- Built on [Three.js](https://threejs.org/).

50
assets/art/PROMPTS.md Normal file
View File

@ -0,0 +1,50 @@
# Generated art — reproducibility log
House rule (brief §7b): every shipped generated asset records its prompt, model,
seed and operator chain so any card can be regenerated in-style. All art here is
the **aesthetic layer only** — never a substitute for real-world data textures.
## Pipeline
- Operator: `flux_local` (MODELBEAST, mflux/MLX on-device, **free & unlimited**)
- Model: `flux2-klein-4b`
- Host: MODELBEAST (`~/Documents/MODELBEAST`), guest `agents` credential
- Post: JPG re-encode at 640px, q82 (`sips`) to stay lean (brief §7 budget)
## Splash / boot hero (`splash/`, candidates only)
- Seeds: 15, 1280×768
- Prompt: *"a grand antique brass orrery mechanism floating in the black void of
deep space, armillary sphere rings and intricate clockwork gears in aged brass
and gold, a glowing sun at the center, faint distant stars and nebula dust,
cinematic volumetric god-rays, ornate engraved astronomical grimoire aesthetic,
moody chiaroscuro, ultra detailed, dark observatory"*
## Grimoire portraits (`grimoire/<body>.jpg`) — info-panel cards
- Seeds run: 12 · **chosen seed: 1** for every body · 800×800 → 640px JPG
- Shared style suffix: *", antique astronomical engraving, ornate brass astrolabe
frame, aged parchment, gold leaf accents, occult grimoire illustration,
intricate etched linework, chiaroscuro, dark background, centered circular
celestial portrait, NOT photorealistic, no text, no words"*
- Per-body subject prompt:
| Body | Subject prompt (prefix) |
|------|-------------------------|
| sun | the radiant Sun as a crowned solar deity with a face of fire, flames and rays |
| mercury | the swift small planet Mercury, cratered grey orb, a winged caduceus |
| venus | the planet Venus veiled in golden sulphur clouds, a rose and hand-mirror |
| earth | the planet Earth, blue and green world with white clouds, encircled by a brass armillary sphere |
| mars | the red planet Mars, ochre desert sphere, a spear and shield, god of war |
| jupiter | the giant planet Jupiter, swirling cloud bands and a great red storm, a thunderbolt, king of gods |
| saturn | the ringed planet Saturn, a scythe and hourglass, aged father of time |
| uranus | the pale cyan ice giant Uranus tilted on its side, a lyre and aurora |
| neptune | the deep blue planet Neptune, a trident and ocean waves, god of the sea |
| pluto | the distant dwarf world Pluto with its moon Charon, a key and a shadowed lantern, lord of the underworld |
Regenerate any card:
```sh
source ~/Documents/MODELBEAST/data/agent.env
~/Documents/MODELBEAST/mb run flux_local -p prompt="<subject><style>" \
-p model=flux2-klein-4b -p seed=1 -p width=800 -p height=800 --wait --download art/candidates/grimoire/<body>/
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

BIN
assets/art/grimoire/sun.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 452 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 487 KiB

BIN
assets/textures/2k_mars.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 733 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 852 KiB

BIN
assets/textures/2k_moon.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

BIN
assets/textures/2k_sun.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 803 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 864 KiB

View File

@ -0,0 +1,33 @@
# Texture credits
All planet, moon, Sun, ring and starfield maps in this directory are from the
**Solar System Scope** texture pack and are used under **CC BY 4.0**.
- Source: https://www.solarsystemscope.com/textures/
- License: Creative Commons Attribution 4.0 International (CC BY 4.0)
https://creativecommons.org/licenses/by/4.0/
- Attribution: *Solar System Scope* (INOVE / solarsystemscope.com). Textures are
based on NASA elevation and imagery data.
Downloaded 2026-07-16 at 2k resolution:
| File | Body |
|------|------|
| 2k_sun.jpg | Sun |
| 2k_mercury.jpg | Mercury |
| 2k_venus_surface.jpg | Venus (surface) |
| 2k_earth_daymap.jpg | Earth (day) |
| 2k_mars.jpg | Mars |
| 2k_jupiter.jpg | Jupiter |
| 2k_saturn.jpg | Saturn |
| 2k_uranus.jpg | Uranus |
| 2k_neptune.jpg | Neptune |
| 2k_moon.jpg | Moon |
| 2k_saturn_ring_alpha.png | Saturn rings (alpha) |
| 2k_stars_milky_way.jpg | Milky Way skybox panorama |
Attribution also appears in the app's HUD footer, as CC BY requires.
**Truth boundary (brief §7b):** these are the *real data* textures — real worlds
get real maps. The stylized grimoire portraits in `../art/grimoire/` are the
*aesthetic* layer (generated art), kept visually and directorially separate.

188
css/style.css Normal file
View File

@ -0,0 +1,188 @@
/* 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; }
.craft-label {
font-size: 9.5px; letter-spacing: 0.3px; white-space: nowrap;
text-shadow: 0 0 4px #000, 0 0 8px #000; transform: translate(-50%, -180%);
user-select: none; padding: 1px 3px;
}
/* ---- 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: 46px; left: 14px; width: 300px; max-height: calc(100vh - 160px);
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; }

BIN
docs/hero.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

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

177
js/ephem.js Normal file
View File

@ -0,0 +1,177 @@
// SOLARGOD ephemeris engine — JPL SSD "Approximate Positions of the Planets"
// (Standish). Tables extracted 2026-07-15 from
// https://ssd.jpl.nasa.gov/planets/approx_pos.html — authoritative, verbatim.
// Angles in degrees, rates per Julian century; `e` is dimensionless (the page's
// "rad" header for e is its own typo). No n-body integration — this IS the spec.
import { centuriesSinceJ2000, normDegPM180, DEG } from './lib.js';
// Row layout: el = [a(au), e, I(deg), L(deg), ϖ long.peri(deg), Ω long.node(deg)]
// rate = same order, per century.
const TABLE1 = {
mercury: { el: [0.38709927, 0.20563593, 7.00497902, 252.25032350, 77.45779628, 48.33076593],
rate: [0.00000037, 0.00001906, -0.00594749, 149472.67411175, 0.16047689, -0.12534081] },
venus: { el: [0.72333566, 0.00677672, 3.39467605, 181.97909950, 131.60246718, 76.67984255],
rate: [0.00000390, -0.00004107, -0.00078890, 58517.81538729, 0.00268329, -0.27769418] },
earth: { el: [1.00000261, 0.01671123, -0.00001531, 100.46457166, 102.93768193, 0.0],
rate: [0.00000562, -0.00004392, -0.01294668, 35999.37244981, 0.32327364, 0.0] },
mars: { el: [1.52371034, 0.09339410, 1.84969142, -4.55343205, -23.94362959, 49.55953891],
rate: [0.00001847, 0.00007882, -0.00813131, 19140.30268499, 0.44441088, -0.29257343] },
jupiter: { el: [5.20288700, 0.04838624, 1.30439695, 34.39644051, 14.72847983, 100.47390909],
rate: [-0.00011607, -0.00013253, -0.00183714, 3034.74612775, 0.21252668, 0.20469106] },
saturn: { el: [9.53667594, 0.05386179, 2.48599187, 49.95424423, 92.59887831, 113.66242448],
rate: [-0.00125060, -0.00050991, 0.00193609, 1222.49362201, -0.41897216, -0.28867794] },
uranus: { el: [19.18916464, 0.04725744, 0.77263783, 313.23810451, 170.95427630, 74.01692503],
rate: [-0.00196176, -0.00004397, -0.00242939, 428.48202785, 0.40805281, 0.04240589] },
neptune: { el: [30.06992276, 0.00859048, 1.77004347, -55.12002969, 44.96476227, 131.78422574],
rate: [0.00026291, 0.00005105, 0.00035372, 218.45945325, -0.32241464, -0.00508664] },
};
// Table 2a — valid 3000 BC3000 AD. Shipped for the extended-range config swap
// (CONFIG.useExtendedRange); unused by v1 UI.
const TABLE2A = {
mercury: { el: [0.38709843, 0.20563661, 7.00559432, 252.25166724, 77.45771895, 48.33961819],
rate: [0.00000000, 0.00002123, -0.00590158, 149472.67486623, 0.15940013, -0.12214182] },
venus: { el: [0.72332102, 0.00676399, 3.39777545, 181.97970850, 131.76755713, 76.67261496],
rate: [-0.00000026, -0.00005107, 0.00043494, 58517.81560260, 0.05679648, -0.27274174] },
earth: { el: [1.00000018, 0.01673163, -0.00054346, 100.46691572, 102.93005885, -5.11260389],
rate: [-0.00000003, -0.00003661, -0.01337178, 35999.37306329, 0.31795260, -0.24123856] },
mars: { el: [1.52371243, 0.09336511, 1.85181869, -4.56813164, -23.91744784, 49.71320984],
rate: [0.00000097, 0.00009149, -0.00724757, 19140.29934243, 0.45223625, -0.26852431] },
jupiter: { el: [5.20248019, 0.04853590, 1.29861416, 34.33479152, 14.27495244, 100.29282654],
rate: [-0.00002864, 0.00018026, -0.00322699, 3034.90371757, 0.18199196, 0.13024619] },
saturn: { el: [9.54149883, 0.05550825, 2.49424102, 50.07571329, 92.86136063, 113.63998702],
rate: [-0.00003065, -0.00032044, 0.00451969, 1222.11494724, 0.54179478, -0.25015002] },
uranus: { el: [19.18797948, 0.04685740, 0.77298127, 314.20276625, 172.43404441, 73.96250215],
rate: [-0.00020455, -0.00001550, -0.00180155, 428.49512595, 0.09266985, 0.05739699] },
neptune: { el: [30.06952752, 0.00895439, 1.77005520, 304.22289287, 46.68158724, 131.78635853],
rate: [0.00006447, 0.00000818, 0.00022400, 218.46515314, 0.01009938, -0.00606302] },
};
// Table 2b — extra terms added to M (deg) for JupiterNeptune, Table 2a only:
// M += b·T² + c·cos(f·T) + s·sin(f·T). [b, c, s, f]
const TABLE2B = {
jupiter: [-0.00012452, 0.06064060, -0.35635438, 38.35125000],
saturn: [0.00025899, -0.13434469, 0.87320147, 38.35125000],
uranus: [0.00058331, -0.97731848, 0.17689245, 7.67025000],
neptune: [-0.00041348, 0.68346318, -0.10162547, 7.67025000],
};
export const PLANET_IDS = ['mercury', 'venus', 'earth', 'mars', 'jupiter', 'saturn', 'uranus', 'neptune'];
let useExtended = false;
export function setExtendedRange(on) { useExtended = !!on; }
// Solve Kepler's equation E e·sinE = M (radians) by NewtonRaphson.
// Seed E₀ = M + e·sinM; converges in a few iters even for e→0.97 (verify gate).
export function solveKepler(M, e, tol = 1e-9, maxIter = 20) {
let E = M + e * Math.sin(M);
for (let i = 0; i < maxIter; i++) {
const dE = (E - e * Math.sin(E) - M) / (1 - e * Math.cos(E));
E -= dE;
if (Math.abs(dE) < tol) break;
}
return E;
}
// The reusable core: resolved classical elements → heliocentric J2000 ecliptic
// {x,y,z} in AU. a[au], e, and iDeg/OmDeg(Ω)/wDeg(ω arg.peri)/Mdeg all in degrees.
// Shared by planets (element+rate) and small bodies (epoch elements) — brief §3.
export function eclFromElements(a, e, iDeg, OmDeg, wDeg, Mdeg, out = {}) {
const M = normDegPM180(Mdeg) * DEG;
const E = solveKepler(M, e);
// Orbital-plane coords (perifocal), AU.
const xp = a * (Math.cos(E) - e);
const yp = a * Math.sqrt(1 - e * e) * Math.sin(E);
const w = wDeg * DEG, Om = OmDeg * DEG, I = iDeg * DEG;
const cw = Math.cos(w), sw = Math.sin(w);
const cO = Math.cos(Om), sO = Math.sin(Om);
const cI = Math.cos(I), sI = Math.sin(I);
// Perifocal → ecliptic rotation (brief §3 step 5 = standard R_z(Ω)R_x(I)R_z(ω)).
out.x = (cw * cO - sw * sO * cI) * xp + (-sw * cO - cw * sO * cI) * yp;
out.y = (cw * sO + sw * cO * cI) * xp + (-sw * sO + cw * cO * cI) * yp;
out.z = (sw * sI) * xp + (cw * sI) * yp;
return out;
}
// Resolve a planet's elements at jd (Table 1, 18002050) → {a,e,I,Om,w,M} deg.
function resolvePlanet(bodyId, jd) {
const T = centuriesSinceJ2000(jd);
const row = TABLE1[bodyId];
if (!row) throw new Error(`ephem: unknown planet ${bodyId}`);
const a = row.el[0] + row.rate[0] * T;
const e = row.el[1] + row.rate[1] * T;
const I = row.el[2] + row.rate[2] * T;
const L = row.el[3] + row.rate[3] * T;
const wb = row.el[4] + row.rate[4] * T; // ϖ longitude of perihelion
const Om = row.el[5] + row.rate[5] * T; // Ω longitude of ascending node
const w = wb - Om; // ω argument of perihelion
const M = L - wb; // mean anomaly (normalize AFTER subtract)
return { a, e, I, Om, w, M };
}
// Extended-range resolver (Table 2a + the 2b M-correction for JupiterNeptune,
// valid 3000 BC3000 AD). f·T uses T in centuries; f is deg-per-century so f·T
// is degrees → convert to rad for the trig. Only used when useExtended is set.
function resolvePlanetExt(bodyId, jd) {
const T = centuriesSinceJ2000(jd);
const row = TABLE2A[bodyId];
const a = row.el[0] + row.rate[0] * T;
const e = row.el[1] + row.rate[1] * T;
const I = row.el[2] + row.rate[2] * T;
const L = row.el[3] + row.rate[3] * T;
const wb = row.el[4] + row.rate[4] * T;
const Om = row.el[5] + row.rate[5] * T;
const w = wb - Om;
let M = L - wb;
const b2 = TABLE2B[bodyId];
if (b2) {
const [b, c, s, f] = b2;
M += b * T * T + c * Math.cos(f * T * DEG) + s * Math.sin(f * T * DEG);
}
return { a, e, I, Om, w, M };
}
// Public: heliocentric J2000 ecliptic position of a planet at jd, AU.
export function helioEcl(bodyId, jd, out = {}) {
const p = useExtended ? resolvePlanetExt(bodyId, jd) : resolvePlanet(bodyId, jd);
return eclFromElements(p.a, p.e, p.I, p.Om, p.w, p.M, out);
}
// Mean motion [deg/day] from semi-major axis via Kepler's third law (Gaussian).
export function meanMotionDegPerDay(a) { return 0.9856076686 / Math.pow(a, 1.5); }
// Resolved semi-major axis [AU] of a planet at jd (for vis-viva speed, year length).
export function semiMajor(bodyId, jd) {
const p = useExtended ? resolvePlanetExt(bodyId, jd) : resolvePlanet(bodyId, jd);
return p.a;
}
// Small-body position (asteroids/comets/Pluto): epoch elements propagated by
// M = M0 + n·(jd epoch). el = {a,e,i,om(Ω),w(ω),ma(M0),epoch}.
export function smallBodyEcl(el, jd, out = {}) {
const n = meanMotionDegPerDay(el.a);
const M = el.ma + n * (jd - el.epoch);
return eclFromElements(el.a, el.e, el.i, el.om, el.w, M, out);
}
// One full orbit sampled in mean anomaly, as a Float64Array of xyz triples
// (nSamples+1 points; last == first so a LineLoop/LineSegments closes cleanly —
// verify gate "orbitPath endpoints join"). Elements frozen at jd (they drift
// slowly; caller rebuilds only when |ΔT| > 0.1 Cy, brief §11).
export function orbitPath(bodyId, jd, nSamples = 256) {
const p = useExtended ? resolvePlanetExt(bodyId, jd) : resolvePlanet(bodyId, jd);
return samplePath(p.a, p.e, p.I, p.Om, p.w, nSamples);
}
export function orbitPathFromElements(el, nSamples = 256) {
return samplePath(el.a, el.e, el.i, el.om, el.w, nSamples);
}
function samplePath(a, e, I, Om, w, nSamples) {
const out = new Float64Array((nSamples + 1) * 3);
const tmp = {};
for (let k = 0; k <= nSamples; k++) {
const M = (360 * k) / nSamples; // 0..360; k=nSamples reproduces k=0
eclFromElements(a, e, I, Om, w, M, tmp);
out[k * 3] = tmp.x; out[k * 3 + 1] = tmp.y; out[k * 3 + 2] = tmp.z;
}
return out;
}

132
js/layers/almanac.js Normal file
View File

@ -0,0 +1,132 @@
// SOLARGOD almanac (Stage 6) — the info panel that knows. On focus it shows a
// grimoire card: a physical fact sheet + hand-written prose + (if generated) a
// MODELBEAST portrait, plus LIVE computed facts that come free from the ephemeris
// — distance from Sun and Earth, sunlight age (light-time), orbital speed, and the
// length of the body's day and year (brief §5). Focusing Earth reveals the GODSIGH
// easter egg. Not a toggleable feed — no HUD row.
const GM_SUN = 1.32712440018e11; // km^3/s^2, heliocentric gravitational parameter
const YEAR_DAYS = 365.256;
const BLURBS = {
sun: "The hearth of the system and 99.86% of its mass — a self-gravitating furnace fusing 600 million tonnes of hydrogen every second. Every world here is a fleck of its leftover disk, still falling around it.",
mercury: "A cratered iron cinder with almost no air, swinging so close to the Sun that it laps every other world. Its days are longer than its years; three rotations fit in two of its tight orbits.",
venus: "Earth's twin gone wrong: a runaway greenhouse under sulphuric cloud, hot enough to melt lead and crushing as the deep sea. It turns backwards, so slowly that a Venusian day outlasts its year.",
earth: "The one world we know that woke up. A thin film of water and air over spinning iron, tilted just so for seasons — the pale blue vantage from which all the rest of this was measured.",
mars: "A rusted desert half Earth's size, streaked with the dry beds of ancient rivers and crowned by Olympus Mons, the tallest volcano known. Cold, thin-aired, and the most-visited graveyard of robots off-world.",
jupiter: "A failed star and the system's great vacuum cleaner, its storms wider than Earth and its gravity shepherding the asteroid belt. Four bright moons circle it in a clockwork Galileo used to unseat the Earth from the centre.",
saturn: "The jewel — a globe you could float on water, wrapped in rings of ice no thicker than a mountain is tall. Titan below hides a haze-orange world of methane rain and hydrocarbon seas.",
uranus: "An ice giant knocked onto its side, likely by an ancient impact, so it rolls around the Sun pole-first. Its faint rings and pale cyan haze mark the coldest atmosphere of any planet.",
neptune: "The last great world, a deep-blue storm-lantern found by mathematics before any eye saw it. Its winds are the fastest in the system, and Triton, its captured moon, orbits backwards toward a slow doom.",
pluto: "The heart-marked dwarf at the edge of the known, half rock and half ice, locked in a slow gravitational dance with its outsized moon Charon. Demoted from planet, it remains the king of the Kuiper Belt.",
moon: "Earth's single great moon, born of a Mars-sized collision and slowly drifting away. It keeps one face turned to us forever, and its pull raises the tides that may have coaxed life ashore.",
io: "The most volcanic body in the system, flexed and heated by Jupiter's tides until it turns itself inside out in sulphur.",
europa: "A cracked shell of ice over a global ocean — perhaps twice Earth's water — and one of the best places to look for life beyond home.",
ganymede: "The largest moon in the system, bigger than Mercury, and the only one with its own magnetic field.",
callisto: "The most heavily cratered world known, a dark ancient face that has barely changed in four billion years.",
titan: "The only moon with a thick atmosphere, raining methane onto seas of liquid hydrocarbon under an orange sky.",
enceladus: "A tiny snowball that jets plumes of ocean water into space from cracks at its south pole.",
triton: "A captured Kuiper Belt world orbiting Neptune backwards, spouting nitrogen geysers across a frozen pink surface.",
charon: "Pluto's giant partner — the two are tidally locked face to face, forever hanging still in each other's sky.",
};
export default function create(ctx) {
const { bodies, ephem, lib, focus } = ctx;
const panel = document.getElementById('infopanel');
let lastFocus = null;
let lastWall = 0;
const _b = {}, _e = {};
function helioOf(id, jd, out) {
const b = bodies[id];
if (id === 'sun') { out.x = out.y = out.z = 0; return out; }
if (b.ephemId) return ephem.helioEcl(b.ephemId, jd, out);
if (b.elements) return ephem.smallBodyEcl(b.elements, jd, out);
if (b.moonOrbit && b.parent) {
// parent heliocentric + the moon's TRUE orbital offset (AU) — so e.g. the
// Moon's distance from Earth is its real ~384,400 km, not 0.
helioOf(b.parent, jd, out);
const theta = 2 * Math.PI * ((jd - lib.J2000_JD) / b.moonOrbit.periodDays);
const rAU = b.moonOrbit.aKm / lib.AU_KM;
const tilt = (bodies[b.parent].axialTiltDeg || 0) * lib.DEG;
const ex = Math.cos(theta) * rAU, ey = Math.sin(theta) * rAU;
out.x += ex; out.y += ey * Math.cos(tilt); out.z += ey * Math.sin(tilt);
return out;
}
if (b.parent) return helioOf(b.parent, jd, out);
out.x = out.y = out.z = 0; return out;
}
function render(id) {
const b = bodies[id];
const artPath = `assets/art/grimoire/${id}.jpg`;
const earthEgg = id === 'earth'
? `<a class="ip-link" href="http://127.0.0.1:8137" target="_blank" rel="noopener">▸ open GODSIGH world view</a>` : '';
panel.innerHTML = `
<div class="ip-close" role="button" aria-label="close">×</div>
<div class="ip-title"></div>
<div class="ip-type"></div>
<img class="ip-art" alt="" />
<div class="ip-blurb"></div>
<div class="ip-facts"></div>
${earthEgg}`;
panel.querySelector('.ip-title').textContent = b.name;
panel.querySelector('.ip-type').textContent = b.type;
panel.querySelector('.ip-blurb').textContent = BLURBS[id] || `A ${b.type}${b.parent ? ' of ' + bodies[b.parent].name : ''} in the solar system.`;
const img = panel.querySelector('.ip-art');
img.src = artPath;
img.onerror = () => { img.style.display = 'none'; };
panel.querySelector('.ip-close').addEventListener('click', () => { panel.hidden = true; });
panel.hidden = false;
}
function fact(label, value) {
return `<div class="ip-fact"><span>${label}</span><span>${value}</span></div>`;
}
function updateLive(id, jd) {
const b = bodies[id];
helioOf(id, jd, _b);
const rSun = Math.hypot(_b.x, _b.y, _b.z); // AU
ephem.helioEcl('earth', jd, _e);
const rEarth = id === 'earth' ? 0 : Math.hypot(_b.x - _e.x, _b.y - _e.y, _b.z - _e.z);
const rows = [];
// physical
rows.push(fact('Radius', lib.fmtKm(b.radiusKm)));
if (b.axialTiltDeg != null) rows.push(fact('Axial tilt', `${b.axialTiltDeg.toFixed(1)}°`));
// live distances
if (id !== 'sun') rows.push(fact('From Sun', `${lib.fmtAU(rSun)} · ${lib.fmtLightTime(rSun)} light`));
rows.push(fact('From Earth', id === 'earth' ? '—' : `${lib.fmtAU(rEarth)} · ${lib.fmtLightTime(rEarth)} light`));
// orbital speed + periods
if (b.type === 'moon') {
const per = Math.abs(b.moonOrbit.periodDays);
const v = (2 * Math.PI * b.moonOrbit.aKm) / (per * 86400);
rows.push(fact('Orbits', bodies[b.parent].name));
rows.push(fact('Orbital speed', lib.fmtSpeed(v)));
rows.push(fact('Orbital period', `${lib.fmtDuration(per)}${b.moonOrbit.periodDays < 0 ? ' (retrograde)' : ''}`));
} else if (id !== 'sun') {
const a = b.elements ? b.elements.a : ephem.semiMajor(b.ephemId, jd);
const rKm = rSun * lib.AU_KM, aKm = a * lib.AU_KM;
const v = Math.sqrt(GM_SUN * (2 / rKm - 1 / aKm)); // vis-viva
rows.push(fact('Orbital speed', lib.fmtSpeed(v)));
rows.push(fact('Year', lib.fmtDuration(YEAR_DAYS * Math.pow(a, 1.5))));
}
if (b.rotationHours) {
rows.push(fact('Day (rotation)', `${lib.fmtDuration(Math.abs(b.rotationHours) / 24)}${b.rotationHours < 0 ? ' (retrograde)' : ''}`));
}
const facts = panel.querySelector('.ip-facts');
if (facts) facts.innerHTML = rows.join('');
}
return {
id: 'almanac',
onClockTick(simMs, jd) {
if (focus.id !== lastFocus) { lastFocus = focus.id; render(focus.id); lastWall = 0; }
if (panel.hidden) return;
const now = performance.now();
if (now - lastWall < 200) return; // ≤5 Hz
lastWall = now;
updateLive(focus.id, jd);
},
};
}

68
js/layers/asteroids.js Normal file
View File

@ -0,0 +1,68 @@
// SOLARGOD asteroids layer (Stage 5) — a main-belt sample from JPL SBDB, each rock
// propagated by the SAME Kepler core as the planets (ephem.smallBodyEcl over epoch
// elements). One Points cloud, positions refreshed ≤10 Hz. Off by default; additive
// and fail-soft (brief §5, §11). Gate: the belt sits as a torus between Mars and
// Jupiter — not a sphere, not a line.
export default function create(ctx) {
const { THREE, CONFIG, ui, scale, ephem, worldGroup } = ctx;
const CAP = 1500;
let elements = [];
let geo = null, points = null, positions = null;
let on = false, lastWall = 0;
const _e = {}, _v = {};
ui.addLayer('asteroids', 'Main belt', false, (v) => { on = v; if (points) points.visible = v; if (v) refresh(ctx.clock.jd, true); });
ui.setStatus('asteroids', 'off', 'off');
load();
async function load() {
ui.setStatus('asteroids', 'fetching SBDB…', 'warn');
try {
const q = `fields=full_name,a,e,i,om,w,ma,epoch&sb-kind=a&sb-class=MBA&limit=${CAP}`;
const j = await (await fetch(`${CONFIG.proxy.sbdb}?${q}`)).json();
const F = j.fields, ix = (k) => F.indexOf(k);
elements = j.data.map((r) => ({
a: +r[ix('a')], e: +r[ix('e')], i: +r[ix('i')], om: +r[ix('om')], w: +r[ix('w')], ma: +r[ix('ma')], epoch: +r[ix('epoch')],
})).filter((el) => el.a > 0 && el.e < 0.98 && Number.isFinite(el.a) && Number.isFinite(el.ma));
build();
ui.setStatus('asteroids', `${elements.length} main-belt · SBDB`, on ? 'ok' : 'off');
} catch (err) {
console.warn('[solargod] asteroids', err.message);
ui.setStatus('asteroids', 'SBDB unavailable', 'err');
}
}
function build() {
geo = new THREE.BufferGeometry();
positions = new Float32Array(elements.length * 3);
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
points = new THREE.Points(geo, new THREE.PointsMaterial({ color: 0x9a8464, size: 0.16, sizeAttenuation: true, transparent: true, opacity: 0.85, depthWrite: false }));
points.frustumCulled = false;
points.visible = on;
worldGroup.add(points);
if (on) refresh(ctx.clock.jd, true);
}
function refresh(jd) {
if (!geo || !elements.length) return; // toggled on before SBDB load completed
for (let k = 0; k < elements.length; k++) {
ephem.smallBodyEcl(elements[k], jd, _e);
scale.viewFromEcl(_e, _v);
positions[k * 3] = _v.x; positions[k * 3 + 1] = _v.y; positions[k * 3 + 2] = _v.z;
}
geo.attributes.position.needsUpdate = true;
}
return {
id: 'asteroids',
onClockTick(simMs, jd) {
if (!on || !points) return;
const now = performance.now();
if (now - lastWall < 100) return; // ≤10 Hz
lastWall = now;
refresh(jd);
},
};
}

129
js/layers/comets.js Normal file
View File

@ -0,0 +1,129 @@
// SOLARGOD comets layer (Stage 5) — famous periodic comets from JPL SBDB, drawn
// with their full elliptical orbit (shared Kepler core), a marker, and an
// anti-sunward tail whose length grows as 1/r² near the Sun. Elliptical only
// (e < 0.98). On by default (brief §5). Gate: Halley's aphelion reaches beyond
// Neptune's orbit — visible in the compressed view, near it.
export default function create(ctx) {
const { THREE, scene, CONFIG, lib, ui, scale, ephem, worldGroup, toLocal, makeLabel } = ctx;
const WANT = [
{ key: '1P', color: '#8fd3ff' },
{ key: '2P', color: '#ffd24d' },
{ key: '67P', color: '#9d8cff' },
{ key: '96P', color: '#6fcf97' },
];
const N = 512;
const glyph = makeDot();
const comets = [];
let on = true;
const _e = {}, _abs = {}, _dir = {}, _tmp = {};
ui.addLayer('comets', 'Comets', true, (v) => {
on = v;
for (const c of comets) { c.line.visible = v; c.spr.visible = v; c.tail.visible = v; c.lbl.obj.visible = v; }
});
ui.setStatus('comets', 'fetching SBDB…', 'warn');
load();
async function load() {
try {
const j = await (await fetch(`${CONFIG.proxy.sbdb}?fields=full_name,a,e,i,om,w,ma,epoch&sb-kind=c&limit=500`)).json();
const F = j.fields, ix = (k) => F.indexOf(k);
for (const w of WANT) {
const row = j.data.find((r) => { const fn = r[0].trim(); return fn.startsWith(w.key + '/') || fn.startsWith(w.key + ' '); });
if (!row) continue;
const name = row[ix('full_name')].trim();
const el = { a: +row[ix('a')], e: +row[ix('e')], i: +row[ix('i')], om: +row[ix('om')], w: +row[ix('w')], ma: +row[ix('ma')], epoch: +row[ix('epoch')] };
if (!(el.a > 0) || el.e >= 0.98) continue; // elliptical only (brief §3)
build(w, name, el);
}
ui.setStatus('comets', comets.length ? `${comets.length} comets · SBDB` : 'none resolved', comets.length ? 'ok' : 'err');
} catch (err) {
console.warn('[solargod] comets', err.message);
ui.setStatus('comets', 'SBDB unavailable', 'err');
}
}
function build(w, name, el) {
// full orbit (absolute view-world, rides worldGroup)
const au = ephem.orbitPathFromElements(el, N);
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(new Float32Array((N + 1) * 3), 3));
const line = new THREE.LineLoop(geo, new THREE.LineBasicMaterial({ color: new THREE.Color(w.color), transparent: true, opacity: 0.5 }));
line.frustumCulled = false; line.visible = on;
worldGroup.add(line);
// marker
const spr = new THREE.Sprite(new THREE.SpriteMaterial({ map: glyph, color: new THREE.Color(w.color), transparent: true, depthWrite: false }));
spr.visible = on; scene.add(spr);
// tail: a 2-vertex line, anti-sunward, updated per frame (render-local space)
const tgeo = new THREE.BufferGeometry();
tgeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(6), 3));
const tail = new THREE.Line(tgeo, new THREE.LineBasicMaterial({ color: new THREE.Color(w.color), transparent: true, opacity: 0.6, blending: THREE.AdditiveBlending, depthWrite: false }));
tail.frustumCulled = false; tail.visible = on; scene.add(tail);
const lbl = makeLabel(spr, name, 'craft-label'); lbl.div.style.color = w.color; lbl.obj.visible = on;
const c = { w, name, el, geo, line, spr, tail, tgeo, lbl, auPath: au, lastT: lib.centuriesSinceJ2000(ctx.clock.jd), lastFillP: NaN };
fillOrbit(c);
c.lastFillP = scale.getP();
comets.push(c);
}
function fillOrbit(c) {
const pos = c.geo.attributes.position.array;
for (let k = 0; k <= N; k++) {
_tmp.x = c.auPath[k * 3]; _tmp.y = c.auPath[k * 3 + 1]; _tmp.z = c.auPath[k * 3 + 2];
scale.viewFromEcl(_tmp, _tmp);
pos[k * 3] = _tmp.x; pos[k * 3 + 1] = _tmp.y; pos[k * 3 + 2] = _tmp.z;
}
c.geo.attributes.position.needsUpdate = true;
c.geo.computeBoundingSphere();
}
let lastTextMs = 0;
return {
id: 'comets',
onClockTick(simMs, jd) {
if (!on) return;
const P = scale.getP();
const camDist = ctx.camera.position.length();
// label DOM writes throttled ≤5 Hz (brief §11)
const nowMs = performance.now();
const updateText = nowMs - lastTextMs > 200;
if (updateText) lastTextMs = nowMs;
for (const c of comets) {
ephem.smallBodyEcl(c.el, jd, _e);
const r = Math.hypot(_e.x, _e.y, _e.z);
scale.viewFromEcl(_e, _abs);
toLocal(_abs, c.spr.position);
c.spr.scale.setScalar(Math.max(0.02, camDist * 0.014));
// anti-sunward tail (away from Sun = +unit position), length ∝ 1/r²
const inv = 1 / (r || 1);
lib.eclToWorld({ x: _e.x * inv, y: _e.y * inv, z: _e.z * inv }, _dir);
const len = lib.clamp(3 / (r * r), 0.3, 8);
const tp = c.tgeo.attributes.position.array;
tp[0] = c.spr.position.x; tp[1] = c.spr.position.y; tp[2] = c.spr.position.z;
tp[3] = c.spr.position.x + _dir.x * len; tp[4] = c.spr.position.y + _dir.y * len; tp[5] = c.spr.position.z + _dir.z * len;
c.tgeo.attributes.position.needsUpdate = true;
if (updateText) c.lbl.div.textContent = `${c.name} · ${lib.fmtAU(r)}`;
// orbit rebuild on T-drift or any P change (covers the tween + its final frame)
const needAu = Math.abs(lib.centuriesSinceJ2000(jd) - c.lastT) > CONFIG.orbitRebuildCy;
if (needAu) { c.auPath = ephem.orbitPathFromElements(c.el, N); c.lastT = lib.centuriesSinceJ2000(jd); }
if (needAu || P !== c.lastFillP) { fillOrbit(c); c.lastFillP = P; }
}
},
handlePick(raycaster) {
const hits = raycaster.intersectObjects(comets.filter((c) => c.spr.visible).map((c) => c.spr), false);
if (hits.length) { const c = comets.find((x) => x.spr === hits[0].object); if (c) ui.toast(c.lbl.div.textContent); return true; }
return false;
},
};
function makeDot() {
const s = 48, cv = document.createElement('canvas'); cv.width = cv.height = s;
const g = cv.getContext('2d');
const grd = g.createRadialGradient(s / 2, s / 2, 0, s / 2, s / 2, s / 2);
grd.addColorStop(0, '#fff'); grd.addColorStop(0.4, 'rgba(255,255,255,0.7)'); grd.addColorStop(1, 'rgba(255,255,255,0)');
g.fillStyle = grd; g.beginPath(); g.arc(s / 2, s / 2, s / 2, 0, 7); g.fill();
const t = new THREE.CanvasTexture(cv); t.colorSpace = THREE.SRGBColorSpace; return t;
}
}

43
js/layers/moons.js Normal file
View File

@ -0,0 +1,43 @@
// SOLARGOD moons layer (Stage 3) — every moon in the registry as a lit sphere,
// positioned by ctx.bodyWorld (main.js computes the local compressed offset in the
// parent's equatorial plane; retrograde via negative period). Moons draw at the
// SAME exaggeration as their parent, so the true radius ratio (e.g. Io/Jupiter
// ≈ 0.026) is preserved. Labels appear only when the parent (or the moon) is the
// focus, to keep the wide view clean (brief §5).
export default function create(ctx) {
const { THREE, scene, CONFIG, ui, scale, bodies, focus, toLocal, effectiveE, makeLabel, registerPick } = ctx;
const entries = [];
for (const id in bodies) {
const b = bodies[id];
if (b.type !== 'moon') continue;
const mat = new THREE.MeshStandardMaterial({ color: new THREE.Color(b.color), roughness: 1, metalness: 0 });
const mesh = new THREE.Mesh(new THREE.SphereGeometry(1, 24, 16), mat);
mesh.visible = true;
scene.add(mesh);
registerPick(mesh, id);
const lbl = makeLabel(mesh, b.name, 'body-label moon');
lbl.obj.visible = false; // CSS2DRenderer honors obj.visible (not div.style.display)
entries.push({ id, b, mesh, lbl });
}
let show = true;
ui.addLayer('moons', 'Moons', true, (on) => { show = on; for (const e of entries) e.mesh.visible = on; });
ui.setStatus('moons', `${entries.length} moons`, 'ok');
return {
id: 'moons',
onClockTick(simMs, jd) {
for (const e of entries) {
const abs = ctx.bodyWorld[e.id];
if (!abs) continue;
toLocal(abs, e.mesh.position);
e.mesh.scale.setScalar(scale.drawRadius(e.b.radiusKm, effectiveE(e.b.parent)));
const near = focus.id === e.b.parent || focus.id === e.id;
e.lbl.obj.visible = show && near;
e.lbl.div.classList.toggle('focused', e.id === focus.id);
}
},
};
}

86
js/layers/neos.js Normal file
View File

@ -0,0 +1,86 @@
// SOLARGOD near-Earth close-approach layer (Stage 5) — the CAD API's ±30-day list
// of close approaches, as highlighted clickable reticles arranged around Earth
// (a "close-approach radar"), colored by miss distance and labelled in lunar
// distances. Off by default; fail-soft (brief §5). NOTE: CAD returns approach
// EVENTS, not orbits — markers are schematic positions around Earth, not tracked
// trajectories; the data (designation, miss distance, date, speed) is real.
export default function create(ctx) {
const { THREE, scene, CONFIG, lib, ui, ephem, toLocal, makeLabel } = ctx;
const LD_AU = 384400 / lib.AU_KM; // one lunar distance in AU
const glyph = makeReticle();
const neos = [];
let on = false;
const _abs = {};
ui.addLayer('neos', 'Close approaches', false, (v) => { on = v; for (const n of neos) { n.spr.visible = v; n.lbl.obj.visible = v; } });
ui.setStatus('neos', 'off', 'off');
load();
async function load() {
ui.setStatus('neos', 'fetching CAD…', 'warn');
try {
const now = new Date(ctx.clock.simMs);
const fmt = (d) => `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}-${String(d.getUTCDate()).padStart(2, '0')}`;
const dmin = fmt(new Date(now.getTime() - 30 * 86400000));
const dmax = fmt(new Date(now.getTime() + 30 * 86400000));
const j = await (await fetch(`${CONFIG.proxy.cad}?date-min=${dmin}&date-max=${dmax}&dist-max=0.05&sort=dist&limit=40`)).json();
const F = j.fields, ix = (k) => F.indexOf(k);
const rows = j.data || [];
rows.forEach((r, i) => build({
des: r[ix('des')], jd: +r[ix('jd')], cd: r[ix('cd')], dist: +r[ix('dist')],
v: +r[ix('v_rel')], h: +r[ix('h')],
}, i, rows.length));
ui.setStatus('neos', neos.length ? `${neos.length} approaches ±30d · CAD · schematic ring` : 'none in window', on ? 'ok' : 'off');
} catch (err) {
console.warn('[solargod] neos', err.message);
ui.setStatus('neos', 'CAD unavailable', 'err');
}
}
function build(d, i, total) {
const ld = d.dist / LD_AU;
const col = ld < 1 ? '#ff4d4d' : ld < 5 ? '#ffb347' : '#7bff9d';
const spr = new THREE.Sprite(new THREE.SpriteMaterial({ map: glyph, color: new THREE.Color(col), transparent: true, depthWrite: false }));
spr.visible = on; scene.add(spr);
const lbl = makeLabel(spr, `${d.des} · ${ld.toFixed(1)} LD`, 'craft-label'); lbl.div.style.color = col; lbl.obj.visible = on;
neos.push({ d, ld, col, spr, lbl, angle: (i / Math.max(1, total)) * Math.PI * 2 });
}
return {
id: 'neos',
onClockTick() {
if (!on) return;
const earth = ctx.bodyWorld.earth;
if (!earth) return;
const camDist = ctx.camera.position.length();
const ringR = camDist * 0.06;
for (const n of neos) {
_abs.x = earth.x; _abs.y = earth.y; _abs.z = earth.z;
toLocal(_abs, n.spr.position);
n.spr.position.x += Math.cos(n.angle) * ringR;
n.spr.position.y += Math.sin(n.angle) * ringR * 0.5;
n.spr.scale.setScalar(Math.max(0.02, camDist * 0.012));
}
},
handlePick(raycaster) {
const hits = raycaster.intersectObjects(neos.filter((n) => n.spr.visible).map((n) => n.spr), false);
if (hits.length) {
const n = neos.find((x) => x.spr === hits[0].object);
if (n) ui.toast(`${n.d.des} · ${n.ld.toFixed(2)} LD · ${n.d.cd} · ${n.d.v.toFixed(1)} km/s`);
return true;
}
return false;
},
};
function makeReticle() {
const s = 48, cv = document.createElement('canvas'); cv.width = cv.height = s;
const g = cv.getContext('2d');
g.strokeStyle = '#fff'; g.lineWidth = 3;
g.beginPath(); g.arc(s / 2, s / 2, s * 0.32, 0, 7); g.stroke();
g.beginPath(); g.moveTo(s / 2, 2); g.lineTo(s / 2, s * 0.18); g.moveTo(s / 2, s - 2); g.lineTo(s / 2, s * 0.82); g.stroke();
const t = new THREE.CanvasTexture(cv); t.colorSpace = THREE.SRGBColorSpace; return t;
}
}

95
js/layers/planets.js Normal file
View File

@ -0,0 +1,95 @@
// SOLARGOD planets layer (Stage 1) — the eight planets + Pluto as textured,
// exaggerated spheres on compressed orbit paths, with labels. Positions come from
// ctx.bodyWorld (computed centrally in main.js); this layer only renders. Orbit
// geometry is rebuilt only when the MEGA/TRUE exponent is animating or elements
// drift past CONFIG.orbitRebuildCy (brief §11).
export default function create(ctx) {
const { THREE, scene, CONFIG, lib, ui, scale, ephem, bodies, worldGroup,
clock, focus, toLocal, effectiveE, makeLabel, registerPick, loadTextureInto, PLANET_ORDER } = ctx;
const ROSTER = [...PLANET_ORDER, 'pluto'];
const orbitMat = new THREE.LineBasicMaterial({ color: CONFIG.colors.orbit, transparent: true, opacity: 0.6 });
const N = CONFIG.orbitSamples;
const entries = [];
const tmp = new THREE.Vector3();
const _v = {};
for (const id of ROSTER) {
const b = bodies[id];
// tilt group holds the spinning sphere so axial tilt + rotation compose.
const group = new THREE.Group();
const mat = new THREE.MeshStandardMaterial({ color: new THREE.Color(b.color), roughness: 1, metalness: 0 });
const mesh = new THREE.Mesh(new THREE.SphereGeometry(1, 48, 32), mat);
group.rotation.z = (b.axialTiltDeg || 0) * lib.DEG;
group.add(mesh);
scene.add(group);
loadTextureInto(mat, b.texture);
registerPick(mesh, id);
const lbl = makeLabel(group, b.name, 'body-label');
// orbit path (absolute view-world, lives under worldGroup)
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(new Float32Array((N + 1) * 3), 3));
const line = new THREE.LineLoop(geo, orbitMat);
line.frustumCulled = false;
worldGroup.add(line);
const e = { id, b, group, mesh, mat, geo, line, lbl, auPath: null, lastT: NaN, lastFillP: NaN };
buildAuPath(e);
fillView(e);
e.lastFillP = scale.getP();
entries.push(e);
}
function buildAuPath(e) {
e.auPath = e.b.ephemId
? ephem.orbitPath(e.b.ephemId, clock.jd, N)
: ephem.orbitPathFromElements(e.b.elements, N);
e.lastT = lib.centuriesSinceJ2000(clock.jd);
}
function fillView(e) {
const pos = e.geo.attributes.position.array;
const au = e.auPath;
for (let k = 0; k <= N; k++) {
_v.x = au[k * 3]; _v.y = au[k * 3 + 1]; _v.z = au[k * 3 + 2];
scale.viewFromEcl(_v, _v);
pos[k * 3] = _v.x; pos[k * 3 + 1] = _v.y; pos[k * 3 + 2] = _v.z;
}
e.geo.attributes.position.needsUpdate = true;
e.geo.computeBoundingSphere();
}
let showOrbits = true, showLabels = true;
ui.addLayer('orbits', 'Orbit paths', true, (on) => { showOrbits = on; for (const e of entries) e.line.visible = on; });
ui.addLayer('labels', 'Body labels', true, (on) => { showLabels = on; for (const e of entries) e.lbl.obj.visible = on; });
ui.setStatus('orbits', `${entries.length} orbits`, 'ok');
ui.setStatus('labels', 'names shown', 'ok');
return {
id: 'planets',
onClockTick(simMs, jd, isLive) {
const P = scale.getP();
for (const e of entries) {
const abs = ctx.bodyWorld[e.id];
if (!abs) continue;
// position (floating-origin JS subtraction) + exaggerated radius
toLocal(abs, e.group.position);
const r = scale.drawRadius(e.b.radiusKm, effectiveE(e.id));
e.mesh.scale.setScalar(r);
// spin: fraction of a rotation since J2000 (retrograde via sign)
if (e.b.rotationHours) {
const rot = ((jd - lib.J2000_JD) * 24) / e.b.rotationHours;
e.mesh.rotation.y = rot * 2 * Math.PI;
}
// focus highlight
e.lbl.div.classList.toggle('focused', e.id === focus.id);
// orbit rebuild: on element drift, or whenever the compression P changed
// (covers the whole MEGA↔TRUE tween AND the final frame it settles on).
const needAu = Math.abs(lib.centuriesSinceJ2000(jd) - e.lastT) > CONFIG.orbitRebuildCy;
if (needAu) buildAuPath(e);
if (needAu || P !== e.lastFillP) { fillView(e); e.lastFillP = P; }
}
},
};
}

66
js/layers/rings.js Normal file
View File

@ -0,0 +1,66 @@
// SOLARGOD rings layer (Stage 3) — Saturn's textured annulus + Uranus's thin
// schematic ring. Sized in true km scaled by the parent's draw scale, tilted with
// the parent's axial tilt, DoubleSide + transparent (brief §7). Texture optional:
// a flat color fallback keeps the ring visible if the PNG is missing.
export default function create(ctx) {
const { THREE, scene, lib, ui, scale, bodies, toLocal, effectiveE, CONFIG } = ctx;
const AU_KM = lib.AU_KM;
const RING_FALLBACK = { saturn: '#c9b98f', uranus: '#8fa3a8' };
const entries = [];
for (const id of ['saturn', 'uranus']) {
const b = bodies[id];
if (!b.rings) continue;
const ratio = b.rings.innerKm / b.rings.outerKm;
const geo = new THREE.RingGeometry(ratio, 1, 128, 1);
radialUV(geo); // remap UV so a radial ring texture maps across width
const mat = new THREE.MeshBasicMaterial({
color: new THREE.Color(b.rings.color || RING_FALLBACK[id] || '#bbb'),
side: THREE.DoubleSide, transparent: true, opacity: id === 'saturn' ? 0.9 : 0.45, depthWrite: false,
});
if (b.rings.texture) {
new THREE.TextureLoader().load(CONFIG.textures.path + b.rings.texture, (tex) => {
tex.colorSpace = THREE.SRGBColorSpace; mat.map = tex; mat.color.set(0xffffff); mat.needsUpdate = true;
}, undefined, () => { /* keep flat color */ });
}
const group = new THREE.Group();
group.rotation.z = (b.axialTiltDeg || 0) * lib.DEG; // same tilt as the planet group
const mesh = new THREE.Mesh(geo, mat);
mesh.rotation.x = -Math.PI / 2; // lay flat into the equatorial (XZ) plane
group.add(mesh);
scene.add(group);
entries.push({ id, b, group, mesh });
}
let show = true;
ui.addLayer('rings', 'Rings', true, (on) => { show = on; for (const e of entries) e.group.visible = on; });
ui.setStatus('rings', 'Saturn · Uranus', 'ok');
return {
id: 'rings',
onClockTick() {
const K = scale.getK();
for (const e of entries) {
const abs = ctx.bodyWorld[e.id];
if (!abs) continue;
toLocal(abs, e.group.position);
const E = effectiveE(e.id);
e.mesh.scale.setScalar((e.b.rings.outerKm / AU_KM) * K * E); // outer km → view units
}
},
};
}
// Remap RingGeometry UVs so u runs 0→1 across the radius (for a 1-D ring texture).
function radialUV(geo) {
const pos = geo.attributes.position, uv = geo.attributes.uv;
const inner = Math.hypot(pos.getX(0), pos.getY(0));
let outer = inner;
for (let i = 0; i < pos.count; i++) outer = Math.max(outer, Math.hypot(pos.getX(i), pos.getY(i)));
for (let i = 0; i < pos.count; i++) {
const r = Math.hypot(pos.getX(i), pos.getY(i));
uv.setXY(i, (r - inner) / (outer - inner || 1), 1);
}
uv.needsUpdate = true;
}

183
js/layers/spacecraft.js Normal file
View File

@ -0,0 +1,183 @@
// SOLARGOD spacecraft layer (Stage 4) — live positions of real deep-space probes
// from JPL Horizons via serve.py's proxy. Each craft's VECTORS table (heliocentric
// J2000 ecliptic, AU) is fetched once over a multi-year span, disk-cached, and
// Catmull-Rom interpolated per frame. Additive + fail-soft: a craft that won't
// resolve is skipped with a warn status, never breaking the scene (brief §4, §0).
export default function create(ctx) {
const { THREE, scene, CONFIG, lib, ui, scale, ephem, clock, worldGroup, toLocal, makeLabel } = ctx;
const ROSTER = [
{ id: 'voyager1', name: 'Voyager 1', cmd: '-31', color: '#ff6ad5' },
{ id: 'voyager2', name: 'Voyager 2', cmd: '-32', color: '#ff9f40' },
{ id: 'newhorizons', name: 'New Horizons', cmd: '-98', color: '#6fd3ff' },
{ id: 'jwst', name: 'JWST', cmd: '-170', color: '#ffe14d' },
{ id: 'parker', name: 'Parker Solar Probe', cmd: '-96', color: '#ff5964' },
{ id: 'juno', name: 'Juno', cmd: '-61', color: '#8f7bff' },
{ id: 'lucy', name: 'Lucy', cmd: '-49', color: '#6fcf97' },
{ id: 'psyche', name: 'Psyche', cmd: '-255', color: '#c9c9c9' },
];
const START = '2023-01-01', STOP = '2030-01-01', STEP = '2 d';
const TRAIL_MAX = 60;
const glyphTex = makeGlyph();
const _abs = {}, _earth = {}, _p = {};
const crafts = [];
let show = true;
for (const c of ROSTER) {
const spr = new THREE.Sprite(new THREE.SpriteMaterial({ map: glyphTex, color: new THREE.Color(c.color), transparent: true, depthWrite: false }));
spr.visible = false;
spr.userData.craftId = c.id;
scene.add(spr);
const tgeo = new THREE.BufferGeometry();
tgeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(TRAIL_MAX * 3), 3));
const trail = new THREE.Line(tgeo, new THREE.LineBasicMaterial({ color: new THREE.Color(c.color), transparent: true, opacity: 0.5 }));
trail.frustumCulled = false; trail.visible = false;
worldGroup.add(trail);
const lbl = makeLabel(spr, c.name, 'craft-label');
lbl.div.style.color = c.color;
lbl.obj.visible = false;
crafts.push({ c, spr, trail, tgeo, lbl, samples: null });
}
ui.addLayer('spacecraft', 'Spacecraft', true, (on) => { show = on; });
ui.setStatus('spacecraft', 'fetching…', 'warn');
loadAll();
// Sequential (not parallel) — Horizons throttles bursts, and the disk cache
// makes reloads free anyway (brief §12: don't hammer JPL).
async function loadAll() {
let ok = 0, fail = 0;
for (let i = 0; i < ROSTER.length; i++) {
const c = ROSTER[i];
try {
const s = await fetchCraft(c);
if (s && s.length > 1) { crafts[i].samples = s; ok++; }
else fail++;
} catch (e) { fail++; console.warn('[solargod] spacecraft', c.id, e.message); }
ui.setStatus('spacecraft', `fetching ${i + 1}/${ROSTER.length}${ok} ok`, 'warn');
}
ui.setStatus('spacecraft', `${ok} tracked${fail ? `, ${fail} unavailable` : ''} · Horizons ok`, ok ? 'ok' : 'err');
}
// Fetch one craft over [start, stop]. If Horizons rejects the span (a craft's
// SPK kernel doesn't cover it — e.g. Psyche pre-launch, Juno past coverage), it
// says exactly where its data begins/ends; parse that and refetch clamped once.
async function fetchCraft(c, start = START, stop = STOP, retries = 2) {
const q = {
format: 'text', COMMAND: `'${c.cmd}'`, OBJ_DATA: 'NO', MAKE_EPHEM: 'YES', EPHEM_TYPE: 'VECTORS',
CENTER: "'500@10'", START_TIME: `'${start}'`, STOP_TIME: `'${stop}'`, STEP_SIZE: `'${STEP}'`,
REF_PLANE: 'ECLIPTIC', REF_SYSTEM: 'J2000', VEC_TABLE: '1', OUT_UNITS: 'AU-D', CSV_FORMAT: 'YES',
};
const qs = Object.entries(q).map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('&');
const res = await fetch(`${CONFIG.proxy.horizons}?${qs}`);
const text = await res.text();
const soe = text.indexOf('$$SOE'), eoe = text.indexOf('$$EOE');
if (soe < 0 || eoe < 0) {
if (retries > 0) {
const prior = text.match(/prior to A\.D\.\s*(\d{4}-[A-Za-z]{3}-\d{2})/);
const after = text.match(/after A\.D\.\s*(\d{4}-[A-Za-z]{3}-\d{2})/);
let ns = start, np = stop, changed = false;
if (prior) { ns = shiftDate(prior[1], +2); changed = ns !== start; }
if (after) { np = shiftDate(after[1], -2); changed = changed || np !== stop; }
if (changed) return fetchCraft(c, ns, np, retries - 1);
}
throw new Error('no ephemeris block');
}
const out = [];
for (const line of text.slice(soe + 5, eoe).split('\n')) {
const p = line.split(',');
if (p.length < 5) continue;
const jd = parseFloat(p[0]), x = parseFloat(p[2]), y = parseFloat(p[3]), z = parseFloat(p[4]);
if (Number.isFinite(jd) && Number.isFinite(x)) out.push({ jd, x, y, z });
}
return out;
}
// "2023-OCT-13" (+ N days) → "YYYY-MM-DD" for a Horizons START/STOP_TIME.
function shiftDate(hzn, days) {
const M = { JAN: 0, FEB: 1, MAR: 2, APR: 3, MAY: 4, JUN: 5, JUL: 6, AUG: 7, SEP: 8, OCT: 9, NOV: 10, DEC: 11 };
const [y, mon, d] = hzn.split('-');
const dt = new Date(Date.UTC(+y, M[mon.toUpperCase()], +d));
dt.setUTCDate(dt.getUTCDate() + days);
return `${dt.getUTCFullYear()}-${String(dt.getUTCMonth() + 1).padStart(2, '0')}-${String(dt.getUTCDate()).padStart(2, '0')}`;
}
// Catmull-Rom sample at jd → {x,y,z} AU, or null if jd is outside the span.
function interp(samples, jd, out) {
if (jd < samples[0].jd || jd > samples[samples.length - 1].jd) return null;
let lo = 0, hi = samples.length - 1;
while (hi - lo > 1) { const m = (lo + hi) >> 1; if (samples[m].jd <= jd) lo = m; else hi = m; }
const p1 = samples[lo], p2 = samples[hi];
const p0 = samples[Math.max(0, lo - 1)], p3 = samples[Math.min(samples.length - 1, hi + 1)];
const t = (jd - p1.jd) / (p2.jd - p1.jd || 1);
out.x = catmull(p0.x, p1.x, p2.x, p3.x, t);
out.y = catmull(p0.y, p1.y, p2.y, p3.y, t);
out.z = catmull(p0.z, p1.z, p2.z, p3.z, t);
return out;
}
let lastTextMs = 0;
return {
id: 'spacecraft',
onClockTick(simMs, jd) {
const camDist = ctx.camera.position.length();
// label DOM writes throttled ≤5 Hz (brief §11); geometry still updates per frame
const nowMs = performance.now();
const updateText = nowMs - lastTextMs > 200;
if (updateText) lastTextMs = nowMs;
let earthDone = false;
for (const cr of crafts) {
const live = show && cr.samples && interp(cr.samples, jd, _p);
if (!live) { cr.spr.visible = false; cr.trail.visible = false; cr.lbl.obj.visible = false; continue; }
if (!earthDone) { ephem.helioEcl('earth', jd, _earth); earthDone = true; }
cr.spr.visible = true; cr.lbl.obj.visible = true;
scale.viewFromEcl(_p, _abs);
toLocal(_abs, cr.spr.position);
cr.spr.scale.setScalar(Math.max(0.01, camDist * 0.02));
// trail: up to TRAIL_MAX cached samples before now
let end = 0; while (end < cr.samples.length && cr.samples[end].jd <= jd) end++;
const start = Math.max(0, end - TRAIL_MAX);
const arr = cr.tgeo.attributes.position.array;
let n = 0;
for (let i = start; i < end; i++, n++) {
scale.viewFromEcl(cr.samples[i], _abs);
arr[n * 3] = _abs.x; arr[n * 3 + 1] = _abs.y; arr[n * 3 + 2] = _abs.z;
}
cr.tgeo.setDrawRange(0, n);
cr.tgeo.attributes.position.needsUpdate = true;
cr.trail.visible = n > 1;
if (updateText) {
const rSun = Math.hypot(_p.x, _p.y, _p.z);
const rEarth = Math.hypot(_p.x - _earth.x, _p.y - _earth.y, _p.z - _earth.z);
cr.lbl.div.textContent = `${cr.c.name} · ${lib.fmtAU(rSun)} · ${lib.fmtLightTime(rEarth)} lt`;
}
}
},
handlePick(raycaster) {
const markers = crafts.filter((c) => c.spr.visible).map((c) => c.spr);
const hits = raycaster.intersectObjects(markers, false);
if (hits.length) {
const cr = crafts.find((c) => c.spr === hits[0].object);
if (cr) ui.toast(cr.lbl.div.textContent || cr.c.name);
return true;
}
return false;
},
};
function catmull(p0, p1, p2, p3, t) {
const t2 = t * t, t3 = t2 * t;
return 0.5 * ((2 * p1) + (-p0 + p2) * t + (2 * p0 - 5 * p1 + 4 * p2 - p3) * t2 + (-p0 + 3 * p1 - 3 * p2 + p3) * t3);
}
function makeGlyph() {
const s = 64, cv = document.createElement('canvas'); cv.width = cv.height = s;
const g = cv.getContext('2d');
g.translate(s / 2, s / 2);
g.fillStyle = '#fff';
g.beginPath(); g.moveTo(0, -s * 0.32); g.lineTo(s * 0.32, 0); g.lineTo(0, s * 0.32); g.lineTo(-s * 0.32, 0); g.closePath(); g.fill();
g.strokeStyle = 'rgba(255,255,255,0.5)'; g.lineWidth = 3; g.beginPath(); g.arc(0, 0, s * 0.42, 0, 7); g.stroke();
const t = new THREE.CanvasTexture(cv); t.colorSpace = THREE.SRGBColorSpace; return t;
}
}

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

499
js/main.js Normal file
View File

@ -0,0 +1,499 @@
// 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, preserveDrawingBuffer: 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);
// Bind to the WebGL canvas, NOT labelRenderer.domElement — that div lives inside
// #labels {pointer-events:none}, so OrbitControls would never receive input.
const controls = new OrbitControls(camera, renderer.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,
fromOffset: { x: 0, y: 0, z: 0 }, // captured origin of the current flight
};
// bodyWorld[id] = absolute view-world position, plain float64 {x,y,z}.
const bodyWorld = Object.create(null);
bodyWorld.sun = { x: 0, y: 0, z: 0 }; // heliocentric origin, never moves
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') 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) return baseE;
// Ramp E→1 with camera distance continuously — including DURING the fly-to — so
// the body doesn't pop when the flight finishes (camDist lerps camFrom→camTo).
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;
// Snapshot the CURRENT (possibly mid-lerp) offset as the flight's origin, so
// re-focusing during a flight starts from where the view actually is — not from
// the previous destination body.
focus.fromOffset = { x: focusOffset.x, y: focusOffset.y, z: focusOffset.z };
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) => {
const started = downPos; downPos = null; // consume this gesture
if (!started || dragMoved) return; // needs a canvas press that didn't drag
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', './layers/moons.js', './layers/rings.js',
'./layers/spacecraft.js', './layers/asteroids.js', './layers/comets.js', './layers/neos.js',
'./layers/almanac.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 from the captured start (fromOffset) to the live
// target body, ease cam dist. Starting from the snapshot (not bodyWorld[fromId])
// means re-focusing mid-flight doesn't jump to the previous destination.
if (focus.t < 1) {
focus.t = lib.clamp(focus.t + dtWall / CONFIG.camera.flightMs, 0, 1);
const e = lib.smoothstep(focus.t);
const a = focus.fromOffset, 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) {
// restore the full spherical pose — inverse of serializeHash's
// heading = atan2(x, z), pitch = asin(y / dist)
const [d, heading, pitch] = pendingCam;
camera.position.set(
d * Math.cos(pitch) * Math.sin(heading),
d * Math.sin(pitch),
d * Math.cos(pitch) * Math.cos(heading));
}
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 };

91
js/scale.js Normal file
View File

@ -0,0 +1,91 @@
// 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, 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).
// Allocation-free (brief §11): the eclToWorld map is inlined with the scale so no
// intermediate object is created — this runs for every body every frame.
export function viewFromEcl(ecl, out) {
// Read all components first so this is safe when out === ecl (callers reuse a
// single scratch, e.g. viewFromEcl(_v, _v)) and still allocation-free.
const x = ecl.x, y = ecl.y, z = ecl.z;
const r = Math.hypot(x, y, z);
if (r === 0) { out.x = out.y = out.z = 0; return out; }
const s = radial(r) / r; // scale factor along the true direction
out.x = x * s; // ecl X → world x
out.y = z * s; // ecl Z (north) → world y
out.z = -y * s; // ecl Y → world z
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);
}

229
serve.py Normal file
View File

@ -0,0 +1,229 @@
#!/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
# CAD is NOT immutable even for past dates: close-approach records keep growing
# as new NEOs are discovered. SBDB elements advance too. Both get the 24 h TTL.
else:
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")
# Horizons returns HTTP 200 even for "no ephemeris" errors — don't cache
# those (a transient/burst failure would otherwise poison the cache). A
# real ephemeris always contains the $$SOE marker.
if name != "horizons" or b"$$SOE" in body:
_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()

201
verify.html Normal file
View File

@ -0,0 +1,201 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>SOLARGOD ▸ verify</title>
<style>
body { background: #05060a; color: #e6ddca; font-family: ui-monospace, Menlo, monospace; font-size: 13px; padding: 24px; }
h1 { color: #d9a441; font-size: 18px; letter-spacing: 2px; }
table { border-collapse: collapse; width: 100%; max-width: 960px; margin-top: 12px; }
th, td { text-align: left; padding: 5px 10px; border-bottom: 1px solid rgba(217,164,65,0.14); }
th { color: #8a8674; font-weight: 600; text-transform: uppercase; font-size: 10px; }
.pass { color: #6fcf6f; font-weight: 700; }
.fail { color: #e06a5a; font-weight: 700; }
.pending { color: #8a8674; }
.detail { color: #8a8674; font-size: 11px; }
#summary { margin-top: 16px; font-size: 15px; }
.importmap-note { color: #8a8674; }
</style>
<script type="importmap">
{ "imports": { "three": "https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js" } }
</script>
</head>
<body>
<h1>SOLARGOD ▸ VERIFY</h1>
<div class="importmap-note">lib.js + ephem.js + scale.js assertions · live Horizons via serve.py proxy. Serve with <code>python3 serve.py</code> then open <code>/verify.html</code>.</div>
<table><thead><tr><th>#</th><th>gate</th><th>result</th><th>detail</th></tr></thead><tbody id="rows"></tbody></table>
<div id="summary" class="pending">running…</div>
<script type="module">
import * as lib from './js/lib.js';
import * as ephem from './js/ephem.js';
import * as scale from './js/scale.js';
import { CONFIG } from './js/config.js';
scale.init(CONFIG);
const rows = document.getElementById('rows');
let n = 0, passed = 0, failed = 0;
function row(name, ok, detail) {
n++; if (ok) passed++; else failed++;
const tr = document.createElement('tr');
tr.innerHTML = `<td>${n}</td><td>${name}</td><td class="${ok ? 'pass' : 'fail'}">${ok ? 'PASS' : 'FAIL'}</td><td class="detail">${detail}</td>`;
rows.appendChild(tr);
}
function pendingRow(name) {
const tr = document.createElement('tr');
tr.innerHTML = `<td></td><td>${name}</td><td class="pending"></td><td class="detail">querying Horizons…</td>`;
rows.appendChild(tr);
return tr;
}
// ---- 1. Horizons ground truth (brief §9), JD 2461236.5 = 2026-07-15 TDB ----
const JD = 2461236.5;
const truth = {
mars: { x: +1.05351078, y: +1.00862522, z: -0.00469549, tol: 0.005 },
jupiter: { x: -3.01967256, y: +4.33218144, z: +0.04956505, tol: 0.01 },
earth: { x: +0.38381080, y: -0.94118486, z: +0.00005421, tol: 0.003 },
};
for (const [id, t] of Object.entries(truth)) {
const p = ephem.helioEcl(id, JD);
const worst = Math.max(Math.abs(p.x - t.x), Math.abs(p.y - t.y), Math.abs(p.z - t.z));
row(`${id} @ 2026-07-15 (heliocentric ecliptic AU)`, worst <= t.tol,
`Δmax=${worst.toExponential(2)} AU (tol ${t.tol}) · got (${p.x.toFixed(5)}, ${p.y.toFixed(5)}, ${p.z.toFixed(5)})`);
}
// ---- 2. Kepler solver converges for e = 0.97 ----
{
const M = 0.5, e = 0.97, E = ephem.solveKepler(M, e);
const resid = Math.abs(E - e * Math.sin(E) - M);
row('Kepler solver e=0.97', resid < 1e-9, `residual ${resid.toExponential(2)} rad`);
}
// ---- 3. orbitPath endpoints join ----
{
const path = ephem.orbitPath('mercury', JD, 128);
const m = path.length / 3;
const d = Math.max(Math.abs(path[0] - path[(m-1)*3]), Math.abs(path[1] - path[(m-1)*3+1]), Math.abs(path[2] - path[(m-1)*3+2]));
row('orbitPath endpoints join', d < 1e-12, `Δ ${d.toExponential(2)} AU`);
}
// ---- 4. eclToWorld round-trips ----
{
const v = { x: 1.234, y: -5.678, z: 0.910 };
const w = lib.eclToWorld(v, {}); const back = lib.worldToEcl(w, {});
const d = Math.max(Math.abs(back.x - v.x), Math.abs(back.y - v.y), Math.abs(back.z - v.z));
row('eclToWorld round-trip', d === 0, `Δ ${d}`);
}
// ---- 5. MEGA→TRUE preserves direction (unit dot ≈ 1) ----
{
const ecl = ephem.helioEcl('jupiter', JD);
const a = scale.viewFromEcl(ecl, {});
scale.setMode('true');
for (let i = 0; i < 60 && scale.isTransitioning(); i++) scale.update(100);
const b = scale.viewFromEcl(ecl, {});
const na = Math.hypot(a.x, a.y, a.z), nb = Math.hypot(b.x, b.y, b.z);
const dot = (a.x * b.x + a.y * b.y + a.z * b.z) / (na * nb);
row('MEGA→TRUE keeps direction', Math.abs(dot - 1) < 1e-9, `unit dot = ${dot.toFixed(12)}`);
scale.setMode('mega'); for (let i = 0; i < 60 && scale.isTransitioning(); i++) scale.update(100);
}
// ---- 5b. Belt sample propagates as a torus between Mars & Jupiter (Stage 5) ----
{
// three synthetic MBAs (a in 2.23.2) at random anomalies must land, in the
// compressed view, at radii strictly between Mars's and Jupiter's.
const rMars = scale.radial(1.524), rJup = scale.radial(5.203);
const els = [
{ a: 2.3, e: 0.1, i: 5, om: 30, w: 60, ma: 10, epoch: JD },
{ a: 2.77, e: 0.08, i: 10, om: 80, w: 73, ma: 200, epoch: JD },
{ a: 3.15, e: 0.15, i: 15, om: 150, w: 200, ma: 300, epoch: JD },
];
let ok = true, rmin = Infinity, rmax = 0;
for (const el of els) {
const p = ephem.smallBodyEcl(el, JD, {});
const r = scale.radial(Math.hypot(p.x, p.y, p.z));
rmin = Math.min(rmin, r); rmax = Math.max(rmax, r);
if (!(r > rMars && r < rJup)) ok = false;
}
row('main belt sits between Mars & Jupiter (view radii)', ok, `belt view-r ${rmin.toFixed(1)}${rmax.toFixed(1)} ∈ (Mars ${rMars.toFixed(1)}, Jupiter ${rJup.toFixed(1)})`);
}
// ---- 5c. Halley's aphelion reaches beyond Neptune's orbit (Stage 5 gate) ----
{
const a = 17.93, e = 0.9679; // 1P/Halley (SBDB)
const aphelion = a * (1 + e), neptune = 30.07;
row("Halley aphelion beyond Neptune", aphelion > neptune, `aphelion ${aphelion.toFixed(1)} AU > Neptune ${neptune} AU`);
}
// ---- 6+7. Live Horizons via proxy — two extra epochs (also gates the proxy) ----
// Mars @ J2000.0 (JD 2451545.0) and Jupiter @ 1900-01-01 (JD 2415020.5).
async function horizonsVec(command, jd) {
const q = {
format: 'text', COMMAND: `'${command}'`, OBJ_DATA: 'NO', MAKE_EPHEM: 'YES',
EPHEM_TYPE: 'VECTORS', CENTER: "'500@10'", TLIST: String(jd),
REF_PLANE: 'ECLIPTIC', REF_SYSTEM: 'J2000', VEC_TABLE: '1', OUT_UNITS: 'AU-D', CSV_FORMAT: 'NO',
};
const qs = Object.entries(q).map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('&');
const res = await fetch(`${CONFIG.proxy.horizons}?${qs}`);
const text = await res.text();
const soe = text.indexOf('$$SOE'), eoe = text.indexOf('$$EOE');
if (soe < 0 || eoe < 0) throw new Error('no $$SOE block ' + text.slice(0, 160));
const block = text.slice(soe, eoe);
const gx = block.match(/X\s*=\s*(-?[\d.]+E?[-+]?\d*)/i);
const gy = block.match(/Y\s*=\s*(-?[\d.]+E?[-+]?\d*)/i);
const gz = block.match(/Z\s*=\s*(-?[\d.]+E?[-+]?\d*)/i);
if (!gx || !gy || !gz) throw new Error('could not parse X/Y/Z');
return { x: parseFloat(gx[1]), y: parseFloat(gy[1]), z: parseFloat(gz[1]) };
}
async function liveCheck(label, id, command, jd, tol) {
const tr = pendingRow(label);
try {
const got = await horizonsVec(command, jd);
const mine = ephem.helioEcl(id, jd);
const worst = Math.max(Math.abs(got.x - mine.x), Math.abs(got.y - mine.y), Math.abs(got.z - mine.z));
tr.remove();
row(label, worst <= tol, `Δmax=${worst.toExponential(2)} AU (tol ${tol}) · Horizons (${got.x.toFixed(5)}, ${got.y.toFixed(5)}, ${got.z.toFixed(5)})`);
} catch (err) {
tr.remove();
row(label, false, `proxy/Horizons error: ${err.message}`);
}
finish();
}
function finish() {
const s = document.getElementById('summary');
s.textContent = `${passed}/${n} gates pass` + (failed ? ` — ${failed} FAILING` : ' — ALL PASS ✦');
s.className = failed ? 'fail' : 'pass';
}
// ---- 10. Voyager 1 (-31) @ 2026-07-15 ≈ (32.07, 136.21, +98.55) AU (brief §9) ----
async function craftCheck(label, command, jd, want, tol) {
const tr = pendingRow(label);
try {
const got = await horizonsVec(command, jd);
const worst = Math.max(Math.abs(got.x - want.x), Math.abs(got.y - want.y), Math.abs(got.z - want.z));
tr.remove();
row(label, worst <= tol, `Δmax=${worst.toFixed(3)} AU (tol ${tol}) · Horizons (${got.x.toFixed(2)}, ${got.y.toFixed(2)}, ${got.z.toFixed(2)}) · ${Math.hypot(got.x,got.y,got.z).toFixed(1)} AU out`);
} catch (err) { tr.remove(); row(label, false, `Horizons error: ${err.message}`); }
finish();
}
// ---- 11. JWST (-170) hugs Earth (Δ < 0.02 AU) ----
async function jwstCheck() {
const label = 'JWST (-170) hugs Earth Δ<0.02 AU';
const tr = pendingRow(label);
try {
const j = await horizonsVec('-170', 2461236.5);
const e = await horizonsVec('399', 2461236.5);
const d = Math.hypot(j.x - e.x, j.y - e.y, j.z - e.z);
tr.remove();
row(label, d < 0.02, `Δ=${(d).toFixed(4)} AU from Earth (${(d*lib.AU_KM/1e6).toFixed(2)}M km)`);
} catch (err) { tr.remove(); row(label, false, `Horizons error: ${err.message}`); }
finish();
}
finish();
// Sequential (not parallel) — Horizons throttles bursts of concurrent requests.
(async () => {
await liveCheck('Mars @ J2000.0 vs live Horizons', 'mars', '499', 2451545.0, 0.01);
await liveCheck('Jupiter @ 1900-01-01 vs live Horizons', 'jupiter', '599', 2415020.5, 0.02);
await craftCheck('Voyager 1 (-31) @ 2026-07-15', '-31', 2461236.5, { x: -32.07, y: -136.21, z: 98.55 }, 0.05);
await jwstCheck();
})();
</script>
</body>
</html>