Compare commits
15 Commits
4b146bef68
...
6391273ac5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6391273ac5 | ||
|
|
4360269ad3 | ||
|
|
3e36a0c16a | ||
|
|
228caf2270 | ||
|
|
4352394a56 | ||
|
|
1f9664a6f9 | ||
|
|
bed15a496c | ||
|
|
b37eb88670 | ||
|
|
5b672f009c | ||
|
|
6084acad0f | ||
|
|
fedf05d8a2 | ||
|
|
f2477513c0 | ||
|
|
fc573f98eb | ||
|
|
c4b572b8bb | ||
|
|
26a5751394 |
2
.gitignore
vendored
@ -1,5 +1,7 @@
|
||||
cache/
|
||||
art/candidates/
|
||||
assets/art/candidates/
|
||||
.DS_Store
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.swp
|
||||
|
||||
107
README.md
@ -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).
|
||||

|
||||
|
||||
## 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 1800–2050 |
|
||||
| **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 1800–2050 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
@ -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: 1–5, 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: 1–2 · **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>/
|
||||
```
|
||||
BIN
assets/art/grimoire/earth.jpg
Normal file
|
After Width: | Height: | Size: 182 KiB |
BIN
assets/art/grimoire/jupiter.jpg
Normal file
|
After Width: | Height: | Size: 212 KiB |
BIN
assets/art/grimoire/mars.jpg
Normal file
|
After Width: | Height: | Size: 175 KiB |
BIN
assets/art/grimoire/mercury.jpg
Normal file
|
After Width: | Height: | Size: 166 KiB |
BIN
assets/art/grimoire/neptune.jpg
Normal file
|
After Width: | Height: | Size: 202 KiB |
BIN
assets/art/grimoire/pluto.jpg
Normal file
|
After Width: | Height: | Size: 219 KiB |
BIN
assets/art/grimoire/saturn.jpg
Normal file
|
After Width: | Height: | Size: 219 KiB |
BIN
assets/art/grimoire/sun.jpg
Normal file
|
After Width: | Height: | Size: 233 KiB |
BIN
assets/art/grimoire/uranus.jpg
Normal file
|
After Width: | Height: | Size: 220 KiB |
BIN
assets/art/grimoire/venus.jpg
Normal file
|
After Width: | Height: | Size: 234 KiB |
BIN
assets/textures/2k_earth_daymap.jpg
Normal file
|
After Width: | Height: | Size: 452 KiB |
BIN
assets/textures/2k_jupiter.jpg
Normal file
|
After Width: | Height: | Size: 487 KiB |
BIN
assets/textures/2k_mars.jpg
Normal file
|
After Width: | Height: | Size: 733 KiB |
BIN
assets/textures/2k_mercury.jpg
Normal file
|
After Width: | Height: | Size: 852 KiB |
BIN
assets/textures/2k_moon.jpg
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
assets/textures/2k_neptune.jpg
Normal file
|
After Width: | Height: | Size: 236 KiB |
BIN
assets/textures/2k_saturn.jpg
Normal file
|
After Width: | Height: | Size: 195 KiB |
BIN
assets/textures/2k_saturn_ring_alpha.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
assets/textures/2k_stars_milky_way.jpg
Normal file
|
After Width: | Height: | Size: 246 KiB |
BIN
assets/textures/2k_sun.jpg
Normal file
|
After Width: | Height: | Size: 803 KiB |
BIN
assets/textures/2k_uranus.jpg
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
assets/textures/2k_venus_surface.jpg
Normal file
|
After Width: | Height: | Size: 864 KiB |
33
assets/textures/CREDITS.md
Normal 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
@ -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
|
After Width: | Height: | Size: 146 KiB |
84
index.html
Normal 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 BY 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
@ -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
@ -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
@ -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 BC–3000 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 Jupiter–Neptune, 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 Newton–Raphson.
|
||||
// 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, 1800–2050) → {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 Jupiter–Neptune,
|
||||
// valid 3000 BC–3000 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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -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
@ -0,0 +1,95 @@
|
||||
// SOLARGOD shared helpers — time (JD / centuries), math, the ecliptic→world
|
||||
// frame map, and human formatters. Pure and dependency-free so verify.html can
|
||||
// import it (and ephem.js) with zero Three.js in scope.
|
||||
|
||||
// ---- constants ----
|
||||
export const AU_KM = 149597870.7; // 1 astronomical unit in km (IAU 2012)
|
||||
export const C_KMS = 299792.458; // speed of light, km/s
|
||||
export const MS_PER_DAY = 86400000;
|
||||
export const J2000_JD = 2451545.0; // 2000-Jan-01 12:00 TT
|
||||
export const UNIX_EPOCH_JD = 2440587.5; // JD of 1970-Jan-01 00:00 UTC
|
||||
export const DEG = Math.PI / 180;
|
||||
export const RAD = 180 / Math.PI;
|
||||
|
||||
// ---- time ----
|
||||
// We treat the sim clock as UTC and ignore the TT/TDB−UTC offset (~69 s in 2026).
|
||||
// At interplanetary scale that is < 1e-5 AU even for Mars — noted, deliberately
|
||||
// dropped (brief §2). JD from unix-ms: divide by ms/day, add the unix-epoch JD.
|
||||
export function jdFromUnixMs(ms) { return ms / MS_PER_DAY + UNIX_EPOCH_JD; }
|
||||
export function unixMsFromJd(jd) { return (jd - UNIX_EPOCH_JD) * MS_PER_DAY; }
|
||||
// Julian centuries since J2000.0.
|
||||
export function centuriesSinceJ2000(jd) { return (jd - J2000_JD) / 36525; }
|
||||
|
||||
// ---- scalar math ----
|
||||
export const clamp = (x, lo, hi) => (x < lo ? lo : x > hi ? hi : x);
|
||||
export const lerp = (a, b, t) => a + (b - a) * t;
|
||||
export function smoothstep(t) { t = clamp(t, 0, 1); return t * t * (3 - 2 * t); }
|
||||
// Normalize degrees to (−180, +180]. Must run in float64 AFTER any large
|
||||
// subtraction (Mercury's L rate is 149473°/Cy → M can be tens of thousands of
|
||||
// degrees before reduction; brief §12).
|
||||
export function normDegPM180(d) {
|
||||
d = d % 360;
|
||||
if (d <= -180) d += 360;
|
||||
else if (d > 180) d -= 360;
|
||||
return d;
|
||||
}
|
||||
export const normDeg360 = (d) => ((d % 360) + 360) % 360;
|
||||
|
||||
// ---- frame convention (fixed here once; nothing else maps by hand) ----
|
||||
// Heliocentric J2000 ecliptic {x,y,z} [AU or view units] → Three.js world:
|
||||
// ecliptic X → world x, ecliptic Z (north) → world y, ecliptic Y → world −z.
|
||||
// Keeps the frame right-handed with Y up. `out` is any {x,y,z} sink (Vector3 ok).
|
||||
export function eclToWorld(ecl, out = {}) {
|
||||
out.x = ecl.x;
|
||||
out.y = ecl.z;
|
||||
out.z = -ecl.y;
|
||||
return out;
|
||||
}
|
||||
// Inverse, for round-trip checks and picking rays.
|
||||
export function worldToEcl(w, out = {}) {
|
||||
out.x = w.x;
|
||||
out.y = -w.z;
|
||||
out.z = w.y;
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---- formatters ----
|
||||
const pad = (n, w = 2) => String(n).padStart(w, '0');
|
||||
// "2026-07-15 00:00:00 UTC" from unix-ms.
|
||||
export function formatUTC(ms) {
|
||||
const d = new Date(ms);
|
||||
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ` +
|
||||
`${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())} UTC`;
|
||||
}
|
||||
export function formatUTCDate(ms) {
|
||||
const d = new Date(ms);
|
||||
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}`;
|
||||
}
|
||||
export function fmtAU(au) {
|
||||
const a = Math.abs(au);
|
||||
if (a < 0.001) return `${(au * AU_KM).toFixed(0)} km`;
|
||||
if (a < 100) return `${au.toFixed(3)} AU`;
|
||||
return `${au.toFixed(1)} AU`;
|
||||
}
|
||||
export function fmtKm(km) {
|
||||
const a = Math.abs(km);
|
||||
if (a >= 1e7) return `${(km / 1e6).toLocaleString('en-US', { maximumFractionDigits: 2 })} M km`;
|
||||
return `${Math.round(km).toLocaleString('en-US')} km`;
|
||||
}
|
||||
// Light-time for a distance in AU → "43 min 12 s" / "1 h 08 m" / "8.3 s".
|
||||
export function fmtLightTime(au) {
|
||||
let s = (au * AU_KM) / C_KMS;
|
||||
if (s < 90) return `${s.toFixed(1)} s`;
|
||||
const h = Math.floor(s / 3600); s -= h * 3600;
|
||||
const m = Math.floor(s / 60); s -= m * 60;
|
||||
if (h > 0) return `${h} h ${pad(m)} m`;
|
||||
return `${m} min ${pad(Math.floor(s))} s`;
|
||||
}
|
||||
// Duration in days → "27.3 d" / "1.88 yr".
|
||||
export function fmtDuration(days) {
|
||||
const a = Math.abs(days);
|
||||
if (a < 2) return `${(a * 24).toFixed(1)} h`;
|
||||
if (a < 900) return `${a.toFixed(1)} d`;
|
||||
return `${(a / 365.25).toFixed(2)} yr`;
|
||||
}
|
||||
export function fmtSpeed(kms) { return `${kms.toFixed(2)} km/s`; }
|
||||
499
js/main.js
Normal 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
@ -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
@ -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
@ -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
@ -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.2–3.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>
|
||||