solargod/js/layers/events.js
monsterrobotparty bdba119484 🌘 closer: event band — the ±2 yr window gets its own full-width strip (opus)
SYZYGY's 104 diamonds clustered into ~1.6% of the 250-yr timeline (invisible in
deep time). Add a second strip under #timeline-outer that maps ONLY the computed
±2 yr window to full width — diamonds live there (same ev-* classes + click-jump),
and the main strip gets a brass bracket marking the window's true extent (1.6%
near, 0.5% in deep time). windowFraction exported + gated. Verified: 22/22
(+band gate); diamonds spread 0.4–98.7%; click-jump works in near AND deep mode;
zero console errors both epochs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 13:17:37 +10:00

237 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// SOLARGOD sky-events layer (Wave 2 · SYZYGY) — the almanac learns to predict.
// Pure geometry on the ephemeris: heliocentric longitude λ(body)=atan2(y,x) and
// the geocentric elongation angle give oppositions, conjunctions and greatest
// elongations with no network at all. Two plain exports (findEvents / nextEvent)
// so verify.html and node can gate the MATH directly; the default factory paints
// diamond marks on the time bar and jumps the clock on click.
//
// Longitude alignment (heliocentric, Earth E, planet P, both from the Sun):
// opposition (outer): normDegPM180(λp λe) crosses 0 — Sun·Earth·planet in line
// solar conj (outer): the same crosses 180
// inferior conj (inner): crosses 0 ; superior conj (inner): crosses 180
// greatest elongation (inner): local max of acos( (E·(PE)) / (|E||PE|) )
import * as ephem from '../ephem.js';
import { BODIES } from '../bodies.js';
import { normDegPM180, RAD, unixMsFromJd, formatUTCDate, fmtDuration } from '../lib.js';
// Bodies that show an opposition (outer) vs an elongation (inner). Pluto rides the
// small-body element set; every other id is an ephem.js planet key.
export const OUTER = ['mars', 'jupiter', 'saturn', 'uranus', 'neptune', 'pluto'];
export const INNER = ['mercury', 'venus'];
export function isOuter(id) { return OUTER.includes(id); }
export function isInner(id) { return INNER.includes(id); }
const PLUTO_EL = BODIES.pluto.elements;
const STEP_DAYS = 5; // coarse scan step; finest synodic (Mercury) still safe
const HOUR = 1 / 24; // bisection tolerance — refine crossings to < 1 hour
// ---- position helpers (scratch objects; all calls are sequential) ----
const _h = {}, _E = {}, _P = {};
function helioAt(id, jd, out) {
return id === 'pluto' ? ephem.smallBodyEcl(PLUTO_EL, jd, out) : ephem.helioEcl(id, jd, out);
}
function lonDeg(id, jd) {
helioAt(id, jd, _h);
return Math.atan2(_h.y, _h.x) * RAD;
}
// signed longitude gap planetEarth, wrapped to (180,180]; 0 = opp/inferior conj.
function deltaLon(id, jd) { return normDegPM180(lonDeg(id, jd) - lonDeg('earth', jd)); }
// shifted so a zero marks the 180° alignment (solar / superior conjunction).
function deltaLon180(id, jd) { return normDegPM180(lonDeg(id, jd) - lonDeg('earth', jd) - 180); }
// Geocentric elongation angle (SunEarthplanet), degrees.
function elongationDeg(id, jd) {
ephem.helioEcl('earth', jd, _E);
helioAt(id, jd, _P);
const sx = -_E.x, sy = -_E.y, sz = -_E.z; // Earth→Sun
const px = _P.x - _E.x, py = _P.y - _E.y, pz = _P.z - _E.z; // Earth→planet
const ns = Math.hypot(sx, sy, sz), np = Math.hypot(px, py, pz);
let c = (sx * px + sy * py + sz * pz) / (ns * np);
c = c > 1 ? 1 : c < -1 ? -1 : c;
return Math.acos(c) * RAD;
}
// East (evening star) when the planet leads the Sun in geocentric ecliptic longitude.
function elongSide(id, jd) {
ephem.helioEcl('earth', jd, _E);
helioAt(id, jd, _P);
const lonSun = Math.atan2(-_E.y, -_E.x) * RAD;
const lonP = Math.atan2(_P.y - _E.y, _P.x - _E.x) * RAD;
return normDegPM180(lonP - lonSun) >= 0 ? 'E' : 'W';
}
// ---- root finding ----
// Bisect a continuous angle fn(id,·) with opposite signs at [a,b] to < 1 hour.
function bisect(id, a, b, fn) {
let fa = fn(id, a);
for (let i = 0; i < 40 && b - a > HOUR; i++) {
const m = 0.5 * (a + b), fm = fn(id, m);
if (fa * fm <= 0) b = m; else { a = m; fa = fm; }
}
return 0.5 * (a + b);
}
// Coarse-scan fn for sign changes; the |Δf|<90 guard rejects the ±180 wrap of a
// wrapped angle (a real crossing steps only a few degrees per 5 days).
function scanZeros(id, jdStart, jdEnd, fn, onCross) {
let ja = jdStart, fa = fn(id, ja);
for (let jb = jdStart + STEP_DAYS; jb <= jdEnd + 1e-9; jb += STEP_DAYS) {
const fb = fn(id, jb);
if (fa !== 0 && fa * fb < 0 && Math.abs(fa - fb) < 90) onCross(bisect(id, ja, jb, fn));
ja = jb; fa = fb;
}
}
// Local maxima of elongation: numeric derivative flips + → , then bisect it to 0.
function scanElongMax(id, jdStart, jdEnd, onMax) {
const h = 0.5;
const der = (jd) => elongationDeg(id, jd + h) - elongationDeg(id, jd - h);
let ja = jdStart, da = der(ja);
for (let jb = jdStart + STEP_DAYS; jb <= jdEnd + 1e-9; jb += STEP_DAYS) {
const db = der(jb);
if (da > 0 && db < 0) {
let a = ja, b = jb;
for (let i = 0; i < 40 && b - a > HOUR; i++) { const m = 0.5 * (a + b); if (der(m) > 0) a = m; else b = m; }
onMax(0.5 * (a + b));
}
ja = jb; da = db;
}
}
// ---- event construction ----
function makeEvent(id, jd, kind) {
const name = BODIES[id].name;
if (kind === 'opposition') return { jd, type: 'opposition', bodies: [id], label: `${name} at opposition` };
if (kind === 'solar') return { jd, type: 'conjunction', bodies: [id], label: `${name} at solar conjunction` };
if (kind === 'inferior') return { jd, type: 'conjunction', bodies: [id], label: `${name} at inferior conjunction` };
if (kind === 'superior') return { jd, type: 'conjunction', bodies: [id], label: `${name} at superior conjunction` };
// greatest elongation — carry side + angle
const side = elongSide(id, jd), ang = elongationDeg(id, jd);
return { jd, type: side === 'E' ? 'elongation-east' : 'elongation-west', bodies: [id],
label: `${name} greatest elongation ${side === 'E' ? 'east' : 'west'} (${ang.toFixed(0)}°)` };
}
function scanBody(id, jdStart, jdEnd) {
const out = [];
const outer = isOuter(id);
scanZeros(id, jdStart, jdEnd, deltaLon, (jd) => out.push(makeEvent(id, jd, outer ? 'opposition' : 'inferior')));
scanZeros(id, jdStart, jdEnd, deltaLon180, (jd) => out.push(makeEvent(id, jd, outer ? 'solar' : 'superior')));
if (!outer) scanElongMax(id, jdStart, jdEnd, (jd) => out.push(makeEvent(id, jd, 'elongation')));
return out;
}
// PLAIN API — pure math, importable with zero Three.js in scope.
// All sky events in [jdStart, jdEnd], chronological.
export function findEvents(jdStart, jdEnd) {
const all = [];
for (const id of [...OUTER, ...INNER]) for (const e of scanBody(id, jdStart, jdEnd)) all.push(e);
all.sort((a, b) => a.jd - b.jd);
return all;
}
// Next headline event for a body strictly after jd — opposition (outer) or
// greatest elongation (inner). Widens the search span until one is found.
export function nextEvent(bodyId, jd) {
const outer = isOuter(bodyId);
const want = outer ? (e) => e.type === 'opposition' : (e) => e.type.startsWith('elongation');
let span = outer ? 820 : 500;
for (let tries = 0; tries < 4; tries++) {
const hit = scanBody(bodyId, jd, jd + span).filter((e) => e.jd > jd + 1e-6 && want(e)).sort((a, b) => a.jd - b.jd)[0];
if (hit) return hit;
span *= 2;
}
return null;
}
// Fraction of an instant within the event band's [winStart, winEnd] window (0..1).
// The band maps the ±2 yr window to full width regardless of near/deep epoch.
// Exported so verify.html gates the mapping without a DOM.
export function windowFraction(ms, winStartMs, winEndMs) {
return (ms - winStartMs) / ((winEndMs - winStartMs) || 1);
}
// ---- default factory: timeline diamonds + HUD row ----
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
// PERIHELION-O event band — the ±2 yr computed window mapped to FULL width in a
// second strip below the timeline, so diamonds spread out instead of clustering
// into ~1.6% of the 250-yr strip (worse in deep time). A bracket on the main
// strip marks the window's true extent, tying the two together.
const band = document.createElement('div');
band.id = 'event-band';
const marks = document.createElement('div');
marks.id = 'event-marks';
band.appendChild(marks);
strip.parentNode.insertBefore(band, strip.nextSibling); // directly under timeline-outer
const bracket = document.createElement('div');
bracket.id = 'event-window-bracket';
strip.appendChild(bracket);
const minMs = CONFIG.time.minMs, maxMs = CONFIG.time.maxMs, spanMs = maxMs - minMs;
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 = '';
const winStartMs = unixMsFromJd(winStart), winEndMs = unixMsFromJd(winEnd);
for (const e of events) {
const ms = unixMsFromJd(e.jd);
const f = windowFraction(ms, winStartMs, winEndMs); // position WITHIN the band
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);
}
// bracket: where the ±2 yr window sits within the full CONFIG.time range (tiny
// in near mode, tinier in deep time — that's the point of the band below it).
const bl = ((winStartMs - minMs) / spanMs) * 100;
const bw = ((winEndMs - winStartMs) / spanMs) * 100;
bracket.style.left = `${Math.min(Math.max(0, bl), 100).toFixed(4)}%`;
bracket.style.width = `${Math.max(0.5, Math.min(bw, 100)).toFixed(4)}%`;
}
return {
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
},
};
}