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
+}