Files
Puechberty Arthur 49fd31f4db first commit
2026-03-30 23:07:36 +02:00

501 lines
16 KiB
TypeScript

/**
* 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<number, { en: string; fr: string; es: string; de: string; pt: string; it: string; ja: string; zh: string; ar: string; ru: string; hi: string }> = {
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];
}