// 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 = `${ev.name}${sds}${ds}`;
ul.appendChild(li);
});
}
document.getElementById("prevDay").addEventListener("click", () => {
currentDate = stepDate(currentDate, -1);
render();
});
document.getElementById("nextDay").addEventListener("click", () => {
currentDate = stepDate(currentDate, +1);
render();
});
render();