ws4kp-linhanced/server/scripts/modules/hazards.mjs
mrkmntal 31c0aced4a
Some checks are pending
build-docker / Build Image (push) Waiting to run
Update hazard list API to use relative URLS. (Much better for subdirectory hosting!)
2026-04-17 17:11:11 -04:00

335 lines
12 KiB
JavaScript

// hourly forecast list
import STATUS from './status.mjs';
import { playAlertTone, stopAlertTone } from './media.mjs';
import { safeJson } from './utils/fetch.mjs';
import deriveHazards from './utils/derived-hazards.mjs';
import WeatherDisplay from './weatherdisplay.mjs';
import { registerDisplay } from './navigation.mjs';
import calculateScrollTiming from './utils/scroll-timing.mjs';
import { debugFlag } from './utils/debug.mjs';
import { withBasePath } from './utils/base-path.mjs';
const hazardLevels = {
Extreme: 10,
Severe: 5,
};
const hazardModifiers = {
'Hurricane Warning': 2,
'Tornado Warning': 3,
'Severe Thunderstorm Warning': 1,
};
const getAlertSignature = (alerts = []) => alerts.map((alert) => alert.id).sort().join('|');
class Hazards extends WeatherDisplay {
constructor(navId, elemId, defaultActive) {
// special height and width for scrolling
super(navId, elemId, 'Hazards', defaultActive);
this.showOnProgress = false;
this.okToDrawCurrentConditions = false;
// force a 1-minute refresh time for the most up-to-date hazards
this.refreshTime = 60_000;
// 0 screens skips this during "play"
this.timing.totalScreens = 0;
// take note of the already-shown alert ids
this.viewedAlerts = new Set();
this.viewedGetCount = 0;
this.alertSignature = '';
// 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) {
// super checks for enabled
const superResult = super.getData(weatherParameters, refresh);
// 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.
// 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();
const alert = this.checkbox.querySelector('.alert');
alert.classList.remove('show');
// if not a refresh (new site), all alerts are new
if (!refresh) {
this.viewedGetCount = 0;
this.viewedAlerts.clear();
}
try {
const previousSignature = this.alertSignature;
if (!this.weatherParameters?.supportsNoaaAlerts) {
this.data = deriveHazards(this.weatherParameters);
} else {
// 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;
}
}
this.alertSignature = getAlertSignature(this.data);
const alertsChanged = previousSignature !== this.alertSignature;
if (alertsChanged) {
this.viewedAlerts.clear();
if (this.data.length > 0) {
playAlertTone();
postMessage({ type: 'current-weather-scroll', method: 'reload' });
} else {
stopAlertTone();
}
}
// 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);
// show alert indicator
if (unViewed > 0) alert.classList.add('show');
// draw the canvas to calculate the new timings and activate hazards in the slide deck again
// unless this has been disabled
if (this.isEnabled) {
this.drawLongCanvas();
}
// Sync hazards to backend history
await this.syncHazardHistory();
} catch (error) {
console.error(`Unexpected Active Alerts error: ${error.message}`);
stopAlertTone();
if (this.isEnabled) this.setStatus(STATUS.failed);
// return undefined to other subscribers
this.getDataCallback(undefined);
return;
}
this.getDataCallback();
if (!superResult) {
// Don't override status - super.getData() already set it to STATUS.disabled
return;
}
this.drawLongCanvas();
}
async drawLongCanvas() {
// get the list element and populate
const list = this.elem.querySelector('.hazard-lines');
list.innerHTML = '';
// Prefer new alerts, but keep active alerts visible even after they've been viewed once.
const unViewed = this.data.filter((data) => !this.viewedAlerts.has(data.id));
const alertsToDisplay = unViewed.length > 0 ? unViewed : this.data;
const lines = alertsToDisplay.map((data) => {
const fillValues = {};
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
return this.fillTemplate('hazard', fillValues);
});
list.append(...lines);
// no alerts, skip this display by setting timing to zero
if (lines.length === 0) {
this.setStatus(STATUS.loaded);
this.timing.totalScreens = 0;
this.setStatus(STATUS.loaded);
return;
}
// update timing
this.setTiming(list);
this.setStatus(STATUS.loaded);
}
setTiming(list) {
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();
}
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) {
// 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.scrollCache.maxOffset, (count - this.scrollTiming.initialCounts) * this.scrollTiming.pixelsPerCount);
// don't let offset go negative
if (offsetY < 0) offsetY = 0;
// 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)
screenIndexFromBaseCount() {
const superValue = super.screenIndexFromBaseCount();
// false is returned when we reach the end of the scroll
if (superValue === false) {
// note the ids shown
this?.data?.forEach((alert) => this.viewedAlerts.add(alert.id));
}
// return the value as expected
return superValue;
}
// 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));
});
}
// Sync current hazards to backend history
async syncHazardHistory() {
try {
// Skip if no weather parameters available
if (!this.weatherParameters) return;
// Format location
const { city, state, country, countryCode, latitude, longitude } = this.weatherParameters;
const location = this.formatLocationLabel(city, state, country, countryCode);
// Create stable location key from lat/lon (rounded to 3 decimal places ~111m precision)
const locationKey = `${parseFloat(latitude).toFixed(3)},${parseFloat(longitude).toFixed(3)}`;
// Normalize hazards for the API
const hazards = (this.data || []).map((hazard) => ({
id: hazard.id,
hazardType: hazard.properties?.event || 'Unknown',
severity: hazard.properties?.severity || 'Unknown',
source: String(hazard.id || '').startsWith('derived-') ? 'derived' : 'noaa',
}));
// Send to backend
await fetch(withBasePath('api/hazard-history'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ location, locationKey, hazards }),
});
} 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;
}
}
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;
};
// register display
const display = new Hazards(0, 'hazards', true);
registerDisplay(display);
export default display.getHazards.bind(display);