Files
clock-site/calendar.js
T

641 lines
32 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.
// Seth Calendar — calendar view
//
// 10 months × 36 days (6 weeks × 6 days), then 5 or 6 holiday days.
// Jan 1 = Month 1 Day 1. Same year numbers as Gregorian.
// ── Moon phase rendering ──────────────────────────────────────────────────────
const KNOWN_NEW_MOON_JD = 2451549.756; // Jan 6, 2000 18:14 UTC
const SYNODIC_MONTH = 29.53058867;
function jdFromCalDate(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;
}
// Returns { age (029.53), illumination (01), waxing (bool) }
function moonPhaseForDate(jsDate) {
const jd = jdFromCalDate(jsDate.getFullYear(), jsDate.getMonth() + 1, jsDate.getDate()) + 0.5;
const daysSince = jd - KNOWN_NEW_MOON_JD;
const cycles = daysSince / SYNODIC_MONTH;
const age = (cycles - Math.floor(cycles)) * SYNODIC_MONTH;
const illumination = (1 - Math.cos(2 * Math.PI * age / SYNODIC_MONTH)) / 2;
const waxing = age < SYNODIC_MONTH / 2;
return { age, illumination, waxing };
}
// Draw a moon phase into a canvas element (size x size pixels)
function drawMoon(canvas, illumination, waxing) {
const size = canvas.width;
const r = size / 2 - 0.5;
const cx = size / 2;
const cy = size / 2;
const ctx = canvas.getContext("2d");
ctx.clearRect(0, 0, size, size);
// Dark side color
const dark = "#1a1a2e";
// Light side color
const light = "#e8dfc0";
// Draw full dark circle first
ctx.beginPath();
ctx.arc(cx, cy, r, 0, 2 * Math.PI);
ctx.fillStyle = dark;
ctx.fill();
// The terminator: lit fraction maps to an ellipse x-scale
// illumination 0 = new (all dark), 0.5 = quarter (semi-circle), 1 = full (all light)
// Ellipse x-radius: at 0.5 it's 0, at 0 or 1 it's r
// Phase angle: 0=new, π/2=first quarter, π=full, 3π/2=last quarter
const phaseAngle = (age => 2 * Math.PI * age / SYNODIC_MONTH)(
waxing ? Math.acos(1 - 2 * illumination) * SYNODIC_MONTH / (2 * Math.PI)
: (SYNODIC_MONTH / 2) + Math.acos(2 * illumination - 1) * SYNODIC_MONTH / (2 * Math.PI)
);
// Simpler direct approach: draw lit half-circle + terminator ellipse
// Lit side: right if waxing, left if waning
// x-scale of terminator ellipse: cos(phase_angle) where angle 0=new, π=full
const angle = 2 * Math.PI * (waxing
? illumination < 0.5 ? illumination : illumination
: illumination);
// Direct: ellipse terminator x-width = (1 - 2*illumination)*r for waxing,
// (2*illumination - 1)*r for waning
const termX = Math.abs(1 - 2 * illumination) * r;
const litOnRight = waxing;
// Draw lit semicircle on the appropriate side
ctx.save();
ctx.beginPath();
ctx.arc(cx, cy, r, -Math.PI / 2, Math.PI / 2, !litOnRight);
ctx.fillStyle = light;
ctx.fill();
ctx.restore();
// Draw terminator ellipse to carve/extend into the lit side
ctx.save();
ctx.beginPath();
// Clip to the dark half
ctx.arc(cx, cy, r + 1, -Math.PI / 2, Math.PI / 2, litOnRight);
ctx.lineTo(cx, cy - r - 1);
ctx.closePath();
ctx.clip();
// Fill terminator ellipse (same as lit color if < half, dark if > half)
ctx.beginPath();
ctx.ellipse(cx, cy, termX, r, 0, 0, 2 * Math.PI);
ctx.fillStyle = illumination < 0.5 ? dark : light;
ctx.fill();
ctx.restore();
// Thin border
ctx.beginPath();
ctx.arc(cx, cy, r, 0, 2 * Math.PI);
ctx.strokeStyle = "rgba(255,255,255,0.15)";
ctx.lineWidth = 0.5;
ctx.stroke();
}
function makeMoonCanvas(jsDate, size) {
const { illumination, waxing } = moonPhaseForDate(jsDate);
const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
canvas.style.width = size + "px";
canvas.style.height = size + "px";
drawMoon(canvas, illumination, waxing);
return canvas;
}
const GREG_MONTHS = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
const GREG_WEEKDAYS = ["Su","M","T","W","Tr","F","S"];
const WEEK_DAY_LABELS = ["D0","D1","D2","D3","D4","D5"];
// --- Holidays ---
// Fixed: { month, day, name, emoji }
// Floating: computed per year via buildFloatingHolidays(year)
const FIXED_HOLIDAYS = [
{ month: 1, day: 1, name: "New Year's Day", emoji: "🎆", url: "https://en.wikipedia.org/wiki/New_Year%27s_Day" },
{ month: 2, day: 13, name: "1 1 1 Day", emoji: "1️⃣" },
{ month: 3, day: 28, name: "2 2 2 Day", emoji: "2️⃣" },
{ month: 5, day: 10, name: "3 3 3 Day", emoji: "3️⃣" },
{ month: 6, day: 22, name: "4 4 4 Day", emoji: "4️⃣" },
{ month: 8, day: 4, name: "5 5 5 Day", emoji: "5️⃣" },
{ month: 4, day: 20, name: "420", emoji: "🌿", url: "https://en.wikipedia.org/wiki/420_(cannabis_culture)" },
{ month: 6, day: 6, name: "4 2 0 Day", emoji: "🌿" },
{ month: 7, day: 10, name: "710", emoji: "🍯", url: "https://weedmaps.com/learn/cannabis-and-its-evolution/everything-you-need-to-know-about-710" },
{ month: 9, day: 16, name: "7 1 0 Day", emoji: "🍯" },
{ month: 1, day: 2, name: "Science Fiction Day", emoji: "🚀", url: "https://en.wikipedia.org/wiki/Isaac_Asimov" },
{ month: 1, day: 3, name: "Festival of Sleep Day", emoji: "😴" },
{ month: 1, day: 16, name: "Nothing Day", emoji: "🕳️", url: "https://en.wikipedia.org/wiki/National_Nothing_Day" },
{ month: 1, day: 20, name: "Penguin Awareness Day", emoji: "🐧", url: "https://en.wikipedia.org/wiki/Penguin" },
{ month: 1, day: 21, name: "Squirrel Appreciation Day", emoji: "🐿️", url: "https://en.wikipedia.org/wiki/Squirrel" },
{ month: 1, day: 25, name: "Opposite Day", emoji: "🙃", url: "https://en.wikipedia.org/wiki/Opposite_Day" },
{ month: 1, day: 27, name: "Chocolate Cake Day", emoji: "🎂", url: "https://en.wikipedia.org/wiki/Chocolate_cake" },
{ month: 1, day: 27, name: "e-Day", emoji: "📐", url: "https://en.wikipedia.org/wiki/E_(mathematical_constant)" },
{ month: 1, day: 31, name: "Backwards Day", emoji: "🔄" },
{ month: 2, day: 2, name: "Groundhog Day", emoji: "🦔", url: "https://en.wikipedia.org/wiki/Groundhog_Day" },
{ month: 2, day: 6, name: "Work Naked Day", emoji: "😳" },
{ month: 2, day: 7, name: "Eat Ice Cream for Breakfast Day", emoji: "🍦" },
{ month: 2, day: 12, name: "Darwin Day", emoji: "🐒", url: "https://en.wikipedia.org/wiki/Darwin_Day" },
{ month: 2, day: 14, name: "Valentine's Day", emoji: "❤️", url: "https://en.wikipedia.org/wiki/Valentine%27s_Day" },
{ month: 2, day: 17, name: "Random Act of Kindness Day", emoji: "🤝", url: "https://en.wikipedia.org/wiki/Random_act_of_kindness" },
{ month: 2, day: 22, name: "Be Humble Day", emoji: "🙇" },
{ month: 2, day: 28, name: "Public Sleeping Day", emoji: "💤" },
{ month: 3, day: 4, name: "March Forth and Do Something Day", emoji: "🚶" },
{ month: 3, day: 9, name: "Napping Day", emoji: "😪", url: "https://en.wikipedia.org/wiki/Napping" },
{ month: 3, day: 10, name: "Mario Day", emoji: "🍄", url: "https://en.wikipedia.org/wiki/Mario_Day" },
{ month: 3, day: 14, name: "Pi Day", emoji: "🥧", url: "https://en.wikipedia.org/wiki/Pi_Day" },
{ month: 3, day: 15, name: "Everything You Think is Wrong Day", emoji: "🤔" },
{ month: 3, day: 17, name: "St. Patrick's Day", emoji: "🍀", url: "https://en.wikipedia.org/wiki/Saint_Patrick%27s_Day" },
{ month: 3, day: 22, name: "International Goof Off Day", emoji: "🤪" },
{ month: 3, day: 23, name: "Puppy Day", emoji: "🐶", url: "https://en.wikipedia.org/wiki/Dog" },
{ month: 3, day: 26, name: "Make Up Your Own Holiday Day", emoji: "📅" },
{ month: 4, day: 1, name: "April Fools' Day", emoji: "🃏", url: "https://en.wikipedia.org/wiki/April_Fools%27_Day" },
{ month: 4, day: 5, name: "First Contact Day", emoji: "👽", url: "https://en.wikipedia.org/wiki/Star_Trek:_First_Contact" },
{ month: 4, day: 12, name: "Grilled Cheese Day", emoji: "🧀", url: "https://en.wikipedia.org/wiki/Grilled_cheese" },
{ month: 4, day: 12, name: "Yuri's Night", emoji: "🛸", url: "https://en.wikipedia.org/wiki/Yuri%27s_Night" },
{ month: 4, day: 16, name: "Wear Pajamas to Work Day", emoji: "🛌" },
{ month: 4, day: 22, name: "Jelly Bean Day", emoji: "🫘", url: "https://en.wikipedia.org/wiki/Jelly_bean" },
{ month: 4, day: 23, name: "Impossible Astronaut Day", emoji: "🕐", url: "https://en.wikipedia.org/wiki/The_Impossible_Astronaut" },
{ month: 5, day: 1, name: "No Pants Day", emoji: "👖", url: "https://en.wikipedia.org/wiki/No_Pants_Day" },
{ month: 5, day: 4, name: "Star Wars Day", emoji: "⚔️", url: "https://en.wikipedia.org/wiki/Star_Wars_Day" },
{ month: 5, day: 5, name: "Cinco de Mayo", emoji: "🌮", url: "https://en.wikipedia.org/wiki/Cinco_de_Mayo" },
{ month: 5, day: 9, name: "Lost Sock Memorial Day", emoji: "🧦" },
{ month: 5, day: 11, name: "Eat What You Want Day", emoji: "🍕" },
{ month: 5, day: 21, name: "Talk Like Yoda Day", emoji: "🟢", url: "https://en.wikipedia.org/wiki/Yoda" },
{ month: 5, day: 25, name: "Towel Day", emoji: "🏖️", url: "https://en.wikipedia.org/wiki/Towel_Day" },
{ month: 5, day: 29, name: "Put a Pillow on Your Fridge Day", emoji: "🛋️" },
{ month: 6, day: 3, name: "Repeat Day", emoji: "🔁", url: "https://en.wikipedia.org/wiki/Repetition" },
{ month: 6, day: 3, name: "Repeat Day", emoji: "🔁", url: "https://en.wikipedia.org/wiki/Repetition" },
{ month: 6, day: 4, name: "Hug Your Cat Day", emoji: "🐱", url: "https://en.wikipedia.org/wiki/Cat" },
{ month: 6, day: 18, name: "International Panic Day", emoji: "😱" },
{ month: 6, day: 19, name: "Juneteenth", emoji: "✊", url: "https://en.wikipedia.org/wiki/Juneteenth" },
{ month: 6, day: 22, name: "Onion Ring Day", emoji: "🧅", url: "https://en.wikipedia.org/wiki/Onion_ring" },
{ month: 6, day: 26, name: "Seth's Birthday!", emoji: "🎂" },
{ month: 6, day: 28, name: "Tau Day", emoji: "📐", url: "https://en.wikipedia.org/wiki/Turn_(angle)" },
{ month: 7, day: 1, name: "International Joke Day", emoji: "😂", url: "https://en.wikipedia.org/wiki/Joke" },
{ month: 7, day: 2, name: "World UFO Day", emoji: "🛸", url: "https://en.wikipedia.org/wiki/World_UFO_Day" },
{ month: 7, day: 4, name: "Independence Day", emoji: "🎇", url: "https://en.wikipedia.org/wiki/Independence_Day_(United_States)" },
{ month: 7, day: 4, name: "Sidewalk Egg Frying Day", emoji: "🍳", url: "https://en.wikipedia.org/wiki/Frying_an_egg_on_the_sidewalk" },
{ month: 7, day: 11, name: "Cheer Up the Lonely Day", emoji: "🤗" },
{ month: 7, day: 13, name: "Embrace Your Geekness Day", emoji: "🤓" },
{ month: 7, day: 14, name: "Pandemonium Day", emoji: "🌀", url: "https://en.wikipedia.org/wiki/Pandemonium" },
{ month: 7, day: 17, name: "World Emoji Day", emoji: "😄", url: "https://en.wikipedia.org/wiki/World_Emoji_Day" },
{ month: 7, day: 19, name: "Stick Out Your Tongue Day", emoji: "👅" },
{ month: 7, day: 22, name: "Pi Approximation Day", emoji: "≈", url: "https://en.wikipedia.org/wiki/Pi_Approximation_Day" },
{ month: 7, day: 27, name: "Take Your Pants for a Walk Day", emoji: "🚶" },
{ month: 8, day: 2, name: "Ice Cream Sandwich Day", emoji: "🍨", url: "https://en.wikipedia.org/wiki/Ice_cream_sandwich" },
{ month: 8, day: 10, name: "Lazy Day", emoji: "🛋️", url: "https://en.wikipedia.org/wiki/Laziness" },
{ month: 8, day: 12, name: "Middle Child Day", emoji: "😐", url: "https://en.wikipedia.org/wiki/Middle_child_syndrome" },
{ month: 8, day: 13, name: "Left-Handers Day", emoji: "🤚", url: "https://en.wikipedia.org/wiki/Left-Handers_Day" },
{ month: 8, day: 15, name: "Relaxation Day", emoji: "🧘" },
{ month: 8, day: 16, name: "Tell a Joke Day", emoji: "🤣" },
{ month: 8, day: 24, name: "Pluto Demoted Day", emoji: "🔭", url: "https://en.wikipedia.org/wiki/IAU_definition_of_planet" },
{ month: 8, day: 24, name: "National Waffle Day", emoji: "🧇", url: "https://en.wikipedia.org/wiki/Waffle" },
{ month: 8, day: 30, name: "Frankenstein Day", emoji: "⚡", url: "https://en.wikipedia.org/wiki/Frankenstein" },
{ month: 9, day: 5, name: "Be Late for Something Day", emoji: "⏰" },
{ month: 9, day: 6, name: "Fight Procrastination Day", emoji: "✅", url: "https://en.wikipedia.org/wiki/Procrastination" },
{ month: 9, day: 13, name: "Blame Someone Else Day", emoji: "👉" },
{ month: 9, day: 13, name: "Fortune Cookie Day", emoji: "🥠", url: "https://en.wikipedia.org/wiki/Fortune_cookie" },
{ month: 9, day: 19, name: "Talk Like a Pirate Day", emoji: "🏴‍☠️", url: "https://en.wikipedia.org/wiki/International_Talk_Like_a_Pirate_Day" },
{ month: 9, day: 22, name: "Elephant Appreciation Day", emoji: "🐘", url: "https://en.wikipedia.org/wiki/Elephant" },
{ month: 9, day: 28, name: "Ask a Stupid Question Day", emoji: "❓" },
{ month: 10, day: 4, name: "National Taco Day", emoji: "🌮", url: "https://en.wikipedia.org/wiki/Taco" },
{ month: 10, day: 12, name: "Moment of Frustration Day", emoji: "😤" },
{ month: 10, day: 14, name: "Dessert Day", emoji: "🍮", url: "https://en.wikipedia.org/wiki/Dessert" },
{ month: 10, day: 21, name: "Back to the Future Day", emoji: "⏱️", url: "https://en.wikipedia.org/wiki/Back_to_the_Future_Day" },
{ month: 10, day: 23, name: "Mole Day", emoji: "🦡", url: "https://en.wikipedia.org/wiki/Mole_Day" },
{ month: 10, day: 31, name: "Halloween", emoji: "🎃", url: "https://en.wikipedia.org/wiki/Halloween" },
{ month: 11, day: 8, name: "Cook Something Bold Day", emoji: "🔥" },
{ month: 11, day: 11, name: "Veterans Day", emoji: "🎖️", url: "https://en.wikipedia.org/wiki/Veterans_Day" },
{ month: 11, day: 13, name: "World Kindness Day", emoji: "💛", url: "https://en.wikipedia.org/wiki/World_Kindness_Day" },
{ month: 11, day: 17, name: "Take a Hike Day", emoji: "🥾" },
{ month: 11, day: 19, name: "Have a Bad Day Day", emoji: "😞" },
{ month: 12, day: 12, name: "Gingerbread House Day", emoji: "🏠", url: "https://en.wikipedia.org/wiki/Gingerbread_house" },
{ month: 12, day: 21, name: "Crossword Puzzle Day", emoji: "📝", url: "https://en.wikipedia.org/wiki/Crossword" },
{ month: 12, day: 25, name: "Christmas", emoji: "🎄", url: "https://en.wikipedia.org/wiki/Christmas" },
{ month: 12, day: 30, name: "Bacon Day", emoji: "🥓", url: "https://en.wikipedia.org/wiki/Bacon" },
{ month: 12, day: 31, name: "New Year's Eve", emoji: "🥂", url: "https://en.wikipedia.org/wiki/New_Year%27s_Eve" },
];
function nthWeekday(year, month, n, weekday) {
// n: 1=first, -1=last. weekday: 0=Mon..6=Sun
const base = new Date(year, month - 1, 1);
if (n > 0) {
const diff = (weekday - base.getDay() + 7) % 7;
base.setDate(1 + diff + (n - 1) * 7);
} else {
const lastDay = new Date(year, month, 0).getDate();
base.setDate(lastDay);
const diff = (base.getDay() - weekday + 7) % 7;
base.setDate(lastDay - diff);
}
return base;
}
function easterDate(year) {
// Anonymous Gregorian algorithm
const a = year % 19, b = Math.floor(year / 100), c = year % 100;
const d = Math.floor(b / 4), e = b % 4, f = Math.floor((b + 8) / 25);
const g = Math.floor((b - f + 1) / 3), h = (19*a + b - d - g + 15) % 30;
const i = Math.floor(c / 4), k = c % 4;
const l = (32 + 2*e + 2*i - h - k) % 7;
const m = Math.floor((a + 11*h + 22*l) / 451);
const month = Math.floor((h + l - 7*m + 114) / 31);
const day = ((h + l - 7*m + 114) % 31) + 1;
return new Date(year, month - 1, day);
}
// Approximate JD of solstice/equinox (Meeus Table 27.a)
// season: 0=March equinox, 1=June solstice, 2=September equinox, 3=December solstice
function seasonJD(year, season) {
const Y = (year - 2000) / 1000;
const jde = [
2451623.80984 + 365242.37404*Y + 0.05169*Y*Y - 0.00411*Y*Y*Y - 0.00057*Y*Y*Y*Y,
2451716.56767 + 365241.62603*Y + 0.00325*Y*Y + 0.00888*Y*Y*Y - 0.00030*Y*Y*Y*Y,
2451810.05917 + 365242.01767*Y - 0.11575*Y*Y + 0.00337*Y*Y*Y + 0.00078*Y*Y*Y*Y,
2451900.05952 + 365242.74049*Y - 0.06223*Y*Y - 0.00823*Y*Y*Y + 0.00032*Y*Y*Y*Y,
][season];
// Convert JD to JS Date (JD 2440587.5 = Unix epoch)
const ms = (jde - 2440587.5) * 86400000;
return new Date(ms);
}
function buildFloatingHolidays(year) {
const results = [];
const add = (date, name, emoji, url) => {
results.push({ month: date.getMonth() + 1, day: date.getDate(), name, emoji, url });
};
add(nthWeekday(year, 1, 3, 1), "MLK Day", "✊", "https://en.wikipedia.org/wiki/Martin_Luther_King_Jr._Day");
add(nthWeekday(year, 2, 3, 1), "Presidents Day", "🏛️", "https://en.wikipedia.org/wiki/Presidents%27_Day");
add(nthWeekday(year, 5, -1, 1), "Memorial Day", "🎖️", "https://en.wikipedia.org/wiki/Memorial_Day");
add(nthWeekday(year, 9, 1, 1), "Labor Day", "🔨", "https://en.wikipedia.org/wiki/Labor_Day");
add(nthWeekday(year, 10, 2, 1), "Columbus Day", "⛵", "https://en.wikipedia.org/wiki/Columbus_Day");
add(nthWeekday(year, 11, 4, 4), "Thanksgiving", "🦃", "https://en.wikipedia.org/wiki/Thanksgiving_(United_States)");
add(easterDate(year), "Easter", "🐣", "https://en.wikipedia.org/wiki/Easter");
const shrove = new Date(easterDate(year));
shrove.setDate(shrove.getDate() - 47);
add(shrove, "Pancake Day", "🥞", "https://en.wikipedia.org/wiki/Shrove_Tuesday");
// Solstices & equinoxes
add(seasonJD(year, 0), "March Equinox", "🌱", "https://en.wikipedia.org/wiki/March_equinox");
add(seasonJD(year, 1), "June Solstice", "☀️", "https://en.wikipedia.org/wiki/June_solstice");
add(seasonJD(year, 2), "September Equinox", "🍂", "https://en.wikipedia.org/wiki/September_equinox");
add(seasonJD(year, 3), "December Solstice", "❄️", "https://en.wikipedia.org/wiki/December_solstice");
return results;
}
// Build a DOY-keyed map of holidays for a given year
function buildHolidayMap(year) {
const map = {};
const cum = [0,31,59,90,120,151,181,212,243,273,304,334];
const leap = isLeapYear(year);
const addHol = ({ month, day, name, emoji, url }) => {
if (month === 2 && day === 29 && !leap) return;
let doy = cum[month - 1] + day;
if (leap && month > 2) doy++;
if (!map[doy]) map[doy] = [];
map[doy].push({ name, emoji, url });
};
FIXED_HOLIDAYS.forEach(addHol);
buildFloatingHolidays(year).forEach(addHol);
return map;
}
// --- Seth calendar helpers ---
function isLeapYear(y) {
return (y % 4 === 0 && y % 100 !== 0) || y % 400 === 0;
}
// Returns { year, doy } for a given JS Date (local time)
function getDayOfYear(date) {
const y = date.getFullYear();
const m = date.getMonth() + 1;
const d = date.getDate();
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 };
}
// Returns a JS Date for day-of-year `doy` in `year`
function dateFromDoy(year, doy) {
return new Date(year, 0, doy); // Jan 0 + doy = doy-th day
}
// Gregorian month/day string for a doy
function gregLabel(year, doy) {
const d = dateFromDoy(year, doy);
return `${GREG_MONTHS[d.getMonth()]} ${d.getDate()}`;
}
// Holiday names (0-indexed: n = 0..4 or 0..5)
function holidayName(n, leap) {
const lastIdx = leap ? 5 : 4;
const names = [
leap ? "Holiday 0 — Boxing Day" : "Holiday 0",
"Holiday 1",
"Holiday 2",
"Holiday 3",
leap ? "Holiday 4" : "Holiday 4 — New Year's Eve",
"Holiday 5 — New Year's Eve", // leap only
];
return names[n] || `Holiday ${n}`;
}
// --- State ---
const today = new Date();
const todayDoy = getDayOfYear(today);
let viewYear = todayDoy.year;
let holidayMap = buildHolidayMap(viewYear);
let viewMonth = (() => {
// Start on today's Seth month (or holiday block)
const doy = todayDoy.doy;
return doy <= 360 ? Math.floor((doy - 1) / 36) : 10; // 10 = holidays
})();
// --- DOM ---
const navTitle = document.getElementById("navTitle");
const navSub = document.getElementById("navSub");
const calGrid = document.getElementById("calGrid");
const monthTabs = document.getElementById("monthTabs");
document.getElementById("prevYear").addEventListener("click", () => { viewYear--; holidayMap = buildHolidayMap(viewYear); render(); });
document.getElementById("nextYear").addEventListener("click", () => { viewYear++; holidayMap = buildHolidayMap(viewYear); render(); });
document.getElementById("prevMonth").addEventListener("click", () => { stepMonth(-1); render(); });
document.getElementById("nextMonth").addEventListener("click", () => { stepMonth(+1); render(); });
function stepMonth(dir) {
viewMonth += dir;
if (viewMonth < 0) { viewMonth = 10; viewYear--; holidayMap = buildHolidayMap(viewYear); }
if (viewMonth > 10) { viewMonth = 0; viewYear++; holidayMap = buildHolidayMap(viewYear); }
}
// --- Render ---
function render() {
renderTabs();
if (viewMonth <= 9) {
renderMonth(viewYear, viewMonth);
} else {
renderHolidays(viewYear);
}
}
function renderTabs() {
monthTabs.innerHTML = "";
for (let m = 0; m <= 9; m++) {
const tab = document.createElement("button");
tab.className = "month-tab" + (m === viewMonth ? " active" : "");
tab.textContent = `Month ${m}`;
tab.addEventListener("click", () => { viewMonth = m; render(); });
monthTabs.appendChild(tab);
}
const htab = document.createElement("button");
htab.className = "month-tab holiday-tab" + (viewMonth === 10 ? " active" : "");
htab.textContent = "Holidays";
htab.addEventListener("click", () => { viewMonth = 10; render(); });
monthTabs.appendChild(htab);
}
function renderMonth(year, month) {
const leap = isLeapYear(year);
// doy of Month N Day 0. In a leap year, months 2-9 start one DOY later
// because leap day (DOY 60) sits inside Month 1.
const monthStart = month * 36 + 1 + (leap && month >= 2 ? 1 : 0);
// Approx gregorian range for subtitle
// Leap M1 ends at DOY 73 (Mar 13) not 72, because the split cell adds 1
const isLeapM1 = leap && month === 1;
const gStart = gregLabel(year, monthStart);
const gEnd = gregLabel(year, monthStart + 35 + (isLeapM1 ? 1 : 0));
navTitle.childNodes[0].textContent = `${year} · Month ${month}`;
navSub.textContent = `${gStart} ${gEnd}`;
// Build grid
const grid = document.createElement("div");
grid.className = "cal-grid";
// Column headers: D0 D1 D2 D3 D4 D5
for (const lbl of WEEK_DAY_LABELS) {
const h = document.createElement("div");
h.className = "col-header";
h.textContent = lbl;
grid.appendChild(h);
}
// In a leap year, M1 W3 D4 is split: top=Feb28(DOY59), bottom=LeapDay(DOY60)
// All cells from dayInMonth>=23 (W3 D5 onward) get doy+1 to skip over leap day
for (let week = 0; week <= 5; week++) {
for (let wd = 0; wd <= 5; wd++) {
const dayInMonth = week * 6 + wd; // 0..35
// In leap M1, dayInMonth 22 = D4 W3 = Feb28/LeapDay split cell
// dayInMonth 23+ shift by +1 to account for leap day
const doy = monthStart + dayInMonth + (isLeapM1 && dayInMonth >= 23 ? 1 : 0);
const isLeapSplit = isLeapM1 && dayInMonth === 22; // the split cell
const cell = document.createElement("div");
cell.className = "cal-cell";
let topHalf = null; // only set for split cells; used by holiday marker logic below
// Weekend coloring
if (wd === 4 || wd === 5) cell.classList.add("seth-weekend");
// For the split cell, gregDate is Feb 28 (the top half)
const gregDate = dateFromDoy(year, doy);
const gregWd = GREG_WEEKDAYS[gregDate.getDay()];
const gregDay = gregDate.getDay(); // 0=Sun, 6=Sat
if (gregDay === 0 || gregDay === 6) cell.classList.add("greg-weekend");
// Highlight today (today could be Feb 28 or Feb 29 in this cell)
const leapDoyTop = doy; // Feb 28
const leapDoyBot = doy + 1; // Feb 29 (leap day)
const isToday = year === todayDoy.year &&
(todayDoy.doy === doy || (isLeapSplit && todayDoy.doy === leapDoyBot));
if (isToday) cell.classList.add("today");
const astroParam = `${gregDate.getFullYear()}-${String(gregDate.getMonth()+1).padStart(2,"0")}-${String(gregDate.getDate()).padStart(2,"0")}`;
if (isLeapSplit) {
// Split cell: transparent outer wrapper containing two independent mini-cells
cell.classList.add("has-leap-split");
// Remove border/bg classes — the outer cell is invisible; halves handle their own styling
cell.classList.remove("seth-weekend", "greg-weekend", "today");
// ── Top half: normal D4 (Feb 28), Seth weekend ──
topHalf = document.createElement("div");
topHalf.className = "leap-top";
const isTopToday = year === todayDoy.year && todayDoy.doy === doy;
if (isTopToday) topHalf.classList.add("leap-top-today");
const topMoonLink = document.createElement("a");
topMoonLink.className = "moon-link";
topMoonLink.href = `/astro?date=${astroParam}`;
topMoonLink.title = `Astronomy for ${gregLabel(year, doy)}`;
topMoonLink.appendChild(makeMoonCanvas(gregDate, 14));
topHalf.appendChild(topMoonLink);
const topNum = document.createElement("div");
topNum.className = "seth-day";
topNum.textContent = dayInMonth;
const topGreg = document.createElement("div");
topGreg.className = "greg-date";
topGreg.textContent = `${gregWd} ${gregLabel(year, doy)}`;
topHalf.appendChild(topNum);
topHalf.appendChild(topGreg);
cell.appendChild(topHalf);
// ── Bottom half: Leap Day (Feb 29), its own independent mini-cell ──
const botHalf = document.createElement("div");
botHalf.className = "leap-bottom";
const isLeapToday = year === todayDoy.year && todayDoy.doy === 60;
if (isLeapToday) botHalf.classList.add("leap-today");
const leapDate = new Date(year, 1, 29); // Feb 29
const leapAstroParam = `${year}-02-29`;
const botMoonLink = document.createElement("a");
botMoonLink.className = "moon-link";
botMoonLink.href = `/astro?date=${leapAstroParam}`;
botMoonLink.title = "Astronomy for Feb 29";
botMoonLink.appendChild(makeMoonCanvas(leapDate, 14));
botHalf.appendChild(botMoonLink);
const botNum = document.createElement("div");
botNum.className = "seth-day";
botNum.textContent = "Leap Day";
const botGreg = document.createElement("div");
botGreg.className = "greg-date";
const leapGregWd = GREG_WEEKDAYS[leapDate.getDay()];
botGreg.textContent = `${leapGregWd} Feb 29`;
botHalf.appendChild(botNum);
botHalf.appendChild(botGreg);
cell.appendChild(botHalf);
} else {
// Normal cell
const moonLink = document.createElement("a");
moonLink.className = "moon-link";
moonLink.href = `/astro?date=${astroParam}`;
moonLink.title = `Astronomy for ${gregLabel(year, doy)}`;
moonLink.appendChild(makeMoonCanvas(gregDate, 14));
cell.appendChild(moonLink);
const dayNum = document.createElement("div");
dayNum.className = "seth-day";
dayNum.textContent = dayInMonth;
const greg = document.createElement("div");
greg.className = "greg-date";
greg.textContent = `${gregWd} ${gregLabel(year, doy)}`;
cell.appendChild(dayNum);
cell.appendChild(greg);
}
// Holiday markers — for split cells, attach to topHalf (Feb 28); otherwise to cell
const holTarget = isLeapSplit ? topHalf : cell;
const hols = holidayMap[doy];
if (hols) {
holTarget.classList.add("has-holiday");
hols.forEach(({ name, emoji, url }) => {
const hl = document.createElement("div");
hl.className = "hol-label";
if (url) {
const a = document.createElement("a");
a.href = url;
a.target = "_blank";
a.rel = "noopener noreferrer";
a.textContent = `${emoji} ${name}`;
hl.appendChild(a);
} else {
hl.textContent = `${emoji} ${name}`;
}
holTarget.appendChild(hl);
});
}
grid.appendChild(cell);
}
}
calGrid.innerHTML = "";
calGrid.appendChild(grid);
}
function renderHolidays(year) {
const leap = isLeapYear(year);
const count = leap ? 6 : 5;
const doyBase = 361; // H0 = doy 361
navTitle.childNodes[0].textContent = `${year} · Holidays`;
navSub.textContent = `${gregLabel(year, doyBase)} ${gregLabel(year, doyBase + count - 1)}`;
const grid = document.createElement("div");
grid.className = "cal-grid holiday-grid";
for (let h = 0; h < count; h++) {
const doy = doyBase + h;
const isToday = year === todayDoy.year && doy === todayDoy.doy;
const cell = document.createElement("div");
cell.className = "cal-cell holiday-cell";
if (isToday) cell.classList.add("today");
const gregDate = dateFromDoy(year, doy);
const gregWd = GREG_WEEKDAYS[gregDate.getDay()];
const astroParam = `${gregDate.getFullYear()}-${String(gregDate.getMonth()+1).padStart(2,"0")}-${String(gregDate.getDate()).padStart(2,"0")}`;
const moonLink = document.createElement("a");
moonLink.className = "moon-link";
moonLink.href = `/astro?date=${astroParam}`;
moonLink.title = `Astronomy for ${gregLabel(year, doy)}`;
moonLink.appendChild(makeMoonCanvas(gregDate, 14));
cell.appendChild(moonLink);
const hNum = document.createElement("div");
hNum.className = "seth-day";
hNum.textContent = `H${h}`;
const greg = document.createElement("div");
greg.className = "greg-date";
greg.textContent = `${gregWd} ${gregLabel(year, doy)}`;
const name = document.createElement("div");
name.className = "holiday-name";
name.textContent = holidayName(h, leap);
cell.appendChild(hNum);
cell.appendChild(greg);
cell.appendChild(name);
grid.appendChild(cell);
}
calGrid.innerHTML = "";
calGrid.appendChild(grid);
}
render();
// "Today" legend link — jumps back to today's month/year
document.getElementById("todayLink").addEventListener("click", e => {
e.preventDefault();
viewYear = todayDoy.year;
viewMonth = todayDoy.doy <= 360 ? Math.floor((todayDoy.doy - 1) / 36) : 10;
holidayMap = buildHolidayMap(viewYear);
render();
});
// Draw sample moon in legend (waxing gibbous ~0.7 illumination)
const legendMoon = document.getElementById("legendMoon");
if (legendMoon) drawMoon(legendMoon, 0.7, true);