Files
clock-site/astro.js
T

469 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 (01)
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" });
}
function loadLocation() {
const fallback = { label: "Home", lat: 40.0, lon: -75.0 };
function fromRaw(raw) {
if (!raw) return null;
try {
const d = JSON.parse(raw);
const lat = Number(d.lat);
const lon = Number(d.lon);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
return { label: d.label || "Home", lat, lon };
} catch {
return null;
}
}
const sessionLoc = fromRaw(sessionStorage.getItem("astro_location"));
if (sessionLoc) return sessionLoc;
const cookieMatch = document.cookie.split("; ").find(x => x.startsWith("astro_location="));
const cookieRaw = cookieMatch ? decodeURIComponent(cookieMatch.slice("astro_location=".length)) : "";
const cookieLoc = fromRaw(cookieRaw);
if (cookieLoc) {
sessionStorage.setItem("astro_location", JSON.stringify(cookieLoc));
return cookieLoc;
}
return fallback;
}
function saveLocation(loc) {
const payload = JSON.stringify(loc);
sessionStorage.setItem("astro_location", payload);
document.cookie = `astro_location=${encodeURIComponent(payload)}; path=/; SameSite=Lax`;
}
let locationState = loadLocation();
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);
document.getElementById("locStatus").textContent = `${locationState.label}: ${locationState.lat.toFixed(4)}, ${locationState.lon.toFixed(4)}`;
// 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`;
// 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, locationState.lat, locationState.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);
});
}
function initLocationUI() {
const labelEl = document.getElementById("locLabel");
const zipEl = document.getElementById("locZip");
const latEl = document.getElementById("locLat");
const lonEl = document.getElementById("locLon");
const statusEl = document.getElementById("locStatus");
labelEl.value = locationState.label || "Home";
latEl.value = Number(locationState.lat).toFixed(4);
lonEl.value = Number(locationState.lon).toFixed(4);
document.getElementById("locSave").addEventListener("click", () => {
const lat = Number(latEl.value);
const lon = Number(lonEl.value);
if (!Number.isFinite(lat) || !Number.isFinite(lon) || lat < -90 || lat > 90 || lon < -180 || lon > 180) {
statusEl.textContent = "Invalid latitude/longitude";
return;
}
locationState = { label: labelEl.value.trim() || "Home", lat, lon };
saveLocation(locationState);
latEl.value = Number(locationState.lat).toFixed(4);
lonEl.value = Number(locationState.lon).toFixed(4);
statusEl.textContent = `Saved: ${locationState.label}`;
render();
});
document.getElementById("locUseZip").addEventListener("click", async () => {
const zip = (zipEl.value || "").trim();
if (!/^\d{5}(-\d{4})?$/.test(zip)) {
statusEl.textContent = "Enter valid US ZIP";
return;
}
statusEl.textContent = "Looking up ZIP...";
try {
const r = await fetch(`https://api.zippopotam.us/us/${encodeURIComponent(zip)}`);
if (!r.ok) throw new Error("zip");
const data = await r.json();
const p = data?.places?.[0];
if (!p) throw new Error("zip");
latEl.value = Number(p.latitude).toFixed(4);
lonEl.value = Number(p.longitude).toFixed(4);
if (!labelEl.value.trim()) labelEl.value = `${p["place name"]}, ${p["state abbreviation"]}`;
statusEl.textContent = "ZIP loaded";
} catch {
statusEl.textContent = "ZIP lookup failed";
}
});
document.getElementById("locGeo").addEventListener("click", () => {
if (!navigator.geolocation) {
statusEl.textContent = "Geolocation unsupported";
return;
}
statusEl.textContent = "Getting location...";
navigator.geolocation.getCurrentPosition(
(pos) => {
latEl.value = String(pos.coords.latitude.toFixed(4));
lonEl.value = String(pos.coords.longitude.toFixed(4));
if (!labelEl.value.trim()) labelEl.value = "Current Location";
statusEl.textContent = "Location loaded";
},
() => { statusEl.textContent = "Location denied/unavailable"; },
{ enableHighAccuracy: true, timeout: 10000 }
);
});
}
document.getElementById("prevDay").addEventListener("click", () => {
currentDate = stepDate(currentDate, -1);
render();
});
document.getElementById("nextDay").addEventListener("click", () => {
currentDate = stepDate(currentDate, +1);
render();
});
initLocationUI();
render();