/** * Lunar Calculation Engine * Based on Jean Meeus' Astronomical Algorithms * Calculates moon phases, illumination, age, and positions */ // Synodic month (average lunation period) in days const SYNODIC_MONTH = 29.53058868; // Known new moon reference: January 6, 2000, 18:14 UTC (Julian date) const KNOWN_NEW_MOON_JD = 2451550.1; export type MoonPhaseName = 'new_moon' | 'waxing_crescent' | 'first_quarter' | 'waxing_gibbous' | 'full_moon' | 'waning_gibbous' | 'last_quarter' | 'waning_crescent'; export interface MoonPhaseInfo { phase: number; // 0-1 (0 = new moon, 0.5 = full moon) phaseName: MoonPhaseName; illumination: number; // 0-100% age: number; // days since new moon emoji: string; angleDeg: number; // 0-360 } export interface MoonEvent { date: Date; phase: 'new_moon' | 'first_quarter' | 'full_moon' | 'last_quarter'; name: string; } export interface FullMoonInfo { date: Date; traditionalName: string; month: number; } /** * Convert a Date to Julian Date */ export function dateToJD(date: Date): number { const y = date.getUTCFullYear(); const m = date.getUTCMonth() + 1; const d = date.getUTCDate() + date.getUTCHours() / 24 + date.getUTCMinutes() / 1440 + date.getUTCSeconds() / 86400; let yr = y; let mo = m; if (mo <= 2) { yr -= 1; mo += 12; } const A = Math.floor(yr / 100); const B = 2 - A + Math.floor(A / 4); return Math.floor(365.25 * (yr + 4716)) + Math.floor(30.6001 * (mo + 1)) + d + B - 1524.5; } /** * Convert Julian Date to Date */ export function jdToDate(jd: number): Date { const z = Math.floor(jd + 0.5); const f = jd + 0.5 - z; let A: number; if (z < 2299161) { A = z; } else { 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); const month = E < 14 ? E - 1 : E - 13; const year = month > 2 ? C - 4716 : C - 4715; const hours = f * 24; const h = Math.floor(hours); const mins = Math.floor((hours - h) * 60); const secs = Math.floor(((hours - h) * 60 - mins) * 60); return new Date(Date.UTC(year, month - 1, day, h, mins, secs)); } /** * Calculate moon phase for a given date * Returns a value between 0 and 1 (0 = new moon, 0.5 = full moon) */ export function getMoonPhase(date: Date): number { const jd = dateToJD(date); const daysSinceNew = jd - KNOWN_NEW_MOON_JD; const cycles = daysSinceNew / SYNODIC_MONTH; const phase = cycles - Math.floor(cycles); return phase < 0 ? phase + 1 : phase; } /** * Get detailed moon phase information */ export function getMoonPhaseInfo(date: Date): MoonPhaseInfo { const phase = getMoonPhase(date); const age = phase * SYNODIC_MONTH; const angleDeg = phase * 360; // Illumination (simplified: 0 at new moon, 100 at full moon) const illumination = Math.round((1 - Math.cos(phase * 2 * Math.PI)) / 2 * 100); let phaseName: MoonPhaseName; let emoji: string; if (phase < 0.0625) { phaseName = 'new_moon'; emoji = '🌑'; } else if (phase < 0.1875) { phaseName = 'waxing_crescent'; emoji = '🌒'; } else if (phase < 0.3125) { phaseName = 'first_quarter'; emoji = '🌓'; } else if (phase < 0.4375) { phaseName = 'waxing_gibbous'; emoji = '🌔'; } else if (phase < 0.5625) { phaseName = 'full_moon'; emoji = '🌕'; } else if (phase < 0.6875) { phaseName = 'waning_gibbous'; emoji = '🌖'; } else if (phase < 0.8125) { phaseName = 'last_quarter'; emoji = '🌗'; } else if (phase < 0.9375) { phaseName = 'waning_crescent'; emoji = '🌘'; } else { phaseName = 'new_moon'; emoji = '🌑'; } return { phase, phaseName, illumination, age, emoji, angleDeg }; } /** * Calculate more precise moon phase times using iterative refinement * targetPhase: 0 = new, 0.25 = first quarter, 0.5 = full, 0.75 = last quarter */ function findPhaseEvent(startJD: number, targetPhase: number): number { // Search forward from startJD let jd = startJD; const step = 1; // 1 day step // Coarse search let prevDiff = 999; for (let i = 0; i < 35; i++) { const daysSinceNew = (jd + i * step) - KNOWN_NEW_MOON_JD; const cycles = daysSinceNew / SYNODIC_MONTH; const phase = cycles - Math.floor(cycles); let diff = phase - targetPhase; if (diff < -0.5) diff += 1; if (diff > 0.5) diff -= 1; const absDiff = Math.abs(diff); if (absDiff < prevDiff) { prevDiff = absDiff; jd = startJD + i * step; } } // Fine search: binary search refinement let lo = jd - 1; let hi = jd + 1; for (let iter = 0; iter < 50; iter++) { const mid = (lo + hi) / 2; const daysSinceNew = mid - KNOWN_NEW_MOON_JD; const cycles = daysSinceNew / SYNODIC_MONTH; const phase = cycles - Math.floor(cycles); let diff = phase - targetPhase; if (diff < -0.5) diff += 1; if (diff > 0.5) diff -= 1; if (Math.abs(diff) < 0.00001) break; if (diff < 0) { lo = mid; } else { hi = mid; } } return (lo + hi) / 2; } /** * Get all moon phase events for a given year */ export function getMoonEventsForYear(year: number): MoonEvent[] { const events: MoonEvent[] = []; const startJD = dateToJD(new Date(Date.UTC(year, 0, 1))); const endJD = dateToJD(new Date(Date.UTC(year, 11, 31, 23, 59, 59))); const phases: { target: number; name: 'new_moon' | 'first_quarter' | 'full_moon' | 'last_quarter' }[] = [ { target: 0, name: 'new_moon' }, { target: 0.25, name: 'first_quarter' }, { target: 0.5, name: 'full_moon' }, { target: 0.75, name: 'last_quarter' }, ]; // Start from a bit before the year to catch early events const searchStart = startJD - 35; for (const { target, name } of phases) { // Find all occurrences through the year let currentJD = searchStart; while (currentJD < endJD + 1) { const eventJD = findPhaseEvent(currentJD, target); if (eventJD >= startJD && eventJD <= endJD + 1) { const date = jdToDate(eventJD); if (date.getUTCFullYear() === year) { events.push({ date, phase: name, name }); } } currentJD = eventJD + 25; // Jump ~25 days to find next cycle } } // Sort by date events.sort((a, b) => a.date.getTime() - b.date.getTime()); // Remove duplicates (same phase within 2 days) const filtered: MoonEvent[] = []; for (const event of events) { const isDuplicate = filtered.some( (e) => e.phase === event.phase && Math.abs(e.date.getTime() - event.date.getTime()) < 2 * 24 * 60 * 60 * 1000 ); if (!isDuplicate) { filtered.push(event); } } return filtered; } /** * Get the next full moon from a given date */ export function getNextFullMoon(from: Date = new Date()): Date { const jd = dateToJD(from); const eventJD = findPhaseEvent(jd, 0.5); const result = jdToDate(eventJD); // If result is in the past, search from a bit later if (result.getTime() < from.getTime()) { const nextJD = findPhaseEvent(jd + 25, 0.5); return jdToDate(nextJD); } return result; } /** * Get the next new moon from a given date */ export function getNextNewMoon(from: Date = new Date()): Date { const jd = dateToJD(from); const eventJD = findPhaseEvent(jd, 0); const result = jdToDate(eventJD); if (result.getTime() < from.getTime()) { const nextJD = findPhaseEvent(jd + 25, 0); return jdToDate(nextJD); } return result; } /** * Traditional full moon names by month (Northern Hemisphere) */ export const FULL_MOON_NAMES: Record = { 1: { en: 'Wolf Moon', fr: 'Lune du Loup', es: 'Luna del Lobo', de: 'Wolfsmond', pt: 'Lua do Lobo', it: 'Luna del Lupo', ja: 'ウルフムーン', zh: '狼月', ar: 'قمر الذئب', ru: 'Волчья Луна', hi: 'भेड़िया चंद्रमा' }, 2: { en: 'Snow Moon', fr: 'Lune de Neige', es: 'Luna de Nieve', de: 'Schneemond', pt: 'Lua da Neve', it: 'Luna della Neve', ja: 'スノームーン', zh: '雪月', ar: 'قمر الثلج', ru: 'Снежная Луна', hi: 'हिम चंद्रमा' }, 3: { en: 'Worm Moon', fr: 'Lune du Ver', es: 'Luna del Gusano', de: 'Wurmmond', pt: 'Lua da Minhoca', it: 'Luna del Verme', ja: 'ワームムーン', zh: '蠕虫月', ar: 'قمر الدودة', ru: 'Червячная Луна', hi: 'कीट चंद्रमा' }, 4: { en: 'Pink Moon', fr: 'Lune Rose', es: 'Luna Rosa', de: 'Rosa Mond', pt: 'Lua Rosa', it: 'Luna Rosa', ja: 'ピンクムーン', zh: '粉红月', ar: 'القمر الوردي', ru: 'Розовая Луна', hi: 'गुलाबी चंद्रमा' }, 5: { en: 'Flower Moon', fr: 'Lune des Fleurs', es: 'Luna de las Flores', de: 'Blumenmond', pt: 'Lua das Flores', it: 'Luna dei Fiori', ja: 'フラワームーン', zh: '花月', ar: 'قمر الزهور', ru: 'Цветочная Луна', hi: 'फूल चंद्रमा' }, 6: { en: 'Strawberry Moon', fr: 'Lune des Fraises', es: 'Luna de Fresa', de: 'Erdbeermond', pt: 'Lua do Morango', it: 'Luna delle Fragole', ja: 'ストロベリームーン', zh: '草莓月', ar: 'قمر الفراولة', ru: 'Клубничная Луна', hi: 'स्ट्रॉबेरी चंद्रमा' }, 7: { en: 'Buck Moon', fr: 'Lune du Cerf', es: 'Luna del Ciervo', de: 'Bockmond', pt: 'Lua do Cervo', it: 'Luna del Cervo', ja: 'バックムーン', zh: '雄鹿月', ar: 'قمر الغزال', ru: 'Оленья Луна', hi: 'हिरण चंद्रमा' }, 8: { en: 'Sturgeon Moon', fr: 'Lune de l\'Esturgeon', es: 'Luna del Esturión', de: 'Störmond', pt: 'Lua do Esturjão', it: 'Luna dello Storione', ja: 'スタージョンムーン', zh: '鲟鱼月', ar: 'قمر سمك الحفش', ru: 'Осетровая Луна', hi: 'स्टर्जन चंद्रमा' }, 9: { en: 'Harvest Moon', fr: 'Lune des Moissons', es: 'Luna de la Cosecha', de: 'Erntemond', pt: 'Lua da Colheita', it: 'Luna del Raccolto', ja: 'ハーベストムーン', zh: '收获月', ar: 'قمر الحصاد', ru: 'Луна Урожая', hi: 'फसल चंद्रमा' }, 10: { en: 'Hunter\'s Moon', fr: 'Lune du Chasseur', es: 'Luna del Cazador', de: 'Jägermond', pt: 'Lua do Caçador', it: 'Luna del Cacciatore', ja: 'ハンターズムーン', zh: '猎人月', ar: 'قمر الصياد', ru: 'Охотничья Луна', hi: 'शिकारी चंद्रमा' }, 11: { en: 'Beaver Moon', fr: 'Lune du Castor', es: 'Luna del Castor', de: 'Bibermond', pt: 'Lua do Castor', it: 'Luna del Castoro', ja: 'ビーバームーン', zh: '海狸月', ar: 'قمر القندس', ru: 'Бобровая Луна', hi: 'बीवर चंद्रमा' }, 12: { en: 'Cold Moon', fr: 'Lune Froide', es: 'Luna Fría', de: 'Kalter Mond', pt: 'Lua Fria', it: 'Luna Fredda', ja: 'コールドムーン', zh: '冷月', ar: 'القمر البارد', ru: 'Холодная Луна', hi: 'ठंडा चंद्रमा' }, }; /** * Get full moons for a given year with traditional names */ export function getFullMoonsForYear(year: number): FullMoonInfo[] { const events = getMoonEventsForYear(year); return events .filter((e) => e.phase === 'full_moon') .map((e) => ({ date: e.date, traditionalName: FULL_MOON_NAMES[e.date.getUTCMonth() + 1]?.en || 'Full Moon', month: e.date.getUTCMonth() + 1, })); } /** * Calculate approximate moon position (simplified) * Returns ecliptic longitude in degrees */ export function getMoonEclipticLongitude(date: Date): number { const jd = dateToJD(date); const T = (jd - 2451545.0) / 36525; // Julian centuries from J2000 // Mean longitude of the Moon let L0 = 218.3165 + 481267.8813 * T; L0 = L0 % 360; if (L0 < 0) L0 += 360; return L0; } /** * Calculate approximate moon declination (for visibility map) */ export function getMoonDeclination(date: Date): number { const jd = dateToJD(date); const T = (jd - 2451545.0) / 36525; // Simplified calculation const D = (297.85 + 445267.1115 * T) % 360; const M = (357.53 + 35999.0503 * T) % 360; const Mp = (134.96 + 477198.8676 * T) % 360; const F = (93.27 + 483202.0175 * T) % 360; const dRad = D * Math.PI / 180; const mRad = M * Math.PI / 180; const mpRad = Mp * Math.PI / 180; const fRad = F * Math.PI / 180; // Moon's ecliptic latitude (simplified) const beta = 5.128 * Math.sin(fRad) + 0.2806 * Math.sin(mpRad + fRad) + 0.2777 * Math.sin(mpRad - fRad) + 0.1732 * Math.sin(2 * dRad - fRad); // Ecliptic longitude (simplified) let lambda = 218.32 + 481267.883 * T + 6.29 * Math.sin(mpRad) - 1.27 * Math.sin(2 * dRad - mpRad) + 0.66 * Math.sin(2 * dRad) + 0.21 * Math.sin(2 * mpRad) - 0.19 * Math.sin(mRad) - 0.11 * Math.sin(2 * fRad); lambda = lambda % 360; // Obliquity of ecliptic const epsilon = 23.439 - 0.00000036 * (jd - 2451545.0); const epsRad = epsilon * Math.PI / 180; const lambdaRad = lambda * Math.PI / 180; const betaRad = beta * Math.PI / 180; // Declination const declination = Math.asin( Math.sin(betaRad) * Math.cos(epsRad) + Math.cos(betaRad) * Math.sin(epsRad) * Math.sin(lambdaRad) ) * 180 / Math.PI; return declination; } /** * Calculate moon right ascension (for visibility) */ export function getMoonRightAscension(date: Date): number { const jd = dateToJD(date); const T = (jd - 2451545.0) / 36525; const Mp = (134.96 + 477198.8676 * T) % 360; const D = (297.85 + 445267.1115 * T) % 360; const M = (357.53 + 35999.0503 * T) % 360; const F = (93.27 + 483202.0175 * T) % 360; const mpRad = Mp * Math.PI / 180; const dRad = D * Math.PI / 180; const mRad = M * Math.PI / 180; const fRad = F * Math.PI / 180; let lambda = 218.32 + 481267.883 * T + 6.29 * Math.sin(mpRad) - 1.27 * Math.sin(2 * dRad - mpRad) + 0.66 * Math.sin(2 * dRad) + 0.21 * Math.sin(2 * mpRad) - 0.19 * Math.sin(mRad) - 0.11 * Math.sin(2 * fRad); lambda = lambda % 360; const beta = 5.128 * Math.sin(fRad); const epsilon = 23.439 - 0.00000036 * (jd - 2451545.0); const epsRad = epsilon * Math.PI / 180; const lambdaRad = lambda * Math.PI / 180; const betaRad = beta * Math.PI / 180; const ra = Math.atan2( Math.sin(lambdaRad) * Math.cos(epsRad) - Math.tan(betaRad) * Math.sin(epsRad), Math.cos(lambdaRad) ) * 180 / Math.PI; return ((ra % 360) + 360) % 360; } /** * Countdown to next full moon */ export function getCountdownToNextFullMoon(from: Date = new Date()): { days: number; hours: number; minutes: number; seconds: number; totalMs: number } { const nextFull = getNextFullMoon(from); const totalMs = nextFull.getTime() - from.getTime(); if (totalMs <= 0) return { days: 0, hours: 0, minutes: 0, seconds: 0, totalMs: 0 }; const days = Math.floor(totalMs / (1000 * 60 * 60 * 24)); const hours = Math.floor((totalMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)); const minutes = Math.floor((totalMs % (1000 * 60 * 60)) / (1000 * 60)); const seconds = Math.floor((totalMs % (1000 * 60)) / 1000); return { days, hours, minutes, seconds, totalMs }; } /** * Get moon events for a specific month */ export function getMoonEventsForMonth(year: number, month: number): MoonEvent[] { const allEvents = getMoonEventsForYear(year); return allEvents.filter(e => e.date.getUTCMonth() + 1 === month); } /** * Get zodiac sign based on ecliptic longitude */ export function getMoonZodiacSign(date: Date): string { const lon = getMoonEclipticLongitude(date); const signs = ['Aries', 'Taurus', 'Gemini', 'Cancer', 'Leo', 'Virgo', 'Libra', 'Scorpio', 'Sagittarius', 'Capricorn', 'Aquarius', 'Pisces']; const index = Math.floor(lon / 30) % 12; return signs[index]; }