diff --git a/server/scripts/modules/travelforecast.mjs b/server/scripts/modules/travelforecast.mjs index bf3b9f1..d5b4f02 100644 --- a/server/scripts/modules/travelforecast.mjs +++ b/server/scripts/modules/travelforecast.mjs @@ -1,34 +1,34 @@ // travel forecast display import STATUS from './status.mjs'; -import { json } from './utils/fetch.mjs'; +import { safeJson, safePromiseAll } from './utils/fetch.mjs'; import { getSmallIcon } from './icons.mjs'; import { DateTime } from '../vendor/auto/luxon.mjs'; import WeatherDisplay from './weatherdisplay.mjs'; import { registerDisplay } from './navigation.mjs'; import settings from './settings.mjs'; +import calculateScrollTiming from './utils/scroll-timing.mjs'; +import { debugFlag } from './utils/debug.mjs'; class TravelForecast extends WeatherDisplay { constructor(navId, elemId, defaultActive) { // special height and width for scrolling super(navId, elemId, 'Travel Forecast', defaultActive); - // set up the timing - this.timing.baseDelay = 20; - // page sizes are 4 cities, calculate the number of pages necessary plus overflow - const pagesFloat = TravelCities.length / 4; - const pages = Math.floor(pagesFloat) - 2; // first page is already displayed, last page doesn't happen - const extra = pages % 1; - const timingStep = 75 * 4; - this.timing.delay = [150 + timingStep]; - // add additional pages - for (let i = 0; i < pages; i += 1) this.timing.delay.push(timingStep); - // add the extra (not exactly 4 pages portion) - if (extra !== 0) this.timing.delay.push(Math.round(this.extra * this.cityHeight)); - // add the final 3 second delay - this.timing.delay.push(150); - // add previous data cache this.previousData = []; + + // 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, + }; } async getData(weatherParameters, refresh) { @@ -45,22 +45,27 @@ class TravelForecast extends WeatherDisplay { // get point then forecast if (!city.point) throw new Error('No pre-loaded point'); let forecast; - try { - forecast = await json(`https://api.weather.gov/gridpoints/${city.point.wfo}/${city.point.x},${city.point.y}/forecast`, { - data: { - units: settings.units.value, - }, - }); + forecast = await safeJson(`https://api.weather.gov/gridpoints/${city.point.wfo}/${city.point.x},${city.point.y}/forecast`, { + data: { + units: settings.units.value, + }, + }); + + if (forecast) { // store for the next run this.previousData[index] = forecast; - } catch (e) { + } else if (this.previousData?.[index]) { // if there's previous data use it - if (this.previousData?.[index]) { - forecast = this.previousData?.[index]; - } else { - // otherwise re-throw for the standard error handling - throw (e); + if (debugFlag('travelforecast')) { + console.warn(`Using previous forecast data for ${city.Name} travel forecast`); } + 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 }; } // determine today or tomorrow (shift periods by 1 if tomorrow) const todayShift = forecast.properties.periods[0].isDaytime ? 0 : 1; @@ -73,14 +78,13 @@ class TravelForecast extends WeatherDisplay { icon: getSmallIcon(forecast.properties.periods[todayShift].icon), }; } catch (error) { - console.error(`GetTravelWeather for ${city.Name} failed`); - console.error(error.status, error.responseJSON); + console.error(`Unexpected error getting Travel Forecast for ${city.Name}: ${error.message}`); return { name: city.Name, error: true }; } }); - // wait for all forecasts - const forecasts = await Promise.all(forecastPromises); + // wait for all forecasts using centralized safe Promise handling + const forecasts = await safePromiseAll(forecastPromises); this.data = forecasts; // test for some data available in at least one forecast @@ -129,6 +133,9 @@ class TravelForecast extends WeatherDisplay { return this.fillTemplate('travel-row', fillValues); }).filter((d) => d); list.append(...lines); + + // update timing based on actual content + this.setTiming(list); } async drawCanvas() { @@ -157,20 +164,50 @@ class TravelForecast extends WeatherDisplay { // 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; + + // 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'; + } + // calculate scroll offset and don't go past end - let offsetY = Math.min(this.elem.querySelector('.travel-lines').offsetHeight - 289, (count - 150)); + 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; - // copy the scrolled portion of the canvas - this.elem.querySelector('.main').scrollTo(0, offsetY); + // use transform instead of scrollTo for hardware acceleration + travelLines.style.transform = `translateY(-${Math.round(offsetY)}px)`; } // necessary to get the lastest long canvas when scrolling getLongCanvas() { return this.longCanvas; } + + 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(); + } } // effectively returns early on the first found date diff --git a/server/scripts/modules/utils/scroll-timing.mjs b/server/scripts/modules/utils/scroll-timing.mjs new file mode 100644 index 0000000..a647a46 --- /dev/null +++ b/server/scripts/modules/utils/scroll-timing.mjs @@ -0,0 +1,66 @@ +// Utility functions for dynamic scroll timing calculations + +/** + * Calculate dynamic scroll timing based on actual content dimensions + * @param {HTMLElement} list - The scrollable content element + * @param {HTMLElement} container - The container element (for measuring display height) + * @param {Object} options - Timing configuration options + * @param {number} options.scrollSpeed - Pixels per second scroll speed (default: 50) + * @param {number} options.initialDelay - Seconds before scrolling starts (default: 3.0) + * @param {number} options.finalPause - Seconds after scrolling ends (default: 3.0) + * @param {number} options.staticDisplay - Seconds for static display when no scrolling needed (default: same as initialDelay + finalPause) + * @param {number} options.baseDelay - Milliseconds per timing count (default: 40) + * @returns {Object} Timing configuration object with delay array, scrollTiming, and baseDelay + */ +const calculateScrollTiming = (list, container, options = {}) => { + const { + scrollSpeed = 50, + initialDelay = 3.0, + finalPause = 3.0, + staticDisplay = initialDelay + finalPause, + baseDelay = 40, + } = options; + + // timing conversion helper + const secondsToTimingCounts = (seconds) => Math.ceil(seconds * 1000 / baseDelay); + + // calculate actual scroll distance needed + const displayHeight = container.offsetHeight; + const contentHeight = list.scrollHeight; + const scrollableHeight = Math.max(0, contentHeight - displayHeight); + + // calculate scroll time based on actual distance and speed + const scrollTimeSeconds = scrollableHeight > 0 ? scrollableHeight / scrollSpeed : 0; + + // convert seconds to timing counts + const initialCounts = secondsToTimingCounts(initialDelay); + const scrollCounts = secondsToTimingCounts(scrollTimeSeconds); + const finalCounts = secondsToTimingCounts(finalPause); + const staticCounts = secondsToTimingCounts(staticDisplay); + + // calculate pixels per count based on our actual scroll distance and time + // This ensures the scroll animation matches our timing perfectly + const pixelsPerCount = scrollCounts > 0 ? scrollableHeight / scrollCounts : 0; + + // Build timing array - simple approach + const delay = []; + + if (scrollableHeight === 0) { + // No scrolling needed - just show static content + delay.push(staticCounts); + } else { + // Initial delay + scroll time + final pause + delay.push(initialCounts + scrollCounts + finalCounts); + } + + return { + baseDelay, + delay, + scrollTiming: { + initialCounts, + pixelsPerCount, + }, + }; +}; + +export default calculateScrollTiming;