merge perihelion/o-closer → main: README wave-2 refresh, event band, directorial moon tint, HERALD prep (fable)

This commit is contained in:
monsterrobotparty 2026-07-16 14:30:20 +10:00
commit 5185c6fa41
11 changed files with 220 additions and 40 deletions

View File

@ -3,11 +3,13 @@
A standalone, browser-based 3D solar-system explorer — the interplanetary sibling A standalone, browser-based 3D solar-system explorer — the interplanetary sibling
of [GODSIGH](../GODSIGH), which watches Earth. Float above a compressed-scale of [GODSIGH](../GODSIGH), which watches Earth. Float above a compressed-scale
("MEGA") solar system with every world visible at once, moving on **real JPL ("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 ephemerides**; scrub a time bar across **six thousand years**; fly the camera into
any planet, its moons circling; toggle **TRUE scale** and watch the system animate orbit around any planet, its moons circling, textured and tidally locked; toggle
out into its real, humbling emptiness. Live layers plot where Voyager 1, JWST and **TRUE scale** and watch the system animate out into its real, humbling emptiness.
Parker Solar Probe are *right now*, plus real asteroids, comets and Earth Live layers plot where Voyager 1, JWST and Parker Solar Probe are *right now*, real
close-approaches. Everything is client-side, free, and keyless. main-belt asteroids, comets, and near-Earth close-approaches on their **true
orbits** — and a computed almanac of oppositions and elongations on the time bar.
Everything is client-side, free, keyless, and runs **fully offline**.
![SOLARGOD](docs/hero.jpg) ![SOLARGOD](docs/hero.jpg)
@ -20,24 +22,29 @@ python3 serve.py # stdlib only — no npm, no build step
`serve.py` statically serves the app and proxies the JPL feeds a browser can't call `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 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, is **vendored** under `vendor/three/`, so the app runs with no CDN. The
orbits, time travel) runs with **zero network calls** — positions are computed deterministic core (Sun, planets, moons, orbits, sky events, time travel) runs with
locally from Keplerian elements; network layers are additive and fail soft. **zero network calls** — positions are computed locally from Keplerian elements;
network layers (spacecraft, asteroids, comets, close-approaches) are additive and
fail soft.
## Verify ## Verify
Open **http://127.0.0.1:8147/verify.html** — an in-browser gate runner that checks 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 the ephemeris against **JPL Horizons ground truth** (hard-coded checkpoints + live
queries through the proxy). All 13 gates should read PASS, e.g.: queries through the proxy). All **22 gates** should read PASS, e.g.:
| Gate | Result | | Gate | Result |
|------|--------| |------|--------|
| Mars / Jupiter / Earth @ 2026-07-15 (heliocentric ecliptic AU) | Δ ≤ 3.3e-4 / 2.9e-3 / 3.9e-5 AU | | 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 | | Kepler @ e=0.97 · orbitPath closes · frame round-trips · MEGA→TRUE keeps direction | exact |
| MEGA→TRUE preserves direction (unit dot ≈ 1) | 1.000000000000 |
| Mars @ J2000 · Jupiter @ 1900 vs **live** Horizons | Δ 4.8e-5 / 2.0e-3 AU | | Mars @ J2000 · Jupiter @ 1900 vs **live** Horizons | Δ 4.8e-5 / 2.0e-3 AU |
| Main belt between Mars & Jupiter · Halley aphelion beyond Neptune | pass | | 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 | | Voyager 1 @ 2026-07-15 = (32.07, 136.21, +98.55) AU · JWST hugs Earth | Δ 0.002 / 0.011 AU |
| **NEO** propagated miss vs CAD (real orbit) | ≤ 25% (median ~0.6%) |
| **Sky events** — Mars opposition ±2 d of 2027-02-19 · Venus elongation cadence · nextEvent | pass |
| **Deep time** — Jupiter @ 1000 BC · Mercury @ 2500 vs live Horizons (Table 2a/2b) | Δ 0.004 / 3.3e-5 AU |
| **Event band** maps the ±2 yr window to full width | pass |
## Layers ## Layers
@ -45,54 +52,60 @@ queries through the proxy). All 13 gates should read PASS, e.g.:
|-------|--------|:------:|-------| |-------|--------|:------:|-------|
| **Planets & orbits** | JPL SSD approximate elements (Standish) | on | 8 planets + Pluto, textured, MEGA-compressed orbit paths | | **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 | | **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 | | **Moons** | NASA/USGS mosaics | on | 21 moons, per-parent compression, retrograde Triton; 8 with real surfaces, **tidally locked**; grayscale mosaics directorially tinted (Titan orange, Europa tan…) |
| **Rings** | true km, scaled to parent | on | Saturn (textured annulus) · Uranus (schematic) | | **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 | | **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 | | **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 | | **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 | | **Close approaches** | JPL **CAD + SBDB** (proxy) | off | ±30-day NEOs at their **true propagated positions** with real orbits (per-object fail-soft to a schematic ring); miss distance in lunar distances |
| **Sky events** | computed (no network) | on | oppositions, conjunctions & greatest elongations as diamonds on the timeline's **event band**; click one to jump the clock |
## Controls ## Controls
- **Focus** — click any body (or use the Focus menu) → the camera flies into orbit - **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. around it; breadcrumb `SOL ▸ JUPITER ▸ EUROPA`; `Esc` steps out one level.
- **MEGA / TRUE** — toggle the scale mode; the transition animates over ~2.5 s. - **MEGA / TRUE** — toggle the scale mode; the transition animates over ~2.5 s.
- **Epoch**`18002050` / `3000 BC 3000 AD`: switch to the extended
Table 2a/2b range (reloads the session, carried in the hash as `&e=1`).
- **Body scale** — 1× / 100× / 1200× radius exaggeration. - **Body scale** — 1× / 100× / 1200× radius exaggeration.
- **Time bar** — NOW, play/pause, reverse, rate presets (`1× · 1 min/s … 1 yr/s`), - **Time bar** — NOW, play/pause, reverse, rate presets (`1× · 1 min/s … 1 yr/s`,
and a scrubbable 18002050 timeline. A **LIVE / SCRUBBED** chip mirrors GODSIGH. plus `1 decade/s · 1 century/s` for deep time), a scrubbable timeline, and an
**event band** below it mapping the computed ±2 yr window to full width (a bracket
on the main strip marks its extent). A **LIVE / SCRUBBED** chip mirrors GODSIGH.
- **Info panel** — appears on focus: a grimoire card with a physical fact sheet, - **Info panel** — appears on focus: a grimoire card with a physical fact sheet,
hand-written prose, a stylized portrait, and **live computed** facts (distance hand-written prose, a stylized portrait, and **live computed** facts (distance
from Sun & Earth, sunlight age, orbital speed, day & year length). Focusing Earth from Sun & Earth, sunlight age, orbital speed, day & year length, and the next
reveals a link to the GODSIGH world view. opposition/elongation). Focusing Earth reveals a link to the GODSIGH world view.
- **Shareable URLs** — the view serializes to the hash - **Shareable URLs** — the view serializes to the hash
(`#f=<body>&t=<time>&s=<mega|true>&L=<layers>&cam=…`); paste one to reproduce a view. (`#f=<body>&t=<time>&s=<mega|true>&L=<layers>&cam=…&e=<0|1>`); paste one to reproduce a view.
- **`?debug=1`** — frame-rate / scale readout. - **`?debug=1`** — frame-rate / scale readout.
## Architecture ## Architecture
No build step, no framework, plain ES modules. One module owns the truth→view No build step, no framework, plain ES modules; Three.js vendored offline. One
transform (`scale.js`); every position is computed in real heliocentric J2000 module owns the truth→view transform (`scale.js`); every position is computed in
ecliptic AU first (`ephem.js`) then compressed. A **floating origin** subtracts the real heliocentric J2000 ecliptic AU first (`ephem.js`) then compressed. A
focused body's position in float64 before writing to the scene graph, so a focused **floating origin** subtracts the focused body's position in float64 before writing
planet never jitters at close zoom. to the scene graph, so a focused planet never jitters at close zoom.
``` ```
index.html · serve.py · verify.html index.html · serve.py · verify.html
css/style.css css/style.css · vendor/three/ (vendored, MIT)
js/ config lib ephem bodies scale ui main js/ config lib ephem bodies scale ui main
js/layers/ planets moons rings spacecraft asteroids comets neos almanac js/layers/ planets moons rings spacecraft asteroids comets neos events almanac
assets/textures/ (CC BY) · assets/art/grimoire/ (generated) assets/textures/ (CC BY + NASA/USGS public domain) · assets/art/grimoire/ (generated)
``` ```
The full execution spec is [SOLARGOD_BRIEF.md](SOLARGOD_BRIEF.md). The full execution spec is [SOLARGOD_BRIEF.md](SOLARGOD_BRIEF.md); wave-2 polish is
[PERIHELION_BRIEF.md](PERIHELION_BRIEF.md).
## Credits ## Credits
- **Positions** — [JPL SSD](https://ssd.jpl.nasa.gov/planets/approx_pos.html) - **Positions** — [JPL SSD](https://ssd.jpl.nasa.gov/planets/approx_pos.html)
approximate elements; live feeds via **JPL Horizons / SBDB / CAD**. approximate elements; live feeds via **JPL Horizons / SBDB / CAD**.
- **Textures** — [Solar System Scope](https://www.solarsystemscope.com/textures/), - **Planet/Sun/ring/sky textures** — [Solar System Scope](https://www.solarsystemscope.com/textures/), CC BY 4.0.
CC BY 4.0 (see `assets/textures/CREDITS.md`). - **Moon surfaces** — NASA/USGS Astrogeology mosaics (public domain); grayscale ones
directorially colour-tinted. See `assets/textures/CREDITS.md`.
- **Grimoire art** — generated locally with MODELBEAST / FLUX (see - **Grimoire art** — generated locally with MODELBEAST / FLUX (see
`assets/art/PROMPTS.md`); the *aesthetic* layer only — real worlds get real data `assets/art/PROMPTS.md`); the *aesthetic* layer only — real worlds get real data.
textures. - Built on [Three.js](https://threejs.org/) (r170, vendored, MIT).
- Built on [Three.js](https://threejs.org/).

View File

@ -53,6 +53,13 @@ under budget). Downloaded 2026-07-16.
Host base URL: `https://planetarymaps.usgs.gov/mosaic/` (served from the USGS Host base URL: `https://planetarymaps.usgs.gov/mosaic/` (served from the USGS
`asc-pds-services` bucket). USGS terms: <https://www.usgs.gov/information-policies-and-instructions/copyrights-and-credits>. `asc-pds-services` bucket). USGS terms: <https://www.usgs.gov/information-policies-and-instructions/copyrights-and-credits>.
**Directorial colourisation (PERIHELION closer).** USGS has no colour equirect for
Europa, Callisto, Titan, Phobos and Charon, so those mosaics ship grayscale. The
app multiplies each by its characteristic hue at render (`bodies.js` `tint`:
Titan orange, Europa/Charon tan, Callisto/Phobos warm-brown) — the *real surface
detail is preserved*; only the colour is an explicit directorial choice, not data.
Io, Ganymede and Triton are USGS colour mosaics and render untinted.
**Truth boundary (brief §7b):** these are the *real data* textures — real worlds **Truth boundary (brief §7b):** these are the *real data* textures — real worlds
get real maps. The stylized grimoire portraits in `../art/grimoire/` are the get real maps. The stylized grimoire portraits in `../art/grimoire/` are the
*aesthetic* layer (generated art), kept visually and directorially separate. *aesthetic* layer (generated art), kept visually and directorially separate.

View File

@ -202,3 +202,20 @@ html, body {
.ev-elong { top: -8px; background: #7fc7d6; box-shadow: 0 0 5px rgba(127, 199, 214, 0.6); } .ev-elong { top: -8px; background: #7fc7d6; box-shadow: 0 0 5px rgba(127, 199, 214, 0.6); }
.ev-conj { top: 2px; width: 5px; height: 5px; background: var(--brass-dim); opacity: 0.6; } .ev-conj { top: 2px; width: 5px; height: 5px; background: var(--brass-dim); opacity: 0.6; }
.ev-conj:hover { opacity: 1; } .ev-conj:hover { opacity: 1; }
/* PERIHELION-O event band: the computed ±2 yr window mapped to full width in a
strip below the timeline (diamonds spread out); a bracket on the main strip
marks the window's real extent. Reuses the ev-* diamond classes above. */
#event-band {
position: relative; height: 14px; margin-top: 6px;
border-radius: 5px; background: rgba(255, 255, 255, 0.04); border: 1px solid var(--border);
}
#event-band #event-marks { inset: 1px 0; }
#event-band .ev-mark { top: 50%; transform: translate(-50%, -50%) rotate(45deg); }
#event-band .ev-mark:hover { transform: translate(-50%, -50%) rotate(45deg) scale(1.7); }
#event-band .ev-conj { top: 50%; }
#event-window-bracket {
position: absolute; top: -3px; bottom: -3px; z-index: 3; pointer-events: none;
border: 1px solid var(--brass); border-radius: 4px;
background: rgba(217, 164, 65, 0.14); box-shadow: 0 0 7px rgba(217, 164, 65, 0.45);
}

28
deploy/HERALD.md Normal file
View File

@ -0,0 +1,28 @@
# HERALD — deploying SOLARGOD
SOLARGOD is a static site + a stdlib `serve.py` that also proxies JPL
(Horizons/SBDB/CAD) and disk-caches responses. `serve.py` binds **127.0.0.1 only**,
so production is the same shape as godstrument.pro: **systemd runs serve.py on the
loopback, nginx reverse-proxies the public domain to it, certbot adds TLS.**
## What this directory gives you
- `solargod.service` — systemd unit (`python3 serve.py 8147` in `/opt/solargod`).
- `nginx-solargod.conf` — nginx server block → `127.0.0.1:8147` (replace `SOLARGOD_DOMAIN`).
- `deploy.sh` — one-shot rsync + install + enable + reload (idempotent; re-run to update).
## One-time
```sh
DOMAIN=orrery.example.com VPS=user@vps-host ./deploy/deploy.sh
ssh $VPS 'sudo certbot --nginx -d orrery.example.com'
```
## ⚠ Blocked on you (cannot be done from the dev box)
1. **Domain / DNS** — pick the hostname and point an A record at the VPS. (I don't
have your DNS or a chosen name — the whole lane waited on this per Part ω.2.)
2. **VPS access** — the `VPS=user@host` with sudo, on the same box that serves
godstrument.pro (so the reverse-proxy pattern matches).
3. Confirm Python 3 + nginx + certbot are present on the VPS (they are for godstrument).
Give me the domain + VPS target and I'll run `deploy.sh` and verify the live site
(orrery loads, planets move, `/verify.html` green, `/proxy/*` reaches JPL). Until
then these artifacts are prepared but **not applied** — nothing has been deployed.

29
deploy/deploy.sh Executable file
View File

@ -0,0 +1,29 @@
#!/usr/bin/env bash
# HERALD — deploy SOLARGOD to the VPS (rsync + systemd + nginx), mirroring the
# godstrument.pro shape. Run from the repo root AFTER the DNS A record resolves.
# DOMAIN=orrery.example.com VPS=user@host ./deploy/deploy.sh
# Idempotent: re-run to push updates (rsync --delete + service restart).
set -euo pipefail
: "${VPS:?set VPS=user@host}"
: "${DOMAIN:?set DOMAIN=your.domain}"
APP=/opt/solargod
echo "→ syncing repo to $VPS:$APP"
rsync -az --delete \
--exclude '.git' --exclude 'cache' --exclude 'assets/art/candidates' \
./ "$VPS:$APP/"
echo "→ installing service + nginx site on $VPS (domain: $DOMAIN)"
ssh "$VPS" "bash -euo pipefail -s" <<EOF
sudo install -m 644 $APP/deploy/solargod.service /etc/systemd/system/solargod.service
sed 's/SOLARGOD_DOMAIN/$DOMAIN/g' $APP/deploy/nginx-solargod.conf \
| sudo tee /etc/nginx/sites-available/solargod >/dev/null
sudo ln -sf /etc/nginx/sites-available/solargod /etc/nginx/sites-enabled/solargod
sudo mkdir -p $APP/cache && sudo chown -R www-data:www-data $APP/cache
sudo systemctl daemon-reload
sudo systemctl enable --now solargod
sudo systemctl restart solargod
sudo nginx -t && sudo systemctl reload nginx
EOF
echo "✓ deployed. TLS: ssh $VPS 'sudo certbot --nginx -d $DOMAIN'"

View File

@ -0,0 +1,17 @@
# HERALD — nginx reverse proxy for SOLARGOD. Replace SOLARGOD_DOMAIN, then run
# certbot for TLS once the A record resolves. serve.py handles both static files
# and the /proxy/* JPL endpoints (with its disk cache), so we proxy everything.
server {
listen 80;
server_name SOLARGOD_DOMAIN;
# (optional perf) serve committed static assets straight from disk instead of
# through Python: root /opt/solargod; try_files $uri @app; location @app { ... }
location / {
proxy_pass http://127.0.0.1:8147;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 90s; # first cold Horizons spacecraft fetch can take 25 s
}
}

18
deploy/solargod.service Normal file
View File

@ -0,0 +1,18 @@
# HERALD — systemd unit for SOLARGOD. serve.py binds 127.0.0.1 only; nginx
# reverse-proxies the public domain to it (mirror of the godstrument.pro shape).
# Install: sudo cp deploy/solargod.service /etc/systemd/system/ && sudo systemctl enable --now solargod
[Unit]
Description=SOLARGOD orrery — serve.py (static + JPL Horizons/SBDB/CAD proxy + disk cache)
After=network.target
[Service]
Type=simple
User=www-data
WorkingDirectory=/opt/solargod
ExecStart=/usr/bin/python3 /opt/solargod/serve.py 8147
Restart=on-failure
RestartSec=3
# serve.py writes its disk cache under WorkingDirectory/cache — keep it writable.
[Install]
WantedBy=multi-user.target

View File

@ -62,18 +62,18 @@ export const BODIES = {
parent: 'earth', moonOrbit: { aKm: 384400, periodDays: 27.322 } }, parent: 'earth', moonOrbit: { aKm: 384400, periodDays: 27.322 } },
phobos: { name: 'Phobos', type: 'moon', radiusKm: 11.27, color: '#8a7c6e', phobos: { name: 'Phobos', type: 'moon', radiusKm: 11.27, color: '#8a7c6e',
texture: '1k_phobos.jpg', parent: 'mars', moonOrbit: { aKm: 9378, periodDays: 0.319 } }, texture: '1k_phobos.jpg', tint: '#a08a76', parent: 'mars', moonOrbit: { aKm: 9378, periodDays: 0.319 } },
deimos: { name: 'Deimos', type: 'moon', radiusKm: 6.2, color: '#9a8c7a', deimos: { name: 'Deimos', type: 'moon', radiusKm: 6.2, color: '#9a8c7a',
texture: null, parent: 'mars', moonOrbit: { aKm: 23459, periodDays: 1.262 } }, texture: null, parent: 'mars', moonOrbit: { aKm: 23459, periodDays: 1.262 } },
io: { name: 'Io', type: 'moon', radiusKm: 1821.6, color: '#e8d24b', io: { name: 'Io', type: 'moon', radiusKm: 1821.6, color: '#e8d24b',
texture: '1k_io.jpg', parent: 'jupiter', moonOrbit: { aKm: 421700, periodDays: 1.769 } }, texture: '1k_io.jpg', parent: 'jupiter', moonOrbit: { aKm: 421700, periodDays: 1.769 } },
europa: { name: 'Europa', type: 'moon', radiusKm: 1560.8, color: '#c9b79c', europa: { name: 'Europa', type: 'moon', radiusKm: 1560.8, color: '#c9b79c',
texture: '1k_europa.jpg', parent: 'jupiter', moonOrbit: { aKm: 671034, periodDays: 3.551 } }, texture: '1k_europa.jpg', tint: '#e6dcc8', parent: 'jupiter', moonOrbit: { aKm: 671034, periodDays: 3.551 } },
ganymede: { name: 'Ganymede', type: 'moon', radiusKm: 2634.1, color: '#9b8e7e', ganymede: { name: 'Ganymede', type: 'moon', radiusKm: 2634.1, color: '#9b8e7e',
texture: '1k_ganymede.jpg', parent: 'jupiter', moonOrbit: { aKm: 1070412, periodDays: 7.155 } }, texture: '1k_ganymede.jpg', parent: 'jupiter', moonOrbit: { aKm: 1070412, periodDays: 7.155 } },
callisto: { name: 'Callisto', type: 'moon', radiusKm: 2410.3, color: '#6b5f52', callisto: { name: 'Callisto', type: 'moon', radiusKm: 2410.3, color: '#6b5f52',
texture: '1k_callisto.jpg', parent: 'jupiter', moonOrbit: { aKm: 1882709, periodDays: 16.689 } }, texture: '1k_callisto.jpg', tint: '#9a8778', parent: 'jupiter', moonOrbit: { aKm: 1882709, periodDays: 16.689 } },
mimas: { name: 'Mimas', type: 'moon', radiusKm: 198.2, color: '#c8c8c8', mimas: { name: 'Mimas', type: 'moon', radiusKm: 198.2, color: '#c8c8c8',
texture: null, parent: 'saturn', moonOrbit: { aKm: 185539, periodDays: 0.942 } }, texture: null, parent: 'saturn', moonOrbit: { aKm: 185539, periodDays: 0.942 } },
@ -86,7 +86,7 @@ export const BODIES = {
rhea: { name: 'Rhea', type: 'moon', radiusKm: 763.8, color: '#c4c4c4', rhea: { name: 'Rhea', type: 'moon', radiusKm: 763.8, color: '#c4c4c4',
texture: null, parent: 'saturn', moonOrbit: { aKm: 527068, periodDays: 4.518 } }, texture: null, parent: 'saturn', moonOrbit: { aKm: 527068, periodDays: 4.518 } },
titan: { name: 'Titan', type: 'moon', radiusKm: 2574.7, color: '#d9a441', titan: { name: 'Titan', type: 'moon', radiusKm: 2574.7, color: '#d9a441',
texture: '1k_titan.jpg', parent: 'saturn', moonOrbit: { aKm: 1221870, periodDays: 15.945 } }, texture: '1k_titan.jpg', tint: '#e0a63f', parent: 'saturn', moonOrbit: { aKm: 1221870, periodDays: 15.945 } },
iapetus: { name: 'Iapetus', type: 'moon', radiusKm: 734.5, color: '#8a7a5e', iapetus: { name: 'Iapetus', type: 'moon', radiusKm: 734.5, color: '#8a7a5e',
texture: null, parent: 'saturn', moonOrbit: { aKm: 3560851, periodDays: 79.33 } }, texture: null, parent: 'saturn', moonOrbit: { aKm: 3560851, periodDays: 79.33 } },
@ -114,7 +114,7 @@ export const BODIES = {
w: 113.76329, ma: 14.86012204, epoch: 2451545.0 }, w: 113.76329, ma: 14.86012204, epoch: 2451545.0 },
}, },
charon: { name: 'Charon', type: 'moon', radiusKm: 606.0, color: '#b3a894', charon: { name: 'Charon', type: 'moon', radiusKm: 606.0, color: '#b3a894',
texture: '1k_charon.jpg', parent: 'pluto', moonOrbit: { aKm: 19591, periodDays: 6.387 } }, texture: '1k_charon.jpg', tint: '#c4b7a4', parent: 'pluto', moonOrbit: { aKm: 19591, periodDays: 6.387 } },
}; };
// Ordered planet ids (draw / menu order). // Ordered planet ids (draw / menu order).

