373 lines
15 KiB
JavaScript
373 lines
15 KiB
JavaScript
// Astronomical calculations for SethPC Astronomy page
|
||
// Moon phase: Jean Meeus "Astronomical Algorithms" simplified
|
||
// Sun rise/set: NOAA solar calculator algorithm
|
||
// Solstice/equinox: Meeus Table 27.a
|
||
|
||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||
|
||
const DEG = Math.PI / 180;
|
||
const RAD = 180 / Math.PI;
|
||
|
||
function frac(x) { return x - Math.floor(x); }
|
||
function mod360(x) { return ((x % 360) + 360) % 360; }
|
||
|
||
// Julian Day Number from calendar date (noon UT)
|
||
function julianDay(year, month, day) {
|
||
if (month <= 2) { year--; month += 12; }
|
||
const A = Math.floor(year / 100);
|
||
const B = 2 - A + Math.floor(A / 4);
|
||
return Math.floor(365.25 * (year + 4716)) + Math.floor(30.6001 * (month + 1)) + day + B - 1524.5;
|
||
}
|
||
|
||
function jdFromDate(date) {
|
||
return julianDay(date.getUTCFullYear(), date.getUTCMonth() + 1, date.getUTCDate()) + date.getUTCHours() / 24 + date.getUTCMinutes() / 1440;
|
||
}
|
||
|
||
function dateFromJD(jd) {
|
||
const z = Math.floor(jd + 0.5);
|
||
const f = jd + 0.5 - z;
|
||
let A = z;
|
||
if (z >= 2299161) {
|
||
const alpha = Math.floor((z - 1867216.25) / 36524.25);
|
||
A = z + 1 + alpha - Math.floor(alpha / 4);
|
||
}
|
||
const B = A + 1524;
|
||
const C = Math.floor((B - 122.1) / 365.25);
|
||
const D = Math.floor(365.25 * C);
|
||
const E = Math.floor((B - D) / 30.6001);
|
||
const day = B - D - Math.floor(30.6001 * E) + f;
|
||
const month = E < 14 ? E - 1 : E - 13;
|
||
const year = month > 2 ? C - 4716 : C - 4715;
|
||
return new Date(Date.UTC(year, month - 1, Math.floor(day)));
|
||
}
|
||
|
||
// ── Moon phase ────────────────────────────────────────────────────────────────
|
||
|
||
// Returns moon age in days (0 = new moon, ~14.77 = full moon, ~29.53 = back to new)
|
||
// and illumination fraction (0–1)
|
||
function moonPhase(date) {
|
||
// Reference new moon: Jan 6, 2000 18:14 UTC (JD 2451549.756)
|
||
const KNOWN_NEW_MOON_JD = 2451549.756;
|
||
const SYNODIC_MONTH = 29.53058867;
|
||
|
||
const jd = jdFromDate(date) + 0.5; // use noon of that day
|
||
const daysSinceNew = jd - KNOWN_NEW_MOON_JD;
|
||
const cycles = daysSinceNew / SYNODIC_MONTH;
|
||
const age = frac(cycles) * SYNODIC_MONTH; // 0..29.53
|
||
|
||
// Illumination: cos curve, 0 at new, 1 at full
|
||
const illumination = (1 - Math.cos(2 * Math.PI * age / SYNODIC_MONTH)) / 2;
|
||
|
||
return { age, illumination, cycles };
|
||
}
|
||
|
||
function moonPhaseName(age) {
|
||
const s = age / 29.53058867;
|
||
if (s < 0.025 || s >= 0.975) return "New Moon";
|
||
if (s < 0.25) return "Waxing Crescent";
|
||
if (s < 0.275) return "First Quarter";
|
||
if (s < 0.475) return "Waxing Gibbous";
|
||
if (s < 0.525) return "Full Moon";
|
||
if (s < 0.725) return "Waning Gibbous";
|
||
if (s < 0.75) return "Last Quarter";
|
||
return "Waning Crescent";
|
||
}
|
||
|
||
function moonPhaseGlyph(age) {
|
||
const s = age / 29.53058867;
|
||
if (s < 0.025 || s >= 0.975) return "🌑";
|
||
if (s < 0.25) return "🌒";
|
||
if (s < 0.275) return "🌓";
|
||
if (s < 0.475) return "🌔";
|
||
if (s < 0.525) return "🌕";
|
||
if (s < 0.725) return "🌖";
|
||
if (s < 0.75) return "🌗";
|
||
return "🌘";
|
||
}
|
||
|
||
// Find JD of next phase after given JD: phase 0=new,1=first,2=full,3=last
|
||
function nextMoonPhaseJD(jd, phase) {
|
||
const SYNODIC_MONTH = 29.53058867;
|
||
const KNOWN_NEW_MOON_JD = 2451549.756;
|
||
const daysSince = jd - KNOWN_NEW_MOON_JD;
|
||
const cycles = daysSince / SYNODIC_MONTH;
|
||
const phaseFrac = phase / 4;
|
||
let n = Math.floor(cycles - phaseFrac) + phaseFrac;
|
||
let targetJD = KNOWN_NEW_MOON_JD + n * SYNODIC_MONTH;
|
||
if (targetJD <= jd) targetJD += SYNODIC_MONTH;
|
||
return targetJD;
|
||
}
|
||
|
||
// ── Sun rise/set (NOAA algorithm) ────────────────────────────────────────────
|
||
|
||
function sunriseSunset(date, lat, lon) {
|
||
const jd = julianDay(date.getUTCFullYear(), date.getUTCMonth() + 1, date.getUTCDate());
|
||
const n = jd - 2451545.0 + 0.0008;
|
||
const Js = n - lon / 360;
|
||
const M = mod360(357.5291 + 0.98560028 * Js);
|
||
const C = 1.9148 * Math.sin(M * DEG) + 0.0200 * Math.sin(2 * M * DEG) + 0.0003 * Math.sin(3 * M * DEG);
|
||
const lam = mod360(M + C + 180 + 102.9372);
|
||
const Jt = 2451545.0 + Js + 0.0053 * Math.sin(M * DEG) - 0.0069 * Math.sin(2 * lam * DEG);
|
||
const sinDec = Math.sin(lam * DEG) * Math.sin(23.4397 * DEG);
|
||
const cosHa = (Math.sin(-0.833 * DEG) - Math.sin(lat * DEG) * sinDec) / (Math.cos(lat * DEG) * Math.cos(Math.asin(sinDec)));
|
||
|
||
if (cosHa < -1) return { rise: null, set: null, noon: jdToTime(Jt), alwaysUp: true };
|
||
if (cosHa > 1) return { rise: null, set: null, noon: jdToTime(Jt), alwaysDown: true };
|
||
|
||
const Ha = Math.acos(cosHa) * RAD / 360;
|
||
const Jrise = Jt - Ha;
|
||
const Jset = Jt + Ha;
|
||
|
||
return {
|
||
rise: jdToTime(Jrise),
|
||
set: jdToTime(Jset),
|
||
noon: jdToTime(Jt),
|
||
riseJD: Jrise,
|
||
setJD: Jset,
|
||
noonJD: Jt,
|
||
};
|
||
}
|
||
|
||
// Convert fractional JD to UTC HH:MM string
|
||
function jdToTime(jd) {
|
||
const totalMin = Math.round(((jd + 0.5) % 1) * 1440);
|
||
const h = Math.floor(totalMin / 60) % 24;
|
||
const m = totalMin % 60;
|
||
return `${String(h).padStart(2,"0")}:${String(m).padStart(2,"0")} UTC`;
|
||
}
|
||
|
||
function dayLengthStr(riseJD, setJD) {
|
||
const mins = Math.round((setJD - riseJD) * 1440);
|
||
const h = Math.floor(mins / 60);
|
||
const m = mins % 60;
|
||
return `${h}h ${String(m).padStart(2,"0")}m`;
|
||
}
|
||
|
||
function jdToDecimalTime(jd) {
|
||
const frac = ((jd + 0.5) % 1 + 1) % 1;
|
||
const dec = frac * 10;
|
||
const h = Math.floor(dec);
|
||
const m = Math.floor((dec - h) * 100);
|
||
return `${h}:${String(m).padStart(2, "0")}`;
|
||
}
|
||
|
||
function dayLengthDecimalStr(riseJD, setJD) {
|
||
const decHours = (setJD - riseJD) * 10;
|
||
const h = Math.floor(decHours);
|
||
const m = Math.floor((decHours - h) * 100);
|
||
return `${h}:${String(m).padStart(2, "0")}`;
|
||
}
|
||
|
||
// ── Seth date helpers ───────────────────────────────────────────────────────
|
||
function isLeapYear(year) {
|
||
return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
|
||
}
|
||
|
||
function getDayOfYearUTC(date) {
|
||
const y = date.getUTCFullYear();
|
||
const m = date.getUTCMonth() + 1;
|
||
const d = date.getUTCDate();
|
||
const cum = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334];
|
||
let doy = cum[m - 1] + d;
|
||
if (isLeapYear(y) && m > 2) doy++;
|
||
return { year: y, doy };
|
||
}
|
||
|
||
function toSethDate(year, dayOfYear) {
|
||
const leap = isLeapYear(year);
|
||
if (leap && dayOfYear === 60) return { type: "leapday", year };
|
||
const idx = (leap && dayOfYear > 60 ? dayOfYear - 1 : dayOfYear) - 1;
|
||
if (idx < 360) {
|
||
const month = Math.floor(idx / 36);
|
||
const dayInM = idx % 36;
|
||
return { type: "month", year, month, day: dayInM };
|
||
}
|
||
return { type: "holiday", year, holiday: idx - 360 };
|
||
}
|
||
|
||
function sethLog(sd) {
|
||
if (sd.type === "leapday") return `Leap Day ${sd.year}`;
|
||
if (sd.type === "holiday") return `H${sd.holiday}.${sd.year}`;
|
||
return `${String(sd.day).padStart(2, "0")}.${sd.month}.${sd.year}`;
|
||
}
|
||
|
||
// ── Solstice / Equinox (Meeus Table 27.a + correction) ───────────────────────
|
||
// Returns approximate JD for each event in a given year
|
||
// season: 0=March equinox, 1=June solstice, 2=September equinox, 3=December solstice
|
||
|
||
function seasonJD(year, season) {
|
||
const Y = (year - 2000) / 1000;
|
||
const JDE0s = [
|
||
// March equinox
|
||
2451623.80984 + 365242.37404*Y + 0.05169*Y*Y - 0.00411*Y*Y*Y - 0.00057*Y*Y*Y*Y,
|
||
// June solstice
|
||
2451716.56767 + 365241.62603*Y + 0.00325*Y*Y + 0.00888*Y*Y*Y - 0.00030*Y*Y*Y*Y,
|
||
// September equinox
|
||
2451810.05917 + 365242.01767*Y - 0.11575*Y*Y + 0.00337*Y*Y*Y + 0.00078*Y*Y*Y*Y,
|
||
// December solstice
|
||
2451900.05952 + 365242.74049*Y - 0.06223*Y*Y - 0.00823*Y*Y*Y + 0.00032*Y*Y*Y*Y,
|
||
];
|
||
return JDE0s[season];
|
||
}
|
||
|
||
function seasonEvents(year) {
|
||
const names = ["March Equinox", "June Solstice", "September Equinox", "December Solstice"];
|
||
const emojis = ["🌱", "☀️", "🍂", "❄️"];
|
||
return names.map((name, i) => {
|
||
const jd = seasonJD(year, i);
|
||
const d = dateFromJD(jd);
|
||
return { name, emoji: emojis[i], date: d, month: d.getUTCMonth() + 1, day: d.getUTCDate() };
|
||
});
|
||
}
|
||
|
||
// ── Moonrise / Moonset (simplified — horizon crossing estimate) ───────────────
|
||
// Uses a simple approximation: moon moves ~13.18°/day, rises ~50min later each day.
|
||
// Good enough for a display page; full computation needs perturbation terms.
|
||
function moonriseApprox(date, lat, lon) {
|
||
// Approximate: moon rises ~50 min later each day than previous
|
||
// Anchor: use sun rise/set as rough guide, offset by moon's daily retardation
|
||
// For a simple display we just note this is approximate
|
||
return null; // placeholder — show "~" note instead
|
||
}
|
||
|
||
// ── DOM ───────────────────────────────────────────────────────────────────────
|
||
|
||
// Parse ?date=YYYY-MM-DD from URL, default to today
|
||
function parseDate() {
|
||
const params = new URLSearchParams(window.location.search);
|
||
const ds = params.get("date");
|
||
if (ds) {
|
||
const [y, m, d] = ds.split("-").map(Number);
|
||
if (y && m && d) return new Date(Date.UTC(y, m - 1, d));
|
||
}
|
||
const now = new Date();
|
||
return new Date(Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()));
|
||
}
|
||
|
||
function dateToParam(date) {
|
||
const y = date.getUTCFullYear();
|
||
const m = String(date.getUTCMonth() + 1).padStart(2, "0");
|
||
const d = String(date.getUTCDate()).padStart(2, "0");
|
||
return `${y}-${m}-${d}`;
|
||
}
|
||
|
||
function stepDate(date, days) {
|
||
const d = new Date(date);
|
||
d.setUTCDate(d.getUTCDate() + days);
|
||
return d;
|
||
}
|
||
|
||
function formatDate(date) {
|
||
return date.toLocaleDateString("en-US", { weekday: "long", year: "numeric", month: "long", day: "numeric", timeZone: "UTC" });
|
||
}
|
||
|
||
// Default lat/lon: roughly central US (no geolocation, just reasonable default)
|
||
// Will use user's timezone offset to pick a reasonable longitude
|
||
function guessLon() {
|
||
const offsetHours = -new Date().getTimezoneOffset() / 60;
|
||
return offsetHours * 15; // rough: UTC offset * 15° per hour
|
||
}
|
||
|
||
const LAT = 40.0; // roughly mid-US latitude
|
||
const LON = guessLon();
|
||
|
||
let currentDate = parseDate();
|
||
|
||
function render() {
|
||
const date = currentDate;
|
||
|
||
// Update URL without reload
|
||
const newUrl = `${window.location.pathname}?date=${dateToParam(date)}`;
|
||
history.replaceState(null, "", newUrl);
|
||
|
||
document.getElementById("dateTitle").textContent = formatDate(date);
|
||
|
||
// Moon
|
||
const { age, illumination } = moonPhase(date);
|
||
document.getElementById("moonGlyph").textContent = moonPhaseGlyph(age);
|
||
document.getElementById("moonName").textContent = moonPhaseName(age);
|
||
document.getElementById("moonPct").textContent =
|
||
`${Math.round(illumination * 100)}% illuminated · ${age.toFixed(1)} days old`;
|
||
document.getElementById("moonAge").textContent = `${age.toFixed(1)} days`;
|
||
|
||
// Next new & full
|
||
const jd = jdFromDate(date);
|
||
const nextNewJD = nextMoonPhaseJD(jd, 0);
|
||
const nextFullJD = nextMoonPhaseJD(jd, 2);
|
||
const nextNewDate = dateFromJD(nextNewJD);
|
||
const nextFullDate = dateFromJD(nextFullJD);
|
||
const nextNewSeth = toSethDate(getDayOfYearUTC(nextNewDate).year, getDayOfYearUTC(nextNewDate).doy);
|
||
const nextFullSeth = toSethDate(getDayOfYearUTC(nextFullDate).year, getDayOfYearUTC(nextFullDate).doy);
|
||
document.getElementById("nextNewSeth").textContent = sethLog(nextNewSeth);
|
||
document.getElementById("nextNewGreg").textContent = nextNewDate.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" });
|
||
document.getElementById("nextFullSeth").textContent = sethLog(nextFullSeth);
|
||
document.getElementById("nextFullGreg").textContent = nextFullDate.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" });
|
||
|
||
// Sun
|
||
const sun = sunriseSunset(date, LAT, LON);
|
||
document.getElementById("sunriseSeth").textContent = sun.riseJD ? jdToDecimalTime(sun.riseJD) : "—";
|
||
document.getElementById("sunriseGreg").textContent = sun.rise || (sun.alwaysUp ? "Midnight sun" : "Below horizon");
|
||
document.getElementById("sunsetSeth").textContent = sun.setJD ? jdToDecimalTime(sun.setJD) : "—";
|
||
document.getElementById("sunsetGreg").textContent = sun.set || (sun.alwaysUp ? "Midnight sun" : "Below horizon");
|
||
document.getElementById("solarNoonSeth").textContent = sun.noonJD ? jdToDecimalTime(sun.noonJD) : "—";
|
||
document.getElementById("solarNoonGreg").textContent = sun.noon;
|
||
document.getElementById("dayLenSeth").textContent = sun.rise && sun.set ? dayLengthDecimalStr(sun.riseJD, sun.setJD) : "—";
|
||
document.getElementById("dayLenGreg").textContent = sun.rise && sun.set ? dayLengthStr(sun.riseJD, sun.setJD) : "—";
|
||
document.getElementById("moonAgeSeth").textContent = `${(age * (10 / 29.53058867)).toFixed(2)} days`;
|
||
document.getElementById("moonAgeGreg").textContent = `${age.toFixed(1)} days`;
|
||
|
||
|
||
|
||
// Year events
|
||
const year = date.getUTCFullYear();
|
||
const events = seasonEvents(year);
|
||
const SYNODIC = 29.53058867;
|
||
const KNOWN_NEW_MOON_JD = 2451549.756;
|
||
|
||
// Add major moon phases for the year
|
||
const moonEvents = [];
|
||
let testJD = julianDay(year, 1, 1);
|
||
const endJD = julianDay(year, 12, 31);
|
||
for (let phase = 0; phase < 4; phase++) {
|
||
let pJD = nextMoonPhaseJD(testJD - SYNODIC, phase);
|
||
while (pJD <= endJD) {
|
||
const d = dateFromJD(pJD);
|
||
if (d.getUTCFullYear() === year) {
|
||
const phaseNames = ["🌑 New Moon", "🌓 First Quarter", "🌕 Full Moon", "🌗 Last Quarter"];
|
||
moonEvents.push({ name: phaseNames[phase], date: d, jd: pJD });
|
||
}
|
||
pJD += SYNODIC;
|
||
}
|
||
}
|
||
moonEvents.sort((a, b) => a.jd - b.jd);
|
||
|
||
const allEvents = [
|
||
...events.map(e => ({ name: `${e.emoji} ${e.name}`, date: e.date, jd: jdFromDate(e.date) })),
|
||
...moonEvents,
|
||
].sort((a, b) => a.jd - b.jd);
|
||
|
||
const ul = document.getElementById("yearEvents");
|
||
ul.innerHTML = "";
|
||
allEvents.forEach(ev => {
|
||
const li = document.createElement("li");
|
||
const ds = ev.date.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", timeZone: "UTC" });
|
||
const sy = getDayOfYearUTC(ev.date);
|
||
const sseth = toSethDate(sy.year, sy.doy);
|
||
const sds = sethLog(sseth);
|
||
const isToday = dateToParam(ev.date) === dateToParam(date);
|
||
li.innerHTML = `<span class="event-name"${isToday ? ' style="color:var(--accent)"' : ""}>${ev.name}</span><span class="event-dual"><span>${sds}</span><span>${ds}</span></span>`;
|
||
ul.appendChild(li);
|
||
});
|
||
}
|
||
|
||
document.getElementById("prevDay").addEventListener("click", () => {
|
||
currentDate = stepDate(currentDate, -1);
|
||
render();
|
||
});
|
||
document.getElementById("nextDay").addEventListener("click", () => {
|
||
currentDate = stepDate(currentDate, +1);
|
||
render();
|
||
});
|
||
|
||
render();
|