From bec80a1ebec0f896489a926fb2257893fd4ef213 Mon Sep 17 00:00:00 2001 From: Eddy G Date: Tue, 24 Jun 2025 23:41:44 -0400 Subject: [PATCH] Refactor alert/hazard system with timing and display improvements - Replace magic numbers with seconds-based timing constants - Switch from scrollTo() to hardware-accelerated transform - Add scroll caching to prevent repeated DOM queries every scroll cycle - Switch to safeJson() for centralized error handling across alert modules - Horizontal alert scroll now goes edge-to-edge - Integrate global speed settings into horizontal scroll timing - Improve error handling flow with better fallback behavior for missing data - Remvoe unused getCurrentData() function in hazards.mjs - Move background color from scrolling element to container to avoid showing the underlying content when scrolling with trasnform --- .../scripts/modules/currentweatherscroll.mjs | 59 ++++++---- server/scripts/modules/hazards.mjs | 105 ++++++++++++------ server/styles/scss/_hazards.scss | 9 +- server/styles/scss/_weather-display.scss | 12 +- 4 files changed, 121 insertions(+), 64 deletions(-) diff --git a/server/scripts/modules/currentweatherscroll.mjs b/server/scripts/modules/currentweatherscroll.mjs index 6064310..2ecffce 100644 --- a/server/scripts/modules/currentweatherscroll.mjs +++ b/server/scripts/modules/currentweatherscroll.mjs @@ -3,11 +3,14 @@ import { elemForEach } from './utils/elem.mjs'; import getCurrentWeather from './currentweather.mjs'; import { currentDisplay } from './navigation.mjs'; import getHazards from './hazards.mjs'; +import settings from './settings.mjs'; // constants const degree = String.fromCharCode(176); -const SCROLL_SPEED = 75; // pixels/second -const DEFAULT_UPDATE = 8; // 0.5s ticks +const SCROLL_SPEED = 100; // pixels/second +const TICK_INTERVAL_MS = 500; // milliseconds per tick +const secondsToTicks = (seconds) => Math.ceil((seconds * 1000) / TICK_INTERVAL_MS); +const DEFAULT_UPDATE = secondsToTicks(4.0); // 4 second default for each current conditions // local variables let interval; @@ -28,7 +31,7 @@ const start = () => { resetFlag = false; // set up the interval if needed if (!interval) { - interval = setInterval(incrementInterval, 500); + interval = setInterval(incrementInterval, TICK_INTERVAL_MS); } // draw the data @@ -70,15 +73,21 @@ const drawScreen = async () => { // get the conditions const data = await getCurrentWeather(); + // create a data object (empty if no valid current weather conditions) + const scrollData = data || {}; + // add the hazards if on screen 0 if (screenIndex === 0) { - data.hazards = await getHazards(() => this.stillWaiting()); + const hazards = await getHazards(); + if (hazards && hazards.length > 0) { + scrollData.hazards = hazards; + } } - // nothing to do if there's no data yet - if (!data) return; + // if we have no current weather and no hazards, there's nothing to display + if (!data && (!scrollData.hazards || scrollData.hazards.length === 0)) return; - const thisScreen = screens[screenIndex](data); + const thisScreen = screens[screenIndex](scrollData); // update classes on the scroll area elemForEach('.weather-display .scroll', (elem) => { @@ -115,7 +124,9 @@ const hazards = (data) => { // test for data if (!data.hazards || data.hazards.length === 0) return false; - const hazard = `${data.hazards[0].properties.event} ${data.hazards[0].properties.description}`; + // since the hazard scroll element has no left/right margins, pad the beginning and end with non-breaking spaces + const padding = ' '.repeat(4); + const hazard = `${padding}${data.hazards[0].properties.event} ${data.hazards[0].properties.description}${padding}`; return { text: hazard, @@ -210,25 +221,35 @@ const drawScrollCondition = (screen) => { // calculate the scroll distance and set a minimum scroll const scrollDistance = Math.max(scrollWidth - clientWidth, 0); - // calculate the scroll time - const scrollTime = scrollDistance / SCROLL_SPEED; - // calculate a new minimum on-screen time +1.0s at start and end - nextUpdate = Math.round(Math.ceil(scrollTime / 0.5) + 4); + // calculate the scroll time (scaled by global speed setting) + const scrollTime = scrollDistance / SCROLL_SPEED * settings.speed.value; + // add 1 second pause at the end of the scroll animation + const endPauseTime = 1.0; + const totalAnimationTime = scrollTime + endPauseTime; + // calculate total on-screen time: animation time + start delay + end pause + const startDelayTime = 1.0; // setTimeout delay below + const totalDisplayTime = totalAnimationTime + startDelayTime; + nextUpdate = secondsToTicks(totalDisplayTime); + + // update the element with initial position and transition + scrollElement.style.transform = 'translateX(0px)'; + scrollElement.style.transition = `transform ${scrollTime.toFixed(1)}s linear`; + scrollElement.style.willChange = 'transform'; // Hint to browser for hardware acceleration + scrollElement.style.backfaceVisibility = 'hidden'; // Force hardware acceleration + scrollElement.style.perspective = '1000px'; // Enable 3D rendering context - // update the element transition and set initial left position - scrollElement.style.left = '0px'; - scrollElement.style.transition = `left linear ${scrollTime.toFixed(1)}s`; elemForEach('.weather-display .scroll .fixed', (elem) => { elem.innerHTML = ''; elem.append(scrollElement.cloneNode(true)); }); - // start the scroll after a short delay + + // start the scroll after the specified delay setTimeout(() => { - // change the left position to trigger the scroll + // change the transform to trigger the scroll elemForEach('.weather-display .scroll .fixed .scroll-area', (elem) => { - elem.style.left = `-${scrollDistance.toFixed(0)}px`; + elem.style.transform = `translateX(-${scrollDistance.toFixed(0)}px)`; }); - }, 1000); + }, startDelayTime * 1000); }; const parseMessage = (event) => { diff --git a/server/scripts/modules/hazards.mjs b/server/scripts/modules/hazards.mjs index 2cd1084..4010fa3 100644 --- a/server/scripts/modules/hazards.mjs +++ b/server/scripts/modules/hazards.mjs @@ -1,9 +1,11 @@ // hourly forecast list 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 calculateScrollTiming from './utils/scroll-timing.mjs'; +import { debugFlag } from './utils/debug.mjs'; const hazardLevels = { Extreme: 10, @@ -32,6 +34,19 @@ class Hazards extends WeatherDisplay { // take note of the already-shown alert ids this.viewedAlerts = new Set(); this.viewedGetCount = 0; + + // cache for scroll calculations + // This cache is essential because baseCountChange() is called 25 times per second (every 40ms) + // during scrolling. Hazard scrolls can vary greatly in length depending on active alerts, but + // 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, + hazardLines: null, + }; } async getData(weatherParameters, refresh) { @@ -53,16 +68,24 @@ class Hazards extends WeatherDisplay { } try { - // get the forecast + // get the forecast using centralized safe handling const url = new URL('https://api.weather.gov/alerts/active'); url.searchParams.append('point', `${this.weatherParameters.latitude},${this.weatherParameters.longitude}`); - const alerts = await json(url, { retryCount: 3, stillWaiting: () => this.stillWaiting() }); - const allUnsortedAlerts = alerts.features ?? []; - const unsortedAlerts = allUnsortedAlerts.slice(0, 5); - const hasImmediate = unsortedAlerts.reduce((acc, hazard) => acc || hazard.properties.urgency === 'Immediate', false); - const sortedAlerts = unsortedAlerts.sort((a, b) => (calcSeverity(b.properties.severity, b.properties.event)) - (calcSeverity(a.properties.severity, a.properties.event))); - const filteredAlerts = sortedAlerts.filter((hazard) => hazard.properties.severity !== 'Unknown' && (!hasImmediate || (hazard.properties.urgency === 'Immediate'))); - this.data = filteredAlerts; + const alerts = await safeJson(url, { retryCount: 3, stillWaiting: () => this.stillWaiting() }); + + if (!alerts) { + if (debugFlag('verbose-failures')) { + console.warn('Active Alerts request failed; assuming no active alerts'); + } + this.data = []; + } else { + const allUnsortedAlerts = alerts.features ?? []; + const unsortedAlerts = allUnsortedAlerts.slice(0, 5); + const hasImmediate = unsortedAlerts.reduce((acc, hazard) => acc || hazard.properties.urgency === 'Immediate', false); + const sortedAlerts = unsortedAlerts.sort((a, b) => (calcSeverity(b.properties.severity, b.properties.event)) - (calcSeverity(a.properties.severity, a.properties.event))); + const filteredAlerts = sortedAlerts.filter((hazard) => hazard.properties.severity !== 'Unknown' && (!hasImmediate || (hazard.properties.urgency === 'Immediate'))); + this.data = filteredAlerts; + } // every 10 times through the get process (10 minutes), reset the viewed messages if (this.viewedGetCount >= 10) { @@ -82,8 +105,7 @@ class Hazards extends WeatherDisplay { // draw the canvas to calculate the new timings and activate hazards in the slide deck again this.drawLongCanvas(); } catch (error) { - console.error('Get hazards failed'); - console.error(error.status, error.responseJSON); + console.error(`Unexpected Active Alerts error: ${error.message}`); if (this.isEnabled) this.setStatus(STATUS.failed); // return undefined to other subscribers this.getDataCallback(undefined); @@ -109,8 +131,12 @@ class Hazards extends WeatherDisplay { const lines = unViewed.map((data) => { const fillValues = {}; - // text - fillValues['hazard-text'] = `${data.properties.event}

