🌓 syzygy: computed sky events — oppositions, conjunctions & greatest elongations from ephem longitudes; timeline diamonds that jump the clock; almanac next-event prediction (opus)

This commit is contained in:
monsterrobotparty 2026-07-16 09:52:39 +10:00
parent e7bcf424d7
commit 9aeb2bbb5f
5 changed files with 298 additions and 0 deletions

View File

@ -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; }

View File

@ -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('');
}

212
js/layers/events.js Normal file
View File

@ -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·(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
},
};
}

View File

@ -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() {

View File

@ -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, 120160 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 — 6080 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 6080 d',
ok && gap >= 60 && gap <= 80, ok ? `gap = ${gap.toFixed(1)} d (tol 6080)` : '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();
}
</script>
</body>
</html>