Stage 6. almanac.js: on-focus grimoire card — physical fact sheet, hand-written prose (grimoire voice), MODELBEAST portrait, and LIVE computed facts free from the ephemeris: distance from Sun & Earth, sunlight age, vis-viva orbital speed, day & year length. Earth easter egg → GODSIGH world view (:8137). Real Solar System Scope 2k textures (CC BY, 6.1 MB) now paint every world + the Milky Way skybox; ephem gains semiMajor() for vis-viva. Generated grimoire portraits (FLUX via MODELBEAST, 10 bodies, seed logged in art/PROMPTS.md) are the AESTHETIC layer only — real worlds get real maps (truth boundary). README with layer table + quickstart + verify; docs/hero.jpg. Also lands the Stage-3 planets label fix (obj.visible). Gate: every clickable body has a card; quickstart works from a fresh clone. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
479 lines
21 KiB
JavaScript
479 lines
21 KiB
JavaScript
// SOLARGOD bootstrap — renderer, sky, sun, clock, focus/camera, floating origin,
|
|
// picking, hash state, and dynamic layer loading.
|
|
//
|
|
// LAYER CONTRACT — each js/layers/*.js default-exports a factory:
|
|
// export default function create(ctx) {
|
|
// const { THREE, scene, camera, CONFIG, lib, ui, scale, ephem, bodies,
|
|
// focus, clock, worldGroup, toLocal, makeLabel, registerPick } = ctx;
|
|
// return { id, onClockTick(simMs, jd, isLive) {}, handlePick(hit) { return false; },
|
|
// clearPick() {} };
|
|
// }
|
|
// main.js computes ctx.bodyWorld[id] (absolute view-world, float64) for EVERY body
|
|
// each frame BEFORE layers tick, so layers only render. Floating origin: subtract
|
|
// the focus body's offset in JS (float64) before writing to mesh.position.
|
|
|
|
import * as THREE from 'three';
|
|
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
|
import { CSS2DRenderer, CSS2DObject } from 'three/addons/renderers/CSS2DRenderer.js';
|
|
|
|
import { CONFIG } from './config.js';
|
|
import * as lib from './lib.js';
|
|
import * as ui from './ui.js';
|
|
import * as scale from './scale.js';
|
|
import * as ephem from './ephem.js';
|
|
import { BODIES, PLANET_ORDER, moonsOf } from './bodies.js';
|
|
|
|
const DEBUG = new URLSearchParams(location.search).has('debug');
|
|
ephem.setExtendedRange(CONFIG.useExtendedRange);
|
|
scale.init(CONFIG);
|
|
|
|
// ---- renderer / scene / camera ----
|
|
const renderer = new THREE.WebGLRenderer({ antialias: true, logarithmicDepthBuffer: true, preserveDrawingBuffer: true });
|
|
renderer.setSize(innerWidth, innerHeight);
|
|
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
|
|
document.getElementById('scene').appendChild(renderer.domElement);
|
|
|
|
const labelRenderer = new CSS2DRenderer();
|
|
labelRenderer.setSize(innerWidth, innerHeight);
|
|
document.getElementById('labels').appendChild(labelRenderer.domElement);
|
|
|
|
const scene = new THREE.Scene();
|
|
const camera = new THREE.PerspectiveCamera(CONFIG.camera.fov, innerWidth / innerHeight, CONFIG.camera.near, CONFIG.camera.far);
|
|
camera.position.set(0, 26, 78);
|
|
|
|
const controls = new OrbitControls(camera, labelRenderer.domElement);
|
|
controls.enableDamping = true;
|
|
controls.dampingFactor = 0.08;
|
|
controls.minDistance = CONFIG.camera.minDistance;
|
|
controls.maxDistance = 5e5;
|
|
controls.target.set(0, 0, 0);
|
|
|
|
// orbit lines ride a group offset (imperceptible float32 residual is fine for
|
|
// lines you never zoom to — bodies get the exact JS subtraction instead).
|
|
const worldGroup = new THREE.Group();
|
|
scene.add(worldGroup);
|
|
|
|
// ---- lighting: one point light at the Sun, faint ambient so night isn't black ----
|
|
const sunLight = new THREE.PointLight(0xfff4e0, 4, 0, 0); // no distance falloff
|
|
scene.add(sunLight);
|
|
scene.add(new THREE.AmbientLight(0x2a2a33, 0.9));
|
|
|
|
// ---- sky ----
|
|
buildSky();
|
|
function buildSky() {
|
|
// Instant procedural starfield as background (also the fallback), then try the
|
|
// real Milky Way panorama and swap it in if present (brief §7).
|
|
scene.background = makeStarfieldTexture();
|
|
const tl = new THREE.TextureLoader();
|
|
tl.load(
|
|
CONFIG.textures.path + CONFIG.textures.skybox,
|
|
(tex) => { tex.mapping = THREE.EquirectangularReflectionMapping; tex.colorSpace = THREE.SRGBColorSpace; scene.background = tex; },
|
|
undefined,
|
|
() => { /* keep procedural starfield */ },
|
|
);
|
|
}
|
|
function makeStarfieldTexture() {
|
|
const w = 2048, h = 1024;
|
|
const c = document.createElement('canvas'); c.width = w; c.height = h;
|
|
const g = c.getContext('2d');
|
|
g.fillStyle = '#05060a'; g.fillRect(0, 0, w, h);
|
|
// faint milky band
|
|
const band = g.createLinearGradient(0, h * 0.38, 0, h * 0.62);
|
|
band.addColorStop(0, 'rgba(60,60,90,0)'); band.addColorStop(0.5, 'rgba(120,110,140,0.12)'); band.addColorStop(1, 'rgba(60,60,90,0)');
|
|
g.fillStyle = band; g.fillRect(0, h * 0.38, w, h * 0.24);
|
|
// stars — deterministic scatter (no Math.random dependence on frame)
|
|
let s = 1234567;
|
|
const rnd = () => { s = (s * 1103515245 + 12345) & 0x7fffffff; return s / 0x7fffffff; };
|
|
for (let i = 0; i < 2600; i++) {
|
|
const x = rnd() * w, y = rnd() * h, r = rnd() * 1.2 + 0.2, a = rnd() * 0.7 + 0.2;
|
|
g.fillStyle = `rgba(255,${240 - rnd() * 60 | 0},${210 - rnd() * 40 | 0},${a})`;
|
|
g.beginPath(); g.arc(x, y, r, 0, 7); g.fill();
|
|
}
|
|
const tex = new THREE.CanvasTexture(c);
|
|
tex.mapping = THREE.EquirectangularReflectionMapping;
|
|
tex.colorSpace = THREE.SRGBColorSpace;
|
|
return tex;
|
|
}
|
|
|
|
function makeGlowSprite() {
|
|
const size = 256, c = document.createElement('canvas'); c.width = c.height = size;
|
|
const g = c.getContext('2d');
|
|
const grd = g.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2);
|
|
grd.addColorStop(0, 'rgba(255,240,190,0.9)'); grd.addColorStop(0.25, 'rgba(255,210,120,0.5)');
|
|
grd.addColorStop(0.5, 'rgba(255,170,80,0.18)'); grd.addColorStop(1, 'rgba(255,150,60,0)');
|
|
g.fillStyle = grd; g.fillRect(0, 0, size, size);
|
|
const spr = new THREE.Sprite(new THREE.SpriteMaterial({ map: new THREE.CanvasTexture(c), blending: THREE.AdditiveBlending, depthWrite: false, transparent: true }));
|
|
return spr;
|
|
}
|
|
|
|
// ---- texture helper (procedural fallback preserved) ----
|
|
const texLoader = new THREE.TextureLoader();
|
|
export function loadTextureInto(material, file, emissive = false) {
|
|
if (!file) return;
|
|
texLoader.load(CONFIG.textures.path + file, (tex) => {
|
|
tex.colorSpace = THREE.SRGBColorSpace;
|
|
if (emissive) { material.map = tex; }
|
|
else { material.map = tex; material.color.set(0xffffff); }
|
|
material.needsUpdate = true;
|
|
}, undefined, () => { /* keep flat color */ });
|
|
}
|
|
|
|
// ---- labels ----
|
|
const labels = [];
|
|
function attachLabel(mesh, text, cls) {
|
|
const div = document.createElement('div');
|
|
div.className = cls;
|
|
div.textContent = text;
|
|
const obj = new CSS2DObject(div);
|
|
obj.position.set(0, 0, 0);
|
|
mesh.add(obj);
|
|
labels.push({ obj, div, mesh });
|
|
return { obj, div };
|
|
}
|
|
|
|
// ---- pick registry ----
|
|
const pickTargets = [];
|
|
function registerPick(mesh, bodyId) { mesh.userData.bodyId = bodyId; pickTargets.push(mesh); }
|
|
|
|
// ---- Sun (core scaffold, not a layer; positioned via toLocal each frame) ----
|
|
const sunMat = new THREE.MeshBasicMaterial({ color: 0xffdd88 });
|
|
const sunMesh = new THREE.Mesh(new THREE.SphereGeometry(1, 48, 48), sunMat);
|
|
scene.add(sunMesh);
|
|
loadTextureInto(sunMat, BODIES.sun.texture, true);
|
|
const sunGlow = makeGlowSprite();
|
|
scene.add(sunGlow);
|
|
attachLabel(sunMesh, BODIES.sun.name, 'body-label');
|
|
registerPick(sunMesh, 'sun');
|
|
|
|
// ==== clock ====
|
|
const clock = {
|
|
simMs: clampTime(Date.now()),
|
|
rateIndex: CONFIG.defaultRateIndex,
|
|
dir: 1,
|
|
playing: true,
|
|
jd: 0,
|
|
isLive: true,
|
|
};
|
|
clock.jd = lib.jdFromUnixMs(clock.simMs);
|
|
function clampTime(ms) { return lib.clamp(ms, CONFIG.time.minMs, CONFIG.time.maxMs); }
|
|
function currentRate() { return CONFIG.rates[clock.rateIndex].value * clock.dir; }
|
|
|
|
// ==== focus system ====
|
|
const focus = {
|
|
id: CONFIG.camera.startFocus,
|
|
fromId: CONFIG.camera.startFocus,
|
|
t: 1, // flight progress 0..1
|
|
camFrom: 0, camTo: 0,
|
|
};
|
|
|
|
// bodyWorld[id] = absolute view-world position, plain float64 {x,y,z}.
|
|
const bodyWorld = Object.create(null);
|
|
const focusOffset = { x: 0, y: 0, z: 0 };
|
|
const _ecl = {};
|
|
|
|
function computeBodyWorld() {
|
|
// planets + sun + small bodies (heliocentric)
|
|
for (const id in BODIES) {
|
|
const b = BODIES[id];
|
|
if (b.type === 'moon') continue;
|
|
if (id === 'sun') { bodyWorld.sun = { x: 0, y: 0, z: 0 }; continue; }
|
|
if (b.ephemId) ephem.helioEcl(b.ephemId, clock.jd, _ecl);
|
|
else if (b.elements) ephem.smallBodyEcl(b.elements, clock.jd, _ecl);
|
|
else continue;
|
|
const w = bodyWorld[id] || (bodyWorld[id] = {});
|
|
scale.viewFromEcl(_ecl, w);
|
|
}
|
|
// moons: parent absolute + local compressed offset
|
|
for (const id in BODIES) {
|
|
const b = BODIES[id];
|
|
if (b.type !== 'moon') continue;
|
|
const parent = bodyWorld[b.parent];
|
|
if (!parent) continue;
|
|
const o = moonLocalOffset(b);
|
|
const w = bodyWorld[id] || (bodyWorld[id] = {});
|
|
w.x = parent.x + o.x; w.y = parent.y + o.y; w.z = parent.z + o.z;
|
|
}
|
|
}
|
|
|
|
// Moon local offset in view-world units (circular orbit in parent's equatorial
|
|
// plane, deterministic phase from jd; retrograde via negative period, brief §8).
|
|
const _mo = {};
|
|
function moonLocalOffset(b) {
|
|
const parent = BODIES[b.parent];
|
|
const per = b.moonOrbit.periodDays;
|
|
const theta = 2 * Math.PI * ((clock.jd - lib.J2000_JD) / per); // signed → retro
|
|
// equatorial-plane unit vector, then tilt by parent axial tilt about ecl-X
|
|
const ex = Math.cos(theta), ey = Math.sin(theta);
|
|
const tilt = (parent.axialTiltDeg || 0) * lib.DEG;
|
|
const ct = Math.cos(tilt), st = Math.sin(tilt);
|
|
// ecliptic direction {x,y,z}: rotate (ex,ey,0) about X by tilt
|
|
const eclDir = { x: ex, y: ey * ct, z: ey * st };
|
|
const parentDrawView = scale.drawRadius(parent.radiusKm, effectiveE(b.parent));
|
|
const R = scale.moonDistance(Math.abs(b.moonOrbit.aKm), parent.radiusKm, parentDrawView);
|
|
// ecl→world unit dir, times R
|
|
lib.eclToWorld({ x: eclDir.x * R, y: eclDir.y * R, z: eclDir.z * R }, _mo);
|
|
return _mo;
|
|
}
|
|
|
|
// Effective exaggeration: for the FOCUSED body lerp E→1 as the camera nears, so
|
|
// you can descend to true scale in orbit without being inside a balloon (§4).
|
|
function effectiveE(id) {
|
|
const b = BODIES[id];
|
|
const baseE = id === 'sun' ? scale.getSunE() : scale.getPlanetE();
|
|
if (id !== focus.id || focus.t < 1) return baseE;
|
|
const exaggR = scale.drawRadius(b.radiusKm, baseE);
|
|
const camDist = camera.position.length();
|
|
const f = lib.clamp(camDist / (exaggR * 8), 0, 1);
|
|
return lib.lerp(1, baseE, f);
|
|
}
|
|
|
|
// Absolute view-world → render-local (JS float64 subtraction, then write float32).
|
|
function toLocal(abs, out) {
|
|
out.set(abs.x - focusOffset.x, abs.y - focusOffset.y, abs.z - focusOffset.z);
|
|
return out;
|
|
}
|
|
|
|
function bodyTrail(id) {
|
|
const trail = [];
|
|
let cur = id;
|
|
while (cur) { trail.unshift({ id: cur, name: BODIES[cur].name }); cur = BODIES[cur].parent; }
|
|
return trail;
|
|
}
|
|
|
|
function setFocus(id, animate = true) {
|
|
if (!BODIES[id]) return;
|
|
focus.fromId = focus.id;
|
|
focus.id = id;
|
|
focus.t = animate ? 0 : 1;
|
|
focus.camFrom = camera.position.length();
|
|
focus.camTo = Math.max(CONFIG.camera.minDistance * 4,
|
|
scale.drawRadius(BODIES[id].radiusKm, id === 'sun' ? scale.getSunE() : scale.getPlanetE()) * CONFIG.camera.focusDistanceFactor);
|
|
controls.enabled = !animate;
|
|
ui.setBreadcrumb(bodyTrail(id), (nid) => setFocus(nid));
|
|
document.getElementById('focus-menu').value = id;
|
|
}
|
|
|
|
// ==== HUD / control wiring ====
|
|
function buildFocusMenu() {
|
|
const sel = document.getElementById('focus-menu');
|
|
const opt = (id, label, indent) => { const o = document.createElement('option'); o.value = id; o.textContent = (indent ? ' ' : '') + label; sel.appendChild(o); };
|
|
opt('sun', '☉ Sun', false);
|
|
for (const p of PLANET_ORDER) {
|
|
opt(p, BODIES[p].name, false);
|
|
for (const m of moonsOf(p)) opt(m, '· ' + BODIES[m].name, true);
|
|
}
|
|
opt('pluto', 'Pluto', false);
|
|
for (const m of moonsOf('pluto')) opt(m, '· ' + BODIES[m].name, true);
|
|
sel.value = focus.id;
|
|
sel.addEventListener('change', () => setFocus(sel.value));
|
|
}
|
|
|
|
function buildRateMenu() {
|
|
const sel = document.getElementById('tb-rate');
|
|
CONFIG.rates.forEach((r, i) => { const o = document.createElement('option'); o.value = i; o.textContent = r.label; sel.appendChild(o); });
|
|
sel.value = clock.rateIndex;
|
|
sel.addEventListener('change', () => { clock.rateIndex = Number(sel.value); });
|
|
}
|
|
|
|
function wireControls() {
|
|
// scale toggle
|
|
document.getElementById('scale-toggle').addEventListener('click', (e) => {
|
|
const btn = e.target.closest('button'); if (!btn) return;
|
|
scale.setMode(btn.dataset.mode);
|
|
for (const b of e.currentTarget.querySelectorAll('button')) b.classList.toggle('active', b === btn);
|
|
ui.toast(btn.dataset.mode === 'true' ? 'TRUE scale — real emptiness' : 'MEGA scale — compressed');
|
|
});
|
|
// body scale
|
|
document.getElementById('bodyscale-toggle').addEventListener('click', (e) => {
|
|
const btn = e.target.closest('button'); if (!btn) return;
|
|
scale.setPlanetE(Number(btn.dataset.e));
|
|
for (const b of e.currentTarget.querySelectorAll('button')) b.classList.toggle('active', b === btn);
|
|
});
|
|
// time controls
|
|
const playBtn = document.getElementById('tb-play');
|
|
const revBtn = document.getElementById('tb-reverse');
|
|
playBtn.classList.toggle('on', clock.playing);
|
|
playBtn.textContent = clock.playing ? '❚❚' : '▶';
|
|
playBtn.addEventListener('click', () => { clock.playing = !clock.playing; playBtn.classList.toggle('on', clock.playing); playBtn.textContent = clock.playing ? '❚❚' : '▶'; });
|
|
revBtn.addEventListener('click', () => { clock.dir *= -1; revBtn.classList.toggle('on', clock.dir < 0); });
|
|
document.getElementById('tb-now').addEventListener('click', () => { clock.simMs = clampTime(Date.now()); });
|
|
// timeline scrub
|
|
const strip = document.getElementById('timeline-outer');
|
|
let scrubbing = false;
|
|
const scrubTo = (clientX) => {
|
|
const r = strip.getBoundingClientRect();
|
|
const f = lib.clamp((clientX - r.left) / r.width, 0, 1);
|
|
clock.simMs = clampTime(CONFIG.time.minMs + f * (CONFIG.time.maxMs - CONFIG.time.minMs));
|
|
};
|
|
strip.addEventListener('pointerdown', (e) => { scrubbing = true; strip.setPointerCapture(e.pointerId); scrubTo(e.clientX); });
|
|
strip.addEventListener('pointermove', (e) => { if (scrubbing) scrubTo(e.clientX); });
|
|
strip.addEventListener('pointerup', (e) => { scrubbing = false; strip.releasePointerCapture(e.pointerId); });
|
|
// Esc steps out one focus level
|
|
addEventListener('keydown', (e) => { if (e.key === 'Escape') { const p = BODIES[focus.id].parent; if (p) setFocus(p); } });
|
|
}
|
|
|
|
// ==== picking ====
|
|
const raycaster = new THREE.Raycaster();
|
|
const ndc = new THREE.Vector2();
|
|
let dragMoved = false, downPos = null;
|
|
renderer.domElement.addEventListener('pointerdown', (e) => { downPos = { x: e.clientX, y: e.clientY }; dragMoved = false; });
|
|
renderer.domElement.addEventListener('pointermove', (e) => { if (downPos && Math.hypot(e.clientX - downPos.x, e.clientY - downPos.y) > 5) dragMoved = true; });
|
|
renderer.domElement.addEventListener('pointerup', (e) => {
|
|
if (dragMoved) return; // was an orbit drag, not a click
|
|
ndc.set((e.clientX / innerWidth) * 2 - 1, -(e.clientY / innerHeight) * 2 + 1);
|
|
raycaster.setFromCamera(ndc, camera);
|
|
raycaster.params.Points = { threshold: 0.5 };
|
|
// layers first (spacecraft/asteroids), then body spheres
|
|
for (const l of activeLayers) { if (l.handlePick && l.handlePick(raycaster)) return; }
|
|
const hits = raycaster.intersectObjects(pickTargets, false);
|
|
if (hits.length) setFocus(hits[0].object.userData.bodyId);
|
|
});
|
|
|
|
// ==== ctx + layer loading ====
|
|
const ctx = {
|
|
THREE, scene, camera, renderer, labelRenderer, controls,
|
|
CONFIG, lib, ui, scale, ephem, bodies: BODIES, worldGroup,
|
|
clock, focus,
|
|
bodyWorld, focusOffset, toLocal, effectiveE,
|
|
makeLabel: attachLabel, registerPick, loadTextureInto,
|
|
moonsOf, PLANET_ORDER,
|
|
};
|
|
|
|
const LAYER_MODULES = ['./layers/planets.js', './layers/moons.js', './layers/rings.js',
|
|
'./layers/spacecraft.js', './layers/asteroids.js', './layers/comets.js', './layers/neos.js',
|
|
'./layers/almanac.js'];
|
|
const activeLayers = [];
|
|
async function loadLayers() {
|
|
const results = await Promise.allSettled(LAYER_MODULES.map((p) => import(p)));
|
|
results.forEach((r, i) => {
|
|
if (r.status === 'rejected') { console.error(`[solargod] layer ${LAYER_MODULES[i]} failed:`, r.reason); return; }
|
|
const factory = r.value.default;
|
|
if (typeof factory !== 'function') { console.warn(`[solargod] ${LAYER_MODULES[i]} no default factory`); return; }
|
|
try { const layer = factory(ctx); if (layer) activeLayers.push(layer); }
|
|
catch (err) { console.error(`[solargod] layer ${LAYER_MODULES[i]} threw:`, err); }
|
|
});
|
|
}
|
|
|
|
// ==== hash state (#f=&t=&s=&L=&cam=) ====
|
|
let pendingLayers = null;
|
|
function applyHash() {
|
|
const raw = location.hash.replace(/^#/, ''); if (!raw) return;
|
|
const p = new URLSearchParams(raw);
|
|
const f = p.get('f'); if (f && BODIES[f]) focus.id = focus.fromId = f;
|
|
const s = p.get('s'); if (s === 'true' || s === 'mega') { scale.setMode(s); syncScaleButtons(s); }
|
|
const t = p.get('t');
|
|
if (t) {
|
|
if (t[0] === '@') { const ms = Number(t.slice(1)); if (Number.isFinite(ms)) clock.simMs = clampTime(ms); }
|
|
else { const off = Number(t); if (Number.isFinite(off) && t.trim() !== '') clock.simMs = clampTime(Date.now() + off * 1000); }
|
|
}
|
|
const cam = p.get('cam');
|
|
if (cam) { const a = cam.split(',').map(Number); if (a.length === 3 && a.every(Number.isFinite)) pendingCam = a; }
|
|
if (p.has('L')) pendingLayers = p.get('L');
|
|
}
|
|
let pendingCam = null;
|
|
function syncScaleButtons(mode) {
|
|
for (const b of document.querySelectorAll('#scale-toggle button')) b.classList.toggle('active', b.dataset.mode === mode);
|
|
}
|
|
function serializeHash() {
|
|
const off = Math.round((clock.simMs - Date.now()) / 1000);
|
|
const t = Math.abs(off) <= CONFIG.time.liveThresholdSec ? '0' : `@${Math.round(clock.simMs)}`;
|
|
const dist = camera.position.length();
|
|
const heading = Math.atan2(camera.position.x, camera.position.z);
|
|
const pitch = Math.asin(lib.clamp(camera.position.y / (dist || 1), -1, 1));
|
|
const cam = `${dist.toFixed(2)},${heading.toFixed(3)},${pitch.toFixed(3)}`;
|
|
const on = ui.getLayerIds().filter((id) => ui.getLayerChecked(id)).join(',');
|
|
return `#f=${focus.id}&t=${t}&s=${scale.getMode()}&L=${on}&cam=${cam}`;
|
|
}
|
|
function startHashSync() {
|
|
setInterval(() => { const h = serializeHash(); if (h !== location.hash) history.replaceState(null, '', h); }, 1000);
|
|
}
|
|
|
|
// ==== render loop ====
|
|
let lastFrame = performance.now();
|
|
let fpsAcc = 0, fpsN = 0, lastHudMs = 0;
|
|
function animate(now) {
|
|
requestAnimationFrame(animate);
|
|
const dtWall = Math.min(now - lastFrame, 100); // clamp big gaps (tab switch)
|
|
lastFrame = now;
|
|
|
|
// advance sim clock
|
|
if (clock.playing) clock.simMs = clampTime(clock.simMs + currentRate() * dtWall);
|
|
clock.jd = lib.jdFromUnixMs(clock.simMs);
|
|
clock.isLive = Math.abs(clock.simMs - Date.now()) <= CONFIG.time.liveThresholdSec * 1000 && Math.abs(currentRate()) <= 1;
|
|
|
|
scale.update(dtWall);
|
|
|
|
// positions (truth → view), one central pass
|
|
computeBodyWorld();
|
|
|
|
// focus flight: lerp offset between old & new focus (both live), ease cam dist
|
|
if (focus.t < 1) {
|
|
focus.t = lib.clamp(focus.t + dtWall / CONFIG.camera.flightMs, 0, 1);
|
|
const e = lib.smoothstep(focus.t);
|
|
const a = bodyWorld[focus.fromId], b = bodyWorld[focus.id];
|
|
focusOffset.x = lib.lerp(a.x, b.x, e); focusOffset.y = lib.lerp(a.y, b.y, e); focusOffset.z = lib.lerp(a.z, b.z, e);
|
|
camera.position.setLength(lib.lerp(focus.camFrom, focus.camTo, e));
|
|
if (focus.t >= 1) controls.enabled = true;
|
|
} else {
|
|
const b = bodyWorld[focus.id];
|
|
focusOffset.x = b.x; focusOffset.y = b.y; focusOffset.z = b.z;
|
|
}
|
|
|
|
// sun mesh + light + glow (core scaffold)
|
|
const sunAbs = bodyWorld.sun;
|
|
const sunR = scale.drawRadius(BODIES.sun.radiusKm, effectiveE('sun'));
|
|
toLocal(sunAbs, sunMesh.position);
|
|
sunMesh.scale.setScalar(sunR);
|
|
sunLight.position.copy(sunMesh.position);
|
|
sunGlow.position.copy(sunMesh.position);
|
|
sunGlow.scale.setScalar(sunR * 7);
|
|
|
|
worldGroup.position.set(-focusOffset.x, -focusOffset.y, -focusOffset.z);
|
|
|
|
// layers
|
|
for (const l of activeLayers) l.onClockTick && l.onClockTick(clock.simMs, clock.jd, clock.isLive);
|
|
|
|
controls.update();
|
|
renderer.render(scene, camera);
|
|
labelRenderer.render(scene, camera);
|
|
|
|
// HUD throttled ~5 Hz
|
|
if (now - lastHudMs > 200) {
|
|
lastHudMs = now;
|
|
ui.setLiveChip(clock.isLive);
|
|
ui.setDateReadout(lib.formatUTC(clock.simMs), clock.isLive);
|
|
const f = (clock.simMs - CONFIG.time.minMs) / (CONFIG.time.maxMs - CONFIG.time.minMs);
|
|
document.getElementById('timeline-fill').style.width = `${f * 100}%`;
|
|
document.getElementById('timeline-cursor').style.left = `${f * 100}%`;
|
|
}
|
|
if (DEBUG) {
|
|
fpsAcc += 1000 / Math.max(dtWall, 1); fpsN++;
|
|
if (now - (window.__lastFps || 0) > 500) { window.__lastFps = now; document.getElementById('debug').textContent = `${(fpsAcc / fpsN).toFixed(0)} fps · ${scale.getMode().toUpperCase()} · P=${scale.getP().toFixed(3)}`; fpsAcc = 0; fpsN = 0; }
|
|
}
|
|
}
|
|
|
|
addEventListener('resize', () => {
|
|
camera.aspect = innerWidth / innerHeight; camera.updateProjectionMatrix();
|
|
renderer.setSize(innerWidth, innerHeight); labelRenderer.setSize(innerWidth, innerHeight);
|
|
});
|
|
|
|
// ==== boot ====
|
|
buildFocusMenu();
|
|
buildRateMenu();
|
|
wireControls();
|
|
if (DEBUG) document.getElementById('debug').hidden = false;
|
|
try { applyHash(); } catch (err) { console.warn('bad hash', err); }
|
|
setFocus(focus.id, false);
|
|
if (pendingCam) { camera.position.setLength(pendingCam[0]); } // heading/pitch restored loosely via length; orbit drag refines
|
|
|
|
loadLayers().then(() => {
|
|
if (pendingLayers !== null) {
|
|
const want = new Set(pendingLayers.split(',').filter(Boolean));
|
|
for (const id of ui.getLayerIds()) ui.setLayerChecked(id, want.has(id));
|
|
}
|
|
startHashSync();
|
|
});
|
|
|
|
requestAnimationFrame(animate);
|
|
window.__solargod = { scene, camera, ctx, activeLayers, clock, focus, scale, ephem, bodyWorld };
|