// display sun and moon data import { preloadImg } from './utils/image.mjs'; import { DateTime } from '../vendor/auto/luxon.mjs'; import STATUS from './status.mjs'; import WeatherDisplay from './weatherdisplay.mjs'; import { registerDisplay, timeZone } from './navigation.mjs'; class Almanac extends WeatherDisplay { constructor(navId, elemId) { super(navId, elemId, 'Almanac', true); // occasional degraded moon icon this.iconPaths = { Full: imageName(Math.random() > 0.995 ? 'Degraded' : 'Full'), Last: imageName('Last'), New: imageName('New'), First: imageName('First'), }; // preload the moon images preloadImg(this.iconPaths.Full); preloadImg(this.iconPaths.Last); preloadImg(this.iconPaths.New); preloadImg(this.iconPaths.First); this.timing.totalScreens = 1; } async getData(weatherParameters, refresh) { const superResponse = super.getData(weatherParameters, refresh); // get sun/moon data const { sun, moon, moonTransit } = this.calcSunMoonData(this.weatherParameters); // store the data this.data = { sun, moon, moonTransit, }; // share data this.getDataCallback(); if (!superResponse) return; // update status this.setStatus(STATUS.loaded); } calcSunMoonData(weatherParameters) { const dayOffsets = [0, 1, 2, 3, 4, 5, 6]; const sun = dayOffsets.map((days) => SunCalc.getTimes(DateTime.local().plus({ days }).toJSDate(), weatherParameters.latitude, weatherParameters.longitude)); const moonTransit = dayOffsets.map((days) => SunCalc.getMoonTimes(DateTime.local().plus({ days }).toJSDate(), weatherParameters.latitude, weatherParameters.longitude)); // brute force the moon phases by scanning the next 30 days const moon = []; // start with yesterday let moonDate = DateTime.local().minus({ days: 1 }); let { phase } = SunCalc.getMoonIllumination(moonDate.toJSDate()); let iterations = 0; do { // get yesterday's moon info const lastPhase = phase; // calculate new values moonDate = moonDate.plus({ days: 1 }); phase = SunCalc.getMoonIllumination(moonDate.toJSDate()).phase; // check for 4 cases if (lastPhase < 0.25 && phase >= 0.25) moon.push(this.getMoonTransition(0.25, 'First', moonDate)); if (lastPhase < 0.50 && phase >= 0.50) moon.push(this.getMoonTransition(0.50, 'Full', moonDate)); if (lastPhase < 0.75 && phase >= 0.75) moon.push(this.getMoonTransition(0.75, 'Last', moonDate)); if (lastPhase > phase) moon.push(this.getMoonTransition(0.00, 'New', moonDate)); // stop after 30 days or 4 moon phases iterations += 1; } while (iterations <= 45 && moon.length < 5); return { sun, moon, moonTransit, }; } // get moon transition from one phase to the next by drilling down by hours, minutes and seconds getMoonTransition(threshold, phaseName, start, iteration = 0) { let moonDate = start; let { phase } = SunCalc.getMoonIllumination(moonDate.toJSDate()); let iterations = 0; const step = { hours: iteration === 0 ? -1 : 0, minutes: iteration === 1 ? 1 : 0, seconds: iteration === 2 ? -1 : 0, milliseconds: iteration === 3 ? 1 : 0, }; // increasing test let test = (lastPhase, testPhase) => lastPhase < threshold && testPhase >= threshold; // decreasing test if (iteration % 2 === 0) test = (lastPhase, testPhase) => lastPhase > threshold && testPhase <= threshold; do { // store last phase const lastPhase = phase; // calculate new phase after step moonDate = moonDate.plus(step); phase = SunCalc.getMoonIllumination(moonDate.toJSDate()).phase; // wrap phases > 0.9 to -0.1 for ease of detection if (phase > 0.9) phase -= 1.0; // compare if (test(lastPhase, phase)) { // last iteration is three, return value if (iteration >= 3) break; // iterate recursively return this.getMoonTransition(threshold, phaseName, moonDate, iteration + 1); } iterations += 1; } while (iterations < 1000); return { phase: phaseName, date: moonDate }; } async drawCanvas() { super.drawCanvas(); const info = this.data; // Generate sun data grid in reading order (left-to-right, top-to-bottom) // Set day names and sunset times const Today = DateTime.local(); const portraitLines = []; const moonPortraitLines = []; // fill all days, even if some are hidden by the mode selection. for (let i = 0; i < 7; i += 1) { // format some data const dayName = Today.plus({ days: i }).toLocaleString({ weekday: 'long' }); const sunrise = formatTimeForColumn(DateTime.fromJSDate(info.sun[i].sunrise)); const sunset = formatTimeForColumn(DateTime.fromJSDate(info.sun[i].sunset)); // these only use the first 3 for standard and wide if (i < 3) { this.elem.querySelector(`.day-${i}`).textContent = dayName; this.elem.querySelector(`.rise-${i}`).textContent = sunrise; this.elem.querySelector(`.set-${i}`).textContent = sunset; } // and also fill the portrait oriented info portraitLines.push(this.fillTemplate('dayname', { 'grid-item': dayName })); portraitLines.push(this.fillTemplate('sunrise', { 'grid-item': sunrise })); portraitLines.push(this.fillTemplate('sunset', { 'grid-item': sunset })); // including the bonus moon rise/set data in portrait const moonrise = formatTimeForColumn(DateTime.fromJSDate(info.moonTransit[i].rise)); const moonset = formatTimeForColumn(DateTime.fromJSDate(info.moonTransit[i].set)); moonPortraitLines.push(this.fillTemplate('dayname', { 'grid-item': dayName })); moonPortraitLines.push(this.fillTemplate('sunrise', { 'grid-item': moonrise })); moonPortraitLines.push(this.fillTemplate('sunset', { 'grid-item': moonset })); } // add the portrait lines to the page const sunPortrait = this.elem.querySelector('.sun-portrait'); const replaceable = sunPortrait.querySelectorAll(':has(.replaceable)'); replaceable.forEach((elem) => elem.remove()); sunPortrait.append(...portraitLines); // and the moon too const moonPortrait = this.elem.querySelector('.moonrise.sun-portrait'); const moonReplaceable = moonPortrait.querySelectorAll(':has(.replaceable)'); moonReplaceable.forEach((elem) => elem.remove()); moonPortrait.append(...moonPortraitLines); // Moon data const days = info.moon.map((MoonPhase, idx) => { const fill = {}; const date = MoonPhase.date.toLocaleString({ month: 'short', day: 'numeric' }); fill.date = date; fill.type = MoonPhase.phase; fill.icon = { type: 'img', src: this.iconPaths[MoonPhase.phase] }; const filledTemplate = this.fillTemplate('day', fill); // add class to hide >4 moon phases when not wide-enhanced if (idx > 3) { filledTemplate.classList.add('wide-enhanced'); } return filledTemplate; }); const daysContainer = this.elem.querySelector('.moon .days'); daysContainer.innerHTML = ''; daysContainer.append(...days); this.finishDraw(); } // make sun and moon data available outside this class // promise allows for data to be requested before it is available async getSun() { return new Promise((resolve) => { if (this.data) resolve(this.data); // data not available, put it into the data callback queue this.getDataCallbacks.push(resolve); }); } } const imageName = (type) => { switch (type) { case 'Full': return 'images/icons/moon-phases/Full-Moon.gif'; case 'Degraded': return 'images/icons/moon-phases/Full-Moon-Degraded.gif'; case 'Last': return 'images/icons/moon-phases/Last-Quarter.gif'; case 'New': return 'images/icons/moon-phases/New-Moon.gif'; case 'First': default: return 'images/icons/moon-phases/First-Quarter.gif'; } }; const formatTimeForColumn = (time) => { // moonrise and set may not have a time each day if (!time.isValid) return '-'; const formatted = time.setZone(timeZone()).toFormat('h:mm a').toUpperCase(); // If mixed digit lengths, pad single-digit hours with non-breaking space if (formatted.length === 8) { return formatted; } return `\u00A0${formatted}`; }; // register display const display = new Almanac(9, 'almanac'); registerDisplay(display); export default display.getSun.bind(display);