ws4kp-linhanced/server/scripts/modules/travelforecast.mjs

322 lines
9.5 KiB
JavaScript
Raw Normal View History

2020-09-04 13:02:20 -05:00
// travel forecast display
2022-11-22 16:19:10 -06:00
import STATUS from './status.mjs';
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';
import calculateScrollTiming from './utils/scroll-timing.mjs';
import { debugFlag } from './utils/debug.mjs';
import { temperature } from './utils/units.mjs';
import { getCachedAggregatedOpenMeteoForecast } from './utils/weather.mjs';
const MIN_TRAVEL_CITIES = 5;
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);
// 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
}
resetScrollCache() {
this.scrollCache = {
displayHeight: 0,
contentHeight: 0,
maxOffset: 0,
travelLines: null,
};
}
2025-04-02 11:10:58 -05:00
async getData(weatherParameters, refresh) {
2020-09-18 11:24:45 -05:00
// super checks for enabled
if (!super.getData(weatherParameters, refresh)) return;
const temperatureConverter = temperature();
const selectedTravelCities = getTravelCitiesForLocation(this.weatherParameters);
const forecastPromises = selectedTravelCities.map(async (city) => {
2020-09-04 13:02:20 -05:00
try {
const forecast = await getCachedAggregatedOpenMeteoForecast(city.Latitude, city.Longitude);
if (!forecast) {
if (debugFlag('verbose-failures')) {
console.warn(`No travel forecast for ${city.Name} available`);
}
return { name: city.Name, error: true };
}
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 {
today,
high: temperatureConverter(todayForecast.temperature_2m_max),
low: temperatureConverter(tomorrowForecast.temperature_2m_min),
2020-09-04 13:02:20 -05:00
name: city.Name,
icon: getSmallIconFromWmoCode(todayForecast.weather_code, today),
2020-09-04 13:02:20 -05:00
};
2023-01-06 14:39:39 -06:00
} catch (error) {
console.error(`Unexpected error getting Travel Forecast for ${city.Name}: ${error.message}`);
return { name: city.Name, error: true };
2020-09-04 13:02:20 -05:00
}
});
// wait for all forecasts using centralized safe Promise handling
const forecasts = await safePromiseAll(forecastPromises);
const validForecasts = forecasts.filter((forecast) => forecast && !forecast.error && forecast.high !== undefined);
const invalidForecasts = forecasts.filter((forecast) => forecast && forecast.error);
this.data = validForecasts.length >= MIN_TRAVEL_CITIES
? validForecasts
: [...validForecasts, ...invalidForecasts].slice(0, Math.max(validForecasts.length, MIN_TRAVEL_CITIES));
// 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);
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 = '';
this.resetScrollCache();
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) => {
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);
}).filter((d) => d);
2022-09-23 15:12:10 -05:00
list.append(...lines);
// 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
}
// screen index change callback just runs the base count callback
screenIndexChange() {
this.baseCountChange(this.navBaseCount);
}
// base count change callback
baseCountChange(count) {
// get the travel lines element and cache measurements if needed
const travelLines = this.elem.querySelector('.travel-lines');
if (!travelLines) return;
const displayHeight = getTravelDisplayHeight(this.elem);
// update cache if needed (when content changes or first run)
if (this.scrollCache.travelLines !== travelLines || this.scrollCache.displayHeight !== displayHeight) {
this.scrollCache.displayHeight = displayHeight;
this.scrollCache.contentHeight = travelLines.scrollHeight;
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';
}
// calculate scroll offset and don't go past end
let offsetY = Math.min(this.scrollCache.maxOffset, (count - this.scrollTiming.initialCounts) * this.scrollTiming.pixelsPerCount);
// don't let offset go negative
if (offsetY < 0) offsetY = 0;
// use transform instead of scrollTo for hardware acceleration
travelLines.style.transform = `translateY(-${Math.round(offsetY)}px)`;
}
2020-09-04 13:02:20 -05:00
// necessary to get the lastest long canvas when scrolling
getLongCanvas() {
return this.longCanvas;
}
setTiming(list) {
const timingConfig = calculateScrollTiming(list, {
offsetHeight: getTravelDisplayHeight(this.elem),
}, {
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
const REGION_BY_COUNTRY_CODE = {
US: 'north-america',
CA: 'north-america',
MX: 'north-america',
BR: 'south-america',
AR: 'south-america',
CL: 'south-america',
PE: 'south-america',
CO: 'south-america',
EC: 'south-america',
UY: 'south-america',
PY: 'south-america',
BO: 'south-america',
VE: 'south-america',
GB: 'europe',
IE: 'europe',
FR: 'europe',
DE: 'europe',
ES: 'europe',
PT: 'europe',
IT: 'europe',
NL: 'europe',
BE: 'europe',
LU: 'europe',
CH: 'europe',
AT: 'europe',
DK: 'europe',
NO: 'europe',
SE: 'europe',
FI: 'europe',
PL: 'europe',
CZ: 'europe',
SK: 'europe',
HU: 'europe',
RO: 'europe',
BG: 'europe',
GR: 'europe',
HR: 'europe',
SI: 'europe',
RS: 'europe',
BA: 'europe',
ME: 'europe',
AL: 'europe',
MK: 'europe',
EE: 'europe',
LV: 'europe',
LT: 'europe',
IS: 'europe',
UA: 'europe',
MD: 'europe',
JP: 'east-asia',
KR: 'east-asia',
CN: 'east-asia',
TW: 'east-asia',
HK: 'east-asia',
MO: 'east-asia',
AU: 'oceania',
NZ: 'oceania',
EG: 'africa',
NG: 'africa',
KE: 'africa',
ZA: 'africa',
MA: 'africa',
TZ: 'africa',
GH: 'africa',
DZ: 'africa',
AE: 'middle-east',
SA: 'middle-east',
IL: 'middle-east',
JO: 'middle-east',
QA: 'middle-east',
KW: 'middle-east',
OM: 'middle-east',
BH: 'middle-east',
LB: 'middle-east',
IQ: 'middle-east',
};
const getTravelCitiesForLocation = (weatherParameters) => {
if (Array.isArray(TravelCities)) return TravelCities;
const countryCode = (weatherParameters?.countryCode ?? '').toUpperCase();
const selectedRegion = REGION_BY_COUNTRY_CODE[countryCode];
return TravelCities[selectedRegion] ?? TravelCities.global ?? TravelCities['north-america'] ?? [];
};
const getTravelDisplayHeight = (elem) => {
const main = elem.querySelector('.main');
const header = elem.querySelector('.column-headers');
if (!main) return 0;
return Math.max(0, main.offsetHeight - (header?.offsetHeight ?? 0));
};
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));