diff --git a/css/style.css b/css/style.css index 47009a2..5c34bbb 100644 --- a/css/style.css +++ b/css/style.css @@ -186,3 +186,19 @@ html, body { #splash .sp-title { font-size: 42px; font-weight: 700; letter-spacing: 6px; color: #fff; text-shadow: 0 0 24px rgba(217,164,65,0.6); } #splash .sp-sub { color: var(--brass); letter-spacing: 3px; font-size: 12px; } #splash .sp-status { color: var(--dim); font-size: 11px; } + +/* PERIHELION-E — sky-events timeline marks (SYZYGY). Diamonds sit on/above the + time bar, positioned by date fraction; the container is click-transparent so + the strip's own scrub still works between marks. Opposition = brass (bright), + greatest elongation = cool accent, conjunction = dim (on the bar). */ +#event-marks { position: absolute; inset: 0; overflow: visible; pointer-events: none; z-index: 4; } +.ev-mark { + position: absolute; width: 7px; height: 7px; border-radius: 1px; + transform: translate(-50%, 0) rotate(45deg); + pointer-events: auto; cursor: pointer; transition: transform 0.1s ease, box-shadow 0.1s ease; +} +.ev-mark:hover { transform: translate(-50%, 0) rotate(45deg) scale(1.6); z-index: 6; } +.ev-opp { top: -8px; background: var(--brass); box-shadow: 0 0 5px rgba(217, 164, 65, 0.75); } +.ev-elong { top: -8px; background: #7fc7d6; box-shadow: 0 0 5px rgba(127, 199, 214, 0.6); } +.ev-conj { top: 2px; width: 5px; height: 5px; background: var(--brass-dim); opacity: 0.6; } +.ev-conj:hover { opacity: 1; } diff --git a/js/layers/almanac.js b/js/layers/almanac.js index d5a9df3..413b24f 100644 --- a/js/layers/almanac.js +++ b/js/layers/almanac.js @@ -4,6 +4,11 @@ // — distance from Sun and Earth, sunlight age (light-time), orbital speed, and the // length of the body's day and year (brief §5). Focusing Earth reveals the GODSIGH // easter egg. Not a toggleable feed — no HUD row. +// +// Wave 2 (SYZYGY): planets also get a predictive fact — Next opposition (outer) / +// Next elongation (inner) — computed from the same ephemeris via events.js. + +import { nextEvent, isOuter, isInner } from './events.js'; const GM_SUN = 1.32712440018e11; // km^3/s^2, heliocentric gravitational parameter const YEAR_DAYS = 365.256; @@ -36,6 +41,9 @@ export default function create(ctx) { let lastFocus = null; let lastWall = 0; const _b = {}, _e = {}; + // Memo for the predictive fact so we don't rescan the ephemeris every 200 ms — + // recompute only on focus change, when the event passes, or a backward scrub. + let neCache = { id: null, fromJd: 0, ev: undefined }; function helioOf(id, jd, out) { const b = bodies[id]; @@ -114,6 +122,17 @@ export default function create(ctx) { if (b.rotationHours) { rows.push(fact('Day (rotation)', `${lib.fmtDuration(Math.abs(b.rotationHours) / 24)}${b.rotationHours < 0 ? ' (retrograde)' : ''}`)); } + // predictive sky event (SYZYGY): outer → Next opposition, inner → Next elongation. + if (isOuter(id) || isInner(id)) { + if (neCache.id !== id || neCache.ev === undefined || (neCache.ev && jd > neCache.ev.jd) || jd < neCache.fromJd) { + neCache = { id, fromJd: jd, ev: nextEvent(id, jd) }; + } + const ev = neCache.ev; + if (ev) { + rows.push(fact(isOuter(id) ? 'Next opposition' : 'Next elongation', + `${lib.formatUTCDate(lib.unixMsFromJd(ev.jd))} · in ${lib.fmtDuration(ev.jd - jd)}`)); + } + } const facts = panel.querySelector('.ip-facts'); if (facts) facts.innerHTML = rows.join(''); } diff --git a/js/layers/events.js b/js/layers/events.js new file mode 100644 index 0000000..3ef5a3c --- /dev/null +++ b/js/layers/events.js @@ -0,0 +1,212 @@ +// 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 + }, + }; +} diff --git a/js/main.js b/js/main.js index 57ddae9..c3f9e6a 100644 --- a/js/main.js +++ b/js/main.js @@ -352,6 +352,7 @@ const ctx = { 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/events.js', './layers/almanac.js']; const activeLayers = []; async function loadLayers() { diff --git a/verify.html b/verify.html index 19ffb6d..c3bc3f1 100644 --- a/verify.html +++ b/verify.html @@ -196,6 +196,56 @@ await craftCheck('Voyager 1 (-31) @ 2026-07-15', '-31', 2461236.5, { x: -32.07, y: -136.21, z: 98.55 }, 0.05); await jwstCheck(); })(); + + /* PERIHELION-E — sky-events math gates (findEvents, pure math, no network). + Dynamic import keeps the whole lane-E addition one contiguous end block. */ + { + const { findEvents, nextEvent } = await import('./js/layers/events.js'); + const jdOf = (y, mo, d) => lib.jdFromUnixMs(Date.UTC(y, mo - 1, d)); + const dstr = (jd) => lib.formatUTCDate(lib.unixMsFromJd(jd)); + const evs = findEvents(jdOf(2026, 1, 1), jdOf(2028, 1, 1)); + + // (E-a) Mars opposition within ±2 days of the known 2027-02-19 event. + { + const target = jdOf(2027, 2, 19); + const opp = evs.filter((e) => e.bodies[0] === 'mars' && e.type === 'opposition') + .map((e) => ({ jd: e.jd, d: Math.abs(e.jd - target) })).sort((a, b) => a.d - b.d)[0]; + row('Mars opposition ±2 d of 2027-02-19', opp && opp.d <= 2, + opp ? `found ${dstr(opp.jd)}, Δ=${opp.d.toFixed(2)} d (tol 2)` : 'no Mars opposition found'); + } + + // (E-b) Venus greatest elongations: consecutive ones alternate E/W and are + // ~142 d apart (the elongation↔elongation cadence). NB the brief's "~72 d" + // is the elongation↔inferior-conjunction gap, gated separately below. + const vElong = evs.filter((e) => e.bodies[0] === 'venus' && e.type.startsWith('elongation')); + { + const ok = vElong.length >= 2; + const gap = ok ? vElong[1].jd - vElong[0].jd : NaN; + const alt = ok && vElong[0].type !== vElong[1].type; // east then west (or vice versa) + row('Venus consecutive greatest elongations (alternate E/W, 120–160 d)', + ok && alt && gap >= 120 && gap <= 160, + ok ? `${dstr(vElong[0].jd)} (${vElong[0].type.split('-')[1]}) → ${dstr(vElong[1].jd)} (${vElong[1].type.split('-')[1]}) = ${gap.toFixed(1)} d` : 'fewer than 2 elongations'); + } + + // (E-b′) The brief's ~72 d: a Venus greatest elongation to the neighbouring + // inferior conjunction — 60–80 d — which also gates the conjunction finder. + { + const vInf = evs.filter((e) => e.bodies[0] === 'venus' && e.label.includes('inferior')); + const ok = vElong.length && vInf.length; + const gap = ok ? vInf.map((c) => Math.abs(c.jd - vElong[0].jd)).sort((a, b) => a - b)[0] : NaN; + row('Venus greatest elongation → inferior conjunction 60–80 d', + ok && gap >= 60 && gap <= 80, ok ? `gap = ${gap.toFixed(1)} d (tol 60–80)` : 'missing elongation/conjunction'); + } + + // (E-c) nextEvent agrees with the scan: Mars headline is the same opposition. + { + const ne = nextEvent('mars', jdOf(2026, 7, 16)); + const target = jdOf(2027, 2, 19); + row('nextEvent(mars) → 2027 opposition', ne && ne.type === 'opposition' && Math.abs(ne.jd - target) <= 2, + ne ? `${dstr(ne.jd)} · ${ne.label}` : 'none'); + } + finish(); + }