feat: moon phase + sunrise/sunset with location settings

This commit is contained in:
catarrh committed 2026-08-02 02:28:43 +03:00
1 parent 5dd8a947c6
commit 672af4b5fb
4 files changed
+271

No files matched your search

+144
View File
@@ -0,0 +1,144 @@
/**
* Astronomical calculations for sunrise/sunset times and moon phase.
* All calculations are client-side, no external APIs required.
*
* Sunrise/sunset algorithm based on NOAA Solar Calculator.
* Moon phase uses the existing Paschalion lunar calculation.
*/
import { JDate } from './jdate';
import { Paschalion } from './paschalion';
/** Result of sunrise/sunset calculation for a given day. */
export interface SolarTimes {
sunrise: string; // "06:34"
sunset: string; // "20:15"
}
/** Moon phase info for a given day. */
export interface LunarInfo {
phase: number; // 0–1 (0=new, 0.5=full)
emoji: string; // 🌑🌒🌓🌔🌕🌖🌗🌘
name: string; // localized phase name (set by caller)
illumination: number; // 0–100 (percentage visible)
}
/**
* Calculate sunrise and sunset times for a given Julian date and location.
* @param jdate Julian date (time is treated as noon)
* @param lat Latitude in degrees (north positive)
* @param lon Longitude in degrees (east positive)
* @param tz Timezone offset from UTC in hours (default: 3 for Moscow)
*/
export function calcSolarTimes(jdate: JDate, lat: number, lon: number, tz: number = 3): SolarTimes | null {
if (lat < -90 || lat > 90 || lon < -180 || lon > 180) return null;
// Julian Day Number at noon (UT)
const jdn = jdate.jdn + 0.5;
// Julian centuries since J2000.0
const T = (jdn - 2451545.0) / 36525;
// Solar mean anomaly (degrees)
const M = 357.52911 + 35999.05029 * T - 0.0001537 * T * T;
// Equation of center
const C = (1.914602 - 0.004817 * T - 0.000014 * T * T) * Math.sin(degToRad(M))
+ (0.019993 - 0.000101 * T) * Math.sin(degToRad(2 * M))
+ 0.000289 * Math.sin(degToRad(3 * M));
// Ecliptic longitude (degrees)
const lambda = M + C + 180 + 102.9372; // +180 for true longitude, +102.9372 for perihelion
// Obliquity of ecliptic
const epsilon = 23.439291 - 0.0130042 * T;
// Solar declination (degrees)
const sinEcl = Math.sin(degToRad(epsilon));
const ySin = sinEcl * Math.sin(degToRad(lambda));
const dec = radToDeg(Math.asin(ySin));
// Equation of time (minutes)
const y = Math.tan(degToRad(epsilon / 2));
const y2 = y * y;
const sin2L = Math.sin(degToRad(2 * lambda));
const sinM = Math.sin(degToRad(M));
const cos2L = Math.cos(degToRad(2 * lambda));
const cos2M = Math.cos(degToRad(2 * M));
const EoT = 4 * radToDeg(
y2 * sin2L - 2 * y * sinM + 4 * y * y2 * sinM * cos2L
- 0.5 * y2 * y2 * cos2L - 1.25 * y2 * cos2M
);
// Hour angle (degrees) — cosH = (sin(-0.833) - sin(lat)*sin(dec)) / (cos(lat)*cos(dec))
const latRad = degToRad(lat);
const decRad = degToRad(dec);
const cosH = (Math.sin(degToRad(-0.833)) - Math.sin(latRad) * Math.sin(decRad))
/ (Math.cos(latRad) * Math.cos(decRad));
// Check for polar day/night
if (cosH < -1) return { sunrise: '—', sunset: '—' }; // polar night (sun never rises)
if (cosH > 1) return { sunrise: '—', sunset: '—' }; // midnight sun (sun never sets)
const H = radToDeg(Math.acos(cosH));
// UTC time of sunrise/sunset (hours)
const sunriseUTC = 12 - H / 15 - EoT / 60 - lon / 15;
const sunsetUTC = 12 + H / 15 - EoT / 60 - lon / 15;
// Convert to local timezone
const sunriseLocal = sunriseUTC + tz;
const sunsetLocal = sunsetUTC + tz;
// Format as HH:MM
const fmt = (hours: number): string => {
const h = Math.floor(((hours % 24) + 24) % 24);
const m = Math.floor((hours - Math.floor(hours)) * 60 + 0.5);
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
};
return {
sunrise: fmt(sunriseLocal),
sunset: fmt(sunsetLocal),
};
}
/** Moon phase info for a given Julian date. */
export function getMoonInfo(jdate: JDate): LunarInfo {
const phase = Paschalion.getLunarPhase(jdate);
// Map phase (0-1) to emoji (0=new, 0.5=full)
let emoji: string;
if (phase < 0.0625) emoji = '🌑'; // new
else if (phase < 0.1875) emoji = '🌒'; // waxing crescent
else if (phase < 0.3125) emoji = '🌓'; // first quarter
else if (phase < 0.4375) emoji = '🌔'; // waxing gibbous
else if (phase < 0.5625) emoji = '🌕'; // full
else if (phase < 0.6875) emoji = '🌖'; // waning gibbous
else if (phase < 0.8125) emoji = '🌗'; // last quarter
else if (phase < 0.9375) emoji = '🌘'; // waning crescent
else emoji = '🌑'; // new
// Illumination (0–100%)
const illumination = Math.round((1 - Math.cos(2 * Math.PI * phase)) / 2 * 100);
return { phase, emoji, name: '', illumination };
}
/** Phase names keyed by phase bucket (0=new, 1-7 for other phases). */
export function getMoonPhaseName(phase: number, lang: string): string {
const names: Record<string, string[]> = {
en: ['New Moon', 'Waxing Crescent', 'First Quarter', 'Waxing Gibbous',
'Full Moon', 'Waning Gibbous', 'Last Quarter', 'Waning Crescent'],
ru: ['Новолуние', 'Растущий серп', 'Первая четверть', 'Растущая луна',
'Полнолуние', 'Убывающая луна', 'Последняя четверть', 'Убывающий серп'],
cu: ['Новолу́нїе', 'Растꙋ́щїй се́рпъ', 'Пе́рваѧ че́тверть', 'Растꙋ́щаѧ лꙋна̀',
'Полнолу́нїе', 'Оу҆быва́ющаѧ лꙋна̀', 'Послѣ́днѧѧ че́тверть', 'Оу҆быва́ющїй се́рпъ'],
};
const list = names[lang] || names.en;
const index = Math.round(phase * 8) % 8;
return list[index];
}
function degToRad(d: number): number { return d * Math.PI / 180; }
function radToDeg(r: number): number { return r * 180 / Math.PI; }
+36
View File
@@ -240,6 +240,10 @@ export interface Translations {
yearOfWorld: string;
fromYear: string;
fromAdam: string;
sunrise: string;
sunset: string;
moonPhase: string;
noLocation: string;
};
// Services
@@ -495,6 +499,11 @@ export interface Translations {
offlineDone: string;
offlineSelectLang: string;
offlineCleared: string;
locationTitle: string;
latitude: string;
longitude: string;
requestLocation: string;
locationNote: string;
};
// Common
@@ -779,6 +788,10 @@ const translations: Record<LanguageCode, Translations> = {
yearOfWorld: 'year of the world',
fromYear: 'from Christ',
fromAdam: 'from Adam',
sunrise: 'Sunrise',
sunset: 'Sunset',
moonPhase: 'Moon',
noLocation: 'Set location in Settings',
},
services: {
@@ -1031,6 +1044,11 @@ const translations: Record<LanguageCode, Translations> = {
offlineDone: 'Done! Data cached for offline use.',
offlineSelectLang: 'Please select at least one language.',
offlineCleared: 'Cache cleared.',
locationTitle: 'Location',
latitude: 'Latitude',
longitude: 'Longitude',
requestLocation: 'Request location',
locationNote: 'Used for sunrise/sunset calculation',
},
common: {
@@ -1313,6 +1331,10 @@ const translations: Record<LanguageCode, Translations> = {
yearOfWorld: 'год от сотворения мира',
fromYear: 'от РХ',
fromAdam: 'от Адама',
sunrise: 'Восход',
sunset: 'Закат',
moonPhase: 'Луна',
noLocation: 'Укажите местоположение в настройках',
},
services: {
@@ -1565,6 +1587,11 @@ const translations: Record<LanguageCode, Translations> = {
offlineDone: 'Готово! Данные закэшированы для офлайн-использования.',
offlineSelectLang: 'Пожалуйста, выберите хотя бы один язык.',
offlineCleared: 'Кэш очищен.',
locationTitle: 'Местоположение',
latitude: 'Широта',
longitude: 'Долгота',
requestLocation: 'Запросить местоположение',
locationNote: 'Используется для расчёта восхода/заката',
},
common: {
@@ -1847,6 +1874,10 @@ const translations: Record<LanguageCode, Translations> = {
yearOfWorld: 'лѣта отъ сотворенiя мiра',
fromYear: 'от РХ',
fromAdam: 'от Адама',
sunrise: 'Восходъ',
sunset: 'Закатъ',
moonPhase: 'Лꙋна̀',
noLocation: 'Оу҆кажі́те мѣстоположе́нїе въ настройкахъ',
},
services: {
@@ -2099,6 +2130,11 @@ const translations: Record<LanguageCode, Translations> = {
offlineDone: 'Готово! Данныя закешированы для офлайн-потребленїя.',
offlineSelectLang: 'Пожалуйста, изберите хотя бы единъ языкъ.',
offlineCleared: 'Кешъ очищенъ.',
locationTitle: 'Мѣстоположе́нїе',
latitude: 'Широта̀',
longitude: 'Долгота̀',
requestLocation: 'Запроси́ти мѣстоположе́нїе',
locationNote: 'И҆спо́льзꙋетсѧ длѧ ра́счета восхо́да / зака́та',
},
common: {