mirror of
https://github.com/netbymatt/ws4kp.git
synced 2026-04-14 15:49:31 -07:00
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
This commit is contained in:
@@ -3,11 +3,14 @@ import { elemForEach } from './utils/elem.mjs';
|
|||||||
import getCurrentWeather from './currentweather.mjs';
|
import getCurrentWeather from './currentweather.mjs';
|
||||||
import { currentDisplay } from './navigation.mjs';
|
import { currentDisplay } from './navigation.mjs';
|
||||||
import getHazards from './hazards.mjs';
|
import getHazards from './hazards.mjs';
|
||||||
|
import settings from './settings.mjs';
|
||||||
|
|
||||||
// constants
|
// constants
|
||||||
const degree = String.fromCharCode(176);
|
const degree = String.fromCharCode(176);
|
||||||
const SCROLL_SPEED = 75; // pixels/second
|
const SCROLL_SPEED = 100; // pixels/second
|
||||||
const DEFAULT_UPDATE = 8; // 0.5s ticks
|
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
|
// local variables
|
||||||
let interval;
|
let interval;
|
||||||
@@ -28,7 +31,7 @@ const start = () => {
|
|||||||
resetFlag = false;
|
resetFlag = false;
|
||||||
// set up the interval if needed
|
// set up the interval if needed
|
||||||
if (!interval) {
|
if (!interval) {
|
||||||
interval = setInterval(incrementInterval, 500);
|
interval = setInterval(incrementInterval, TICK_INTERVAL_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
// draw the data
|
// draw the data
|
||||||
@@ -70,15 +73,21 @@ const drawScreen = async () => {
|
|||||||
// get the conditions
|
// get the conditions
|
||||||
const data = await getCurrentWeather();
|
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
|
// add the hazards if on screen 0
|
||||||
if (screenIndex === 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 we have no current weather and no hazards, there's nothing to display
|
||||||
if (!data) return;
|
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
|
// update classes on the scroll area
|
||||||
elemForEach('.weather-display .scroll', (elem) => {
|
elemForEach('.weather-display .scroll', (elem) => {
|
||||||
@@ -115,7 +124,9 @@ const hazards = (data) => {
|
|||||||
// test for data
|
// test for data
|
||||||
if (!data.hazards || data.hazards.length === 0) return false;
|
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 {
|
return {
|
||||||
text: hazard,
|
text: hazard,
|
||||||
@@ -210,25 +221,35 @@ const drawScrollCondition = (screen) => {
|
|||||||
|
|
||||||
// calculate the scroll distance and set a minimum scroll
|
// calculate the scroll distance and set a minimum scroll
|
||||||
const scrollDistance = Math.max(scrollWidth - clientWidth, 0);
|
const scrollDistance = Math.max(scrollWidth - clientWidth, 0);
|
||||||
// calculate the scroll time
|
// calculate the scroll time (scaled by global speed setting)
|
||||||
const scrollTime = scrollDistance / SCROLL_SPEED;
|
const scrollTime = scrollDistance / SCROLL_SPEED * settings.speed.value;
|
||||||
// calculate a new minimum on-screen time +1.0s at start and end
|
// add 1 second pause at the end of the scroll animation
|
||||||
nextUpdate = Math.round(Math.ceil(scrollTime / 0.5) + 4);
|
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) => {
|
elemForEach('.weather-display .scroll .fixed', (elem) => {
|
||||||
elem.innerHTML = '';
|
elem.innerHTML = '';
|
||||||
elem.append(scrollElement.cloneNode(true));
|
elem.append(scrollElement.cloneNode(true));
|
||||||
});
|
});
|
||||||
// start the scroll after a short delay
|
|
||||||
|
// start the scroll after the specified delay
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
// change the left position to trigger the scroll
|
// change the transform to trigger the scroll
|
||||||
elemForEach('.weather-display .scroll .fixed .scroll-area', (elem) => {
|
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) => {
|
const parseMessage = (event) => {
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
// hourly forecast list
|
// hourly forecast list
|
||||||
|
|
||||||
import STATUS from './status.mjs';
|
import STATUS from './status.mjs';
|
||||||
import { json } from './utils/fetch.mjs';
|
import { safeJson } from './utils/fetch.mjs';
|
||||||
import WeatherDisplay from './weatherdisplay.mjs';
|
import WeatherDisplay from './weatherdisplay.mjs';
|
||||||
import { registerDisplay } from './navigation.mjs';
|
import { registerDisplay } from './navigation.mjs';
|
||||||
|
import calculateScrollTiming from './utils/scroll-timing.mjs';
|
||||||
|
import { debugFlag } from './utils/debug.mjs';
|
||||||
|
|
||||||
const hazardLevels = {
|
const hazardLevels = {
|
||||||
Extreme: 10,
|
Extreme: 10,
|
||||||
@@ -32,6 +34,19 @@ class Hazards extends WeatherDisplay {
|
|||||||
// take note of the already-shown alert ids
|
// take note of the already-shown alert ids
|
||||||
this.viewedAlerts = new Set();
|
this.viewedAlerts = new Set();
|
||||||
this.viewedGetCount = 0;
|
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) {
|
async getData(weatherParameters, refresh) {
|
||||||
@@ -53,16 +68,24 @@ class Hazards extends WeatherDisplay {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// get the forecast
|
// get the forecast using centralized safe handling
|
||||||
const url = new URL('https://api.weather.gov/alerts/active');
|
const url = new URL('https://api.weather.gov/alerts/active');
|
||||||
url.searchParams.append('point', `${this.weatherParameters.latitude},${this.weatherParameters.longitude}`);
|
url.searchParams.append('point', `${this.weatherParameters.latitude},${this.weatherParameters.longitude}`);
|
||||||
const alerts = await json(url, { retryCount: 3, stillWaiting: () => this.stillWaiting() });
|
const alerts = await safeJson(url, { retryCount: 3, stillWaiting: () => this.stillWaiting() });
|
||||||
const allUnsortedAlerts = alerts.features ?? [];
|
|
||||||
const unsortedAlerts = allUnsortedAlerts.slice(0, 5);
|
if (!alerts) {
|
||||||
const hasImmediate = unsortedAlerts.reduce((acc, hazard) => acc || hazard.properties.urgency === 'Immediate', false);
|
if (debugFlag('verbose-failures')) {
|
||||||
const sortedAlerts = unsortedAlerts.sort((a, b) => (calcSeverity(b.properties.severity, b.properties.event)) - (calcSeverity(a.properties.severity, a.properties.event)));
|
console.warn('Active Alerts request failed; assuming no active alerts');
|
||||||
const filteredAlerts = sortedAlerts.filter((hazard) => hazard.properties.severity !== 'Unknown' && (!hasImmediate || (hazard.properties.urgency === 'Immediate')));
|
}
|
||||||
this.data = filteredAlerts;
|
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
|
// every 10 times through the get process (10 minutes), reset the viewed messages
|
||||||
if (this.viewedGetCount >= 10) {
|
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
|
// draw the canvas to calculate the new timings and activate hazards in the slide deck again
|
||||||
this.drawLongCanvas();
|
this.drawLongCanvas();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Get hazards failed');
|
console.error(`Unexpected Active Alerts error: ${error.message}`);
|
||||||
console.error(error.status, error.responseJSON);
|
|
||||||
if (this.isEnabled) this.setStatus(STATUS.failed);
|
if (this.isEnabled) this.setStatus(STATUS.failed);
|
||||||
// return undefined to other subscribers
|
// return undefined to other subscribers
|
||||||
this.getDataCallback(undefined);
|
this.getDataCallback(undefined);
|
||||||
@@ -109,8 +131,12 @@ class Hazards extends WeatherDisplay {
|
|||||||
|
|
||||||
const lines = unViewed.map((data) => {
|
const lines = unViewed.map((data) => {
|
||||||
const fillValues = {};
|
const fillValues = {};
|
||||||
// text
|
const description = data.properties.description
|
||||||
fillValues['hazard-text'] = `${data.properties.event}<br/><br/>${data.properties.description.replaceAll('\n\n', '<br/><br/>').replaceAll('\n', ' ')}`;
|
.replaceAll('\n\n', '<br/><br/>')
|
||||||
|
.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}<br/><br/>${description}<br/><br/><br/><br/>`; // Add some padding to scroll off the bottom a bit
|
||||||
|
|
||||||
return this.fillTemplate('hazard', fillValues);
|
return this.fillTemplate('hazard', fillValues);
|
||||||
});
|
});
|
||||||
@@ -131,16 +157,16 @@ class Hazards extends WeatherDisplay {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setTiming(list) {
|
setTiming(list) {
|
||||||
// set up the timing
|
const container = this.elem.querySelector('.main');
|
||||||
this.timing.baseDelay = 20;
|
const timingConfig = calculateScrollTiming(list, container, {
|
||||||
// 24 hours = 6 pages
|
finalPause: 2.0, // shorter final pause for hazards
|
||||||
const pages = Math.max(Math.ceil(list.scrollHeight / 480) - 4);
|
});
|
||||||
const timingStep = 480;
|
|
||||||
this.timing.delay = [150 + timingStep];
|
// Apply the calculated timing
|
||||||
// add additional pages
|
this.timing.baseDelay = timingConfig.baseDelay;
|
||||||
for (let i = 0; i < pages; i += 1) this.timing.delay.push(timingStep);
|
this.timing.delay = timingConfig.delay;
|
||||||
// add the final 3 second delay
|
this.scrollTiming = timingConfig.scrollTiming;
|
||||||
this.timing.delay.push(250);
|
|
||||||
this.calcNavTiming();
|
this.calcNavTiming();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,25 +188,30 @@ class Hazards extends WeatherDisplay {
|
|||||||
|
|
||||||
// base count change callback
|
// base count change callback
|
||||||
baseCountChange(count) {
|
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
|
// 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
|
// don't let offset go negative
|
||||||
if (offsetY < 0) offsetY = 0;
|
if (offsetY < 0) offsetY = 0;
|
||||||
|
|
||||||
// move the element
|
// use transform instead of scrollTo for hardware acceleration
|
||||||
this.elem.querySelector('.main').scrollTo(0, offsetY);
|
hazardLines.style.transform = `translateY(-${Math.round(offsetY)}px)`;
|
||||||
}
|
|
||||||
|
|
||||||
// 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));
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// after we roll through the hazards once, don't display again until the next refresh (10 minutes)
|
// after we roll through the hazards once, don't display again until the next refresh (10 minutes)
|
||||||
|
|||||||
@@ -1,17 +1,16 @@
|
|||||||
@use 'shared/_colors'as c;
|
@use 'shared/_colors' as c;
|
||||||
@use 'shared/_utils'as u;
|
@use 'shared/_utils' as u;
|
||||||
|
|
||||||
.weather-display .main.hazards {
|
.weather-display .main.hazards {
|
||||||
&.main {
|
&.main {
|
||||||
overflow-y: hidden;
|
overflow-y: hidden;
|
||||||
height: 480px;
|
height: 480px;
|
||||||
|
background-color: rgb(112, 35, 35);
|
||||||
|
|
||||||
.hazard-lines {
|
.hazard-lines {
|
||||||
min-height: 400px;
|
min-height: 400px;
|
||||||
padding-top: 10px;
|
padding-top: 10px;
|
||||||
|
|
||||||
background-color: rgb(112, 35, 35);
|
|
||||||
|
|
||||||
.hazard {
|
.hazard {
|
||||||
font-family: 'Star4000';
|
font-family: 'Star4000';
|
||||||
font-size: 24pt;
|
font-size: 24pt;
|
||||||
@@ -26,4 +25,4 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
@use 'shared/_colors'as c;
|
@use 'shared/_colors' as c;
|
||||||
@use 'shared/_utils'as u;
|
@use 'shared/_utils' as u;
|
||||||
|
|
||||||
.weather-display {
|
.weather-display {
|
||||||
width: 640px;
|
width: 640px;
|
||||||
@@ -129,6 +129,12 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove margins for hazard scrolls to maximize text space
|
||||||
|
&.hazard .fixed {
|
||||||
|
margin-left: 0;
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.scroll-header {
|
.scroll-header {
|
||||||
height: 26px;
|
height: 26px;
|
||||||
font-family: "Star4000 Small";
|
font-family: "Star4000 Small";
|
||||||
@@ -150,4 +156,4 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user