feat: moon phase + sunrise/sunset with location settings
This commit is contained in:
1 parent
5dd8a947c6
commit
672af4b5fb
4 files changed
+271
No files matched your search
@@ -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; }
|
||||
@@ -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: {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in new issue
Block a user