2022-12-14 16:28:33 -06:00
|
|
|
// hourly forecast list
|
|
|
|
|
|
|
|
|
|
import STATUS from './status.mjs';
|
2026-04-10 15:02:23 -04:00
|
|
|
import { playAlertTone, stopAlertTone } from './media.mjs';
|
2025-06-24 23:41:44 -04:00
|
|
|
import { safeJson } from './utils/fetch.mjs';
|
2026-04-10 12:49:19 -04:00
|
|
|
import deriveHazards from './utils/derived-hazards.mjs';
|
2022-12-14 16:28:33 -06:00
|
|
|
import WeatherDisplay from './weatherdisplay.mjs';
|
2024-10-21 19:21:05 -05:00
|
|
|
import { registerDisplay } from './navigation.mjs';
|
2025-06-24 23:41:44 -04:00
|
|
|
import calculateScrollTiming from './utils/scroll-timing.mjs';
|
|
|
|
|
import { debugFlag } from './utils/debug.mjs';
|
2026-04-17 17:11:11 -04:00
|
|
|
import { withBasePath } from './utils/base-path.mjs';
|
2022-12-14 16:28:33 -06:00
|
|
|
|
|
|
|
|
const hazardLevels = {
|
|
|
|
|
Extreme: 10,
|
|
|
|
|
Severe: 5,
|
|
|
|
|
};
|
|
|
|
|
|
2024-10-08 09:52:04 -05:00
|
|
|
const hazardModifiers = {
|
|
|
|
|
'Hurricane Warning': 2,
|
|
|
|
|
'Tornado Warning': 3,
|
|
|
|
|
'Severe Thunderstorm Warning': 1,
|
2024-10-21 19:21:05 -05:00
|
|
|
};
|
2024-10-08 09:52:04 -05:00
|
|
|
|
2026-04-09 22:13:59 -04:00
|
|
|
const getAlertSignature = (alerts = []) => alerts.map((alert) => alert.id).sort().join('|');
|
|
|
|
|
|
2022-12-14 16:28:33 -06:00
|
|
|
class Hazards extends WeatherDisplay {
|
|
|
|
|
constructor(navId, elemId, defaultActive) {
|
|
|
|
|
// special height and width for scrolling
|
|
|
|
|
super(navId, elemId, 'Hazards', defaultActive);
|
|
|
|
|
this.showOnProgress = false;
|
2025-06-01 23:24:24 -05:00
|
|
|
this.okToDrawCurrentConditions = false;
|
2022-12-14 16:28:33 -06:00
|
|
|
|
2025-06-02 15:57:58 -05:00
|
|
|
// force a 1-minute refresh time for the most up-to-date hazards
|
|
|
|
|
this.refreshTime = 60_000;
|
|
|
|
|
|
2022-12-14 16:28:33 -06:00
|
|
|
// 0 screens skips this during "play"
|
|
|
|
|
this.timing.totalScreens = 0;
|
2025-06-02 15:57:58 -05:00
|
|
|
|
|
|
|
|
// take note of the already-shown alert ids
|
|
|
|
|
this.viewedAlerts = new Set();
|
|
|
|
|
this.viewedGetCount = 0;
|
2026-04-09 22:13:59 -04:00
|
|
|
this.alertSignature = '';
|
2025-06-24 23:41:44 -04:00
|
|
|
|
|
|
|
|
// 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,
|
|
|
|
|
};
|
2022-12-14 16:28:33 -06:00
|
|
|
}
|
|
|
|
|
|
2025-04-02 11:10:58 -05:00
|
|
|
async getData(weatherParameters, refresh) {
|
2022-12-14 16:28:33 -06:00
|
|
|
// super checks for enabled
|
2025-04-02 16:45:11 -05:00
|
|
|
const superResult = super.getData(weatherParameters, refresh);
|
2025-04-02 22:10:59 -05:00
|
|
|
// hazards performs a silent refresh, but does not fall back to a previous fetch if no data is available
|
|
|
|
|
// this is intentional to ensure the latest alerts only are displayed.
|
2022-12-14 16:28:33 -06:00
|
|
|
|
2025-06-02 15:57:58 -05:00
|
|
|
// auto reload must be set up specifically for hazards in case it is disabled via checkbox (for the bottom line scroll)
|
|
|
|
|
if (this.autoRefreshHandle === null) this.setAutoReload();
|
|
|
|
|
|
2022-12-14 16:28:33 -06:00
|
|
|
const alert = this.checkbox.querySelector('.alert');
|
|
|
|
|
alert.classList.remove('show');
|
|
|
|
|
|
2025-06-02 15:57:58 -05:00
|
|
|
// if not a refresh (new site), all alerts are new
|
|
|
|
|
if (!refresh) {
|
|
|
|
|
this.viewedGetCount = 0;
|
|
|
|
|
this.viewedAlerts.clear();
|
|
|
|
|
}
|
|
|
|
|
|
2022-12-14 16:28:33 -06:00
|
|
|
try {
|
2026-04-09 22:13:59 -04:00
|
|
|
const previousSignature = this.alertSignature;
|
2026-04-10 12:49:19 -04:00
|
|
|
if (!this.weatherParameters?.supportsNoaaAlerts) {
|
|
|
|
|
this.data = deriveHazards(this.weatherParameters);
|
2025-06-24 23:41:44 -04:00
|
|
|
} else {
|
2026-04-10 12:49:19 -04:00
|
|
|
// 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}`);
|
|
|
|
|
url.searchParams.append('status', 'actual');
|
|
|
|
|
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;
|
|
|
|
|
}
|
2025-06-24 23:41:44 -04:00
|
|
|
}
|
2026-04-09 22:13:59 -04:00
|
|
|
this.alertSignature = getAlertSignature(this.data);
|
|
|
|
|
const alertsChanged = previousSignature !== this.alertSignature;
|
|
|
|
|
if (alertsChanged) {
|
|
|
|
|
this.viewedAlerts.clear();
|
|
|
|
|
if (this.data.length > 0) {
|
2026-04-10 15:02:23 -04:00
|
|
|
playAlertTone();
|
2026-04-09 22:13:59 -04:00
|
|
|
postMessage({ type: 'current-weather-scroll', method: 'reload' });
|
2026-04-10 15:02:23 -04:00
|
|
|
} else {
|
|
|
|
|
stopAlertTone();
|
2026-04-09 22:13:59 -04:00
|
|
|
}
|
|
|
|
|
}
|
2022-12-14 16:28:33 -06:00
|
|
|
|
2025-06-02 15:57:58 -05:00
|
|
|
// every 10 times through the get process (10 minutes), reset the viewed messages
|
|
|
|
|
if (this.viewedGetCount >= 10) {
|
|
|
|
|
this.viewedGetCount = 0;
|
|
|
|
|
this.viewedAlerts.clear();
|
|
|
|
|
}
|
|
|
|
|
this.viewedGetCount += 1;
|
|
|
|
|
|
|
|
|
|
// count up un-viewed alerts
|
|
|
|
|
const unViewed = this.data.reduce((count, hazard) => {
|
|
|
|
|
if (!this.viewedAlerts.has(hazard.id)) return count + 1;
|
|
|
|
|
return count;
|
|
|
|
|
}, 0);
|
|
|
|
|
|
2022-12-14 16:28:33 -06:00
|
|
|
// show alert indicator
|
2025-06-02 15:57:58 -05:00
|
|
|
if (unViewed > 0) alert.classList.add('show');
|
|
|
|
|
// draw the canvas to calculate the new timings and activate hazards in the slide deck again
|
2025-09-09 19:36:23 -05:00
|
|
|
// unless this has been disabled
|
|
|
|
|
if (this.isEnabled) {
|
|
|
|
|
this.drawLongCanvas();
|
|
|
|
|
}
|
2026-04-16 17:02:25 -04:00
|
|
|
|
|
|
|
|
// Sync hazards to backend history
|
|
|
|
|
await this.syncHazardHistory();
|
2023-01-06 14:39:39 -06:00
|
|
|
} catch (error) {
|
2025-06-24 23:41:44 -04:00
|
|
|
console.error(`Unexpected Active Alerts error: ${error.message}`);
|
2026-04-10 15:02:23 -04:00
|
|
|
stopAlertTone();
|
2022-12-14 16:28:33 -06:00
|
|
|
if (this.isEnabled) this.setStatus(STATUS.failed);
|
|
|
|
|
// return undefined to other subscribers
|
|
|
|
|
this.getDataCallback(undefined);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
this.getDataCallback();
|
|
|
|
|
|
2022-12-19 10:14:33 -06:00
|
|
|
if (!superResult) {
|
2025-09-09 19:36:23 -05:00
|
|
|
// Don't override status - super.getData() already set it to STATUS.disabled
|
2022-12-19 10:14:33 -06:00
|
|
|
return;
|
|
|
|
|
}
|
2022-12-14 16:28:33 -06:00
|
|
|
this.drawLongCanvas();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async drawLongCanvas() {
|
|
|
|
|
// get the list element and populate
|
|
|
|
|
const list = this.elem.querySelector('.hazard-lines');
|
|
|
|
|
list.innerHTML = '';
|
|
|
|
|
|
2026-04-09 22:13:59 -04:00
|
|
|
// Prefer new alerts, but keep active alerts visible even after they've been viewed once.
|
2025-06-02 15:57:58 -05:00
|
|
|
const unViewed = this.data.filter((data) => !this.viewedAlerts.has(data.id));
|
2026-04-09 22:13:59 -04:00
|
|
|
const alertsToDisplay = unViewed.length > 0 ? unViewed : this.data;
|
2025-06-02 15:57:58 -05:00
|
|
|
|
2026-04-09 22:13:59 -04:00
|
|
|
const lines = alertsToDisplay.map((data) => {
|
2022-12-14 16:28:33 -06:00
|
|
|
const fillValues = {};
|
2025-06-24 23:41:44 -04:00
|
|
|
const description = data.properties.description
|
|
|
|
|
.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
|
2022-12-14 16:28:33 -06:00
|
|
|
|
|
|
|
|
return this.fillTemplate('hazard', fillValues);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
list.append(...lines);
|
|
|
|
|
|
|
|
|
|
// no alerts, skip this display by setting timing to zero
|
|
|
|
|
if (lines.length === 0) {
|
2022-12-19 10:14:33 -06:00
|
|
|
this.setStatus(STATUS.loaded);
|
2022-12-14 16:28:33 -06:00
|
|
|
this.timing.totalScreens = 0;
|
2022-12-14 21:47:27 -06:00
|
|
|
this.setStatus(STATUS.loaded);
|
2022-12-14 16:28:33 -06:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// update timing
|
2025-06-02 15:57:58 -05:00
|
|
|
this.setTiming(list);
|
|
|
|
|
this.setStatus(STATUS.loaded);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setTiming(list) {
|
2025-06-24 23:41:44 -04:00
|
|
|
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;
|
|
|
|
|
|
2022-12-19 10:14:33 -06:00
|
|
|
this.calcNavTiming();
|
2022-12-14 16:28:33 -06:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
drawCanvas() {
|
|
|
|
|
super.drawCanvas();
|
|
|
|
|
this.finishDraw();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
showCanvas() {
|
|
|
|
|
// special to hourly to draw the remainder of the canvas
|
|
|
|
|
this.drawCanvas();
|
|
|
|
|
super.showCanvas();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// screen index change callback just runs the base count callback
|
|
|
|
|
screenIndexChange() {
|
|
|
|
|
this.baseCountChange(this.navBaseCount);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// base count change callback
|
|
|
|
|
baseCountChange(count) {
|
2025-06-24 23:41:44 -04:00
|
|
|
// 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';
|
|
|
|
|
}
|
|
|
|
|
|
2022-12-14 16:28:33 -06:00
|
|
|
// calculate scroll offset and don't go past end
|
2025-06-24 23:41:44 -04:00
|
|
|
let offsetY = Math.min(this.scrollCache.maxOffset, (count - this.scrollTiming.initialCounts) * this.scrollTiming.pixelsPerCount);
|
2022-12-14 16:28:33 -06:00
|
|
|
|
|
|
|
|
// don't let offset go negative
|
|
|
|
|
if (offsetY < 0) offsetY = 0;
|
|
|
|
|
|
2025-06-24 23:41:44 -04:00
|
|
|
// use transform instead of scrollTo for hardware acceleration
|
|
|
|
|
hazardLines.style.transform = `translateY(-${Math.round(offsetY)}px)`;
|
2022-12-14 16:28:33 -06:00
|
|
|
}
|
2024-10-08 09:52:04 -05:00
|
|
|
|
|
|
|
|
// after we roll through the hazards once, don't display again until the next refresh (10 minutes)
|
|
|
|
|
screenIndexFromBaseCount() {
|
|
|
|
|
const superValue = super.screenIndexFromBaseCount();
|
|
|
|
|
// false is returned when we reach the end of the scroll
|
|
|
|
|
if (superValue === false) {
|
2025-06-02 15:57:58 -05:00
|
|
|
// note the ids shown
|
|
|
|
|
this?.data?.forEach((alert) => this.viewedAlerts.add(alert.id));
|
2024-10-08 09:52:04 -05:00
|
|
|
}
|
|
|
|
|
// return the value as expected
|
|
|
|
|
return superValue;
|
|
|
|
|
}
|
2025-06-01 23:24:24 -05:00
|
|
|
|
|
|
|
|
// make data available outside this class
|
|
|
|
|
// promise allows for data to be requested before it is available
|
|
|
|
|
async getHazards(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));
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-04-16 17:02:25 -04:00
|
|
|
|
|
|
|
|
// Sync current hazards to backend history
|
|
|
|
|
async syncHazardHistory() {
|
|
|
|
|
try {
|
|
|
|
|
// Skip if no weather parameters available
|
|
|
|
|
if (!this.weatherParameters) return;
|
|
|
|
|
|
|
|
|
|
// Format location
|
2026-04-16 18:32:53 -04:00
|
|
|
const { city, state, country, countryCode, latitude, longitude } = this.weatherParameters;
|
2026-04-16 17:02:25 -04:00
|
|
|
const location = this.formatLocationLabel(city, state, country, countryCode);
|
|
|
|
|
|
2026-04-16 18:32:53 -04:00
|
|
|
// Create stable location key from lat/lon (rounded to 3 decimal places ~111m precision)
|
|
|
|
|
const locationKey = `${parseFloat(latitude).toFixed(3)},${parseFloat(longitude).toFixed(3)}`;
|
|
|
|
|
|
2026-04-16 17:02:25 -04:00
|
|
|
// Normalize hazards for the API
|
|
|
|
|
const hazards = (this.data || []).map((hazard) => ({
|
|
|
|
|
id: hazard.id,
|
|
|
|
|
hazardType: hazard.properties?.event || 'Unknown',
|
|
|
|
|
severity: hazard.properties?.severity || 'Unknown',
|
2026-04-17 11:44:16 -04:00
|
|
|
source: String(hazard.id || '').startsWith('derived-') ? 'derived' : 'noaa',
|
2026-04-16 17:02:25 -04:00
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
// Send to backend
|
2026-04-19 13:50:02 -04:00
|
|
|
const response = await fetch(withBasePath('api/hazard-history'), {
|
2026-04-16 17:02:25 -04:00
|
|
|
method: 'POST',
|
|
|
|
|
headers: {
|
|
|
|
|
'Content-Type': 'application/json',
|
|
|
|
|
},
|
2026-04-16 18:32:53 -04:00
|
|
|
body: JSON.stringify({ location, locationKey, hazards }),
|
2026-04-16 17:02:25 -04:00
|
|
|
});
|
2026-04-19 13:50:02 -04:00
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
throw new Error(`Hazard history sync failed with status ${response.status}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
window.dispatchEvent(new CustomEvent('hazard-history-updated'));
|
2026-04-16 17:02:25 -04:00
|
|
|
} catch (error) {
|
|
|
|
|
// Silently fail - hazard history is non-critical
|
|
|
|
|
if (debugFlag('verbose-failures')) {
|
|
|
|
|
console.warn('Failed to sync hazard history:', error.message);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Format location label for history
|
|
|
|
|
formatLocationLabel(city, state, country, countryCode) {
|
|
|
|
|
const cleanCity = city?.trim() || 'Unknown';
|
|
|
|
|
|
|
|
|
|
// US locations: "City, State"
|
|
|
|
|
if (countryCode === 'US' || countryCode === 'USA') {
|
|
|
|
|
const cleanState = state?.trim();
|
|
|
|
|
return cleanState ? `${cleanCity}, ${cleanState}` : cleanCity;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Non-US locations: "City, Country"
|
|
|
|
|
const cleanCountry = country?.trim();
|
|
|
|
|
return cleanCountry ? `${cleanCity}, ${cleanCountry}` : cleanCity;
|
|
|
|
|
}
|
2024-10-08 09:52:04 -05:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const calcSeverity = (severity, event) => {
|
|
|
|
|
// base severity plus some modifiers for specific types of warnings
|
|
|
|
|
const baseSeverity = hazardLevels[severity] ?? 0;
|
|
|
|
|
const modifiedSeverity = hazardModifiers[event] ?? 0;
|
|
|
|
|
return baseSeverity + modifiedSeverity;
|
2024-10-21 19:21:05 -05:00
|
|
|
};
|
2022-12-14 16:28:33 -06:00
|
|
|
|
|
|
|
|
// register display
|
2025-06-01 23:24:24 -05:00
|
|
|
const display = new Hazards(0, 'hazards', true);
|
|
|
|
|
registerDisplay(display);
|
|
|
|
|
|
|
|
|
|
export default display.getHazards.bind(display);
|