a6b3f039d8
Pages: /, /simple, /decimal, /seth, /calendar, /astro, /convert, /timegov Features: Seth Calendar (10×36 + holidays), decimal time, moon phases, astronomy (sun/moon), bidirectional time converter, Seth date display, leap day split cell in calendar grid.
649 lines
32 KiB
JavaScript
649 lines
32 KiB
JavaScript
// 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 (0–29.53), illumination (0–1), 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);
|
||
const monthStart = month * 36 + 1; // doy of Month N Day 0 (N=0..9)
|
||
|
||
// Approx gregorian range for subtitle
|
||
const gStart = gregLabel(year, monthStart);
|
||
const gEnd = gregLabel(year, monthStart + 35);
|
||
|
||
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
|
||
const isLeapM1 = leap && month === 1;
|
||
|
||
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)}`;
|
||
const topWd = document.createElement("div");
|
||
topWd.className = "greg-date";
|
||
topWd.style.color = "#555";
|
||
topWd.textContent = `W${week} D${wd}`;
|
||
topHalf.appendChild(topNum);
|
||
topHalf.appendChild(topGreg);
|
||
topHalf.appendChild(topWd);
|
||
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)}`;
|
||
|
||
const wdLabel = document.createElement("div");
|
||
wdLabel.className = "greg-date";
|
||
wdLabel.style.color = "#555";
|
||
wdLabel.textContent = `W${week} D${wd}`;
|
||
|
||
cell.appendChild(dayNum);
|
||
cell.appendChild(greg);
|
||
cell.appendChild(wdLabel);
|
||
}
|
||
|
||
// 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);
|