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