Compare commits

...

9 Commits

Author SHA1 Message Date
monsterrobotparty
b42473a055 🔭 review: post-merge integration — sbdb_lookup into CONFIG.proxy, era-aware BC dates in lib formatters (fable)
Merged suite: 21/21 gates in-browser incl. NEO real-orbit (err 1.16%),
Jupiter@1000BC Δ0.004 AU, 2b-proof. Deep-time readout now '1000 BC-01-15'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 10:32:38 +10:00
monsterrobotparty
0883cc50b8 merge perihelion/v-vessel → main: vendored three + deep time, gate green (verify gate-count guard raised to 18 in resolution) (fable) 2026-07-16 10:28:23 +10:00
monsterrobotparty
c1c1afb0db 🛳 vessel: vendor three@0.170.0 (offline) + deep-time epoch mode (opus)
V.1 — cut the CDN tether. Vendored the exact pinned files the importmap used
(three.module.js r170 + OrbitControls + CSS2DRenderer, ~1.29 MB) under
vendor/three/, mirroring the package layout so the addons' bare `import from
'three'` resolves. Import graph verified shallow (addons import only 'three';
the bundle is self-contained). Both importmaps (index.html, verify.html) now
point at ./vendor/three/… — zero jsdelivr/CDN URLs remain. MIT LICENSE +
PROVENANCE.txt retained.

V.2 — open deep time. EPOCH toggle (1800–2050 / 3000 BC–3000 AD) next to
MEGA/TRUE; deep mode is a hash-carried page mode (&e=1) read before ephem/clock
init → ephem.setExtendedRange(true) + CONFIG.time swaps to the deep bounds.
Timeline end labels + scrub tooltip now read from CONFIG (were hardcoded
1800/2050). Toggle serializes current view and reloads with the flipped e token
(orbit paths + element resolution are baked per-session). Appended 1 decade/s
and 1 century/s rates. Hash e token added to applyHash/serializeHash.

V.3 — appended verify gates (PERIHELION-V): Jupiter @1000 BC (extended 2a+2b)
vs live Horizons barycenter tol 0.15 AU (Δ 0.0041); Mercury @2500 tol 0.02 AU
(Δ 3e-5); Table 2b M-correction proven live (zeroing it worsens Jupiter@1000BC
by 0.0214 AU). Core 13 gates untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 10:28:23 +10:00
monsterrobotparty
c794ca1182 merge perihelion/e-syzygy → main: computed sky events, gate green (append-append verify.html resolved) (fable) 2026-07-16 10:26:28 +10:00
monsterrobotparty
8bfce067d5 merge perihelion/m-selene → main: moon maps + tidal lock, gate green (fable) 2026-07-16 10:24:47 +10:00
monsterrobotparty
28255dbb54 merge perihelion/n-neargod → main: real NEO orbits, gate green (fable) 2026-07-16 10:24:47 +10:00
monsterrobotparty
be5f8ad544 🌖 selene: real NASA/USGS maps + tidal lock for the major moons (opus)
- source 8 public-domain equirectangular mosaics (io/europa/ganymede/callisto/
  titan/triton/charon/phobos) from usgs astrogeology, downscale to 1024x512 jpg
  (0.92 mb added total, each <=143 kb); credits.md lists source/mission/license
- bodies.js: texture field on the 8 sourced moon entries only
- moons.js: load textures via ctx.loadTextureInto (same path as planets.js, so
  the moon's own 2k map also renders now); flat-color 404 fallback preserved
- moons.js tick: tidal lock rotation.y = 2pi*(jd-J2000)/period + pi (signed
  period gives retrograde spin for free)
2026-07-16 09:55:07 +10:00
monsterrobotparty
9aeb2bbb5f 🌓 syzygy: computed sky events — oppositions, conjunctions & greatest elongations from ephem longitudes; timeline diamonds that jump the clock; almanac next-event prediction (opus) 2026-07-16 09:52:39 +10:00
monsterrobotparty
a57fa196ac 🌑 neargod: real orbits for close approaches — CAD des → SBDB sbdb.api full-prec elements → shared Kepler core (ephem.smallBodyEcl); true marker + 25%-opacity ellipse per NEO, per-object fail-soft to the wave-1 schematic ring, honest 'N real · M schematic · CAD+SBDB' status; +sbdb_lookup upstream; +verify gate (opus) 2026-07-16 09:46:45 +10:00
26 changed files with 57010 additions and 44 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

BIN
assets/textures/1k_io.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

View File

@ -1,7 +1,9 @@
# 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**.
## `2k_*` planet, Sun, ring & starfield maps — Solar System Scope (CC BY 4.0)
The **`2k_`** 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)
@ -28,6 +30,29 @@ Downloaded 2026-07-16 at 2k resolution:
Attribution also appears in the app's HUD footer, as CC BY requires.
## `1k_*` moon maps — USGS Astrogeology / NASA (public domain)
Added wave 2 (PERIHELION · SELENE). Global equirectangular (simple-cylindrical)
mosaics from the **USGS Astrogeology Science Center** (`planetarymaps.usgs.gov`),
built from NASA mission imagery. USGS/NASA-produced planetary data are in the
**U.S. public domain** (no copyright asserted). Each was downloaded as a GeoTIFF
and downscaled to 1024×512 JPEG with `sips` (Europa re-encoded at q70 to stay
under budget). Downloaded 2026-07-16.
| File | Body | Mission / instrument | Source (USGS Astrogeology) | License |
|------|------|----------------------|----------------------------|---------|
| 1k_io.jpg | Io | Galileo SSI + Voyager, color-merge | `.../mosaic/Io_Galileo_SSI_Global_Mosaic_ClrMerge_1km.tif` | Public domain |
| 1k_europa.jpg | Europa | Voyager + Galileo SSI, global 500 m | `.../mosaic/Europa_Voyager_GalileoSSI_global_mosaic_500m.tif` | Public domain |
| 1k_ganymede.jpg | Ganymede | Voyager + Galileo SSI, global color 1435 m | `.../mosaic/Ganymede_Voyager_GalileoSSI_Global_ClrMosaic_1435m.tif` | Public domain |
| 1k_callisto.jpg | Callisto | Voyager + Galileo SSI, global 1 km | `.../mosaic/Callisto_Voyager_GalileoSSI_global_mosaic_1km.tif` | Public domain |
| 1k_titan.jpg | Titan | Cassini ISS, global mosaic (PIA19658), 4 km | `.../mosaic/Titan_ISS_P19658_Mosaic_Global_4km.tif` | Public domain |
| 1k_triton.jpg | Triton | Voyager 2, global color mosaic (gap-filled) 600 m | `.../mosaic/Triton_Voyager2_ClrMosaic_GlobalFill_600m.tif` | Public domain |
| 1k_charon.jpg | Charon | New Horizons LORRI/MVIC, global mosaic 300 m (Jul 2017) | `.../mosaic/Charon_NewHorizons_Global_Mosaic_300m_Jul2017_8bit.tif` | Public domain |
| 1k_phobos.jpg | Phobos | Mars Express HRSC-SRC, global mosaic 16 ppd | `.../mosaic/Phobos_ME_SRC_Mosaic_Global_16ppd.tif` | Public domain |
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>.
**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.

View File

@ -186,3 +186,19 @@ html, body {
#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; }
/* PERIHELION-E sky-events timeline marks (SYZYGY). Diamonds sit on/above the
time bar, positioned by date fraction; the container is click-transparent so
the strip's own scrub still works between marks. Opposition = brass (bright),
greatest elongation = cool accent, conjunction = dim (on the bar). */
#event-marks { position: absolute; inset: 0; overflow: visible; pointer-events: none; z-index: 4; }
.ev-mark {
position: absolute; width: 7px; height: 7px; border-radius: 1px;
transform: translate(-50%, 0) rotate(45deg);
pointer-events: auto; cursor: pointer; transition: transform 0.1s ease, box-shadow 0.1s ease;
}
.ev-mark:hover { transform: translate(-50%, 0) rotate(45deg) scale(1.6); z-index: 6; }
.ev-opp { top: -8px; background: var(--brass); box-shadow: 0 0 5px rgba(217, 164, 65, 0.75); }
.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:hover { opacity: 1; }

View File

@ -5,11 +5,12 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>SOLARGOD ▸ Orrery</title>
<link rel="stylesheet" href="css/style.css" />
<!-- Three.js is vendored (offline-first, zero CDN). See vendor/three/PROVENANCE.txt. -->
<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/"
"three": "./vendor/three/build/three.module.js",
"three/addons/": "./vendor/three/examples/jsm/"
}
}
</script>
@ -35,6 +36,14 @@
</div>
</div>
<div class="ctl-row slider-row">
<label class="ctl-label">Epoch</label>
<div class="seg" id="epoch-toggle" title="Deep-time range — switching reloads the session onto Table 2a/2b">
<button data-epoch="near" class="active" type="button">18002050</button>
<button data-epoch="deep" type="button">3000 BC 3000 AD</button>
</div>
</div>
<div class="ctl-row slider-row">
<label class="ctl-label">Body scale</label>
<div class="seg" id="bodyscale-toggle">
@ -66,11 +75,11 @@
<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-outer" title="Scrub">
<div id="timeline-fill"></div>
<div id="timeline-cursor"></div>
</div>
<div class="tb-ends"><span>1800</span><span>2050</span></div>
<div class="tb-ends"><span id="tb-end-min">1800</span><span id="tb-end-max">2050</span></div>
</div>
<!-- info panel (left, Stage 6) -->

View File

@ -62,18 +62,18 @@ export const BODIES = {
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 } },
texture: '1k_phobos.jpg', 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 } },
texture: '1k_io.jpg', 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 } },
texture: '1k_europa.jpg', 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 } },
texture: '1k_ganymede.jpg', 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 } },
texture: '1k_callisto.jpg', 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 } },
@ -86,7 +86,7 @@ export const BODIES = {
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 } },
texture: '1k_titan.jpg', 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 } },
@ -103,7 +103,7 @@ export const BODIES = {
// 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 } },
texture: '1k_triton.jpg', parent: 'neptune', moonOrbit: { aKm: 354759, periodDays: -5.877 } },
// ---- Pluto system (small-body epoch elements; texture-less spheres) ----
pluto: {
@ -114,7 +114,7 @@ export const BODIES = {
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 } },
texture: '1k_charon.jpg', parent: 'pluto', moonOrbit: { aKm: 19591, periodDays: 6.387 } },
};
// Ordered planet ids (draw / menu order).

View File

@ -6,13 +6,18 @@ export const CONFIG = {
proxy: {
horizons: 'proxy/horizons',
sbdb: 'proxy/sbdb',
sbdbLookup: 'proxy/sbdb_lookup',
cad: 'proxy/cad',
},
// Clock validity = Table 1 range. Scrubbing clamps here (brief §2).
// Clock validity = Table 1 range. Scrubbing clamps here (brief §2). Deep-time
// (EPOCH toggle, &e=1) swaps min/maxMs to the deep bounds below — main.js does
// the swap at boot, before the clock inits. JS Date handles astronomical years.
time: {
minMs: Date.UTC(1800, 0, 1),
maxMs: Date.UTC(2050, 0, 1),
deepMinMs: Date.UTC(-2999, 0, 1), // 3000 BC (Table 2a extended range)
deepMaxMs: Date.UTC(2999, 11, 31), // 3000 AD
liveThresholdSec: 60, // |sim wall| ≤ 60 s at 1× → LIVE
},
@ -53,6 +58,8 @@ export const CONFIG = {
{ label: '1 wk/s', value: 604800 },
{ label: '1 mo/s', value: 2629800 }, // 30.4375 d
{ label: '1 yr/s', value: 31557600 }, // 365.25 d
{ label: '1 decade/s', value: 315576000 }, // 10 · 365.25 d — deep-time travel
{ label: '1 century/s', value: 3155760000 }, // 100 · 365.25 d
],
defaultRateIndex: 3, // 1 day/s
@ -65,7 +72,7 @@ export const CONFIG = {
orbitRebuildCy: 0.1,
orbitSamples: 512,
useExtendedRange: false, // Table 2a swap — plumbing only, no v1 UI
useExtendedRange: false, // default (near mode); main.js flips it on for &e=1 deep time
colors: {
accent: '#d9a441', // brass

View File

@ -4,6 +4,11 @@
// — 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.
//
// Wave 2 (SYZYGY): planets also get a predictive fact — Next opposition (outer) /
// Next elongation (inner) — computed from the same ephemeris via events.js.
import { nextEvent, isOuter, isInner } from './events.js';
const GM_SUN = 1.32712440018e11; // km^3/s^2, heliocentric gravitational parameter
const YEAR_DAYS = 365.256;
@ -36,6 +41,9 @@ export default function create(ctx) {
let lastFocus = null;
let lastWall = 0;
const _b = {}, _e = {};
// Memo for the predictive fact so we don't rescan the ephemeris every 200 ms —
// recompute only on focus change, when the event passes, or a backward scrub.
let neCache = { id: null, fromJd: 0, ev: undefined };
function helioOf(id, jd, out) {
const b = bodies[id];
@ -114,6 +122,17 @@ export default function create(ctx) {
if (b.rotationHours) {
rows.push(fact('Day (rotation)', `${lib.fmtDuration(Math.abs(b.rotationHours) / 24)}${b.rotationHours < 0 ? ' (retrograde)' : ''}`));
}
// predictive sky event (SYZYGY): outer → Next opposition, inner → Next elongation.
if (isOuter(id) || isInner(id)) {
if (neCache.id !== id || neCache.ev === undefined || (neCache.ev && jd > neCache.ev.jd) || jd < neCache.fromJd) {
neCache = { id, fromJd: jd, ev: nextEvent(id, jd) };
}
const ev = neCache.ev;
if (ev) {
rows.push(fact(isOuter(id) ? 'Next opposition' : 'Next elongation',
`${lib.formatUTCDate(lib.unixMsFromJd(ev.jd))} · in ${lib.fmtDuration(ev.jd - jd)}`));
}
}
const facts = panel.querySelector('.ip-facts');
if (facts) facts.innerHTML = rows.join('');
}

212
js/layers/events.js Normal file
View File

@ -0,0 +1,212 @@
// SOLARGOD sky-events layer (Wave 2 · SYZYGY) — the almanac learns to predict.
// Pure geometry on the ephemeris: heliocentric longitude λ(body)=atan2(y,x) and
// the geocentric elongation angle give oppositions, conjunctions and greatest
// elongations with no network at all. Two plain exports (findEvents / nextEvent)
// so verify.html and node can gate the MATH directly; the default factory paints
// diamond marks on the time bar and jumps the clock on click.
//
// Longitude alignment (heliocentric, Earth E, planet P, both from the Sun):
// opposition (outer): normDegPM180(λp λe) crosses 0 — Sun·Earth·planet in line
// solar conj (outer): the same crosses 180
// inferior conj (inner): crosses 0 ; superior conj (inner): crosses 180
// greatest elongation (inner): local max of acos( (E·(PE)) / (|E||PE|) )
import * as ephem from '../ephem.js';
import { BODIES } from '../bodies.js';
import { normDegPM180, RAD, unixMsFromJd, formatUTCDate, fmtDuration } from '../lib.js';
// Bodies that show an opposition (outer) vs an elongation (inner). Pluto rides the
// small-body element set; every other id is an ephem.js planet key.
export const OUTER = ['mars', 'jupiter', 'saturn', 'uranus', 'neptune', 'pluto'];
export const INNER = ['mercury', 'venus'];
export function isOuter(id) { return OUTER.includes(id); }
export function isInner(id) { return INNER.includes(id); }
const PLUTO_EL = BODIES.pluto.elements;
const STEP_DAYS = 5; // coarse scan step; finest synodic (Mercury) still safe
const HOUR = 1 / 24; // bisection tolerance — refine crossings to < 1 hour
// ---- position helpers (scratch objects; all calls are sequential) ----
const _h = {}, _E = {}, _P = {};
function helioAt(id, jd, out) {
return id === 'pluto' ? ephem.smallBodyEcl(PLUTO_EL, jd, out) : ephem.helioEcl(id, jd, out);
}
function lonDeg(id, jd) {
helioAt(id, jd, _h);
return Math.atan2(_h.y, _h.x) * RAD;
}
// signed longitude gap planetEarth, wrapped to (180,180]; 0 = opp/inferior conj.
function deltaLon(id, jd) { return normDegPM180(lonDeg(id, jd) - lonDeg('earth', jd)); }
// shifted so a zero marks the 180° alignment (solar / superior conjunction).
function deltaLon180(id, jd) { return normDegPM180(lonDeg(id, jd) - lonDeg('earth', jd) - 180); }
// Geocentric elongation angle (SunEarthplanet), degrees.
function elongationDeg(id, jd) {
ephem.helioEcl('earth', jd, _E);
helioAt(id, jd, _P);
const sx = -_E.x, sy = -_E.y, sz = -_E.z; // Earth→Sun
const px = _P.x - _E.x, py = _P.y - _E.y, pz = _P.z - _E.z; // Earth→planet
const ns = Math.hypot(sx, sy, sz), np = Math.hypot(px, py, pz);
let c = (sx * px + sy * py + sz * pz) / (ns * np);
c = c > 1 ? 1 : c < -1 ? -1 : c;
return Math.acos(c) * RAD;
}
// East (evening star) when the planet leads the Sun in geocentric ecliptic longitude.
function elongSide(id, jd) {
ephem.helioEcl('earth', jd, _E);
helioAt(id, jd, _P);
const lonSun = Math.atan2(-_E.y, -_E.x) * RAD;
const lonP = Math.atan2(_P.y - _E.y, _P.x - _E.x) * RAD;
return normDegPM180(lonP - lonSun) >= 0 ? 'E' : 'W';
}
// ---- root finding ----
// Bisect a continuous angle fn(id,·) with opposite signs at [a,b] to < 1 hour.
function bisect(id, a, b, fn) {
let fa = fn(id, a);
for (let i = 0; i < 40 && b - a > HOUR; i++) {
const m = 0.5 * (a + b), fm = fn(id, m);
if (fa * fm <= 0) b = m; else { a = m; fa = fm; }
}
return 0.5 * (a + b);
}
// Coarse-scan fn for sign changes; the |Δf|<90 guard rejects the ±180 wrap of a
// wrapped angle (a real crossing steps only a few degrees per 5 days).
function scanZeros(id, jdStart, jdEnd, fn, onCross) {
let ja = jdStart, fa = fn(id, ja);
for (let jb = jdStart + STEP_DAYS; jb <= jdEnd + 1e-9; jb += STEP_DAYS) {
const fb = fn(id, jb);
if (fa !== 0 && fa * fb < 0 && Math.abs(fa - fb) < 90) onCross(bisect(id, ja, jb, fn));
ja = jb; fa = fb;
}
}
// Local maxima of elongation: numeric derivative flips + → , then bisect it to 0.
function scanElongMax(id, jdStart, jdEnd, onMax) {
const h = 0.5;
const der = (jd) => elongationDeg(id, jd + h) - elongationDeg(id, jd - h);
let ja = jdStart, da = der(ja);
for (let jb = jdStart + STEP_DAYS; jb <= jdEnd + 1e-9; jb += STEP_DAYS) {
const db = der(jb);
if (da > 0 && db < 0) {
let a = ja, b = jb;
for (let i = 0; i < 40 && b - a > HOUR; i++) { const m = 0.5 * (a + b); if (der(m) > 0) a = m; else b = m; }
onMax(0.5 * (a + b));
}
ja = jb; da = db;
}
}
// ---- event construction ----
function makeEvent(id, jd, kind) {
const name = BODIES[id].name;
if (kind === 'opposition') return { jd, type: 'opposition', bodies: [id], label: `${name} at opposition` };
if (kind === 'solar') return { jd, type: 'conjunction', bodies: [id], label: `${name} at solar conjunction` };
if (kind === 'inferior') return { jd, type: 'conjunction', bodies: [id], label: `${name} at inferior conjunction` };
if (kind === 'superior') return { jd, type: 'conjunction', bodies: [id], label: `${name} at superior conjunction` };
// greatest elongation — carry side + angle
const side = elongSide(id, jd), ang = elongationDeg(id, jd);
return { jd, type: side === 'E' ? 'elongation-east' : 'elongation-west', bodies: [id],
label: `${name} greatest elongation ${side === 'E' ? 'east' : 'west'} (${ang.toFixed(0)}°)` };
}
function scanBody(id, jdStart, jdEnd) {
const out = [];
const outer = isOuter(id);
scanZeros(id, jdStart, jdEnd, deltaLon, (jd) => out.push(makeEvent(id, jd, outer ? 'opposition' : 'inferior')));
scanZeros(id, jdStart, jdEnd, deltaLon180, (jd) => out.push(makeEvent(id, jd, outer ? 'solar' : 'superior')));
if (!outer) scanElongMax(id, jdStart, jdEnd, (jd) => out.push(makeEvent(id, jd, 'elongation')));
return out;
}
// PLAIN API — pure math, importable with zero Three.js in scope.
// All sky events in [jdStart, jdEnd], chronological.
export function findEvents(jdStart, jdEnd) {
const all = [];
for (const id of [...OUTER, ...INNER]) for (const e of scanBody(id, jdStart, jdEnd)) all.push(e);
all.sort((a, b) => a.jd - b.jd);
return all;
}
// Next headline event for a body strictly after jd — opposition (outer) or
// greatest elongation (inner). Widens the search span until one is found.
export function nextEvent(bodyId, jd) {
const outer = isOuter(bodyId);
const want = outer ? (e) => e.type === 'opposition' : (e) => e.type.startsWith('elongation');
let span = outer ? 820 : 500;
for (let tries = 0; tries < 4; tries++) {
const hit = scanBody(bodyId, jd, jd + span).filter((e) => e.jd > jd + 1e-6 && want(e)).sort((a, b) => a.jd - b.jd)[0];
if (hit) return hit;
span *= 2;
}
return null;
}
// ---- default factory: timeline diamonds + HUD row ----
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'; }
export default function create(ctx) {
const { CONFIG, ui, clock } = ctx;
const strip = document.getElementById('timeline-outer');
if (!strip) return null; // no time bar (e.g. headless) — plain exports still work
const marks = document.createElement('div');
marks.id = 'event-marks';
strip.appendChild(marks);
const minMs = CONFIG.time.minMs, maxMs = CONFIG.time.maxMs, spanMs = maxMs - minMs;
let on = true, events = [];
let winStart = 0, winEnd = 0; // computed window in JD
let queue = null, pending = []; // chunked-scan state
let lastCheck = 0;
ui.addLayer('events', 'Sky events', true, (v) => { on = v; marks.style.display = v ? '' : 'none'; });
startScan(clock.jd);
// Kick off a fresh ±2 yr scan centred on jd; bodies are processed one-per-tick.
function startScan(centerJd) {
winStart = centerJd - WINDOW_DAYS; winEnd = centerJd + WINDOW_DAYS;
queue = [...OUTER, ...INNER]; pending = [];
ui.setStatus('events', 'computing…', 'warn');
}
function step() {
if (!queue) return;
const id = queue.shift();
for (const e of scanBody(id, winStart, winEnd)) pending.push(e);
if (queue.length) { ui.setStatus('events', `computing… ${8 - queue.length}/8`, 'warn'); return; }
queue = null;
pending.sort((a, b) => a.jd - b.jd);
events = pending; pending = [];
render();
ui.setStatus('events', `${events.length} events ±2 yr · computed`, on ? 'ok' : 'off');
}
function render() {
marks.textContent = '';
for (const e of events) {
const ms = unixMsFromJd(e.jd);
const f = (ms - minMs) / spanMs;
if (f < 0 || f > 1) continue;
const d = document.createElement('div');
d.className = `ev-mark ev-${cssKind(e.type)}`;
d.style.left = `${(f * 100).toFixed(4)}%`;
d.title = `${e.label} · ${formatUTCDate(ms)}`;
d.addEventListener('pointerdown', (ev) => {
ev.stopPropagation(); // don't let the strip's scrub-capture hijack the jump
clock.simMs = Math.max(minMs, Math.min(maxMs, ms));
});
marks.appendChild(d);
}
}
return {
id: 'events',
onClockTick(simMs, jd) {
if (queue) { step(); return; } // finish the current scan before anything else
const now = performance.now();
if (now - lastCheck < 250) return; // window check ≤4 Hz
lastCheck = now;
if (jd < winStart || jd > winEnd) startScan(jd); // clock left the window → recompute
},
};
}

View File

@ -6,7 +6,8 @@
// 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 { THREE, scene, CONFIG, lib, ui, scale, bodies, focus, toLocal, effectiveE,
makeLabel, registerPick, loadTextureInto } = ctx;
const entries = [];
for (const id in bodies) {
@ -16,6 +17,7 @@ export default function create(ctx) {
const mesh = new THREE.Mesh(new THREE.SphereGeometry(1, 24, 16), mat);
mesh.visible = true;
scene.add(mesh);
loadTextureInto(mat, b.texture); // NASA/USGS map when present; flat color otherwise (§7 fallback)
registerPick(mesh, id);
const lbl = makeLabel(mesh, b.name, 'body-label moon');
lbl.obj.visible = false; // CSS2DRenderer honors obj.visible (not div.style.display)
@ -34,6 +36,10 @@ export default function create(ctx) {
if (!abs) continue;
toLocal(abs, e.mesh.position);
e.mesh.scale.setScalar(scale.drawRadius(e.b.radiusKm, effectiveE(e.b.parent)));
// tidal lock: spin at the SAME signed orbital rate main.js positions by, so
// one face stays toward the parent (negative period → retrograde for free).
const theta = 2 * Math.PI * ((jd - lib.J2000_JD) / e.b.moonOrbit.periodDays);
e.mesh.rotation.y = theta + Math.PI;
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);

View File

@ -1,25 +1,48 @@
// 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.
// SOLARGOD near-Earth close-approach layer (Stage 5 · wave 2 NEARGOD) — the CAD
// API's ±30-day list of close approaches, each object now drawn at its TRUE
// propagated position with its REAL orbit. CAD gives designations, miss distance,
// date, speed; SBDB's lookup endpoint (sbdb.api, full precision) gives the
// osculating elements, propagated by the SAME shared Kepler core as the belt
// (ephem.smallBodyEcl). Off by default; additive and fail-soft PER OBJECT — a
// lookup that won't resolve falls back to the wave-1 schematic ring for that one
// object, and the status line reports the split honestly (brief §5, Lane N).
// Parse an SBDB lookup response → {a,e,i,om,w,ma,epoch} (deg/AU/JD) or null.
// Elements arrive as an array of {name, value} STRINGS; epoch is orbit.epoch (JD,
// TDB). Reject non-finite or e≥0.98 (the wave-1 elliptical-only rule, brief §3).
// Exported so verify.html's gate exercises the identical parser.
export function parseSbdbElements(j) {
const o = j && j.orbit;
if (!o || !Array.isArray(o.elements)) return null;
const m = {};
for (const e of o.elements) m[e.name] = Number(e.value);
const el = { a: m.a, e: m.e, i: m.i, om: m.om, w: m.w, ma: m.ma, epoch: Number(o.epoch) };
for (const v of Object.values(el)) if (!Number.isFinite(v)) return null;
if (el.e >= 0.98 || !(el.a > 0)) return null;
return el;
}
export default function create(ctx) {
const { THREE, scene, CONFIG, lib, ui, ephem, toLocal, makeLabel } = ctx;
const { THREE, scene, CONFIG, lib, ui, scale, ephem, worldGroup, toLocal, makeLabel } = ctx;
const LD_AU = 384400 / lib.AU_KM; // one lunar distance in AU
const SBDB_LOOKUP = CONFIG.proxy.sbdbLookup;
const N = 256; // orbit samples per NEO
const glyph = makeReticle();
const neos = [];
let on = false;
const _abs = {};
const _e = {}, _abs = {}, _tmp = {};
ui.addLayer('neos', 'Close approaches', false, (v) => { on = v; for (const n of neos) { n.spr.visible = v; n.lbl.obj.visible = v; } });
ui.addLayer('neos', 'Close approaches', false, (v) => {
on = v;
for (const n of neos) { n.spr.visible = v; n.lbl.obj.visible = v; if (n.line) n.line.visible = v; }
});
ui.setStatus('neos', 'off', 'off');
load();
async function load() {
ui.setStatus('neos', 'fetching CAD…', 'warn');
let rows;
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')}`;
@ -27,16 +50,33 @@ export default function create(ctx) {
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({
rows = (j.data || []).map((r) => ({
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);
console.warn('[solargod] neos CAD', err.message);
ui.setStatus('neos', 'CAD unavailable', 'err');
return;
}
// Build every marker up-front (schematic ring by default), then upgrade each to
// a true orbit as its SBDB lookup lands. SEQUENTIAL — sbdb.api burst-throttles,
// and the disk cache makes reloads instant (brief §12: don't hammer JPL).
rows.forEach((d, i) => build(d, i, rows.length));
let real = 0, sched = 0;
for (let i = 0; i < neos.length; i++) {
const n = neos[i];
try {
const res = await fetch(`${SBDB_LOOKUP}?sstr=${encodeURIComponent(n.d.des)}&full-prec=true`);
const el = parseSbdbElements(await res.json());
if (el) { attachOrbit(n, el); real++; } else sched++;
} catch (err) { sched++; console.warn('[solargod] neos', n.d.des, err.message); }
ui.setStatus('neos', `fetching ${i + 1}/${neos.length}${real} real`, 'warn');
}
ui.setStatus('neos',
neos.length ? `${real} real orbits · ${sched} schematic · CAD+SBDB` : 'none in window',
on ? 'ok' : 'off');
}
function build(d, i, total) {
@ -45,22 +85,56 @@ export default function create(ctx) {
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 });
neos.push({ d, ld, col, spr, lbl, angle: (i / Math.max(1, total)) * Math.PI * 2, el: null, line: null, geo: null, auPath: null, lastFillP: NaN });
}
// Attach the real elliptical orbit (rides worldGroup in absolute view-world, like
// the comets). Refilled from the frozen AU path whenever the MEGA/TRUE P changes.
function attachOrbit(n, el) {
n.el = el;
n.auPath = ephem.orbitPathFromElements(el, N);
n.geo = new THREE.BufferGeometry();
n.geo.setAttribute('position', new THREE.BufferAttribute(new Float32Array((N + 1) * 3), 3));
n.line = new THREE.LineLoop(n.geo, new THREE.LineBasicMaterial({ color: new THREE.Color(n.col), transparent: true, opacity: 0.25 }));
n.line.frustumCulled = false; n.line.visible = on;
worldGroup.add(n.line);
fillOrbit(n);
}
function fillOrbit(n) {
const pos = n.geo.attributes.position.array;
for (let k = 0; k <= N; k++) {
_tmp.x = n.auPath[k * 3]; _tmp.y = n.auPath[k * 3 + 1]; _tmp.z = n.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;
}
n.geo.attributes.position.needsUpdate = true;
n.geo.computeBoundingSphere();
n.lastFillP = scale.getP();
}
return {
id: 'neos',
onClockTick() {
onClockTick(simMs, jd) {
if (!on) return;
const earth = ctx.bodyWorld.earth;
if (!earth) return;
const camDist = ctx.camera.position.length();
const P = scale.getP();
const earth = ctx.bodyWorld.earth;
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;
if (n.el) {
// true propagated position (same core as the belt)
ephem.smallBodyEcl(n.el, jd, _e);
scale.viewFromEcl(_e, _abs);
toLocal(_abs, n.spr.position);
if (P !== n.lastFillP) fillOrbit(n); // track the MEGA↔TRUE tween
} else if (earth) {
// schematic ring fallback for THIS object alone (wave-1 placement)
_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));
}
},

View File

@ -55,15 +55,18 @@ export function worldToEcl(w, out = {}) {
// ---- formatters ----
const pad = (n, w = 2) => String(n).padStart(w, '0');
// Astronomical year → human era label: 0 = "1 BC", 999 = "1000 BC" (deep time,
// wave 2 VESSEL — a raw getUTCFullYear() would render 1000 BC as "-0999").
const eraYear = (y) => (y <= 0 ? `${1 - y} BC` : String(y).padStart(4, '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())} ` +
return `${eraYear(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())}`;
return `${eraYear(d.getUTCFullYear())}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}`;
}
export function fmtAU(au) {
const a = Math.abs(au);

View File

@ -24,6 +24,17 @@ import * as ephem from './ephem.js';
import { BODIES, PLANET_ORDER, moonsOf } from './bodies.js';
const DEBUG = new URLSearchParams(location.search).has('debug');
// Deep-time (EPOCH) mode is a page-level switch carried in the hash as &e=1. Read
// it HERE — before ephem/clock init — so the whole session runs on the extended
// Table 2a/2b elements and the wider clock window. Toggling it reloads the page
// (see wireEpochToggle); orbit paths + element resolution are baked per-session.
const DEEP_TIME = new URLSearchParams(location.hash.replace(/^#/, '')).get('e') === '1';
if (DEEP_TIME) {
CONFIG.useExtendedRange = true;
CONFIG.time.minMs = CONFIG.time.deepMinMs;
CONFIG.time.maxMs = CONFIG.time.deepMaxMs;
}
ephem.setExtendedRange(CONFIG.useExtendedRange);
scale.init(CONFIG);
@ -285,6 +296,34 @@ function buildRateMenu() {
sel.addEventListener('change', () => { clock.rateIndex = Number(sel.value); });
}
// Deep-time is a whole-session mode (extended tables + a wider clock + per-session
// baked orbit paths), so switching serializes the current view and RELOADS with the
// flipped &e token — deterministic, and honest about what re-resolves underneath.
function wireEpochToggle() {
const seg = document.getElementById('epoch-toggle');
if (!seg) return;
for (const b of seg.querySelectorAll('button')) b.classList.toggle('active', (b.dataset.epoch === 'deep') === DEEP_TIME);
seg.addEventListener('click', (e) => {
const btn = e.target.closest('button'); if (!btn) return;
const wantDeep = btn.dataset.epoch === 'deep';
if (wantDeep === DEEP_TIME) return; // already in this mode
location.hash = serializeHash(wantDeep); // keep focus/time/scale/layers/cam, flip e
location.reload();
});
}
// Timeline end labels + scrub tooltip follow CONFIG.time (were hardcoded 1800/2050).
function epochYearLabel(ms) {
const y = new Date(ms).getUTCFullYear(); // astronomical year: 0 = 1 BC, -1 = 2 BC …
return y <= 0 ? `${1 - y} BC` : String(y);
}
function applyTimelineBounds() {
const lo = epochYearLabel(CONFIG.time.minMs), hi = epochYearLabel(CONFIG.time.maxMs);
const min = document.getElementById('tb-end-min'); if (min) min.textContent = lo;
const max = document.getElementById('tb-end-max'); if (max) max.textContent = hi;
const strip = document.getElementById('timeline-outer'); if (strip) strip.title = `Scrub ${lo}${hi}`;
}
function wireControls() {
// scale toggle
document.getElementById('scale-toggle').addEventListener('click', (e) => {
@ -352,6 +391,7 @@ const ctx = {
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/events.js',
'./layers/almanac.js'];
const activeLayers = [];
async function loadLayers() {
@ -380,12 +420,16 @@ function applyHash() {
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');
// `e` (deep-time) was already consumed at module load — it must precede clock/
// ephem init, so it can't be re-applied here. It stays in the round-trip; the
// EPOCH toggle reloads to change it. Warn if it somehow drifted post-load.
if ((p.get('e') === '1') !== DEEP_TIME) console.warn('[solargod] hash e-token changed after load — reload to apply');
}
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() {
function serializeHash(deep = DEEP_TIME) {
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();
@ -393,7 +437,8 @@ function serializeHash() {
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}`;
const e = deep ? '&e=1' : ''; // deep-time token — omitted in near mode (clean links)
return `#f=${focus.id}&t=${t}&s=${scale.getMode()}&L=${on}&cam=${cam}${e}`;
}
function startHashSync() {
setInterval(() => { const h = serializeHash(); if (h !== location.hash) history.replaceState(null, '', h); }, 1000);
@ -474,6 +519,8 @@ addEventListener('resize', () => {
buildFocusMenu();
buildRateMenu();
wireControls();
wireEpochToggle();
applyTimelineBounds();
if (DEBUG) document.getElementById('debug').hidden = false;
try { applyHash(); } catch (err) { console.warn('bad hash', err); }
setFocus(focus.id, false);

View File

@ -36,6 +36,7 @@ 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",
"sbdb_lookup": "https://ssd-api.jpl.nasa.gov/sbdb.api",
"cad": "https://ssd-api.jpl.nasa.gov/cad.api",
}

21
vendor/three/LICENSE vendored Normal file
View File

@ -0,0 +1,21 @@
The MIT License
Copyright © 2010-2024 three.js authors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

31
vendor/three/PROVENANCE.txt vendored Normal file
View File

@ -0,0 +1,31 @@
SOLARGOD vendored dependency — Three.js
=======================================
Version : three@0.170.0 (REVISION '170')
License : MIT (see ./LICENSE — retained verbatim from the package)
Fetched : 2026-07-16 via jsdelivr CDN
Source URLs (exact files, pinned version):
build/three.module.js
https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js
examples/jsm/controls/OrbitControls.js
https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/controls/OrbitControls.js
examples/jsm/renderers/CSS2DRenderer.js
https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/renderers/CSS2DRenderer.js
LICENSE
https://cdn.jsdelivr.net/npm/three@0.170.0/LICENSE
Why vendored (not an npm dep): SOLARGOD has no build step and no node_modules.
These are the *exact* pinned files the importmap used to pull from the CDN, copied
in so the app boots with zero network. Directory layout mirrors the package so the
addons' bare `import ... from 'three'` resolves through the importmap:
index.html / verify.html importmap →
"three": "./vendor/three/build/three.module.js"
"three/addons/": "./vendor/three/examples/jsm/"
Import graph (verified shallow): OrbitControls.js and CSS2DRenderer.js import ONLY
from bare 'three'; three.module.js is a self-contained bundle with no external or
relative imports. No further files are needed.
Do not edit these files — they are upstream verbatim. Re-vendor by re-fetching the
same pinned URLs.

54571
vendor/three/build/three.module.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,238 @@
import {
Matrix4,
Object3D,
Vector2,
Vector3
} from 'three';
class CSS2DObject extends Object3D {
constructor( element = document.createElement( 'div' ) ) {
super();
this.isCSS2DObject = true;
this.element = element;
this.element.style.position = 'absolute';
this.element.style.userSelect = 'none';
this.element.setAttribute( 'draggable', false );
this.center = new Vector2( 0.5, 0.5 ); // ( 0, 0 ) is the lower left; ( 1, 1 ) is the top right
this.addEventListener( 'removed', function () {
this.traverse( function ( object ) {
if (
object.element instanceof object.element.ownerDocument.defaultView.Element &&
object.element.parentNode !== null
) {
object.element.remove();
}
} );
} );
}
copy( source, recursive ) {
super.copy( source, recursive );
this.element = source.element.cloneNode( true );
this.center = source.center;
return this;
}
}
//
const _vector = new Vector3();
const _viewMatrix = new Matrix4();
const _viewProjectionMatrix = new Matrix4();
const _a = new Vector3();
const _b = new Vector3();
class CSS2DRenderer {
constructor( parameters = {} ) {
const _this = this;
let _width, _height;
let _widthHalf, _heightHalf;
const cache = {
objects: new WeakMap()
};
const domElement = parameters.element !== undefined ? parameters.element : document.createElement( 'div' );
domElement.style.overflow = 'hidden';
this.domElement = domElement;
this.getSize = function () {
return {
width: _width,
height: _height
};
};
this.render = function ( scene, camera ) {
if ( scene.matrixWorldAutoUpdate === true ) scene.updateMatrixWorld();
if ( camera.parent === null && camera.matrixWorldAutoUpdate === true ) camera.updateMatrixWorld();
_viewMatrix.copy( camera.matrixWorldInverse );
_viewProjectionMatrix.multiplyMatrices( camera.projectionMatrix, _viewMatrix );
renderObject( scene, scene, camera );
zOrder( scene );
};
this.setSize = function ( width, height ) {
_width = width;
_height = height;
_widthHalf = _width / 2;
_heightHalf = _height / 2;
domElement.style.width = width + 'px';
domElement.style.height = height + 'px';
};
function hideObject( object ) {
if ( object.isCSS2DObject ) object.element.style.display = 'none';
for ( let i = 0, l = object.children.length; i < l; i ++ ) {
hideObject( object.children[ i ] );
}
}
function renderObject( object, scene, camera ) {
if ( object.visible === false ) {
hideObject( object );
return;
}
if ( object.isCSS2DObject ) {
_vector.setFromMatrixPosition( object.matrixWorld );
_vector.applyMatrix4( _viewProjectionMatrix );
const visible = ( _vector.z >= - 1 && _vector.z <= 1 ) && ( object.layers.test( camera.layers ) === true );
const element = object.element;
element.style.display = visible === true ? '' : 'none';
if ( visible === true ) {
object.onBeforeRender( _this, scene, camera );
element.style.transform = 'translate(' + ( - 100 * object.center.x ) + '%,' + ( - 100 * object.center.y ) + '%)' + 'translate(' + ( _vector.x * _widthHalf + _widthHalf ) + 'px,' + ( - _vector.y * _heightHalf + _heightHalf ) + 'px)';
if ( element.parentNode !== domElement ) {
domElement.appendChild( element );
}
object.onAfterRender( _this, scene, camera );
}
const objectData = {
distanceToCameraSquared: getDistanceToSquared( camera, object )
};
cache.objects.set( object, objectData );
}
for ( let i = 0, l = object.children.length; i < l; i ++ ) {
renderObject( object.children[ i ], scene, camera );
}
}
function getDistanceToSquared( object1, object2 ) {
_a.setFromMatrixPosition( object1.matrixWorld );
_b.setFromMatrixPosition( object2.matrixWorld );
return _a.distanceToSquared( _b );
}
function filterAndFlatten( scene ) {
const result = [];
scene.traverseVisible( function ( object ) {
if ( object.isCSS2DObject ) result.push( object );
} );
return result;
}
function zOrder( scene ) {
const sorted = filterAndFlatten( scene ).sort( function ( a, b ) {
if ( a.renderOrder !== b.renderOrder ) {
return b.renderOrder - a.renderOrder;
}
const distanceA = cache.objects.get( a ).distanceToCameraSquared;
const distanceB = cache.objects.get( b ).distanceToCameraSquared;
return distanceA - distanceB;
} );
const zMax = sorted.length;
for ( let i = 0, l = sorted.length; i < l; i ++ ) {
sorted[ i ].element.style.zIndex = zMax - i;
}
}
}
}
export { CSS2DObject, CSS2DRenderer };

View File

@ -16,8 +16,9 @@
#summary { margin-top: 16px; font-size: 15px; }
.importmap-note { color: #8a8674; }
</style>
<!-- Three.js vendored (offline-first, zero CDN). See vendor/three/PROVENANCE.txt. -->
<script type="importmap">
{ "imports": { "three": "https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js" } }
{ "imports": { "three": "./vendor/three/build/three.module.js", "three/addons/": "./vendor/three/examples/jsm/" } }
</script>
</head>
<body>
@ -196,6 +197,168 @@
await craftCheck('Voyager 1 (-31) @ 2026-07-15', '-31', 2461236.5, { x: -32.07, y: -136.21, z: 98.55 }, 0.05);
await jwstCheck();
})();
/* PERIHELION-N — NEARGOD close-approach gate. A real NEO's propagated
heliocentric distance to Earth(≈EMB) at its CAD close-approach epoch must
match CAD's miss distance within 25% — generous on purpose: Earth≈EMB,
two-body propagation, CAD dist is geocentric; the point is catching
wrong-by-an-AU bugs, not arcseconds. Exercises the full lane-N pipeline:
CAD des → sbdb.api full-prec elements → parseSbdbElements → shared Kepler
core (ephem.smallBodyEcl), the identical code path the layer draws. */
import { parseSbdbElements } from './js/layers/neos.js';
async function neoCloseApproachGate() {
const label = 'NEO propagated miss vs CAD (real orbit)';
const tr = pendingRow(label);
try {
const LD_AU = 384400 / lib.AU_KM;
// Fixed window anchored at the sim's default date so the gate is
// deterministic and always populated (past close approaches stay in CAD).
const cad = await (await fetch(`${CONFIG.proxy.cad}?date-min=2026-06-16&date-max=2026-08-15&dist-max=0.05&sort=dist&limit=40`)).json();
const F = cad.fields, ix = (k) => F.indexOf(k);
const rows = cad.data || [];
if (!rows.length) throw new Error('CAD returned no rows');
// Pick the largest-miss approach: 25% of a bigger distance is the most
// robust absolute tolerance (least sensitive to EMB≈Earth + two-body drift).
let best = null;
for (const r of rows) { const dist = +r[ix('dist')]; if (!best || dist > best.dist) best = { des: r[ix('des')], jd: +r[ix('jd')], dist }; }
const j = await (await fetch(`${CONFIG.proxy.sbdbLookup}?sstr=${encodeURIComponent(best.des)}&full-prec=true`)).json();
const el = parseSbdbElements(j);
if (!el) throw new Error(`no elliptical elements for ${best.des}`);
const neo = ephem.smallBodyEcl(el, best.jd, {});
const emb = ephem.helioEcl('earth', best.jd, {});
const d = Math.hypot(neo.x - emb.x, neo.y - emb.y, neo.z - emb.z);
const relErr = Math.abs(d - best.dist) / best.dist;
tr.remove();
row(label, relErr < 0.25,
`${best.des} @ jd ${best.jd.toFixed(3)}: propagated ${(d / LD_AU).toFixed(2)} LD vs CAD ${(best.dist / LD_AU).toFixed(2)} LD — err ${(relErr * 100).toFixed(2)}% (tol 25%)`);
} catch (err) { tr.remove(); row(label, false, `neos/SBDB error: ${err.message}`); }
finish();
}
neoCloseApproachGate();
/* PERIHELION-E — sky-events math gates (findEvents, pure math, no network).
Dynamic import keeps the whole lane-E addition one contiguous end block. */
{
const { findEvents, nextEvent } = await import('./js/layers/events.js');
const jdOf = (y, mo, d) => lib.jdFromUnixMs(Date.UTC(y, mo - 1, d));
const dstr = (jd) => lib.formatUTCDate(lib.unixMsFromJd(jd));
const evs = findEvents(jdOf(2026, 1, 1), jdOf(2028, 1, 1));
// (E-a) Mars opposition within ±2 days of the known 2027-02-19 event.
{
const target = jdOf(2027, 2, 19);
const opp = evs.filter((e) => e.bodies[0] === 'mars' && e.type === 'opposition')
.map((e) => ({ jd: e.jd, d: Math.abs(e.jd - target) })).sort((a, b) => a.d - b.d)[0];
row('Mars opposition ±2 d of 2027-02-19', opp && opp.d <= 2,
opp ? `found ${dstr(opp.jd)}, Δ=${opp.d.toFixed(2)} d (tol 2)` : 'no Mars opposition found');
}
// (E-b) Venus greatest elongations: consecutive ones alternate E/W and are
// ~142 d apart (the elongation↔elongation cadence). NB the brief's "~72 d"
// is the elongation↔inferior-conjunction gap, gated separately below.
const vElong = evs.filter((e) => e.bodies[0] === 'venus' && e.type.startsWith('elongation'));
{
const ok = vElong.length >= 2;
const gap = ok ? vElong[1].jd - vElong[0].jd : NaN;
const alt = ok && vElong[0].type !== vElong[1].type; // east then west (or vice versa)
row('Venus consecutive greatest elongations (alternate E/W, 120160 d)',
ok && alt && gap >= 120 && gap <= 160,
ok ? `${dstr(vElong[0].jd)} (${vElong[0].type.split('-')[1]}) → ${dstr(vElong[1].jd)} (${vElong[1].type.split('-')[1]}) = ${gap.toFixed(1)} d` : 'fewer than 2 elongations');
}
// (E-b) The brief's ~72 d: a Venus greatest elongation to the neighbouring
// inferior conjunction — 6080 d — which also gates the conjunction finder.
{
const vInf = evs.filter((e) => e.bodies[0] === 'venus' && e.label.includes('inferior'));
const ok = vElong.length && vInf.length;
const gap = ok ? vInf.map((c) => Math.abs(c.jd - vElong[0].jd)).sort((a, b) => a - b)[0] : NaN;
row('Venus greatest elongation → inferior conjunction 6080 d',
ok && gap >= 60 && gap <= 80, ok ? `gap = ${gap.toFixed(1)} d (tol 6080)` : 'missing elongation/conjunction');
}
// (E-c) nextEvent agrees with the scan: Mars headline is the same opposition.
{
const ne = nextEvent('mars', jdOf(2026, 7, 16));
const target = jdOf(2027, 2, 19);
row('nextEvent(mars) → 2027 opposition', ne && ne.type === 'opposition' && Math.abs(ne.jd - target) <= 2,
ne ? `${dstr(ne.jd)} · ${ne.label}` : 'none');
}
finish();
}
/* PERIHELION-V — deep-time (extended Table 2a/2b) accuracy gates.
Capture the "mine" positions with the extended range ON, then restore it OFF
synchronously and atomically: ES modules are singletons, so setExtendedRange
mutates the same ephem the live gates above (Mars@J2000 / Jupiter@1900, which
expect Table 1) share — the flag must never be ON across an await. */
const JD_JUP_1000BC = 1355818.5; // 1000 BC-01-01
const JD_MERC_2500 = lib.jdFromUnixMs(Date.UTC(2500, 0, 1));
// Scratch extended-Jupiter resolve — a faithful copy of ephem's Table 2a+2b
// math so the 2b M-correction can be ZEROED without editing ephem.js.
const T2A_JUP = { 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] };
const B2_JUP = [-0.00012452, 0.06064060, -0.35635438, 38.35125000]; // [b, c, s, f]
function jupiterExtScratch(jd, with2b) {
const T = lib.centuriesSinceJ2000(jd);
const a = T2A_JUP.el[0] + T2A_JUP.rate[0] * T, e = T2A_JUP.el[1] + T2A_JUP.rate[1] * T;
const I = T2A_JUP.el[2] + T2A_JUP.rate[2] * T, L = T2A_JUP.el[3] + T2A_JUP.rate[3] * T;
const wb = T2A_JUP.el[4] + T2A_JUP.rate[4] * T, Om = T2A_JUP.el[5] + T2A_JUP.rate[5] * T;
let M = L - wb;
if (with2b) { const [b, c, s, f] = B2_JUP; M += b * T * T + c * Math.cos(f * T * lib.DEG) + s * Math.sin(f * T * lib.DEG); }
return ephem.eclFromElements(a, e, I, Om, wb - Om, M, {});
}
let jupMine, mercMine, jupZeroed;
{
ephem.setExtendedRange(true);
jupMine = ephem.helioEcl('jupiter', JD_JUP_1000BC, {});
mercMine = ephem.helioEcl('mercury', JD_MERC_2500, {});
ephem.setExtendedRange(false); // restore before any await
jupZeroed = jupiterExtScratch(JD_JUP_1000BC, false); // 2b correction removed
}
const dmax3 = (a, b) => Math.max(Math.abs(a.x - b.x), Math.abs(a.y - b.y), Math.abs(a.z - b.z));
(async () => {
// Wait for the base live gates + lane-N/E gates (18 total) to finish — ThreadingHTTPServer
// does NOT serialize upstream calls, so we must never fetch Horizons in parallel.
for (let i = 0; i < 480 && n < 18; i++) await new Promise((r) => setTimeout(r, 250));
// Jupiter ground truth: barycenter '5' (DE441, long span). Planet-center '599'
// has NO Horizons ephemeris before A.D. 1600; the SSD approximate elements model
// the giant-planet SYSTEM barycenter anyway (Jupiter↔bary ≈ 5e-4 AU ≪ tol).
let jupH = null;
{
const label = 'Jupiter @1000 BC (extended 2a+2b) vs live Horizons — tol 0.15 AU';
const tr = pendingRow(label);
try {
jupH = await horizonsVec('5', JD_JUP_1000BC);
const w = dmax3(jupMine, jupH);
tr.remove();
row(label, w <= 0.15, `Δmax=${w.toFixed(4)} AU (tol 0.15) · bary '5' (${jupH.x.toFixed(4)}, ${jupH.y.toFixed(4)}, ${jupH.z.toFixed(4)}) · mine (${jupMine.x.toFixed(4)}, ${jupMine.y.toFixed(4)}, ${jupMine.z.toFixed(4)})`);
} catch (err) { tr.remove(); row(label, false, `Horizons error: ${err.message}`); }
finish();
}
{
const label = 'Mercury @2500-01-01 (extended 2a) vs live Horizons — tol 0.02 AU';
const tr = pendingRow(label);
try {
const mercH = await horizonsVec('199', JD_MERC_2500);
const w = dmax3(mercMine, mercH);
tr.remove();
row(label, w <= 0.02, `Δmax=${w.toExponential(2)} AU (tol 0.02) · Horizons (${mercH.x.toFixed(5)}, ${mercH.y.toFixed(5)}, ${mercH.z.toFixed(5)})`);
} catch (err) { tr.remove(); row(label, false, `Horizons error: ${err.message}`); }
finish();
}
{
// Prove Table 2b's M-correction is LIVE: zeroing it must worsen Jupiter@1000 BC
// (reuses the Jupiter Horizons vector fetched above — no extra request).
const label = 'Table 2b M-correction engages — zeroing it worsens Jupiter@1000 BC';
if (jupH) {
const dWith = dmax3(jupMine, jupH), dZero = dmax3(jupZeroed, jupH);
row(label, dZero > dWith, `with 2b Δ=${dWith.toFixed(4)} AU → zeroed Δ=${dZero.toFixed(4)} AU (worse by ${(dZero - dWith).toFixed(4)} AU)`);
} else {
row(label, false, 'skipped — Jupiter Horizons fetch failed above');
}
finish();
}
})();
</script>
</body>
</html>