From 09a21e6f5a7d6115178b4bfc23da117612f8edb3 Mon Sep 17 00:00:00 2001 From: Eddy G Date: Tue, 24 Jun 2025 23:08:25 -0400 Subject: [PATCH] Add content-aware transition timing; remove expired forecasts - DOM-based measurement system for accurate forecast lines - Replace fixed-timing with dynamic timing based on actual forecast lines - Filter out expired forecasts - Improve error handling and only set failed state if enabled - Debug logging for timing calculations and content measurement - Switch from json() to safeJson() for centralized error handling --- server/scripts/modules/localforecast.mjs | 226 ++++++++++++++++++++--- 1 file changed, 197 insertions(+), 29 deletions(-) diff --git a/server/scripts/modules/localforecast.mjs b/server/scripts/modules/localforecast.mjs index da02279..e305714 100644 --- a/server/scripts/modules/localforecast.mjs +++ b/server/scripts/modules/localforecast.mjs @@ -1,17 +1,21 @@ // display text based local forecast import STATUS from './status.mjs'; -import { json } from './utils/fetch.mjs'; +import { safeJson } from './utils/fetch.mjs'; import WeatherDisplay from './weatherdisplay.mjs'; import { registerDisplay } from './navigation.mjs'; import settings from './settings.mjs'; +import filterExpiredPeriods from './utils/forecast-utils.mjs'; +import { debugFlag } from './utils/debug.mjs'; class LocalForecast extends WeatherDisplay { + static BASE_FORECAST_DURATION_MS = 5000; // Base duration (in ms) for a standard 3-5 line forecast page + constructor(navId, elemId) { super(navId, elemId, 'Local Forecast', true); // set timings - this.timing.baseDelay = 5000; + this.timing.baseDelay = LocalForecast.BASE_FORECAST_DURATION_MS; } async getData(weatherParameters, refresh) { @@ -22,13 +26,13 @@ class LocalForecast extends WeatherDisplay { // check for data, or if there's old data available if (!rawData && !this.data) { // fail for no old or new data - this.setStatus(STATUS.failed); + if (this.isEnabled) this.setStatus(STATUS.failed); return; } // store the data this.data = rawData || this.data; - // parse raw data - const conditions = parse(this.data); + // parse raw data and filter out expired periods + const conditions = parse(this.data, this.weatherParameters.forecast); // read each text this.screenTexts = conditions.map((condition) => { @@ -46,34 +50,32 @@ class LocalForecast extends WeatherDisplay { forecastsElem.innerHTML = ''; forecastsElem.append(...templates); - // increase each forecast height to a multiple of container height + // Get page height for screen calculations this.pageHeight = forecastsElem.parentNode.offsetHeight; - templates.forEach((forecast) => { - const newHeight = Math.ceil(forecast.scrollHeight / this.pageHeight) * this.pageHeight; - forecast.style.height = `${newHeight}px`; - }); - this.timing.totalScreens = forecastsElem.scrollHeight / this.pageHeight; + this.calculateContentAwareTiming(templates); + this.calcNavTiming(); + this.setStatus(STATUS.loaded); } // get the unformatted data (also used by extended forecast) async getRawData(weatherParameters) { - // request us or si units - try { - return await json(weatherParameters.forecast, { - data: { - units: settings.units.value, - }, - retryCount: 3, - stillWaiting: () => this.stillWaiting(), - }); - } catch (error) { - console.error(`GetWeatherForecast failed: ${weatherParameters.forecast}`); - console.error(error.status, error.responseJSON); + // request us or si units using centralized safe handling + const data = await safeJson(weatherParameters.forecast, { + data: { + units: settings.units.value, + }, + retryCount: 3, + stillWaiting: () => this.stillWaiting(), + }); + + if (!data) { return false; } + + return data; } async drawCanvas() { @@ -84,14 +86,180 @@ class LocalForecast extends WeatherDisplay { this.finishDraw(); } + + // calculate dynamic timing based on height measurement template approach + calculateContentAwareTiming(templates) { + if (!templates || templates.length === 0) { + this.timing.delay = 1; // fallback to single delay if no templates + return; + } + + // Use the original base duration constant for timing calculations + const originalBaseDuration = LocalForecast.BASE_FORECAST_DURATION_MS; + this.timing.baseDelay = 250; // use 250ms per count for precise timing control + + // Get line height from CSS for accurate calculations + const sampleForecast = templates[0]; + const computedStyle = window.getComputedStyle(sampleForecast); + const lineHeight = parseInt(computedStyle.lineHeight, 10); + + // Calculate the actual width that forecast text uses + // Use the forecast container that's already been set up + const forecastContainer = this.elem.querySelector('.local-forecast .container'); + let effectiveWidth; + + if (!forecastContainer) { + console.error('LocalForecast: Could not find forecast container for width calculation, using fallback width'); + effectiveWidth = 492; // "magic number" from manual calculations as fallback + } else { + const containerStyle = window.getComputedStyle(forecastContainer); + const containerWidth = forecastContainer.offsetWidth; + const paddingLeft = parseInt(containerStyle.paddingLeft, 10) || 0; + const paddingRight = parseInt(containerStyle.paddingRight, 10) || 0; + effectiveWidth = containerWidth - paddingLeft - paddingRight; + + if (debugFlag('localforecast')) { + console.log(`LocalForecast: Using measurement width of ${effectiveWidth}px (container=${containerWidth}px, padding=${paddingLeft}+${paddingRight}px)`); + } + } + + // Measure each forecast period to get actual line counts + const forecastLineCounts = []; + templates.forEach((template, index) => { + const currentHeight = template.offsetHeight; + const currentLines = Math.round(currentHeight / lineHeight); + + if (currentLines > 7) { + // Multi-page forecasts measure correctly, so use the measurement directly + forecastLineCounts.push(currentLines); + + if (debugFlag('localforecast')) { + console.log(`LocalForecast: Forecast ${index} measured ${currentLines} lines (${currentHeight}px direct measurement, ${lineHeight}px line-height)`); + } + } else { + // If may be 7 lines or less, we need to pad the content to ensure proper height measurement + // Short forecasts are capped by CSS min-height: 280px (7 lines) + // Add 7
tags to force height beyond the minimum, then subtract the padding + const originalHTML = template.innerHTML; + const paddingBRs = '
'.repeat(7); + template.innerHTML = originalHTML + paddingBRs; + + // Measure the padded height + const paddedHeight = template.offsetHeight; + const paddedLines = Math.round(paddedHeight / lineHeight); + + // Calculate actual content lines by subtracting the 7 BR lines we added + const actualLines = Math.max(1, paddedLines - 7); + + // Restore original content + template.innerHTML = originalHTML; + + forecastLineCounts.push(actualLines); + + if (debugFlag('localforecast')) { + console.log(`LocalForecast: Forecast ${index} measured ${actualLines} lines (${paddedHeight}px with padding - ${7 * lineHeight}px = ${actualLines * lineHeight}px actual, ${lineHeight}px line-height)`); + } + } + }); + + // Apply height padding for proper scrolling display (keep existing system working) + templates.forEach((forecast) => { + const newHeight = Math.ceil(forecast.offsetHeight / this.pageHeight) * this.pageHeight; + forecast.style.height = `${newHeight}px`; + }); + + // Calculate total screens based on padded height (for navigation system) + const forecastsElem = templates[0].parentNode; + const totalHeight = forecastsElem.scrollHeight; + this.timing.totalScreens = Math.round(totalHeight / this.pageHeight); + + // Now calculate timing based on actual measured line counts, ignoring padding + const maxLinesPerScreen = 7; // 280px / 40px line height + const screenTimings = []; forecastLineCounts.forEach((lines, forecastIndex) => { + if (lines <= maxLinesPerScreen) { + // Single screen for this forecast + screenTimings.push({ forecastIndex, lines, type: 'single' }); + } else { + // Multiple screens for this forecast + let remainingLines = lines; + let isFirst = true; + + while (remainingLines > 0) { + const linesThisScreen = Math.min(remainingLines, maxLinesPerScreen); + const type = isFirst ? 'first-of-multi' : 'remainder'; + + screenTimings.push({ forecastIndex, lines: linesThisScreen, type }); + + remainingLines -= linesThisScreen; + isFirst = false; + } + } + }); + + // Create timing array based on measured line counts + const screenDelays = screenTimings.map((screenInfo, screenIndex) => { + const screenLines = screenInfo.lines; + + // Apply timing rules based on actual screen content lines + let timingMultiplier; + if (screenLines === 1) { + timingMultiplier = 0.6; // 1 line = shortest (3.0s at normal speed) + } else if (screenLines === 2) { + timingMultiplier = 0.8; // 2 lines = shorter (4.0s at normal speed) + } else if (screenLines >= 6) { + timingMultiplier = 1.4; // 6+ lines = longer (7.0s at normal speed) + } else { + timingMultiplier = 1.0; // 3-5 lines = normal (5.0s at normal speed) + } + + // Convert to base counts + const desiredDurationMs = timingMultiplier * originalBaseDuration; + const baseCounts = Math.round(desiredDurationMs / this.timing.baseDelay); + + if (debugFlag('localforecast')) { + console.log(`LocalForecast: Screen ${screenIndex}: ${screenLines} lines, ${timingMultiplier.toFixed(2)}x multiplier, ${desiredDurationMs}ms desired, ${baseCounts} counts (forecast ${screenInfo.forecastIndex}, ${screenInfo.type})`); + } + + return baseCounts; + }); + + // Adjust timing array to match actual screen count if needed + while (screenDelays.length < this.timing.totalScreens) { + // Add fallback timing for extra screens + const fallbackCounts = Math.round(originalBaseDuration / this.timing.baseDelay); + screenDelays.push(fallbackCounts); + console.warn(`LocalForecast: using fallback timing for Screen ${screenDelays.length - 1}: 5 lines, 1.00x multiplier, ${fallbackCounts} counts`); + } + + // Truncate if we have too many calculated screens + if (screenDelays.length > this.timing.totalScreens) { + const removed = screenDelays.splice(this.timing.totalScreens); + console.warn(`LocalForecast: Truncated ${removed.length} excess screen timings`); + } + + // Set the timing array based on screen content + this.timing.delay = screenDelays; + + if (debugFlag('localforecast')) { + console.log(`LocalForecast: Final screen count - calculated: ${screenTimings.length}, actual: ${this.timing.totalScreens}, timing array: ${screenDelays.length}`); + const multipliers = screenDelays.map((counts) => counts * this.timing.baseDelay / originalBaseDuration); + console.log('LocalForecast: Screen multipliers:', multipliers); + console.log('LocalForecast: Expected durations (ms):', screenDelays.map((counts) => counts * this.timing.baseDelay)); + } + } } // format the forecast -// only use the first 6 lines -const parse = (forecast) => forecast.properties.periods.slice(0, 6).map((text) => ({ - // format day and text - DayName: text.name.toUpperCase(), - Text: text.detailedForecast, -})); +// filter out expired periods, then use the first 6 forecasts +const parse = (forecast, forecastUrl) => { + const allPeriods = forecast.properties.periods; + const activePeriods = filterExpiredPeriods(allPeriods, forecastUrl); + + return activePeriods.slice(0, 6).map((text) => ({ + // format day and text + DayName: text.name.toUpperCase(), + Text: text.detailedForecast, + })); +}; // register display registerDisplay(new LocalForecast(7, 'local-forecast'));