2020-09-04 13:02:20 -05:00
// display text based local forecast
2022-11-22 16:19:10 -06:00
import STATUS from './status.mjs' ;
2025-06-24 23:08:25 -04:00
import { safeJson } from './utils/fetch.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-02-23 23:29:39 -06:00
import settings from './settings.mjs' ;
2025-06-24 23:08:25 -04:00
import filterExpiredPeriods from './utils/forecast-utils.mjs' ;
import { debugFlag } from './utils/debug.mjs' ;
2020-09-04 13:02:20 -05:00
class LocalForecast extends WeatherDisplay {
2025-06-24 23:08:25 -04:00
static BASE _FORECAST _DURATION _MS = 5000 ; // Base duration (in ms) for a standard 3-5 line forecast page
2020-10-29 16:44:28 -05:00
constructor ( navId , elemId ) {
2022-11-21 21:50:22 -06:00
super ( navId , elemId , 'Local Forecast' , true ) ;
2020-09-04 13:02:20 -05:00
// set timings
2025-06-24 23:08:25 -04:00
this . timing . baseDelay = LocalForecast . BASE _FORECAST _DURATION _MS ;
2020-09-04 13:02:20 -05:00
}
2025-04-02 20:52:33 -05:00
async getData ( weatherParameters , refresh ) {
if ( ! super . getData ( weatherParameters , refresh ) ) return ;
2020-09-04 13:02:20 -05:00
// get raw data
2025-04-02 20:52:33 -05:00
const rawData = await this . getRawData ( this . weatherParameters ) ;
// check for data, or if there's old data available
if ( ! rawData && ! this . data ) {
// fail for no old or new data
2025-06-24 23:08:25 -04:00
if ( this . isEnabled ) this . setStatus ( STATUS . failed ) ;
2020-09-09 15:23:19 -05:00
return ;
}
2025-04-02 20:52:33 -05:00
// store the data
this . data = rawData || this . data ;
2025-06-24 23:08:25 -04:00
// parse raw data and filter out expired periods
const conditions = parse ( this . data , this . weatherParameters . forecast ) ;
2020-09-04 13:02:20 -05:00
// read each text
2022-08-04 11:07:35 -05:00
this . screenTexts = conditions . map ( ( condition ) => {
2020-09-04 13:02:20 -05:00
// process the text
2020-10-29 16:44:28 -05:00
let text = ` ${ condition . DayName . toUpperCase ( ) } ... ` ;
2022-12-06 16:25:28 -06:00
const conditionText = condition . Text ;
2020-09-04 13:02:20 -05:00
text += conditionText . toUpperCase ( ) . replace ( '...' , ' ' ) ;
2022-08-04 11:07:35 -05:00
return text ;
} ) ;
2020-10-29 16:44:28 -05:00
2022-08-04 11:07:35 -05:00
// fill the forecast texts
const templates = this . screenTexts . map ( ( text ) => this . fillTemplate ( 'forecast' , { text } ) ) ;
const forecastsElem = this . elem . querySelector ( '.forecasts' ) ;
forecastsElem . innerHTML = '' ;
forecastsElem . append ( ... templates ) ;
2025-06-24 23:08:25 -04:00
// Get page height for screen calculations
2025-05-16 14:42:11 -05:00
this . pageHeight = forecastsElem . parentNode . offsetHeight ;
2020-09-04 13:02:20 -05:00
2025-06-24 23:08:25 -04:00
this . calculateContentAwareTiming ( templates ) ;
2020-09-12 23:49:51 -05:00
this . calcNavTiming ( ) ;
2025-06-24 23:08:25 -04:00
2020-09-09 15:23:19 -05:00
this . setStatus ( STATUS . loaded ) ;
2020-09-04 13:02:20 -05:00
}
// get the unformatted data (also used by extended forecast)
async getRawData ( weatherParameters ) {
2025-06-24 23:08:25 -04:00
// 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 ) {
2020-09-04 13:02:20 -05:00
return false ;
}
2025-06-24 23:08:25 -04:00
return data ;
2020-09-04 13:02:20 -05:00
}
async drawCanvas ( ) {
super . drawCanvas ( ) ;
2022-08-04 11:07:35 -05:00
const top = - this . screenIndex * this . pageHeight ;
this . elem . querySelector ( '.forecasts' ) . style . top = ` ${ top } px ` ;
2020-09-04 13:02:20 -05:00
this . finishDraw ( ) ;
}
2025-06-24 23:08:25 -04:00
// 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 <br> tags to force height beyond the minimum, then subtract the padding
const originalHTML = template . innerHTML ;
const paddingBRs = '<br/>' . 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 ) ) ;
}
}
2020-10-29 16:44:28 -05:00
}
2022-12-06 16:14:56 -06:00
2022-12-09 13:51:51 -06:00
// format the forecast
2025-06-24 23:08:25 -04:00
// 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 ,
} ) ) ;
} ;
2022-12-06 16:14:56 -06:00
// register display
2022-12-14 16:28:33 -06:00
registerDisplay ( new LocalForecast ( 7 , 'local-forecast' ) ) ;