2020-09-04 13:02:20 -05:00
|
|
|
// travel forecast display
|
2022-11-22 16:19:10 -06:00
|
|
|
import STATUS from './status.mjs';
|
2026-04-07 16:51:50 -04:00
|
|
|
import { safePromiseAll } from './utils/fetch.mjs';
|
|
|
|
|
import { getSmallIconFromWmoCode } from './icons.mjs';
|
2022-11-22 16:19:10 -06:00
|
|
|
import { DateTime } from '../vendor/auto/luxon.mjs';
|
2022-11-22 16:29:10 -06:00
|
|
|
import WeatherDisplay from './weatherdisplay.mjs';
|
2022-12-06 16:14:56 -06:00
|
|
|
import { registerDisplay } from './navigation.mjs';
|
2025-06-24 23:39:40 -04:00
|
|
|
import calculateScrollTiming from './utils/scroll-timing.mjs';
|
|
|
|
|
import { debugFlag } from './utils/debug.mjs';
|
2026-04-07 16:51:50 -04:00
|
|
|
import { temperature } from './utils/units.mjs';
|
|
|
|
|
import { getAggregatedOpenMeteoForecast } from './utils/weather.mjs';
|
2020-09-04 13:02:20 -05:00
|
|
|
|
|
|
|
|
class TravelForecast extends WeatherDisplay {
|
2020-09-18 11:24:45 -05:00
|
|
|
constructor(navId, elemId, defaultActive) {
|
2020-09-04 13:02:20 -05:00
|
|
|
// special height and width for scrolling
|
2022-11-21 21:50:22 -06:00
|
|
|
super(navId, elemId, 'Travel Forecast', defaultActive);
|
2020-09-04 15:46:31 -05:00
|
|
|
|
2025-04-02 16:45:11 -05:00
|
|
|
// add previous data cache
|
|
|
|
|
this.previousData = [];
|
2025-06-24 23:39:40 -04:00
|
|
|
|
|
|
|
|
// cache for scroll calculations
|
|
|
|
|
// This cache is essential because baseCountChange() is called 25 times per second (every 40ms)
|
|
|
|
|
// during scrolling. Travel forecast scroll duration varies based on the number of cities configured.
|
|
|
|
|
// Without caching, we'd perform hundreds of expensive DOM layout queries during each scroll cycle.
|
|
|
|
|
// The cache reduces this to one calculation when content changes, then reuses cached values to try
|
|
|
|
|
// and get smoother scrolling.
|
|
|
|
|
this.scrollCache = {
|
|
|
|
|
displayHeight: 0,
|
|
|
|
|
contentHeight: 0,
|
|
|
|
|
maxOffset: 0,
|
|
|
|
|
travelLines: null,
|
|
|
|
|
};
|
2020-09-04 13:02:20 -05:00
|
|
|
}
|
|
|
|
|
|
2025-04-02 11:10:58 -05:00
|
|
|
async getData(weatherParameters, refresh) {
|
2020-09-18 11:24:45 -05:00
|
|
|
// super checks for enabled
|
2025-04-02 16:45:11 -05:00
|
|
|
if (!super.getData(weatherParameters, refresh)) return;
|
|
|
|
|
|
|
|
|
|
// clear stored data if not refresh
|
|
|
|
|
if (!refresh) {
|
|
|
|
|
this.previousData = [];
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-07 16:51:50 -04:00
|
|
|
const temperatureConverter = temperature();
|
|
|
|
|
|
2025-04-02 16:45:11 -05:00
|
|
|
const forecastPromises = TravelCities.map(async (city, index) => {
|
2020-09-04 13:02:20 -05:00
|
|
|
try {
|
2025-04-02 16:45:11 -05:00
|
|
|
let forecast;
|
2026-04-07 16:51:50 -04:00
|
|
|
forecast = await getAggregatedOpenMeteoForecast(city.Latitude, city.Longitude);
|
2025-06-24 23:39:40 -04:00
|
|
|
|
|
|
|
|
if (forecast) {
|
2025-04-02 16:45:11 -05:00
|
|
|
// store for the next run
|
|
|
|
|
this.previousData[index] = forecast;
|
2025-06-24 23:39:40 -04:00
|
|
|
} else if (this.previousData?.[index]) {
|
2025-04-02 16:45:11 -05:00
|
|
|
// if there's previous data use it
|
2025-06-24 23:39:40 -04:00
|
|
|
if (debugFlag('travelforecast')) {
|
|
|
|
|
console.warn(`Using previous forecast data for ${city.Name} travel forecast`);
|
2025-04-02 16:45:11 -05:00
|
|
|
}
|
2025-06-24 23:39:40 -04:00
|
|
|
forecast = this.previousData?.[index];
|
|
|
|
|
} else {
|
|
|
|
|
// no current data and no previous data available
|
|
|
|
|
if (debugFlag('verbose-failures')) {
|
|
|
|
|
console.warn(`No travel forecast for ${city.Name} available`);
|
|
|
|
|
}
|
|
|
|
|
return { name: city.Name, error: true };
|
2025-04-02 16:45:11 -05:00
|
|
|
}
|
2026-04-07 16:51:50 -04:00
|
|
|
|
|
|
|
|
const [todayKey, tomorrowKey] = Object.keys(forecast.aggregatedForecast);
|
|
|
|
|
const todayForecast = forecast.aggregatedForecast[todayKey];
|
|
|
|
|
const tomorrowForecast = forecast.aggregatedForecast[tomorrowKey] ?? todayForecast;
|
|
|
|
|
if (!todayForecast) {
|
|
|
|
|
throw new Error('No aggregated travel forecast available');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const firstHour = todayForecast.hours[0] ?? {};
|
|
|
|
|
const today = Boolean(firstHour.is_day ?? 1);
|
2020-09-04 13:02:20 -05:00
|
|
|
// return a pared-down forecast
|
|
|
|
|
return {
|
2026-04-07 16:51:50 -04:00
|
|
|
today,
|
|
|
|
|
high: temperatureConverter(todayForecast.temperature_2m_max),
|
|
|
|
|
low: temperatureConverter(tomorrowForecast.temperature_2m_min),
|
2020-09-04 13:02:20 -05:00
|
|
|
name: city.Name,
|
2026-04-07 16:51:50 -04:00
|
|
|
icon: getSmallIconFromWmoCode(todayForecast.weather_code, today),
|
2020-09-04 13:02:20 -05:00
|
|
|
};
|
2023-01-06 14:39:39 -06:00
|
|
|
} catch (error) {
|
2025-06-24 23:39:40 -04:00
|
|
|
console.error(`Unexpected error getting Travel Forecast for ${city.Name}: ${error.message}`);
|
2022-11-14 14:23:31 -06:00
|
|
|
return { name: city.Name, error: true };
|
2020-09-04 13:02:20 -05:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2025-06-24 23:39:40 -04:00
|
|
|
// wait for all forecasts using centralized safe Promise handling
|
|
|
|
|
const forecasts = await safePromiseAll(forecastPromises);
|
2020-09-04 13:02:20 -05:00
|
|
|
this.data = forecasts;
|
2020-09-09 15:23:19 -05:00
|
|
|
|
|
|
|
|
// test for some data available in at least one forecast
|
2023-01-06 16:18:33 -06:00
|
|
|
const hasData = this.data.some((forecast) => forecast.high);
|
2020-09-09 15:23:19 -05:00
|
|
|
if (!hasData) {
|
|
|
|
|
this.setStatus(STATUS.noData);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2020-09-17 16:34:38 -05:00
|
|
|
this.setStatus(STATUS.loaded);
|
|
|
|
|
this.drawLongCanvas();
|
2020-09-04 13:02:20 -05:00
|
|
|
}
|
|
|
|
|
|
2020-10-29 16:44:28 -05:00
|
|
|
async drawLongCanvas() {
|
2022-09-23 15:12:10 -05:00
|
|
|
// get the element and populate
|
|
|
|
|
const list = this.elem.querySelector('.travel-lines');
|
|
|
|
|
list.innerHTML = '';
|
2020-10-20 16:37:11 -05:00
|
|
|
|
2020-09-04 13:02:20 -05:00
|
|
|
// set up variables
|
|
|
|
|
const cities = this.data;
|
|
|
|
|
|
2022-09-23 15:12:10 -05:00
|
|
|
const lines = cities.map((city) => {
|
2022-11-14 14:23:31 -06:00
|
|
|
if (city.error) return false;
|
2023-01-06 14:39:39 -06:00
|
|
|
const fillValues = {
|
|
|
|
|
city,
|
|
|
|
|
};
|
2020-09-04 13:02:20 -05:00
|
|
|
|
2020-09-17 16:34:38 -05:00
|
|
|
// check for forecast data
|
|
|
|
|
if (city.icon) {
|
2022-09-23 15:12:10 -05:00
|
|
|
fillValues.city = city.name;
|
2020-09-04 13:02:20 -05:00
|
|
|
// get temperatures and convert if necessary
|
2022-12-06 16:25:28 -06:00
|
|
|
const { low, high } = city;
|
2020-09-04 13:02:20 -05:00
|
|
|
|
2020-09-17 16:34:38 -05:00
|
|
|
// convert to strings with no decimal
|
|
|
|
|
const lowString = Math.round(low).toString();
|
|
|
|
|
const highString = Math.round(high).toString();
|
|
|
|
|
|
2022-09-23 15:12:10 -05:00
|
|
|
fillValues.low = lowString;
|
|
|
|
|
fillValues.high = highString;
|
2023-01-06 14:39:39 -06:00
|
|
|
const { icon } = city;
|
2020-09-17 16:34:38 -05:00
|
|
|
|
2023-01-06 14:39:39 -06:00
|
|
|
fillValues.icon = { type: 'img', src: icon };
|
2020-09-17 16:34:38 -05:00
|
|
|
} else {
|
2022-09-23 15:12:10 -05:00
|
|
|
fillValues.error = 'NO TRAVEL DATA AVAILABLE';
|
2020-09-17 16:34:38 -05:00
|
|
|
}
|
2022-09-23 15:12:10 -05:00
|
|
|
return this.fillTemplate('travel-row', fillValues);
|
2022-11-14 14:23:31 -06:00
|
|
|
}).filter((d) => d);
|
2022-09-23 15:12:10 -05:00
|
|
|
list.append(...lines);
|
2025-06-24 23:39:40 -04:00
|
|
|
|
|
|
|
|
// update timing based on actual content
|
|
|
|
|
this.setTiming(list);
|
2020-09-17 16:34:38 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async drawCanvas() {
|
|
|
|
|
// there are technically 2 canvases: the standard canvas and the extra-long canvas that contains the complete
|
|
|
|
|
// list of cities. The second canvas is copied into the standard canvas to create the scroll
|
|
|
|
|
super.drawCanvas();
|
|
|
|
|
|
|
|
|
|
// set up variables
|
|
|
|
|
const cities = this.data;
|
2020-09-04 13:02:20 -05:00
|
|
|
|
2022-12-09 13:51:51 -06:00
|
|
|
this.elem.querySelector('.header .title.dual .bottom').innerHTML = `For ${getTravelCitiesDayName(cities)}`;
|
2020-09-04 13:02:20 -05:00
|
|
|
|
|
|
|
|
this.finishDraw();
|
2020-09-17 16:34:38 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async showCanvas() {
|
|
|
|
|
// special to travel forecast to draw the remainder of the canvas
|
|
|
|
|
await this.drawCanvas();
|
|
|
|
|
super.showCanvas();
|
2020-09-04 13:02:20 -05:00
|
|
|
}
|
|
|
|
|
|
2020-09-04 15:46:31 -05:00
|
|
|
// screen index change callback just runs the base count callback
|
|
|
|
|
screenIndexChange() {
|
|
|
|
|
this.baseCountChange(this.navBaseCount);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// base count change callback
|
|
|
|
|
baseCountChange(count) {
|
2025-06-24 23:39:40 -04:00
|
|
|
// get the travel lines element and cache measurements if needed
|
|
|
|
|
const travelLines = this.elem.querySelector('.travel-lines');
|
|
|
|
|
if (!travelLines) return;
|
|
|
|
|
|
|
|
|
|
// update cache if needed (when content changes or first run)
|
|
|
|
|
if (this.scrollCache.travelLines !== travelLines || this.scrollCache.displayHeight === 0) {
|
|
|
|
|
this.scrollCache.displayHeight = this.elem.querySelector('.main').offsetHeight;
|
|
|
|
|
this.scrollCache.contentHeight = travelLines.offsetHeight;
|
|
|
|
|
this.scrollCache.maxOffset = Math.max(0, this.scrollCache.contentHeight - this.scrollCache.displayHeight);
|
|
|
|
|
this.scrollCache.travelLines = travelLines;
|
|
|
|
|
|
|
|
|
|
// Set up hardware acceleration on the travel lines element
|
|
|
|
|
travelLines.style.willChange = 'transform';
|
|
|
|
|
travelLines.style.backfaceVisibility = 'hidden';
|
|
|
|
|
}
|
|
|
|
|
|
2020-09-04 15:46:31 -05:00
|
|
|
// calculate scroll offset and don't go past end
|
2025-06-24 23:39:40 -04:00
|
|
|
let offsetY = Math.min(this.scrollCache.maxOffset, (count - this.scrollTiming.initialCounts) * this.scrollTiming.pixelsPerCount);
|
2020-09-04 15:46:31 -05:00
|
|
|
|
|
|
|
|
// don't let offset go negative
|
|
|
|
|
if (offsetY < 0) offsetY = 0;
|
|
|
|
|
|
2025-06-24 23:39:40 -04:00
|
|
|
// use transform instead of scrollTo for hardware acceleration
|
|
|
|
|
travelLines.style.transform = `translateY(-${Math.round(offsetY)}px)`;
|
2020-09-04 15:46:31 -05:00
|
|
|
}
|
|
|
|
|
|
2020-09-04 13:02:20 -05:00
|
|
|
// necessary to get the lastest long canvas when scrolling
|
|
|
|
|
getLongCanvas() {
|
|
|
|
|
return this.longCanvas;
|
|
|
|
|
}
|
2025-06-24 23:39:40 -04:00
|
|
|
|
|
|
|
|
setTiming(list) {
|
|
|
|
|
const container = this.elem.querySelector('.main');
|
|
|
|
|
const timingConfig = calculateScrollTiming(list, container, {
|
|
|
|
|
staticDisplay: 5.0, // special static display time for travel forecast
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Apply the calculated timing
|
|
|
|
|
this.timing.baseDelay = timingConfig.baseDelay;
|
|
|
|
|
this.timing.delay = timingConfig.delay;
|
|
|
|
|
this.scrollTiming = timingConfig.scrollTiming;
|
|
|
|
|
|
|
|
|
|
this.calcNavTiming();
|
|
|
|
|
}
|
2020-10-29 16:44:28 -05:00
|
|
|
}
|
2022-11-22 16:19:10 -06:00
|
|
|
|
2022-12-09 13:51:51 -06:00
|
|
|
// effectively returns early on the first found date
|
|
|
|
|
const getTravelCitiesDayName = (cities) => cities.reduce((dayName, city) => {
|
|
|
|
|
if (city && dayName === '') {
|
|
|
|
|
// today or tomorrow
|
|
|
|
|
const day = DateTime.local().plus({ days: (city.today) ? 0 : 1 });
|
|
|
|
|
// return the day
|
|
|
|
|
return day.toLocaleString({ weekday: 'long' });
|
|
|
|
|
}
|
|
|
|
|
return dayName;
|
|
|
|
|
}, '');
|
|
|
|
|
|
2022-12-06 16:14:56 -06:00
|
|
|
// register display, not active by default
|
2022-12-14 16:28:33 -06:00
|
|
|
registerDisplay(new TravelForecast(5, 'travel', false));
|