🛳 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>
This commit is contained in:
monsterrobotparty 2026-07-16 09:55:38 +10:00
parent c794ca1182
commit c1c1afb0db
9 changed files with 56529 additions and 9 deletions

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

@ -9,10 +9,14 @@ export const CONFIG = {
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 +57,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 +71,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

@ -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) => {
@ -381,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();
@ -394,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);
@ -475,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);

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>
@ -284,6 +285,80 @@
}
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>