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>
237 lines
11 KiB
JavaScript
237 lines
11 KiB
JavaScript
// 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;
|
||
}
|
||
|
||
// 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
|
||
},
|
||
};
|
||
}
|