Compare commits
9 Commits
e7bcf424d7
...
b42473a055
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b42473a055 | ||
|
|
0883cc50b8 | ||
|
|
c1c1afb0db | ||
|
|
c794ca1182 | ||
|
|
8bfce067d5 | ||
|
|
28255dbb54 | ||
|
|
be5f8ad544 | ||
|
|
9aeb2bbb5f | ||
|
|
a57fa196ac |
BIN
assets/textures/1k_callisto.jpg
Normal file
|
After Width: | Height: | Size: 130 KiB |
BIN
assets/textures/1k_charon.jpg
Normal file
|
After Width: | Height: | Size: 98 KiB |
BIN
assets/textures/1k_europa.jpg
Normal file
|
After Width: | Height: | Size: 135 KiB |
BIN
assets/textures/1k_ganymede.jpg
Normal file
|
After Width: | Height: | Size: 140 KiB |
BIN
assets/textures/1k_io.jpg
Normal file
|
After Width: | Height: | Size: 123 KiB |
BIN
assets/textures/1k_phobos.jpg
Normal file
|
After Width: | Height: | Size: 111 KiB |
BIN
assets/textures/1k_titan.jpg
Normal file
|
After Width: | Height: | Size: 124 KiB |
BIN
assets/textures/1k_triton.jpg
Normal file
|
After Width: | Height: | Size: 87 KiB |
@ -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.
|
||||
|
||||
@ -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; }
|
||||
|
||||
17
index.html
@ -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">1800–2050</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) -->
|
||||
|
||||
16
js/bodies.js
@ -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).
|
||||
|
||||
11
js/config.js
@ -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
|
||||
|
||||
@ -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
@ -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·(P−E)) / (|E||P−E|) )
|
||||
|
||||
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 planet−Earth, 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 (Sun–Earth–planet), 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
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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));
|
||||
}
|
||||
},
|
||||
|
||||
@ -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);
|
||||
|
||||
51
js/main.js
@ -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);
|
||||
|
||||
1
serve.py
@ -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
@ -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
@ -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
1523
vendor/three/examples/jsm/controls/OrbitControls.js
vendored
Normal file
238
vendor/three/examples/jsm/renderers/CSS2DRenderer.js
vendored
Normal 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 };
|
||||
165
verify.html
@ -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, 120–160 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 — 60–80 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 60–80 d',
|
||||
ok && gap >= 60 && gap <= 80, ok ? `gap = ${gap.toFixed(1)} d (tol 60–80)` : '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>
|
||||
|
||||