View File

@ -140,6 +140,13 @@ export function nextEvent(bodyId, jd) {
return null; return null;
} }
// Fraction of an instant within the event band's [winStart, winEnd] window (0..1).
// The band maps the ±2 yr window to full width regardless of near/deep epoch.
// Exported so verify.html gates the mapping without a DOM.
export function windowFraction(ms, winStartMs, winEndMs) {
return (ms - winStartMs) / ((winEndMs - winStartMs) || 1);
}
// ---- default factory: timeline diamonds + HUD row ---- // ---- default factory: timeline diamonds + HUD row ----
const WINDOW_DAYS = 730; // ±2 years, computed lazily around the current sim date const WINDOW_DAYS = 730; // ±2 years, computed lazily around the current sim date
function cssKind(type) { return type === 'opposition' ? 'opp' : type.startsWith('elongation') ? 'elong' : 'conj'; } function cssKind(type) { return type === 'opposition' ? 'opp' : type.startsWith('elongation') ? 'elong' : 'conj'; }
@ -149,9 +156,19 @@ export default function create(ctx) {
const strip = document.getElementById('timeline-outer'); const strip = document.getElementById('timeline-outer');
if (!strip) return null; // no time bar (e.g. headless) — plain exports still work if (!strip) return null; // no time bar (e.g. headless) — plain exports still work
// PERIHELION-O event band — the ±2 yr computed window mapped to FULL width in a
// second strip below the timeline, so diamonds spread out instead of clustering
// into ~1.6% of the 250-yr strip (worse in deep time). A bracket on the main
// strip marks the window's true extent, tying the two together.
const band = document.createElement('div');
band.id = 'event-band';
const marks = document.createElement('div'); const marks = document.createElement('div');
marks.id = 'event-marks'; marks.id = 'event-marks';
strip.appendChild(marks); band.appendChild(marks);
strip.parentNode.insertBefore(band, strip.nextSibling); // directly under timeline-outer
const bracket = document.createElement('div');
bracket.id = 'event-window-bracket';
strip.appendChild(bracket);
const minMs = CONFIG.time.minMs, maxMs = CONFIG.time.maxMs, spanMs = maxMs - minMs; const minMs = CONFIG.time.minMs, maxMs = CONFIG.time.maxMs, spanMs = maxMs - minMs;
let on = true, events = []; let on = true, events = [];
@ -183,9 +200,10 @@ export default function create(ctx) {
function render() { function render() {
marks.textContent = ''; marks.textContent = '';
const winStartMs = unixMsFromJd(winStart), winEndMs = unixMsFromJd(winEnd);
for (const e of events) { for (const e of events) {
const ms = unixMsFromJd(e.jd); const ms = unixMsFromJd(e.jd);
const f = (ms - minMs) / spanMs; const f = windowFraction(ms, winStartMs, winEndMs); // position WITHIN the band
if (f < 0 || f > 1) continue; if (f < 0 || f > 1) continue;
const d = document.createElement('div'); const d = document.createElement('div');
d.className = `ev-mark ev-${cssKind(e.type)}`; d.className = `ev-mark ev-${cssKind(e.type)}`;
@ -197,6 +215,12 @@ export default function create(ctx) {
}); });
marks.appendChild(d); marks.appendChild(d);
} }
// bracket: where the ±2 yr window sits within the full CONFIG.time range (tiny
// in near mode, tinier in deep time — that's the point of the band below it).
const bl = ((winStartMs - minMs) / spanMs) * 100;
const bw = ((winEndMs - winStartMs) / spanMs) * 100;
bracket.style.left = `${Math.min(Math.max(0, bl), 100).toFixed(4)}%`;
bracket.style.width = `${Math.max(0.5, Math.min(bw, 100)).toFixed(4)}%`;
} }
return { return {

View File

@ -17,7 +17,17 @@ export default function create(ctx) {
const mesh = new THREE.Mesh(new THREE.SphereGeometry(1, 24, 16), mat); const mesh = new THREE.Mesh(new THREE.SphereGeometry(1, 24, 16), mat);
mesh.visible = true; mesh.visible = true;
scene.add(mesh); scene.add(mesh);
loadTextureInto(mat, b.texture); // NASA/USGS map when present; flat color otherwise (§7 fallback) if (b.tint) {
// Directorial colourisation: the USGS mosaics for these moons are grayscale
// (no color equirect exists), so multiply the real surface by the moon's
// characteristic hue — keeps true detail, adds the iconic colour. On a load
// error the flat b.color already set on the material stands (§7 fallback).
new THREE.TextureLoader().load(CONFIG.textures.path + b.texture, (tex) => {
tex.colorSpace = THREE.SRGBColorSpace; mat.map = tex; mat.color.set(b.tint); mat.needsUpdate = true;
});
} else {
loadTextureInto(mat, b.texture); // NASA/USGS map when present; flat color otherwise (§7 fallback)
}
registerPick(mesh, id); registerPick(mesh, id);
const lbl = makeLabel(mesh, b.name, 'body-label moon'); const lbl = makeLabel(mesh, b.name, 'body-label moon');
lbl.obj.visible = false; // CSS2DRenderer honors obj.visible (not div.style.display) lbl.obj.visible = false; // CSS2DRenderer honors obj.visible (not div.style.display)

View File

@ -286,6 +286,23 @@
finish(); finish();
} }
/* PERIHELION-O — event band: the computed ±2 yr window maps to full band width.
windowFraction is pure; the click-jump-in-band is a browser real-input test. */
{
const { windowFraction, findEvents } = await import('./js/layers/events.js');
const okEnds = windowFraction(1000, 1000, 2000) === 0
&& windowFraction(2000, 1000, 2000) === 1
&& windowFraction(1500, 1000, 2000) === 0.5;
// a real event (Mars 2027 opposition) lands strictly inside a ±2 yr band.
const c = lib.jdFromUnixMs(Date.UTC(2026, 6, 16));
const wS = lib.unixMsFromJd(c - 730), wE = lib.unixMsFromJd(c + 730);
const opp = findEvents(c - 730, c + 730).find((e) => e.bodies[0] === 'mars' && e.type === 'opposition');
const fr = opp ? windowFraction(lib.unixMsFromJd(opp.jd), wS, wE) : NaN;
row('event band maps ±2 yr window to full width', okEnds && !!opp && fr > 0 && fr < 1,
`ends 0/0.5/1 ok=${okEnds} · Mars-opp band-fraction=${opp ? fr.toFixed(3) : 'n/a'}`);
finish();
}
/* PERIHELION-V — deep-time (extended Table 2a/2b) accuracy gates. /* PERIHELION-V — deep-time (extended Table 2a/2b) accuracy gates.
Capture the "mine" positions with the extended range ON, then restore it OFF Capture the "mine" positions with the extended range ON, then restore it OFF
synchronously and atomically: ES modules are singletons, so setExtendedRange synchronously and atomically: ES modules are singletons, so setExtendedRange