From 672af4b5fbb57c4272960b20f7b771d67b4510d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D1=82=D0=BE=D0=BD=20=D0=A2=D1=80=D0=BE=D1=88?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Sun, 2 Aug 2026 02:28:43 +0300 Subject: [PATCH] feat: moon phase + sunrise/sunset with location settings --- src/core/astronomy.ts | 144 ++++++++++++++++++++++++++++++++++++++++ src/core/i18n.ts | 36 ++++++++++ src/ui/calendar-view.ts | 35 ++++++++++ src/ui/settings-view.ts | 56 ++++++++++++++++ 4 files changed, 271 insertions(+) create mode 100644 src/core/astronomy.ts diff --git a/src/core/astronomy.ts b/src/core/astronomy.ts new file mode 100644 index 0000000..722aa74 --- /dev/null +++ b/src/core/astronomy.ts @@ -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 = { + 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; } \ No newline at end of file diff --git a/src/core/i18n.ts b/src/core/i18n.ts index c74a843..b138228 100644 --- a/src/core/i18n.ts +++ b/src/core/i18n.ts @@ -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 = { 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 = { 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 = { yearOfWorld: 'год от сотворения мира', fromYear: 'от РХ', fromAdam: 'от Адама', + sunrise: 'Восход', + sunset: 'Закат', + moonPhase: 'Луна', + noLocation: 'Укажите местоположение в настройках', }, services: { @@ -1565,6 +1587,11 @@ const translations: Record = { offlineDone: 'Готово! Данные закэшированы для офлайн-использования.', offlineSelectLang: 'Пожалуйста, выберите хотя бы один язык.', offlineCleared: 'Кэш очищен.', + locationTitle: 'Местоположение', + latitude: 'Широта', + longitude: 'Долгота', + requestLocation: 'Запросить местоположение', + locationNote: 'Используется для расчёта восхода/заката', }, common: { @@ -1847,6 +1874,10 @@ const translations: Record = { yearOfWorld: 'лѣта отъ сотворенiя мiра', fromYear: 'от РХ', fromAdam: 'от Адама', + sunrise: 'Восходъ', + sunset: 'Закатъ', + moonPhase: 'Лꙋна̀', + noLocation: 'Оу҆кажі́те мѣстоположе́нїе въ настройкахъ', }, services: { @@ -2099,6 +2130,11 @@ const translations: Record = { offlineDone: 'Готово! Данныя закешированы для офлайн-потребленїя.', offlineSelectLang: 'Пожалуйста, изберите хотя бы единъ языкъ.', offlineCleared: 'Кешъ очищенъ.', + locationTitle: 'Мѣстоположе́нїе', + latitude: 'Широта̀', + longitude: 'Долгота̀', + requestLocation: 'Запроси́ти мѣстоположе́нїе', + locationNote: 'И҆спо́льзꙋетсѧ длѧ ра́счета восхо́да / зака́та', }, common: { diff --git a/src/ui/calendar-view.ts b/src/ui/calendar-view.ts index e23ffe3..3ee06f9 100644 --- a/src/ui/calendar-view.ts +++ b/src/ui/calendar-view.ts @@ -10,7 +10,9 @@ import type { FastingPeriod } from '../core/fasting'; import { getTranslations, type LanguageCode } from '../core/i18n'; import { DataCache } from '../core/data-cache'; import { fontClass } from './settings-view'; +import { loadSettings } from './settings-view'; import { ServiceView } from './service-view'; +import { calcSolarTimes, getMoonInfo, getMoonPhaseName } from '../core/astronomy'; let cachedFastingPeriods: FastingPeriod[] | null = null; const lifeCache = new Map(); @@ -596,6 +598,10 @@ export class CalendarView {

${this.t.loading}

+
+ ${this.t.loading} +
+
@@ -928,6 +934,35 @@ export class CalendarView { } } + // Astro panel: sunrise/sunset + moon phase + const astroPanel = document.getElementById('astro-panel'); + if (astroPanel) { + try { + const settings = loadSettings(); + const lat = settings.latitude; + const lng = settings.longitude; + if (lat !== undefined && lng !== undefined && !isNaN(lat) && !isNaN(lng)) { + const solar = calcSolarTimes(this.currentDate, lat, lng, 3); + const moon = getMoonInfo(this.currentDate); + const moonName = getMoonPhaseName(moon.phase, this.language); + astroPanel.innerHTML = ` + ☀ ${this.t.calendar.sunrise}: + ${solar?.sunrise ?? '—'} + | + ${this.t.calendar.sunset}: + ${solar?.sunset ?? '—'} + | + ${this.t.calendar.moonPhase}: + ${moon.emoji} ${moonName} (${moon.illumination}%) + `; + } else { + astroPanel.innerHTML = `${this.t.calendar.noLocation}`; + } + } catch { + astroPanel.innerHTML = `${this.t.loading}`; + } + } + // Load services const svcPanel = document.getElementById('services-panel'); if (svcPanel) { diff --git a/src/ui/settings-view.ts b/src/ui/settings-view.ts index 6cceb63..bf410d7 100644 --- a/src/ui/settings-view.ts +++ b/src/ui/settings-view.ts @@ -33,6 +33,8 @@ interface Settings { verseNewLine: boolean; calendarType: 'julian' | 'gregorian'; serviceRole: 'priest' | 'reader' | 'auto'; + latitude: number; + longitude: number; } const DEFAULT_SETTINGS: Settings = { @@ -44,6 +46,8 @@ const DEFAULT_SETTINGS: Settings = { verseNewLine: false, calendarType: 'julian', serviceRole: 'priest', + latitude: 55.7558, + longitude: 37.6176, }; function defaultBibleForLanguage(lang: string): string { @@ -175,6 +179,29 @@ export class SettingsView { + +
+

${t.settings.locationTitle}

+
+
+ + +
+
+ + +
+
+
+ + ${t.settings.locationNote} +
+
+

${t.settings.bibleSettings}

@@ -353,6 +380,35 @@ export class SettingsView { } }); + // Location + const saveLatLng = () => { + const lat = parseFloat((document.getElementById('latitude') as HTMLInputElement)?.value); + const lng = parseFloat((document.getElementById('longitude') as HTMLInputElement)?.value); + if (!isNaN(lat) && !isNaN(lng)) { + this.settings.latitude = lat; + this.settings.longitude = lng; + saveSettings(this.settings); + } + }; + document.getElementById('latitude')?.addEventListener('change', saveLatLng); + document.getElementById('longitude')?.addEventListener('change', saveLatLng); + document.getElementById('request-location')?.addEventListener('click', () => { + if ('geolocation' in navigator) { + navigator.geolocation.getCurrentPosition( + (pos) => { + this.settings.latitude = Math.round(pos.coords.latitude * 10000) / 10000; + this.settings.longitude = Math.round(pos.coords.longitude * 10000) / 10000; + saveSettings(this.settings); + (document.getElementById('latitude') as HTMLInputElement).value = String(this.settings.latitude); + (document.getElementById('longitude') as HTMLInputElement).value = String(this.settings.longitude); + }, + () => alert('Could not get location. Make sure location access is allowed.') + ); + } else { + alert('Geolocation is not available in this browser.'); + } + }); + // Offline content - auto-select ru+cu when ru is selected document.querySelectorAll('.offline-lang').forEach(el => { el.addEventListener('change', () => {