${data.properties.description.replaceAll('\n\n', '

').replaceAll('\n', ' ')}`; + const description = data.properties.description + .replaceAll('\n\n', '

') + .replaceAll('\n', ' ') + .replace(/(\S)\.\.\.(\S)/g, '$1... $2'); // Add space after ... when surrounded by non-whitespace to improve text-wrappability + + fillValues['hazard-text'] = `${data.properties.event}

${description}



`; // Add some padding to scroll off the bottom a bit return this.fillTemplate('hazard', fillValues); }); @@ -131,16 +157,16 @@ class Hazards extends WeatherDisplay { } setTiming(list) { - // set up the timing - this.timing.baseDelay = 20; - // 24 hours = 6 pages - const pages = Math.max(Math.ceil(list.scrollHeight / 480) - 4); - const timingStep = 480; - this.timing.delay = [150 + timingStep]; - // add additional pages - for (let i = 0; i < pages; i += 1) this.timing.delay.push(timingStep); - // add the final 3 second delay - this.timing.delay.push(250); + const container = this.elem.querySelector('.main'); + const timingConfig = calculateScrollTiming(list, container, { + finalPause: 2.0, // shorter final pause for hazards + }); + + // Apply the calculated timing + this.timing.baseDelay = timingConfig.baseDelay; + this.timing.delay = timingConfig.delay; + this.scrollTiming = timingConfig.scrollTiming; + this.calcNavTiming(); } @@ -162,25 +188,30 @@ class Hazards extends WeatherDisplay { // base count change callback baseCountChange(count) { + // get the hazard lines element and cache measurements if needed + const hazardLines = this.elem.querySelector('.hazard-lines'); + if (!hazardLines) return; + + // update cache if needed (when content changes or first run) + if (this.scrollCache.hazardLines !== hazardLines || this.scrollCache.displayHeight === 0) { + this.scrollCache.displayHeight = this.elem.querySelector('.main').offsetHeight; + this.scrollCache.contentHeight = hazardLines.offsetHeight; + this.scrollCache.maxOffset = Math.max(0, this.scrollCache.contentHeight - this.scrollCache.displayHeight); + this.scrollCache.hazardLines = hazardLines; + + // Set up hardware acceleration on the hazard lines element + hazardLines.style.willChange = 'transform'; + hazardLines.style.backfaceVisibility = 'hidden'; + } + // calculate scroll offset and don't go past end - let offsetY = Math.min(this.elem.querySelector('.hazard-lines').offsetHeight - 390, (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; - // move the element - this.elem.querySelector('.main').scrollTo(0, offsetY); - } - - // make data available outside this class - // promise allows for data to be requested before it is available - async getCurrentData(stillWaiting) { - if (stillWaiting) this.stillWaitingCallbacks.push(stillWaiting); - return new Promise((resolve) => { - if (this.data) resolve(this.data); - // data not available, put it into the data callback queue - this.getDataCallbacks.push(() => resolve(this.data)); - }); + // use transform instead of scrollTo for hardware acceleration + hazardLines.style.transform = `translateY(-${Math.round(offsetY)}px)`; } // after we roll through the hazards once, don't display again until the next refresh (10 minutes) diff --git a/server/styles/scss/_hazards.scss b/server/styles/scss/_hazards.scss index b2473be..8e94316 100644 --- a/server/styles/scss/_hazards.scss +++ b/server/styles/scss/_hazards.scss @@ -1,17 +1,16 @@ -@use 'shared/_colors'as c; -@use 'shared/_utils'as u; +@use 'shared/_colors' as c; +@use 'shared/_utils' as u; .weather-display .main.hazards { &.main { overflow-y: hidden; height: 480px; + background-color: rgb(112, 35, 35); .hazard-lines { min-height: 400px; padding-top: 10px; - background-color: rgb(112, 35, 35); - .hazard { font-family: 'Star4000'; font-size: 24pt; @@ -26,4 +25,4 @@ } } } -} \ No newline at end of file +} diff --git a/server/styles/scss/_weather-display.scss b/server/styles/scss/_weather-display.scss index 24d1c24..2af2af9 100644 --- a/server/styles/scss/_weather-display.scss +++ b/server/styles/scss/_weather-display.scss @@ -1,5 +1,5 @@ -@use 'shared/_colors'as c; -@use 'shared/_utils'as u; +@use 'shared/_colors' as c; +@use 'shared/_utils' as u; .weather-display { width: 640px; @@ -129,6 +129,12 @@ overflow: hidden; } + // Remove margins for hazard scrolls to maximize text space + &.hazard .fixed { + margin-left: 0; + margin-right: 0; + } + .scroll-header { height: 26px; font-family: "Star4000 Small"; @@ -150,4 +156,4 @@ } } -} \ No newline at end of file +}