// 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();