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: {
+35
View File
@@ -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<string, LifeData | null>();
@@ -596,6 +598,10 @@ export class CalendarView {
<p class="text-sm text-navy-light italic">${this.t.loading}</p>
</div>
<div id="astro-panel" class="mb-4 p-2 bg-white/50 border border-gold/20 rounded-lg text-sm text-navy">
<span class="text-navy-light italic">${this.t.loading}</span>
</div>
<div id="services-panel" class="mb-4 border-t border-gold/20 pt-3"></div>
</div>
</div>
@@ -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 = `
<span class="font-bold text-navy">☀ ${this.t.calendar.sunrise}:</span>
<span class="text-navy">${solar?.sunrise ?? '—'}</span>
<span class="mx-2 text-gold/40">|</span>
<span class="font-bold text-navy">${this.t.calendar.sunset}:</span>
<span class="text-navy">${solar?.sunset ?? '—'}</span>
<span class="mx-2 text-gold/40">|</span>
<span class="font-bold text-navy">${this.t.calendar.moonPhase}:</span>
<span class="text-navy">${moon.emoji} ${moonName} (${moon.illumination}%)</span>
`;
} else {
astroPanel.innerHTML = `<span class="text-navy-light italic">${this.t.calendar.noLocation}</span>`;
}
} catch {
astroPanel.innerHTML = `<span class="text-navy-light italic">${this.t.loading}</span>`;
}
}
// Load services
const svcPanel = document.getElementById('services-panel');
if (svcPanel) {
+56
View File
@@ -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 {
</div>
</div>
<!-- Location -->
<div class="mb-6">
<h3 class="text-lg font-bold text-red mb-3">${t.settings.locationTitle}</h3>
<div class="flex gap-2 mb-2">
<div class="flex-1">
<label class="block text-xs font-bold text-navy mb-1">${t.settings.latitude}</label>
<input type="number" id="latitude" step="0.0001" value="${this.settings.latitude}"
class="w-full border border-gold/30 rounded p-2 bg-white text-navy text-sm">
</div>
<div class="flex-1">
<label class="block text-xs font-bold text-navy mb-1">${t.settings.longitude}</label>
<input type="number" id="longitude" step="0.0001" value="${this.settings.longitude}"
class="w-full border border-gold/30 rounded p-2 bg-white text-navy text-sm">
</div>
</div>
<div class="flex items-center gap-2">
<button id="request-location" class="bg-navy text-parchment rounded px-3 py-1 text-sm hover:bg-navy-light transition-colors">
${t.settings.requestLocation}
</button>
<span class="text-xs text-navy-light">${t.settings.locationNote}</span>
</div>
</div>
<!-- Bible Settings -->
<div class="mb-6">
<h3 class="text-lg font-bold text-red mb-3">${t.settings.bibleSettings}</h3>
@@ -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', () => {