merge perihelion/e-syzygy → main: computed sky events, gate green (append-append verify.html resolved) (fable)
This commit is contained in:
commit
c794ca1182
@ -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; }
|
||||
|
||||
@ -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
212
js/layers/events.js
Normal 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·(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
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -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() {
|
||||
|
||||
50
verify.html
50
verify.html
@ -234,6 +234,56 @@
|
||||
finish();
|
||||
}
|
||||
neoCloseApproachGate();
|
||||
|
||||
/* 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();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user