solargod/js/layers/events.js

213 lines
9.3 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;
}
// ---- 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
},
};
}