mirror of
https://github.com/netbymatt/ws4kp.git
synced 2026-04-17 00:59:29 -07:00
Merge remote-tracking branch 'eddyg/station-name-improvements' into code-refactor
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,243 +0,0 @@
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const TravelCities = [
|
||||
{
|
||||
Name: 'Atlanta',
|
||||
Latitude: 33.749,
|
||||
Longitude: -84.388,
|
||||
point: {
|
||||
x: 51,
|
||||
y: 87,
|
||||
wfo: 'FFC',
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: 'Boston',
|
||||
Latitude: 42.3584,
|
||||
Longitude: -71.0598,
|
||||
point: {
|
||||
x: 71,
|
||||
y: 90,
|
||||
wfo: 'BOX',
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: 'Chicago',
|
||||
Latitude: 41.9796,
|
||||
Longitude: -87.9045,
|
||||
point: {
|
||||
x: 66,
|
||||
y: 77,
|
||||
wfo: 'LOT',
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: 'Cleveland',
|
||||
Latitude: 41.4995,
|
||||
Longitude: -81.6954,
|
||||
point: {
|
||||
x: 83,
|
||||
y: 65,
|
||||
wfo: 'CLE',
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: 'Dallas',
|
||||
Latitude: 32.8959,
|
||||
Longitude: -97.0372,
|
||||
point: {
|
||||
x: 80,
|
||||
y: 109,
|
||||
wfo: 'FWD',
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: 'Denver',
|
||||
Latitude: 39.7391,
|
||||
Longitude: -104.9847,
|
||||
point: {
|
||||
x: 63,
|
||||
y: 61,
|
||||
wfo: 'BOU',
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: 'Detroit',
|
||||
Latitude: 42.3314,
|
||||
Longitude: -83.0457,
|
||||
point: {
|
||||
x: 66,
|
||||
y: 34,
|
||||
wfo: 'DTX',
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: 'Hartford',
|
||||
Latitude: 41.7637,
|
||||
Longitude: -72.6851,
|
||||
point: {
|
||||
x: 21,
|
||||
y: 54,
|
||||
wfo: 'BOX',
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: 'Houston',
|
||||
Latitude: 29.7633,
|
||||
Longitude: -95.3633,
|
||||
point: {
|
||||
x: 65,
|
||||
y: 97,
|
||||
wfo: 'HGX',
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: 'Indianapolis',
|
||||
Latitude: 39.7684,
|
||||
Longitude: -86.158,
|
||||
point: {
|
||||
x: 58,
|
||||
y: 69,
|
||||
wfo: 'IND',
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: 'Los Angeles',
|
||||
Latitude: 34.0522,
|
||||
Longitude: -118.2437,
|
||||
point: {
|
||||
x: 155,
|
||||
y: 45,
|
||||
wfo: 'LOX',
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: 'Miami',
|
||||
Latitude: 25.7743,
|
||||
Longitude: -80.1937,
|
||||
point: {
|
||||
x: 110,
|
||||
y: 51,
|
||||
wfo: 'MFL',
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: 'Minneapolis',
|
||||
Latitude: 44.98,
|
||||
Longitude: -93.2638,
|
||||
point: {
|
||||
x: 108,
|
||||
y: 72,
|
||||
wfo: 'MPX',
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: 'New York',
|
||||
Latitude: 40.7142,
|
||||
Longitude: -74.0059,
|
||||
point: {
|
||||
x: 33,
|
||||
y: 35,
|
||||
wfo: 'OKX',
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: 'Norfolk',
|
||||
Latitude: 36.8468,
|
||||
Longitude: -76.2852,
|
||||
point: {
|
||||
x: 90,
|
||||
y: 52,
|
||||
wfo: 'AKQ',
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: 'Orlando',
|
||||
Latitude: 28.5383,
|
||||
Longitude: -81.3792,
|
||||
point: {
|
||||
x: 26,
|
||||
y: 68,
|
||||
wfo: 'MLB',
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: 'Philadelphia',
|
||||
Latitude: 39.9523,
|
||||
Longitude: -75.1638,
|
||||
point: {
|
||||
x: 50,
|
||||
y: 76,
|
||||
wfo: 'PHI',
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: 'Pittsburgh',
|
||||
Latitude: 40.4406,
|
||||
Longitude: -79.9959,
|
||||
point: {
|
||||
x: 78,
|
||||
y: 66,
|
||||
wfo: 'PBZ',
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: 'St. Louis',
|
||||
Latitude: 38.6273,
|
||||
Longitude: -90.1979,
|
||||
point: {
|
||||
x: 95,
|
||||
y: 74,
|
||||
wfo: 'LSX',
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: 'San Francisco',
|
||||
Latitude: 37.7749,
|
||||
Longitude: -122.4194,
|
||||
point: {
|
||||
x: 85,
|
||||
y: 105,
|
||||
wfo: 'MTR',
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: 'Seattle',
|
||||
Latitude: 47.6062,
|
||||
Longitude: -122.3321,
|
||||
point: {
|
||||
x: 125,
|
||||
y: 68,
|
||||
wfo: 'SEW',
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: 'Syracuse',
|
||||
Latitude: 43.0481,
|
||||
Longitude: -76.1474,
|
||||
point: {
|
||||
x: 52,
|
||||
y: 99,
|
||||
wfo: 'BGM',
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: 'Tampa',
|
||||
Latitude: 27.9475,
|
||||
Longitude: -82.4584,
|
||||
point: {
|
||||
x: 71,
|
||||
y: 97,
|
||||
wfo: 'TBW',
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: 'Washington DC',
|
||||
Latitude: 38.8951,
|
||||
Longitude: -77.0364,
|
||||
point: {
|
||||
x: 97,
|
||||
y: 71,
|
||||
wfo: 'LWX',
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -1,12 +1,14 @@
|
||||
import { json } from './modules/utils/fetch.mjs';
|
||||
import noSleep from './modules/utils/nosleep.mjs';
|
||||
import {
|
||||
message as navMessage, isPlaying, resize, resetStatuses, latLonReceived,
|
||||
message as navMessage, isPlaying, resize, resetStatuses, latLonReceived, isIOS,
|
||||
} from './modules/navigation.mjs';
|
||||
import { round2 } from './modules/utils/units.mjs';
|
||||
import { parseQueryString } from './modules/share.mjs';
|
||||
import settings from './modules/settings.mjs';
|
||||
import AutoComplete from './modules/autocomplete.mjs';
|
||||
import { loadAllData } from './modules/utils/data-loader.mjs';
|
||||
import { debugFlag } from './modules/utils/debug.mjs';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
init();
|
||||
@@ -24,11 +26,27 @@ const categories = [
|
||||
'Postal', 'Populated Place',
|
||||
];
|
||||
const category = categories.join(',');
|
||||
const TXT_ADDRESS_SELECTOR = '#txtAddress';
|
||||
const TXT_ADDRESS_SELECTOR = '#txtLocation';
|
||||
const TOGGLE_FULL_SCREEN_SELECTOR = '#ToggleFullScreen';
|
||||
const BNT_GET_GPS_SELECTOR = '#btnGetGps';
|
||||
|
||||
const init = () => {
|
||||
const init = async () => {
|
||||
// Load core data first - app cannot function without it
|
||||
try {
|
||||
await loadAllData(typeof OVERRIDES !== 'undefined' && OVERRIDES.VERSION ? OVERRIDES.VERSION : '');
|
||||
} catch (error) {
|
||||
console.error('Failed to load core application data:', error);
|
||||
// Show error message to user and halt initialization
|
||||
document.body.innerHTML = `
|
||||
<div>
|
||||
<h2>Unable to load Weather Data</h2>
|
||||
<p>The application cannot start because core data failed to load.</p>
|
||||
<p>Please check your connection and try refreshing.</p>
|
||||
</div>
|
||||
`;
|
||||
return; // Stop initialization
|
||||
}
|
||||
|
||||
document.querySelector(TXT_ADDRESS_SELECTOR).addEventListener('focus', (e) => {
|
||||
e.target.select();
|
||||
});
|
||||
@@ -39,7 +57,15 @@ const init = () => {
|
||||
document.querySelector('#NavigatePrevious').addEventListener('click', btnNavigatePreviousClick);
|
||||
document.querySelector('#NavigatePlay').addEventListener('click', btnNavigatePlayClick);
|
||||
document.querySelector('#ToggleScanlines').addEventListener('click', btnNavigateToggleScanlines);
|
||||
document.querySelector(TOGGLE_FULL_SCREEN_SELECTOR).addEventListener('click', btnFullScreenClick);
|
||||
|
||||
// Hide fullscreen button on iOS since it doesn't support true fullscreen
|
||||
const fullscreenButton = document.querySelector(TOGGLE_FULL_SCREEN_SELECTOR);
|
||||
if (isIOS()) {
|
||||
fullscreenButton.style.display = 'none';
|
||||
} else {
|
||||
fullscreenButton.addEventListener('click', btnFullScreenClick);
|
||||
}
|
||||
|
||||
const btnGetGps = document.querySelector(BNT_GET_GPS_SELECTOR);
|
||||
btnGetGps.addEventListener('click', btnGetGpsClick);
|
||||
if (!navigator.geolocation) btnGetGps.style.display = 'none';
|
||||
@@ -47,9 +73,6 @@ const init = () => {
|
||||
document.querySelector('#divTwc').addEventListener('mousemove', () => {
|
||||
if (document.fullscreenElement) updateFullScreenNavigate();
|
||||
});
|
||||
// local change detection when exiting full screen via ESC key (or other non button click methods)
|
||||
window.addEventListener('resize', fullScreenResizeCheck);
|
||||
fullScreenResizeCheck.wasFull = false;
|
||||
|
||||
document.querySelector('#btnGetLatLng').addEventListener('click', () => autoComplete.directFormSubmit());
|
||||
|
||||
@@ -89,6 +112,7 @@ const init = () => {
|
||||
const query = parsedParameters.latLonQuery ?? localStorage.getItem('latLonQuery');
|
||||
const latLon = parsedParameters.latLon ?? localStorage.getItem('latLon');
|
||||
const fromGPS = localStorage.getItem('latLonFromGPS') && !loadFromParsed;
|
||||
|
||||
if (query && latLon && !fromGPS) {
|
||||
const txtAddress = document.querySelector(TXT_ADDRESS_SELECTOR);
|
||||
txtAddress.value = query;
|
||||
@@ -98,9 +122,21 @@ const init = () => {
|
||||
btnGetGpsClick();
|
||||
}
|
||||
|
||||
// if kiosk mode was set via the query string, also play immediately
|
||||
settings.kiosk.value = parsedParameters['settings-kiosk-checkbox'] === 'true';
|
||||
const play = parsedParameters['settings-kiosk-checkbox'] ?? localStorage.getItem('play');
|
||||
// Handle kiosk mode initialization
|
||||
const urlKioskCheckbox = parsedParameters['settings-kiosk-checkbox'];
|
||||
|
||||
// If kiosk=false is specified, disable kiosk mode and clear any stored value
|
||||
if (urlKioskCheckbox === 'false') {
|
||||
settings.kiosk.value = false;
|
||||
// Clear stored value by using conditional storage with false
|
||||
settings.kiosk.conditionalStoreToLocalStorage(false, false);
|
||||
} else if (urlKioskCheckbox === 'true') {
|
||||
// if kiosk mode was set via the query string, enable it
|
||||
settings.kiosk.value = true;
|
||||
}
|
||||
|
||||
// Auto-play logic: also play immediately if kiosk mode is enabled
|
||||
const play = settings.kiosk.value || urlKioskCheckbox === 'true' ? 'true' : localStorage.getItem('play');
|
||||
if (play === null || play === 'true') postMessage('navButton', 'play');
|
||||
|
||||
document.querySelector('#btnClearQuery').addEventListener('click', () => {
|
||||
@@ -125,6 +161,7 @@ const init = () => {
|
||||
};
|
||||
|
||||
const autocompleteOnSelect = async (suggestion) => {
|
||||
// Note: it's fine that this uses json instead of safeJson since it's infrequent and user-initiated
|
||||
const data = await json('https://geocode.arcgis.com/arcgis/rest/services/World/GeocodeServer/find', {
|
||||
data: {
|
||||
text: suggestion.value,
|
||||
@@ -171,27 +208,42 @@ const btnFullScreenClick = () => {
|
||||
return false;
|
||||
};
|
||||
|
||||
const enterFullScreen = () => {
|
||||
// This is async because modern browsers return a Promise from requestFullscreen
|
||||
const enterFullScreen = async () => {
|
||||
const element = document.querySelector('#divTwc');
|
||||
|
||||
// Supports most browsers and their versions.
|
||||
const requestMethod = element.requestFullScreen || element.webkitRequestFullScreen
|
||||
|| element.mozRequestFullScreen || element.msRequestFullscreen;
|
||||
const requestMethod = element.requestFullscreen || element.webkitRequestFullscreen || element.mozRequestFullscreen || element.msRequestFullscreen;
|
||||
|
||||
if (requestMethod) {
|
||||
// Native full screen.
|
||||
requestMethod.call(element, { navigationUI: 'hide' });
|
||||
try {
|
||||
// Native full screen with options for optimal display
|
||||
await requestMethod.call(element, {
|
||||
navigationUI: 'hide',
|
||||
allowsInlineMediaPlayback: true,
|
||||
});
|
||||
|
||||
if (debugFlag('fullscreen')) {
|
||||
setTimeout(() => {
|
||||
console.log(`🖥️ Fullscreen engaged. window=${window.innerWidth}x${window.innerHeight} fullscreenElement=${!!document.fullscreenElement}`);
|
||||
}, 150);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Fullscreen request failed:', error);
|
||||
}
|
||||
} else {
|
||||
// iOS doesn't support FullScreen API.
|
||||
window.scrollTo(0, 0);
|
||||
resize(true); // Force resize for iOS
|
||||
}
|
||||
resize();
|
||||
updateFullScreenNavigate();
|
||||
|
||||
// change hover text and image
|
||||
const img = document.querySelector(TOGGLE_FULL_SCREEN_SELECTOR);
|
||||
img.src = 'images/nav/ic_fullscreen_exit_white_24dp_2x.png';
|
||||
img.title = 'Exit fullscreen';
|
||||
if (img && img.style.display !== 'none') {
|
||||
img.src = 'images/nav/ic_fullscreen_exit_white_24dp_2x.png';
|
||||
img.title = 'Exit fullscreen';
|
||||
}
|
||||
};
|
||||
|
||||
const exitFullscreen = () => {
|
||||
@@ -202,20 +254,22 @@ const exitFullscreen = () => {
|
||||
document.exitFullscreen();
|
||||
} else if (document.webkitExitFullscreen) {
|
||||
document.webkitExitFullscreen();
|
||||
} else if (document.mozCancelFullScreen) {
|
||||
document.mozCancelFullScreen();
|
||||
} else if (document.mozCancelFullscreen) {
|
||||
document.mozCancelFullscreen();
|
||||
} else if (document.msExitFullscreen) {
|
||||
document.msExitFullscreen();
|
||||
}
|
||||
resize();
|
||||
// Note: resize will be called by fullscreenchange event listener
|
||||
exitFullScreenVisibilityChanges();
|
||||
};
|
||||
|
||||
const exitFullScreenVisibilityChanges = () => {
|
||||
// change hover text and image
|
||||
const img = document.querySelector(TOGGLE_FULL_SCREEN_SELECTOR);
|
||||
img.src = 'images/nav/ic_fullscreen_white_24dp_2x.png';
|
||||
img.title = 'Enter fullscreen';
|
||||
if (img && img.style.display !== 'none') {
|
||||
img.src = 'images/nav/ic_fullscreen_white_24dp_2x.png';
|
||||
img.title = 'Enter fullscreen';
|
||||
}
|
||||
document.querySelector('#divTwc').classList.remove('no-cursor');
|
||||
const divTwcBottom = document.querySelector('#divTwcBottom');
|
||||
divTwcBottom.classList.remove('hidden');
|
||||
@@ -295,10 +349,20 @@ const updateFullScreenNavigate = () => {
|
||||
};
|
||||
|
||||
const documentKeydown = (e) => {
|
||||
// don't trigger on ctrl/alt/shift modified key
|
||||
if (e.altKey || e.ctrlKey || e.shiftKey) return false;
|
||||
const { key } = e;
|
||||
|
||||
// Handle Ctrl+K to exit kiosk mode (even when other modifiers would normally be ignored)
|
||||
if (e.ctrlKey && (key === 'k' || key === 'K')) {
|
||||
e.preventDefault();
|
||||
if (settings.kiosk?.value) {
|
||||
settings.kiosk.value = false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// don't trigger on ctrl/alt/shift modified key for other shortcuts
|
||||
if (e.altKey || e.ctrlKey || e.shiftKey) return false;
|
||||
|
||||
if (document.fullscreenElement || document.activeElement === document.body) {
|
||||
switch (key) {
|
||||
case ' ': // Space
|
||||
@@ -397,21 +461,6 @@ const getForecastFromLatLon = (latitude, longitude, fromGps = false) => {
|
||||
});
|
||||
};
|
||||
|
||||
// check for change in full screen triggered by browser and run local functions
|
||||
const fullScreenResizeCheck = () => {
|
||||
if (fullScreenResizeCheck.wasFull && !document.fullscreenElement) {
|
||||
// leaving full screen
|
||||
exitFullScreenVisibilityChanges();
|
||||
}
|
||||
if (!fullScreenResizeCheck.wasFull && document.fullscreenElement) {
|
||||
// entering full screen
|
||||
// can't do much here because a UI interaction is required to change the full screen div element
|
||||
}
|
||||
|
||||
// store state of fullscreen element for next change detection
|
||||
fullScreenResizeCheck.wasFull = !!document.fullscreenElement;
|
||||
};
|
||||
|
||||
const getCustomCode = async () => {
|
||||
// fetch the custom file and see if it returns a 200 status
|
||||
const response = await fetch('scripts/custom.js', { method: 'HEAD' });
|
||||
|
||||
@@ -113,17 +113,28 @@ class Almanac extends WeatherDisplay {
|
||||
async drawCanvas() {
|
||||
super.drawCanvas();
|
||||
const info = this.data;
|
||||
|
||||
// Generate sun data grid in reading order (left-to-right, top-to-bottom)
|
||||
|
||||
// Set day names
|
||||
const Today = DateTime.local();
|
||||
const Tomorrow = Today.plus({ days: 1 });
|
||||
this.elem.querySelector('.day-1').textContent = Today.toLocaleString({ weekday: 'long' });
|
||||
this.elem.querySelector('.day-2').textContent = Tomorrow.toLocaleString({ weekday: 'long' });
|
||||
|
||||
// sun and moon data
|
||||
this.elem.querySelector('.day-1').innerHTML = Today.toLocaleString({ weekday: 'long' });
|
||||
this.elem.querySelector('.day-2').innerHTML = Tomorrow.toLocaleString({ weekday: 'long' });
|
||||
this.elem.querySelector('.rise-1').innerHTML = timeFormat(DateTime.fromJSDate(info.sun[0].sunrise));
|
||||
this.elem.querySelector('.rise-2').innerHTML = timeFormat(DateTime.fromJSDate(info.sun[1].sunrise));
|
||||
this.elem.querySelector('.set-1').innerHTML = timeFormat(DateTime.fromJSDate(info.sun[0].sunset));
|
||||
this.elem.querySelector('.set-2').innerHTML = timeFormat(DateTime.fromJSDate(info.sun[1].sunset));
|
||||
const todaySunrise = DateTime.fromJSDate(info.sun[0].sunrise);
|
||||
const todaySunset = DateTime.fromJSDate(info.sun[0].sunset);
|
||||
const [todaySunriseFormatted, todaySunsetFormatted] = formatTimesForColumn([todaySunrise, todaySunset]);
|
||||
this.elem.querySelector('.rise-1').textContent = todaySunriseFormatted;
|
||||
this.elem.querySelector('.set-1').textContent = todaySunsetFormatted;
|
||||
|
||||
const tomorrowSunrise = DateTime.fromJSDate(info.sun[1].sunrise);
|
||||
const tomorrowSunset = DateTime.fromJSDate(info.sun[1].sunset);
|
||||
const [tomorrowSunriseFormatted, tomorrowSunsetformatted] = formatTimesForColumn([tomorrowSunrise, tomorrowSunset]);
|
||||
this.elem.querySelector('.rise-2').textContent = tomorrowSunriseFormatted;
|
||||
this.elem.querySelector('.set-2').textContent = tomorrowSunsetformatted;
|
||||
|
||||
// Moon data
|
||||
const days = info.moon.map((MoonPhase) => {
|
||||
const fill = {};
|
||||
|
||||
@@ -168,7 +179,20 @@ const imageName = (type) => {
|
||||
}
|
||||
};
|
||||
|
||||
const timeFormat = (dt) => dt.setZone(timeZone()).toLocaleString(DateTime.TIME_SIMPLE).toLowerCase();
|
||||
const formatTimesForColumn = (times) => {
|
||||
const formatted = times.map((dt) => dt.setZone(timeZone()).toFormat('h:mm a').toUpperCase());
|
||||
|
||||
// Check if any time has a 2-digit hour (starts with '1')
|
||||
const hasTwoDigitHour = formatted.some((time) => time.startsWith('1'));
|
||||
|
||||
// If mixed digit lengths, pad single-digit hours with non-breaking space
|
||||
if (hasTwoDigitHour) {
|
||||
return formatted.map((time) => (time.startsWith('1') ? time : `\u00A0${time}`));
|
||||
}
|
||||
|
||||
// Otherwise, no padding needed
|
||||
return formatted;
|
||||
};
|
||||
|
||||
// register display
|
||||
const display = new Almanac(9, 'almanac');
|
||||
|
||||
@@ -192,7 +192,7 @@ class AutoComplete {
|
||||
|
||||
let result = this.cachedResponses[search];
|
||||
if (!result) {
|
||||
// make the request
|
||||
// make the request; using json here instead of safeJson is fine because it's infrequent and user-initiated
|
||||
const resultRaw = await json(url);
|
||||
|
||||
// use the provided parser
|
||||
@@ -296,8 +296,11 @@ class AutoComplete {
|
||||
|
||||
// if a click is detected on the page, generally we hide the suggestions, unless the click was within the autocomplete elements
|
||||
checkOutsideClick(e) {
|
||||
if (e.target.id === 'txtAddress') return;
|
||||
if (e.target?.parentNode?.classList.contains(this.options.containerClass)) return;
|
||||
if (e.target.id === 'txtLocation') return;
|
||||
// Fix autocomplete crash on outside click detection
|
||||
// Add optional chaining to prevent TypeError when checking classList.contains()
|
||||
// on elements that may not have a classList property.
|
||||
if (e.target?.parentNode?.classList?.contains(this.options.containerClass)) return;
|
||||
this.hideSuggestions();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +1,22 @@
|
||||
// current weather conditions display
|
||||
import STATUS from './status.mjs';
|
||||
import { preloadImg } from './utils/image.mjs';
|
||||
import { json } from './utils/fetch.mjs';
|
||||
import { safeJson } from './utils/fetch.mjs';
|
||||
import { directionToNSEW } from './utils/calc.mjs';
|
||||
import { locationCleanup } from './utils/string.mjs';
|
||||
import { getLargeIcon } from './icons.mjs';
|
||||
import WeatherDisplay from './weatherdisplay.mjs';
|
||||
import { registerDisplay } from './navigation.mjs';
|
||||
import augmentObservationWithMetar from './utils/metar.mjs';
|
||||
import {
|
||||
temperature, windSpeed, pressure, distanceMeters, distanceKilometers,
|
||||
} from './utils/units.mjs';
|
||||
import { debugFlag } from './utils/debug.mjs';
|
||||
import { isDataStale, enhanceObservationWithMapClick } from './utils/mapclick.mjs';
|
||||
|
||||
// some stations prefixed do not provide all the necessary data
|
||||
const skipStations = ['U', 'C', 'H', 'W', 'Y', 'T', 'S', 'M', 'O', 'L', 'A', 'F', 'B', 'N', 'V', 'R', 'D', 'E', 'I', 'G', 'J'];
|
||||
|
||||
const REQUIRED_VALUES = [
|
||||
'windSpeed',
|
||||
'dewpoint',
|
||||
'barometricPressure',
|
||||
'visibility',
|
||||
'relativeHumidity',
|
||||
];
|
||||
class CurrentWeather extends WeatherDisplay {
|
||||
constructor(navId, elemId) {
|
||||
super(navId, elemId, 'Current Conditions', true);
|
||||
@@ -44,48 +40,107 @@ class CurrentWeather extends WeatherDisplay {
|
||||
while (!observations && stationNum < filteredStations.length) {
|
||||
// get the station
|
||||
station = filteredStations[stationNum];
|
||||
const stationId = station.properties.stationIdentifier;
|
||||
|
||||
stationNum += 1;
|
||||
|
||||
let candidateObservation;
|
||||
try {
|
||||
// station observations
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
observations = await json(`${station.id}/observations`, {
|
||||
candidateObservation = await safeJson(`${station.id}/observations`, {
|
||||
data: {
|
||||
limit: 2,
|
||||
limit: 2, // we need the two most recent observations to calculate pressure direction
|
||||
},
|
||||
retryCount: 3,
|
||||
stillWaiting: () => this.stillWaiting(),
|
||||
});
|
||||
|
||||
if (observations.features.length === 0) throw new Error(`No features returned for station: ${station.properties.stationIdentifier}, trying next station`);
|
||||
|
||||
// one weather value in the right side column is allowed to be missing. Count them up.
|
||||
// eslint-disable-next-line no-loop-func
|
||||
const valuesCount = REQUIRED_VALUES.reduce((prev, cur) => {
|
||||
const value = observations.features[0].properties?.[cur]?.value;
|
||||
if (value !== null && value !== undefined) return prev + 1;
|
||||
// ceiling is a special case :,-(
|
||||
const ceiling = observations.features[0].properties?.cloudLayers[0]?.base?.value;
|
||||
if (cur === 'ceiling' && ceiling !== null && ceiling !== undefined) return prev + 1;
|
||||
return prev;
|
||||
}, 0);
|
||||
|
||||
// test data quality
|
||||
if (observations.features[0].properties.temperature.value === null
|
||||
|| observations.features[0].properties.textDescription === null
|
||||
|| observations.features[0].properties.textDescription === ''
|
||||
|| observations.features[0].properties.icon === null
|
||||
|| valuesCount < REQUIRED_VALUES.length - 1) {
|
||||
observations = undefined;
|
||||
throw new Error(`Incomplete data set for: ${station.properties.stationIdentifier}, trying next station`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
observations = undefined;
|
||||
console.error(`Unexpected error getting Current Conditions for station ${stationId}: ${error.message} (trying next station)`);
|
||||
candidateObservation = undefined;
|
||||
}
|
||||
|
||||
// Check if request was successful and has data
|
||||
if (candidateObservation && candidateObservation.features?.length > 0) {
|
||||
// Attempt making observation data usable with METAR data
|
||||
const originalData = { ...candidateObservation.features[0].properties };
|
||||
candidateObservation.features[0].properties = augmentObservationWithMetar(candidateObservation.features[0].properties);
|
||||
const metarFields = [
|
||||
{ name: 'temperature', check: (orig, metar) => orig.temperature?.value === null && metar.temperature?.value !== null },
|
||||
{ name: 'windSpeed', check: (orig, metar) => orig.windSpeed?.value === null && metar.windSpeed?.value !== null },
|
||||
{ name: 'windDirection', check: (orig, metar) => orig.windDirection?.value === null && metar.windDirection?.value !== null },
|
||||
{ name: 'windGust', check: (orig, metar) => orig.windGust?.value === null && metar.windGust?.value !== null },
|
||||
{ name: 'dewpoint', check: (orig, metar) => orig.dewpoint?.value === null && metar.dewpoint?.value !== null },
|
||||
{ name: 'barometricPressure', check: (orig, metar) => orig.barometricPressure?.value === null && metar.barometricPressure?.value !== null },
|
||||
{ name: 'relativeHumidity', check: (orig, metar) => orig.relativeHumidity?.value === null && metar.relativeHumidity?.value !== null },
|
||||
{ name: 'visibility', check: (orig, metar) => orig.visibility?.value === null && metar.visibility?.value !== null },
|
||||
{ name: 'ceiling', check: (orig, metar) => orig.cloudLayers?.[0]?.base?.value === null && metar.cloudLayers?.[0]?.base?.value !== null },
|
||||
];
|
||||
const augmentedData = candidateObservation.features[0].properties;
|
||||
const metarReplacements = metarFields.filter((field) => field.check(originalData, augmentedData)).map((field) => field.name);
|
||||
if (debugFlag('currentweather') && metarReplacements.length > 0) {
|
||||
console.log(`Current Conditions for station ${stationId} were augmented with METAR data for ${metarReplacements.join(', ')}`);
|
||||
}
|
||||
|
||||
// test data quality - check required fields and allow one optional field to be missing
|
||||
const requiredFields = [
|
||||
{ name: 'temperature', check: (props) => props.temperature?.value === null, required: true },
|
||||
{ name: 'textDescription', check: (props) => props.textDescription === null || props.textDescription === '', required: true },
|
||||
{ name: 'icon', check: (props) => props.icon === null, required: true },
|
||||
{ name: 'windSpeed', check: (props) => props.windSpeed?.value === null, required: false },
|
||||
{ name: 'dewpoint', check: (props) => props.dewpoint?.value === null, required: false },
|
||||
{ name: 'barometricPressure', check: (props) => props.barometricPressure?.value === null, required: false },
|
||||
{ name: 'visibility', check: (props) => props.visibility?.value === null, required: false },
|
||||
{ name: 'relativeHumidity', check: (props) => props.relativeHumidity?.value === null, required: false },
|
||||
{ name: 'ceiling', check: (props) => props.cloudLayers?.[0]?.base?.value === null, required: false },
|
||||
];
|
||||
|
||||
// Use enhanced observation with MapClick fallback
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const enhancedResult = await enhanceObservationWithMapClick(augmentedData, {
|
||||
requiredFields,
|
||||
maxOptionalMissing: 1, // Allow one optional field to be missing
|
||||
stationId,
|
||||
stillWaiting: () => this.stillWaiting(),
|
||||
debugContext: 'currentweather',
|
||||
});
|
||||
|
||||
candidateObservation.features[0].properties = enhancedResult.data;
|
||||
const { missingFields } = enhancedResult;
|
||||
const missingRequired = missingFields.filter((fieldName) => {
|
||||
const field = requiredFields.find((f) => f.name === fieldName && f.required);
|
||||
return !!field;
|
||||
});
|
||||
const missingOptional = missingFields.filter((fieldName) => {
|
||||
const field = requiredFields.find((f) => f.name === fieldName && !f.required);
|
||||
return !!field;
|
||||
});
|
||||
const missingOptionalCount = missingOptional.length;
|
||||
|
||||
// Check final data quality
|
||||
// Allow one optional field to be missing
|
||||
if (missingRequired.length === 0 && missingOptionalCount <= 1) {
|
||||
// Station data is good, use it
|
||||
observations = candidateObservation;
|
||||
if (debugFlag('currentweather') && missingOptional.length > 0) {
|
||||
console.log(`Data for station ${stationId} is missing optional fields: ${missingOptional.join(', ')} (acceptable)`);
|
||||
}
|
||||
} else {
|
||||
const allMissing = [...missingRequired, ...missingOptional];
|
||||
if (debugFlag('currentweather')) {
|
||||
console.log(`Data for station ${stationId} is missing fields: ${allMissing.join(', ')} (${missingRequired.length} required, ${missingOptionalCount} optional) (trying next station)`);
|
||||
}
|
||||
}
|
||||
} else if (debugFlag('verbose-failures')) {
|
||||
if (!candidateObservation) {
|
||||
console.log(`Current Conditions for station ${stationId} failed, trying next station`);
|
||||
} else {
|
||||
console.log(`No features returned for station ${stationId}, trying next station`);
|
||||
}
|
||||
}
|
||||
}
|
||||
// test for data received
|
||||
if (!observations) {
|
||||
console.error('All current weather stations exhausted');
|
||||
console.error('Current Conditions failure: all nearby weather stations exhausted!');
|
||||
if (this.isEnabled) this.setStatus(STATUS.failed);
|
||||
// send failed to subscribers
|
||||
this.getDataCallback(undefined);
|
||||
@@ -99,14 +154,36 @@ class CurrentWeather extends WeatherDisplay {
|
||||
// stop here if we're disabled
|
||||
if (!superResult) return;
|
||||
|
||||
// preload the icon
|
||||
preloadImg(getLargeIcon(observations.features[0].properties.icon));
|
||||
// Data is available, ensure we're enabled for display
|
||||
this.timing.totalScreens = 1;
|
||||
|
||||
// Check final data age
|
||||
const { isStale, ageInMinutes } = isDataStale(observations.features[0].properties.timestamp, 80); // hourly observation + 20 minute propagation delay
|
||||
this.isStaleData = isStale;
|
||||
|
||||
if (isStale && debugFlag('currentweather')) {
|
||||
console.warn(`Current Conditions: Data is ${ageInMinutes.toFixed(0)} minutes old (from ${new Date(observations.features[0].properties.timestamp).toISOString()})`);
|
||||
}
|
||||
|
||||
// preload the icon if available
|
||||
if (observations.features[0].properties.icon) {
|
||||
const iconResult = getLargeIcon(observations.features[0].properties.icon);
|
||||
if (iconResult) {
|
||||
preloadImg(iconResult);
|
||||
}
|
||||
}
|
||||
this.setStatus(STATUS.loaded);
|
||||
}
|
||||
|
||||
async drawCanvas() {
|
||||
super.drawCanvas();
|
||||
|
||||
// Update header text based on data staleness
|
||||
const headerTop = this.elem.querySelector('.header .title .top');
|
||||
if (headerTop) {
|
||||
headerTop.textContent = this.isStaleData ? 'Recent' : 'Current';
|
||||
}
|
||||
|
||||
let condition = this.data.observations.textDescription;
|
||||
if (condition.length > 15) {
|
||||
condition = shortConditions(condition);
|
||||
@@ -209,17 +286,23 @@ const parseData = (data) => {
|
||||
data.WindGust = windConverter(observations.windGust.value);
|
||||
data.WindUnit = windConverter.units;
|
||||
data.Humidity = Math.round(observations.relativeHumidity.value);
|
||||
data.Icon = getLargeIcon(observations.icon);
|
||||
|
||||
// Get the large icon, but provide a fallback if it returns false
|
||||
const iconResult = getLargeIcon(observations.icon);
|
||||
data.Icon = iconResult || observations.icon; // Use original icon if getLargeIcon returns false
|
||||
|
||||
data.PressureDirection = '';
|
||||
data.TextConditions = observations.textDescription;
|
||||
|
||||
// set wind speed of 0 as calm
|
||||
if (data.WindSpeed === 0) data.WindSpeed = 'Calm';
|
||||
|
||||
// difference since last measurement (pascals, looking for difference of more than 150)
|
||||
const pressureDiff = (observations.barometricPressure.value - data.features[1].properties.barometricPressure.value);
|
||||
if (pressureDiff > 150) data.PressureDirection = 'R';
|
||||
if (pressureDiff < -150) data.PressureDirection = 'F';
|
||||
// if two measurements are available, use the difference (in pascals) to determine pressure trend
|
||||
if (data.features.length > 1 && data.features[1].properties.barometricPressure?.value) {
|
||||
const pressureDiff = (observations.barometricPressure.value - data.features[1].properties.barometricPressure.value);
|
||||
if (pressureDiff > 150) data.PressureDirection = 'R';
|
||||
if (pressureDiff < -150) data.PressureDirection = 'F';
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -29,7 +32,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
|
||||
@@ -71,15 +74,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 = workingScreens[screenIndex](data);
|
||||
const thisScreen = workingScreens[screenIndex](scrollData);
|
||||
|
||||
// update classes on the scroll area
|
||||
elemForEach('.weather-display .scroll', (elem) => {
|
||||
@@ -116,7 +125,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,
|
||||
@@ -218,25 +229,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) => {
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
// display extended forecast graphically
|
||||
// technically uses the same data as the local forecast, we'll let the browser do the caching of that
|
||||
// (technically this uses the same data as the local forecast, but we'll let the cache deal with that)
|
||||
|
||||
import STATUS from './status.mjs';
|
||||
import { json } from './utils/fetch.mjs';
|
||||
import { safeJson } from './utils/fetch.mjs';
|
||||
import { DateTime } from '../vendor/auto/luxon.mjs';
|
||||
import { getLargeIcon } from './icons.mjs';
|
||||
import { preloadImg } from './utils/image.mjs';
|
||||
import WeatherDisplay from './weatherdisplay.mjs';
|
||||
import { registerDisplay } from './navigation.mjs';
|
||||
import settings from './settings.mjs';
|
||||
import filterExpiredPeriods from './utils/forecast-utils.mjs';
|
||||
import { debugFlag } from './utils/debug.mjs';
|
||||
|
||||
class ExtendedForecast extends WeatherDisplay {
|
||||
constructor(navId, elemId) {
|
||||
@@ -21,27 +23,30 @@ class ExtendedForecast extends WeatherDisplay {
|
||||
async getData(weatherParameters, refresh) {
|
||||
if (!super.getData(weatherParameters, refresh)) return;
|
||||
|
||||
// request us or si units
|
||||
try {
|
||||
this.data = await json(this.weatherParameters.forecast, {
|
||||
// request us or si units using centralized safe handling
|
||||
this.data = await safeJson(this.weatherParameters.forecast, {
|
||||
data: {
|
||||
units: settings.units.value,
|
||||
},
|
||||
retryCount: 3,
|
||||
stillWaiting: () => this.stillWaiting(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Unable to get extended forecast');
|
||||
console.error(error.status, error.responseJSON);
|
||||
// if there's no previous data, fail
|
||||
|
||||
// if there's no new data and no previous data, fail
|
||||
if (!this.data) {
|
||||
this.setStatus(STATUS.failed);
|
||||
// console.warn(`Unable to get extended forecast for ${this.weatherParameters.latitude},${this.weatherParameters.longitude} in ${this.weatherParameters.state}`);
|
||||
if (this.isEnabled) this.setStatus(STATUS.failed);
|
||||
return;
|
||||
}
|
||||
|
||||
// we only get here if there was data (new or existing)
|
||||
this.screenIndex = 0;
|
||||
this.setStatus(STATUS.loaded);
|
||||
} catch (error) {
|
||||
console.error(`Unexpected error getting Extended Forecast: ${error.message}`);
|
||||
if (this.isEnabled) this.setStatus(STATUS.failed);
|
||||
}
|
||||
// we only get here if there was no error above
|
||||
this.screenIndex = 0;
|
||||
this.setStatus(STATUS.loaded);
|
||||
}
|
||||
|
||||
async drawCanvas() {
|
||||
@@ -49,7 +54,7 @@ class ExtendedForecast extends WeatherDisplay {
|
||||
|
||||
// determine bounds
|
||||
// grab the first three or second set of three array elements
|
||||
const forecast = parse(this.data.properties.periods).slice(0 + 3 * this.screenIndex, 3 + this.screenIndex * 3);
|
||||
const forecast = parse(this.data.properties.periods, this.weatherParameters.forecast).slice(0 + 3 * this.screenIndex, 3 + this.screenIndex * 3);
|
||||
|
||||
// create each day template
|
||||
const days = forecast.map((Day) => {
|
||||
@@ -78,19 +83,52 @@ class ExtendedForecast extends WeatherDisplay {
|
||||
}
|
||||
|
||||
// the api provides the forecast in 12 hour increments, flatten to day increments with high and low temperatures
|
||||
const parse = (fullForecast) => {
|
||||
// create a list of days starting with today
|
||||
const Days = [0, 1, 2, 3, 4, 5, 6];
|
||||
const parse = (fullForecast, forecastUrl) => {
|
||||
// filter out expired periods first
|
||||
const activePeriods = filterExpiredPeriods(fullForecast, forecastUrl);
|
||||
|
||||
if (debugFlag('extendedforecast')) {
|
||||
console.log('ExtendedForecast: First few active periods:');
|
||||
activePeriods.slice(0, 4).forEach((period, index) => {
|
||||
console.log(` [${index}] ${period.name}: ${period.startTime} to ${period.endTime} (isDaytime: ${period.isDaytime})`);
|
||||
});
|
||||
}
|
||||
|
||||
// Skip the first period if it's nighttime (like "Tonight") since extended forecast
|
||||
// should focus on upcoming full days, not the end of the current day
|
||||
let startIndex = 0;
|
||||
let dateOffset = 0; // offset for date labels when we skip periods
|
||||
|
||||
if (activePeriods.length > 0 && !activePeriods[0].isDaytime) {
|
||||
startIndex = 1;
|
||||
dateOffset = 1; // start date labels from tomorrow since we're skipping tonight
|
||||
if (debugFlag('extendedforecast')) {
|
||||
console.log(`ExtendedForecast: Skipping first period "${activePeriods[0].name}" because it's nighttime`);
|
||||
}
|
||||
} else if (activePeriods.length > 0) {
|
||||
if (debugFlag('extendedforecast')) {
|
||||
console.log(`ExtendedForecast: Starting with first period "${activePeriods[0].name}" because it's daytime`);
|
||||
}
|
||||
}
|
||||
|
||||
// create a list of days starting with the appropriate day
|
||||
const Days = [0, 1, 2, 3, 4, 5, 6];
|
||||
const dates = Days.map((shift) => {
|
||||
const date = DateTime.local().startOf('day').plus({ days: shift });
|
||||
const date = DateTime.local().startOf('day').plus({ days: shift + dateOffset });
|
||||
return date.toLocaleString({ weekday: 'short' });
|
||||
});
|
||||
|
||||
if (debugFlag('extendedforecast')) {
|
||||
console.log(`ExtendedForecast: Generated date labels: [${dates.join(', ')}]`);
|
||||
}
|
||||
|
||||
// track the destination forecast index
|
||||
let destIndex = 0;
|
||||
const forecast = [];
|
||||
fullForecast.forEach((period) => {
|
||||
|
||||
for (let i = startIndex; i < activePeriods.length; i += 1) {
|
||||
const period = activePeriods[i];
|
||||
|
||||
// create the destination object if necessary
|
||||
if (!forecast[destIndex]) {
|
||||
forecast.push({
|
||||
@@ -110,12 +148,21 @@ const parse = (fullForecast) => {
|
||||
if (period.isDaytime) {
|
||||
// day time is the high temperature
|
||||
fDay.high = period.temperature;
|
||||
destIndex += 1;
|
||||
// Wait for the corresponding night period to increment
|
||||
} else {
|
||||
// low temperature
|
||||
fDay.low = period.temperature;
|
||||
// Increment after processing night period
|
||||
destIndex += 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (debugFlag('extendedforecast')) {
|
||||
console.log('ExtendedForecast: Final forecast array:');
|
||||
forecast.forEach((day, index) => {
|
||||
console.log(` [${index}] ${day.dayName}: High=${day.high}°, Low=${day.low}°, Text="${day.text}"`);
|
||||
});
|
||||
}
|
||||
|
||||
return forecast;
|
||||
};
|
||||
|
||||
@@ -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}<br/><br/>${data.properties.description.replaceAll('\n\n', '<br/><br/>').replaceAll('\n', ' ')}`;
|
||||
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);
|
||||
});
|
||||
@@ -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)
|
||||
|
||||
@@ -31,8 +31,8 @@ class HourlyGraph extends WeatherDisplay {
|
||||
if (!super.getData(undefined, refresh)) return;
|
||||
|
||||
const data = await getHourlyData(() => this.stillWaiting());
|
||||
if (data === undefined) {
|
||||
this.setStatus(STATUS.failed);
|
||||
if (!data) {
|
||||
if (this.isEnabled) this.setStatus(STATUS.failed);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,60 +2,70 @@
|
||||
|
||||
import STATUS from './status.mjs';
|
||||
import { DateTime, Interval, Duration } from '../vendor/auto/luxon.mjs';
|
||||
import { json } from './utils/fetch.mjs';
|
||||
import { safeJson } from './utils/fetch.mjs';
|
||||
import { temperature as temperatureUnit, distanceKilometers } from './utils/units.mjs';
|
||||
import { getHourlyIcon } from './icons.mjs';
|
||||
import { directionToNSEW } from './utils/calc.mjs';
|
||||
import WeatherDisplay from './weatherdisplay.mjs';
|
||||
import { registerDisplay, timeZone } from './navigation.mjs';
|
||||
import getSun from './almanac.mjs';
|
||||
import calculateScrollTiming from './utils/scroll-timing.mjs';
|
||||
import { debugFlag } from './utils/debug.mjs';
|
||||
|
||||
class Hourly extends WeatherDisplay {
|
||||
constructor(navId, elemId, defaultActive) {
|
||||
// special height and width for scrolling
|
||||
super(navId, elemId, 'Hourly Forecast', defaultActive);
|
||||
|
||||
// set up the timing
|
||||
this.timing.baseDelay = 20;
|
||||
// 24 hours = 6 pages
|
||||
const pages = 4; // first page is already displayed, last page doesn't happen
|
||||
const timingStep = 75 * 4;
|
||||
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(150);
|
||||
// cache for scroll calculations
|
||||
// This cache is essential because baseCountChange() is called 25 times per second (every 40ms)
|
||||
// during scrolling. Without caching, we'd perform hundreds of expensive DOM layout queries during
|
||||
// the full 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,
|
||||
hourlyLines: null,
|
||||
};
|
||||
}
|
||||
|
||||
async getData(weatherParameters, refresh) {
|
||||
// super checks for enabled
|
||||
const superResponse = super.getData(weatherParameters, refresh);
|
||||
let forecast;
|
||||
|
||||
try {
|
||||
// get the forecast
|
||||
forecast = await json(this.weatherParameters.forecastGridData, { retryCount: 3, stillWaiting: () => this.stillWaiting() });
|
||||
// parse the forecast
|
||||
this.data = await parseForecast(forecast.properties);
|
||||
} catch (error) {
|
||||
console.error('Get hourly forecast failed');
|
||||
console.error(error.status, error.responseJSON);
|
||||
// use old data if available
|
||||
if (this.data) {
|
||||
console.log('Using previous hourly forecast');
|
||||
// don't return, this.data is usable from the previous update
|
||||
} else {
|
||||
const forecast = await safeJson(this.weatherParameters.forecastGridData, { retryCount: 3, stillWaiting: () => this.stillWaiting() });
|
||||
|
||||
if (forecast) {
|
||||
try {
|
||||
// parse the forecast
|
||||
this.data = await parseForecast(forecast.properties);
|
||||
} catch (error) {
|
||||
console.error(`Hourly forecast parsing failed: ${error.message}`);
|
||||
}
|
||||
} else if (debugFlag('verbose-failures')) {
|
||||
console.warn(`Using previous hourly forecast for ${this.weatherParameters.forecastGridData}`);
|
||||
}
|
||||
|
||||
// use old data if available, fail if no data at all
|
||||
if (!this.data) {
|
||||
if (this.isEnabled) this.setStatus(STATUS.failed);
|
||||
// return undefined to other subscribers
|
||||
this.getDataCallback(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
this.getDataCallback();
|
||||
if (!superResponse) return;
|
||||
|
||||
this.setStatus(STATUS.loaded);
|
||||
this.drawLongCanvas();
|
||||
} catch (error) {
|
||||
console.error(`Unexpected error getting hourly forecast: ${error.message}`);
|
||||
if (this.isEnabled) this.setStatus(STATUS.failed);
|
||||
this.getDataCallback(undefined);
|
||||
}
|
||||
|
||||
this.getDataCallback();
|
||||
if (!superResponse) return;
|
||||
|
||||
this.setStatus(STATUS.loaded);
|
||||
this.drawLongCanvas();
|
||||
}
|
||||
|
||||
async drawLongCanvas() {
|
||||
@@ -102,6 +112,9 @@ class Hourly extends WeatherDisplay {
|
||||
});
|
||||
|
||||
list.append(...lines);
|
||||
|
||||
// update timing based on actual content
|
||||
this.setTiming(list);
|
||||
}
|
||||
|
||||
drawCanvas() {
|
||||
@@ -122,19 +135,35 @@ class Hourly extends WeatherDisplay {
|
||||
|
||||
// base count change callback
|
||||
baseCountChange(count) {
|
||||
// get the hourly lines element and cache measurements if needed
|
||||
const hourlyLines = this.elem.querySelector('.hourly-lines');
|
||||
if (!hourlyLines) return;
|
||||
|
||||
// update cache if needed (when content changes or first run)
|
||||
if (this.scrollCache.hourlyLines !== hourlyLines || this.scrollCache.displayHeight === 0) {
|
||||
this.scrollCache.displayHeight = this.elem.querySelector('.main').offsetHeight;
|
||||
this.scrollCache.contentHeight = hourlyLines.offsetHeight;
|
||||
this.scrollCache.maxOffset = Math.max(0, this.scrollCache.contentHeight - this.scrollCache.displayHeight);
|
||||
this.scrollCache.hourlyLines = hourlyLines;
|
||||
|
||||
// Set up hardware acceleration on the hourly lines element
|
||||
hourlyLines.style.willChange = 'transform';
|
||||
hourlyLines.style.backfaceVisibility = 'hidden';
|
||||
}
|
||||
|
||||
// calculate scroll offset and don't go past end
|
||||
let offsetY = Math.min(this.elem.querySelector('.hourly-lines').offsetHeight - 289, (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;
|
||||
|
||||
// copy the scrolled portion of the canvas
|
||||
this.elem.querySelector('.main').scrollTo(0, offsetY);
|
||||
// use transform instead of scrollTo for hardware acceleration
|
||||
hourlyLines.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) {
|
||||
async getHourlyData(stillWaiting) {
|
||||
if (stillWaiting) this.stillWaitingCallbacks.push(stillWaiting);
|
||||
// an external caller has requested data, set up auto reload
|
||||
this.setAutoReload();
|
||||
@@ -144,6 +173,18 @@ class Hourly extends WeatherDisplay {
|
||||
this.getDataCallbacks.push(() => resolve(this.data));
|
||||
});
|
||||
}
|
||||
|
||||
setTiming(list) {
|
||||
const container = this.elem.querySelector('.main');
|
||||
const timingConfig = calculateScrollTiming(list, container);
|
||||
|
||||
// Apply the calculated timing
|
||||
this.timing.baseDelay = timingConfig.baseDelay;
|
||||
this.timing.delay = timingConfig.delay;
|
||||
this.scrollTiming = timingConfig.scrollTiming;
|
||||
|
||||
this.calcNavTiming();
|
||||
}
|
||||
}
|
||||
|
||||
// extract specific values from forecast and format as an array
|
||||
@@ -192,7 +233,7 @@ const determineIcon = async (skyCover, weather, iceAccumulation, probabilityOfPr
|
||||
};
|
||||
|
||||
// expand a set of values with durations to an hour-by-hour array
|
||||
const expand = (data) => {
|
||||
const expand = (data, maxHours = 24) => {
|
||||
const startOfHour = DateTime.utc().startOf('hour').toMillis();
|
||||
const result = []; // resulting expanded values
|
||||
data.forEach((item) => {
|
||||
@@ -202,12 +243,12 @@ const expand = (data) => {
|
||||
// loop through duration at one hour intervals
|
||||
do {
|
||||
// test for timestamp greater than now
|
||||
if (startTime >= startOfHour && result.length < 24) {
|
||||
if (startTime >= startOfHour && result.length < maxHours) {
|
||||
result.push(item.value); // push data array
|
||||
} // timestamp is after now
|
||||
// increment start time by 1 hour
|
||||
startTime += 3_600_000;
|
||||
} while (startTime < endTime && result.length < 24);
|
||||
} while (startTime < endTime && result.length < maxHours);
|
||||
}); // for each value
|
||||
|
||||
return result;
|
||||
@@ -217,4 +258,4 @@ const expand = (data) => {
|
||||
const display = new Hourly(3, 'hourly', false);
|
||||
registerDisplay(display);
|
||||
|
||||
export default display.getCurrentData.bind(display);
|
||||
export default display.getHourlyData.bind(display);
|
||||
|
||||
@@ -1,55 +1,65 @@
|
||||
/* spell-checker: disable */
|
||||
// internal function to add path to returned icon
|
||||
import parseIconUrl from './icons-parse.mjs';
|
||||
|
||||
const addPath = (icon) => `images/icons/current-conditions/${icon}`;
|
||||
|
||||
const largeIcon = (link, _isNightTime) => {
|
||||
if (!link) return false;
|
||||
let conditionIcon;
|
||||
let probability;
|
||||
let isNightTime;
|
||||
|
||||
// extract day or night if not provided
|
||||
const isNightTime = _isNightTime ?? link.indexOf('/night/') >= 0;
|
||||
|
||||
// grab everything after the last slash ending at any of these: ?&,
|
||||
const afterLastSlash = link.toLowerCase().match(/[^/]+$/)[0];
|
||||
let conditionName = afterLastSlash.match(/(.*?)[&,.?]/)[1];
|
||||
// using probability as a crude heavy/light indication where possible
|
||||
const value = +(link.match(/,(\d{2,3})/) ?? [0, 100])[1];
|
||||
|
||||
// if a 'DualImage' is captured, adjust to just the j parameter
|
||||
if (conditionName === 'dualimage') {
|
||||
const match = link.match(/&j=(.*)&/);
|
||||
[, conditionName] = match;
|
||||
try {
|
||||
({ conditionIcon, probability, isNightTime } = parseIconUrl(link, _isNightTime));
|
||||
} catch (error) {
|
||||
console.warn(`largeIcon: ${error.message}`);
|
||||
// Return a fallback icon to prevent downstream errors
|
||||
return addPath(_isNightTime ? 'Clear.gif' : 'Sunny.gif');
|
||||
}
|
||||
|
||||
// find the icon
|
||||
switch (conditionName + (isNightTime ? '-n' : '')) {
|
||||
switch (conditionIcon + (isNightTime ? '-n' : '')) {
|
||||
case 'skc':
|
||||
case 'hot':
|
||||
case 'haze':
|
||||
case 'cold':
|
||||
return addPath('Sunny.gif');
|
||||
|
||||
case 'skc-n':
|
||||
case 'nskc':
|
||||
case 'nskc-n':
|
||||
return addPath('Clear.gif');
|
||||
|
||||
case 'haze':
|
||||
return addPath('Sunny.gif');
|
||||
|
||||
case 'haze-n':
|
||||
return addPath('Clear.gif');
|
||||
|
||||
case 'cold':
|
||||
return addPath('Sunny.gif');
|
||||
|
||||
case 'cold-n':
|
||||
return addPath('Clear.gif');
|
||||
|
||||
case 'sct':
|
||||
case 'dust':
|
||||
case 'dust-n':
|
||||
return addPath('Smoke.gif');
|
||||
|
||||
case 'few':
|
||||
return addPath('Partly-Cloudy.gif');
|
||||
|
||||
case 'few-n':
|
||||
return addPath('Mostly-Clear.gif');
|
||||
|
||||
case 'sct':
|
||||
return addPath('Partly-Cloudy.gif');
|
||||
|
||||
case 'sct-n':
|
||||
return addPath('Mostly-Clear.gif');
|
||||
|
||||
case 'bkn':
|
||||
return addPath('Partly-Cloudy.gif');
|
||||
|
||||
case 'bkn-n':
|
||||
case 'few-n':
|
||||
case 'nfew-n':
|
||||
case 'nfew':
|
||||
case 'sct-n':
|
||||
case 'nsct':
|
||||
case 'nsct-n':
|
||||
return addPath('Mostly-Clear.gif');
|
||||
|
||||
case 'ovc':
|
||||
case 'novc':
|
||||
case 'ovc-n':
|
||||
return addPath('Cloudy.gif');
|
||||
|
||||
@@ -70,8 +80,10 @@ const largeIcon = (link, _isNightTime) => {
|
||||
return addPath('Smoke.gif');
|
||||
|
||||
case 'rain_showers':
|
||||
case 'rain_showers_hi':
|
||||
case 'rain_showers_high':
|
||||
case 'rain_showers-n':
|
||||
case 'rain_showers_hi-n':
|
||||
case 'rain_showers_high-n':
|
||||
return addPath('Shower.gif');
|
||||
|
||||
@@ -81,10 +93,11 @@ const largeIcon = (link, _isNightTime) => {
|
||||
|
||||
case 'snow':
|
||||
case 'snow-n':
|
||||
if (value > 50) return addPath('Heavy-Snow.gif');
|
||||
if (probability > 50) return addPath('Heavy-Snow.gif');
|
||||
return addPath('Light-Snow.gif');
|
||||
|
||||
case 'rain_snow':
|
||||
case 'rain_snow-n':
|
||||
return addPath('Rain-Snow.gif');
|
||||
|
||||
case 'snow_fzra':
|
||||
@@ -98,43 +111,66 @@ const largeIcon = (link, _isNightTime) => {
|
||||
return addPath('Freezing-Rain.gif');
|
||||
|
||||
case 'snow_sleet':
|
||||
case 'snow_sleet-n':
|
||||
return addPath('Snow-Sleet.gif');
|
||||
|
||||
case 'tsra_sct':
|
||||
case 'tsra':
|
||||
return addPath('Scattered-Thunderstorms-Day.gif');
|
||||
|
||||
case 'tsra_sct-n':
|
||||
return addPath('Scattered-Thunderstorms-Night.gif');
|
||||
|
||||
case 'tsra':
|
||||
return addPath('Scattered-Thunderstorms-Day.gif');
|
||||
|
||||
case 'tsra-n':
|
||||
return addPath('Scattered-Thunderstorms-Night.gif');
|
||||
|
||||
case 'tsra_hi':
|
||||
case 'tsra_hi-n':
|
||||
return addPath('Thunderstorm.gif');
|
||||
|
||||
case 'tornado':
|
||||
case 'tornado-n':
|
||||
return addPath('Thunderstorm.gif');
|
||||
|
||||
case 'hurricane':
|
||||
case 'tropical_storm':
|
||||
case 'hurricane-n':
|
||||
case 'tropical_storm':
|
||||
case 'tropical_storm-n':
|
||||
return addPath('Thunderstorm.gif');
|
||||
|
||||
case 'wind_few':
|
||||
case 'wind_sct':
|
||||
case 'wind_bkn':
|
||||
case 'wind_ovc':
|
||||
case 'wind_skc':
|
||||
case 'wind_few-n':
|
||||
case 'wind_bkn-n':
|
||||
case 'wind_ovc-n':
|
||||
return addPath('Windy.gif');
|
||||
|
||||
case 'wind_skc-n':
|
||||
return addPath('Windy.gif');
|
||||
|
||||
case 'wind_few':
|
||||
case 'wind_few-n':
|
||||
return addPath('Windy.gif');
|
||||
|
||||
case 'wind_sct':
|
||||
case 'wind_sct-n':
|
||||
return addPath('Windy.gif');
|
||||
|
||||
case 'wind_bkn':
|
||||
case 'wind_bkn-n':
|
||||
return addPath('Windy.gif');
|
||||
|
||||
case 'wind_ovc':
|
||||
case 'wind_ovc-n':
|
||||
return addPath('Windy.gif');
|
||||
|
||||
case 'blizzard':
|
||||
case 'blizzard-n':
|
||||
return addPath('Blowing-Snow.gif');
|
||||
|
||||
default:
|
||||
console.log(`Unable to locate icon for ${conditionName} ${link} ${isNightTime}`);
|
||||
return false;
|
||||
default: {
|
||||
console.warn(`Unknown weather condition '${conditionIcon}' from ${link}; using fallback icon`);
|
||||
// Return a reasonable fallback instead of false to prevent downstream errors
|
||||
return addPath(isNightTime ? 'Clear.gif' : 'Sunny.gif');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
93
server/scripts/modules/icons/icons-parse.mjs
Normal file
93
server/scripts/modules/icons/icons-parse.mjs
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Parses weather.gov icon URLs and extracts weather condition information
|
||||
* Handles both single and dual condition formats according to the weather.gov API spec
|
||||
*
|
||||
* NOTE: The 'icon' properties are marked as deprecated in the API documentation. This
|
||||
* is because it will eventually be replaced with a more generic value that is not a URL.
|
||||
*/
|
||||
|
||||
import { debugFlag } from '../utils/debug.mjs';
|
||||
|
||||
/**
|
||||
* Parses a weather.gov icon URL and extracts condition and timing information
|
||||
* @param {string} iconUrl - Icon URL from weather.gov API (e.g., "/icons/land/day/skc?size=medium")
|
||||
* @param {boolean} _isNightTime - Optional override for night time determination
|
||||
* @returns {Object} Parsed icon data with conditionIcon, probability, and isNightTime
|
||||
*/
|
||||
const parseIconUrl = (iconUrl, _isNightTime) => {
|
||||
if (!iconUrl) {
|
||||
throw new Error('No icon URL provided');
|
||||
}
|
||||
|
||||
// Parse icon URL according to API spec: /icons/{set}/{timeOfDay}/{condition}?{params}
|
||||
// where {condition} might be single (skc) or dual (tsra_hi,20/rain,50)
|
||||
// Each period will have an icon, or two if there is changing weather during that period
|
||||
// see https://github.com/weather-gov/api/discussions/557#discussioncomment-9949521
|
||||
// (On the weather.gov site, changing conditions results in a "dualImage" forecast icon)
|
||||
const iconUrlPattern = /\/icons\/(?<set>\w+)\/(?<timeOfDay>day|night)\/(?<condition>[^?]+)(?:\?(?<params>.*))?$/i;
|
||||
const match = iconUrl.match(iconUrlPattern);
|
||||
|
||||
if (!match?.groups) {
|
||||
throw new Error(`Unable to parse icon URL format: ${iconUrl}`);
|
||||
}
|
||||
|
||||
const { timeOfDay, condition } = match.groups;
|
||||
|
||||
// Determine if it's night time with preference strategy:
|
||||
// 1. Primary: use _isNightTime parameter if provided (such as from API's isDaytime property)
|
||||
// 2. Secondary: use timeOfDay parsed from URL
|
||||
let isNightTime;
|
||||
if (_isNightTime !== undefined) {
|
||||
isNightTime = _isNightTime;
|
||||
} else if (timeOfDay === 'day') {
|
||||
isNightTime = false;
|
||||
} else if (timeOfDay === 'night') {
|
||||
isNightTime = true;
|
||||
} else {
|
||||
console.warn(`parseIconUrl: unexpected timeOfDay value: ${timeOfDay}`);
|
||||
isNightTime = false;
|
||||
}
|
||||
|
||||
// Dual conditions can have a probability
|
||||
// Examples: "tsra_hi,30/sct", "rain_showers,30/tsra_hi,50", "hot/tsra_hi,70"
|
||||
let conditionIcon;
|
||||
let probability;
|
||||
if (condition.includes('/')) { // Two conditions
|
||||
const conditions = condition.split('/');
|
||||
const firstCondition = conditions[0] || '';
|
||||
const secondCondition = conditions[1] || '';
|
||||
|
||||
const [firstIcon, firstProb] = firstCondition.split(',');
|
||||
const [secondIcon, secondProb] = secondCondition.split(',');
|
||||
|
||||
// Default to 100% probability if not specified (high confidence)
|
||||
const firstProbability = parseInt(firstProb, 10) || 100;
|
||||
const secondProbability = parseInt(secondProb, 10) || 100;
|
||||
|
||||
if (secondIcon !== firstIcon) {
|
||||
// When there's more than one condition, use the second condition
|
||||
// QUESTION: should the condition with the higher probability determine which one to use?
|
||||
// if (firstProbability >= secondProbability) { ... }
|
||||
conditionIcon = secondIcon;
|
||||
probability = secondProbability;
|
||||
if (debugFlag('icons')) {
|
||||
console.debug(`2️⃣ Using second condition: '${secondCondition}' instead of first '${firstCondition}'`);
|
||||
}
|
||||
} else {
|
||||
conditionIcon = firstIcon;
|
||||
probability = firstProbability;
|
||||
}
|
||||
} else { // Single condition
|
||||
const [name, prob] = condition.split(',');
|
||||
conditionIcon = name;
|
||||
probability = parseInt(prob, 10) || 100;
|
||||
}
|
||||
|
||||
return {
|
||||
conditionIcon,
|
||||
probability,
|
||||
isNightTime,
|
||||
};
|
||||
};
|
||||
|
||||
export default parseIconUrl;
|
||||
@@ -1,52 +1,46 @@
|
||||
// internal function to add path to returned icon
|
||||
import parseIconUrl from './icons-parse.mjs';
|
||||
|
||||
const addPath = (icon) => `images/icons/regional-maps/${icon}`;
|
||||
|
||||
const smallIcon = (link, _isNightTime) => {
|
||||
// extract day or night if not provided
|
||||
const isNightTime = _isNightTime ?? link.indexOf('/night/') >= 0;
|
||||
let conditionIcon;
|
||||
let probability;
|
||||
let isNightTime;
|
||||
|
||||
// grab everything after the last slash ending at any of these: ?&,
|
||||
const afterLastSlash = link.toLowerCase().match(/[^/]+$/)[0];
|
||||
let conditionName = afterLastSlash.match(/(.*?)[&,.?]/)[1];
|
||||
// using probability as a crude heavy/light indication where possible
|
||||
const value = +(link.match(/,(\d{2,3})/) ?? [0, 100])[1];
|
||||
|
||||
// if a 'DualImage' is captured, adjust to just the j parameter
|
||||
if (conditionName === 'dualimage') {
|
||||
const match = link.match(/&j=(.*)&/);
|
||||
[, conditionName] = match;
|
||||
try {
|
||||
({ conditionIcon, probability, isNightTime } = parseIconUrl(link, _isNightTime));
|
||||
} catch (error) {
|
||||
console.warn(`smallIcon: ${error.message}`);
|
||||
// Return a fallback icon to prevent downstream errors
|
||||
return addPath(_isNightTime ? 'Clear-1992.gif' : 'Sunny.gif');
|
||||
}
|
||||
|
||||
// find the icon
|
||||
switch (conditionName + (isNightTime ? '-n' : '')) {
|
||||
// handle official weather.gov API condition icons
|
||||
switch (conditionIcon + (isNightTime ? '-n' : '')) {
|
||||
case 'skc':
|
||||
return addPath('Sunny.gif');
|
||||
|
||||
case 'skc-n':
|
||||
case 'nskc':
|
||||
case 'nskc-n':
|
||||
case 'cold-n':
|
||||
return addPath('Clear-1992.gif');
|
||||
|
||||
case 'few':
|
||||
return addPath('Partly-Cloudy.gif');
|
||||
|
||||
case 'few-n':
|
||||
return addPath('Partly-Clear-1994.gif');
|
||||
|
||||
case 'sct':
|
||||
return addPath('Partly-Cloudy.gif');
|
||||
|
||||
case 'sct-n':
|
||||
return addPath('Partly-Cloudy-Night.gif');
|
||||
|
||||
case 'bkn':
|
||||
return addPath('Mostly-Cloudy-1994.gif');
|
||||
|
||||
case 'bkn-n':
|
||||
case 'few-n':
|
||||
case 'nfew-n':
|
||||
case 'nfew':
|
||||
return addPath('Partly-Clear-1994.gif');
|
||||
|
||||
case 'sct':
|
||||
case 'few':
|
||||
return addPath('Partly-Cloudy.gif');
|
||||
|
||||
case 'sct-n':
|
||||
case 'nsct':
|
||||
case 'nsct-n':
|
||||
case 'haze-n':
|
||||
return addPath('Partly-Cloudy-Night.gif');
|
||||
|
||||
case 'ovc':
|
||||
case 'ovc-n':
|
||||
return addPath('Cloudy.gif');
|
||||
@@ -55,39 +49,33 @@ const smallIcon = (link, _isNightTime) => {
|
||||
case 'fog-n':
|
||||
return addPath('Fog.gif');
|
||||
|
||||
case 'rain_sleet':
|
||||
return addPath('Rain-Sleet.gif');
|
||||
|
||||
case 'rain_showers':
|
||||
case 'rain_showers_high':
|
||||
return addPath('Scattered-Showers-1994.gif');
|
||||
|
||||
case 'rain_showers-n':
|
||||
case 'rain_showers_high-n':
|
||||
return addPath('Scattered-Showers-Night-1994.gif');
|
||||
|
||||
case 'rain':
|
||||
case 'rain-n':
|
||||
return addPath('Rain-1992.gif');
|
||||
|
||||
case 'rain_showers':
|
||||
return addPath('Scattered-Showers-1994.gif');
|
||||
|
||||
case 'rain_showers-n':
|
||||
return addPath('Scattered-Showers-Night-1994.gif');
|
||||
|
||||
case 'rain_showers_hi':
|
||||
return addPath('Scattered-Showers-1994.gif');
|
||||
|
||||
case 'rain_showers_hi-n':
|
||||
return addPath('Scattered-Showers-Night-1994.gif');
|
||||
|
||||
case 'snow':
|
||||
case 'snow-n':
|
||||
if (value > 50) return addPath('Heavy-Snow-1994.gif');
|
||||
if (probability > 50) return addPath('Heavy-Snow-1994.gif');
|
||||
return addPath('Light-Snow.gif');
|
||||
|
||||
case 'rain_snow':
|
||||
case 'rain_snow-n':
|
||||
return addPath('Rain-Snow-1992.gif');
|
||||
|
||||
case 'snow_fzra':
|
||||
case 'snow_fzra-n':
|
||||
return addPath('Freezing-Rain-Snow-1994.gif');
|
||||
|
||||
case 'fzra':
|
||||
case 'fzra-n':
|
||||
case 'rain_fzra':
|
||||
case 'rain_fzra-n':
|
||||
return addPath('Freezing-Rain-1992.gif');
|
||||
case 'rain_sleet':
|
||||
return addPath('Rain-Sleet.gif');
|
||||
|
||||
case 'snow_sleet':
|
||||
case 'snow_sleet-n':
|
||||
@@ -97,64 +85,97 @@ const smallIcon = (link, _isNightTime) => {
|
||||
case 'sleet-n':
|
||||
return addPath('Sleet.gif');
|
||||
|
||||
case 'tsra_sct':
|
||||
case 'fzra':
|
||||
case 'fzra-n':
|
||||
return addPath('Freezing-Rain-1992.gif');
|
||||
|
||||
case 'rain_fzra':
|
||||
case 'rain_fzra-n':
|
||||
return addPath('Freezing-Rain-1992.gif');
|
||||
|
||||
case 'snow_fzra':
|
||||
case 'snow_fzra-n':
|
||||
return addPath('Freezing-Rain-Snow-1994.gif');
|
||||
|
||||
case 'tsra':
|
||||
return addPath('Scattered-Tstorms-1994.gif');
|
||||
|
||||
case 'tsra_sct-n':
|
||||
case 'tsra-n':
|
||||
return addPath('Scattered-Tstorms-Night-1994.gif');
|
||||
|
||||
case 'tsra_sct':
|
||||
return addPath('Scattered-Tstorms-1994.gif');
|
||||
|
||||
case 'tsra_sct-n':
|
||||
return addPath('Scattered-Tstorms-Night-1994.gif');
|
||||
|
||||
case 'tsra_hi':
|
||||
case 'tsra_hi-n':
|
||||
case 'hurricane':
|
||||
case 'tropical_storm':
|
||||
case 'hurricane-n':
|
||||
case 'tropical_storm-n':
|
||||
return addPath('Thunderstorm.gif');
|
||||
|
||||
case 'wind':
|
||||
case 'wind_':
|
||||
case 'wind_few':
|
||||
case 'wind_sct':
|
||||
case 'wind-n':
|
||||
case 'wind_-n':
|
||||
case 'wind_few-n':
|
||||
return addPath('Wind.gif');
|
||||
case 'tornado':
|
||||
case 'tornado-n':
|
||||
return addPath('Thunderstorm.gif');
|
||||
|
||||
case 'wind_bkn':
|
||||
case 'wind_ovc':
|
||||
case 'wind_bkn-n':
|
||||
case 'wind_ovc-n':
|
||||
return addPath('Cloudy-Wind.gif');
|
||||
case 'hurricane':
|
||||
case 'hurricane-n':
|
||||
return addPath('Thunderstorm.gif');
|
||||
|
||||
case 'tropical_storm':
|
||||
case 'tropical_storm-n':
|
||||
return addPath('Thunderstorm.gif');
|
||||
|
||||
case 'wind_skc':
|
||||
return addPath('Sunny-Wind-1994.gif');
|
||||
|
||||
case 'wind_skc-n':
|
||||
return addPath('Clear-Wind-1994.gif');
|
||||
|
||||
case 'wind_few':
|
||||
case 'wind_few-n':
|
||||
return addPath('Wind.gif');
|
||||
|
||||
case 'wind_sct':
|
||||
return addPath('Wind.gif');
|
||||
|
||||
case 'wind_sct-n':
|
||||
return addPath('Clear-Wind-1994.gif');
|
||||
|
||||
case 'blizzard':
|
||||
case 'blizzard-n':
|
||||
return addPath('Blowing Snow.gif');
|
||||
case 'wind_bkn':
|
||||
case 'wind_bkn-n':
|
||||
return addPath('Cloudy-Wind.gif');
|
||||
|
||||
case 'cold':
|
||||
return addPath('Cold.gif');
|
||||
case 'wind_ovc':
|
||||
case 'wind_ovc-n':
|
||||
return addPath('Cloudy-Wind.gif');
|
||||
|
||||
case 'dust':
|
||||
case 'dust-n':
|
||||
return addPath('Smoke.gif');
|
||||
|
||||
case 'smoke':
|
||||
case 'smoke-n':
|
||||
return addPath('Smoke.gif');
|
||||
|
||||
case 'haze':
|
||||
case 'haze-n':
|
||||
return addPath('Haze.gif');
|
||||
|
||||
case 'hot':
|
||||
return addPath('Hot.gif');
|
||||
|
||||
case 'haze':
|
||||
return addPath('Haze.gif');
|
||||
case 'cold':
|
||||
case 'cold-n':
|
||||
return addPath('Cold.gif');
|
||||
|
||||
case 'blizzard':
|
||||
case 'blizzard-n':
|
||||
return addPath('Blowing Snow.gif');
|
||||
|
||||
default:
|
||||
console.log(`Unable to locate regional icon for ${conditionName} ${link} ${isNightTime}`);
|
||||
return false;
|
||||
console.warn(`Unknown weather condition '${conditionIcon}' from ${link}; using fallback icon`);
|
||||
// Return a reasonable fallback instead of false to prevent downstream errors
|
||||
return addPath(isNightTime ? 'Clear-1992.gif' : 'Sunny.gif');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
// current weather conditions display
|
||||
import { distance as calcDistance, directionToNSEW } from './utils/calc.mjs';
|
||||
import { json } from './utils/fetch.mjs';
|
||||
import { safeJson, safePromiseAll } from './utils/fetch.mjs';
|
||||
import STATUS from './status.mjs';
|
||||
import { locationCleanup } from './utils/string.mjs';
|
||||
import { temperature, windSpeed } from './utils/units.mjs';
|
||||
import WeatherDisplay from './weatherdisplay.mjs';
|
||||
import { registerDisplay } from './navigation.mjs';
|
||||
import augmentObservationWithMetar from './utils/metar.mjs';
|
||||
import settings from './settings.mjs';
|
||||
import { debugFlag } from './utils/debug.mjs';
|
||||
import { enhanceObservationWithMapClick } from './utils/mapclick.mjs';
|
||||
|
||||
class LatestObservations extends WeatherDisplay {
|
||||
constructor(navId, elemId) {
|
||||
@@ -32,14 +35,17 @@ class LatestObservations extends WeatherDisplay {
|
||||
// try up to 30 regional stations
|
||||
const regionalStations = sortedStations.slice(0, 30);
|
||||
|
||||
// get data for regional stations
|
||||
// get first 7 stations
|
||||
// Fetch stations sequentially in batches to avoid unnecessary API calls.
|
||||
// We start with the 7 closest stations and only fetch more if some fail,
|
||||
// stopping as soon as we have 7 valid stations with data.
|
||||
const actualConditions = [];
|
||||
let lastStation = Math.min(regionalStations.length, 7);
|
||||
let firstStation = 0;
|
||||
while (actualConditions.length < 7 && (lastStation) <= regionalStations.length) {
|
||||
// Sequential fetching is intentional here - we want to try closest stations first
|
||||
// and only fetch additional batches if needed, rather than hitting all 30 stations at once
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const someStations = await getStations(regionalStations.slice(firstStation, lastStation));
|
||||
const someStations = await this.getStations(regionalStations.slice(firstStation, lastStation));
|
||||
|
||||
actualConditions.push(...someStations);
|
||||
// update counters
|
||||
@@ -58,6 +64,79 @@ class LatestObservations extends WeatherDisplay {
|
||||
this.setStatus(STATUS.loaded);
|
||||
}
|
||||
|
||||
// This is a class method because it needs access to the instance's `stillWaiting` method
|
||||
async getStations(stations) {
|
||||
// Use centralized safe Promise handling to avoid unhandled AbortError rejections
|
||||
const stationData = await safePromiseAll(stations.map(async (station) => {
|
||||
try {
|
||||
const data = await safeJson(`https://api.weather.gov/stations/${station.id}/observations/latest`, {
|
||||
retryCount: 1,
|
||||
stillWaiting: () => this.stillWaiting(),
|
||||
});
|
||||
|
||||
if (!data) {
|
||||
if (debugFlag('verbose-failures')) {
|
||||
console.log(`Failed to get Latest Observations for station ${station.id}`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Enhance observation data with METAR parsing for missing fields
|
||||
const originalData = { ...data.properties };
|
||||
data.properties = augmentObservationWithMetar(data.properties);
|
||||
const metarFields = [
|
||||
{ name: 'temperature', check: (orig, metar) => orig.temperature.value === null && metar.temperature.value !== null },
|
||||
{ name: 'windSpeed', check: (orig, metar) => orig.windSpeed.value === null && metar.windSpeed.value !== null },
|
||||
{ name: 'windDirection', check: (orig, metar) => orig.windDirection.value === null && metar.windDirection.value !== null },
|
||||
];
|
||||
const augmentedData = data.properties;
|
||||
const metarReplacements = metarFields.filter((field) => field.check(originalData, augmentedData)).map((field) => field.name);
|
||||
if (debugFlag('latestobservations') && metarReplacements.length > 0) {
|
||||
console.log(`Latest Observations for station ${station.id} were augmented with METAR data for ${metarReplacements.join(', ')}`);
|
||||
}
|
||||
|
||||
// test data quality
|
||||
const requiredFields = [
|
||||
{ name: 'temperature', check: (props) => props.temperature?.value === null },
|
||||
{ name: 'windSpeed', check: (props) => props.windSpeed?.value === null },
|
||||
{ name: 'windDirection', check: (props) => props.windDirection?.value === null },
|
||||
{ name: 'textDescription', check: (props) => props.textDescription === null || props.textDescription === '' },
|
||||
];
|
||||
|
||||
// Use enhanced observation with MapClick fallback
|
||||
const enhancedResult = await enhanceObservationWithMapClick(data.properties, {
|
||||
requiredFields,
|
||||
stationId: station.id,
|
||||
stillWaiting: () => this.stillWaiting(),
|
||||
debugContext: 'latestobservations',
|
||||
});
|
||||
|
||||
data.properties = enhancedResult.data;
|
||||
const { missingFields } = enhancedResult;
|
||||
|
||||
// Check final data quality
|
||||
if (missingFields.length > 0) {
|
||||
if (debugFlag('latestobservations')) {
|
||||
console.log(`Latest Observations for station ${station.id} is missing fields: ${missingFields.join(', ')}`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// format the return values
|
||||
return {
|
||||
...data.properties,
|
||||
StationId: station.id,
|
||||
city: station.city,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Unexpected error getting latest observations for station ${station.id}: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}));
|
||||
// filter false (no data or other error)
|
||||
return stationData.filter((d) => d);
|
||||
}
|
||||
|
||||
async drawCanvas() {
|
||||
super.drawCanvas();
|
||||
const conditions = this.data;
|
||||
@@ -106,6 +185,7 @@ class LatestObservations extends WeatherDisplay {
|
||||
this.finishDraw();
|
||||
}
|
||||
}
|
||||
|
||||
const shortenCurrentConditions = (_condition) => {
|
||||
let condition = _condition;
|
||||
condition = condition.replace(/Light/, 'L');
|
||||
@@ -124,28 +204,5 @@ const shortenCurrentConditions = (_condition) => {
|
||||
condition = condition.replace(/ with /, '/');
|
||||
return condition;
|
||||
};
|
||||
|
||||
const getStations = async (stations) => {
|
||||
const stationData = await Promise.all(stations.map(async (station) => {
|
||||
try {
|
||||
const data = await json(`https://api.weather.gov/stations/${station.id}/observations/latest`, { retryCount: 1, stillWaiting: () => this.stillWaiting() });
|
||||
// test for temperature, weather and wind values present
|
||||
if (data.properties.temperature.value === null
|
||||
|| data.properties.textDescription === ''
|
||||
|| data.properties.windSpeed.value === null) return false;
|
||||
// format the return values
|
||||
return {
|
||||
...data.properties,
|
||||
StationId: station.id,
|
||||
city: station.city,
|
||||
};
|
||||
} catch {
|
||||
console.log(`Unable to get latest observations for ${station.id}`);
|
||||
return false;
|
||||
}
|
||||
}));
|
||||
// filter false (no data or other error)
|
||||
return stationData.filter((d) => d);
|
||||
};
|
||||
// register display
|
||||
registerDisplay(new LatestObservations(2, 'latest-observations'));
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
// display text based local forecast
|
||||
|
||||
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 settings from './settings.mjs';
|
||||
import filterExpiredPeriods from './utils/forecast-utils.mjs';
|
||||
import { debugFlag } from './utils/debug.mjs';
|
||||
|
||||
class LocalForecast extends WeatherDisplay {
|
||||
static BASE_FORECAST_DURATION_MS = 5000; // Base duration (in ms) for a standard 3-5 line forecast page
|
||||
|
||||
constructor(navId, elemId) {
|
||||
super(navId, elemId, 'Local Forecast', true);
|
||||
|
||||
// set timings
|
||||
this.timing.baseDelay = 5000;
|
||||
this.timing.baseDelay = LocalForecast.BASE_FORECAST_DURATION_MS;
|
||||
}
|
||||
|
||||
async getData(weatherParameters, refresh) {
|
||||
@@ -22,13 +26,13 @@ class LocalForecast extends WeatherDisplay {
|
||||
// check for data, or if there's old data available
|
||||
if (!rawData && !this.data) {
|
||||
// fail for no old or new data
|
||||
this.setStatus(STATUS.failed);
|
||||
if (this.isEnabled) this.setStatus(STATUS.failed);
|
||||
return;
|
||||
}
|
||||
// store the data
|
||||
this.data = rawData || this.data;
|
||||
// parse raw data
|
||||
const conditions = parse(this.data);
|
||||
// parse raw data and filter out expired periods
|
||||
const conditions = parse(this.data, this.weatherParameters.forecast);
|
||||
|
||||
// read each text
|
||||
this.screenTexts = conditions.map((condition) => {
|
||||
@@ -46,34 +50,32 @@ class LocalForecast extends WeatherDisplay {
|
||||
forecastsElem.innerHTML = '';
|
||||
forecastsElem.append(...templates);
|
||||
|
||||
// increase each forecast height to a multiple of container height
|
||||
// Get page height for screen calculations
|
||||
this.pageHeight = forecastsElem.parentNode.offsetHeight;
|
||||
templates.forEach((forecast) => {
|
||||
const newHeight = Math.ceil(forecast.scrollHeight / this.pageHeight) * this.pageHeight;
|
||||
forecast.style.height = `${newHeight}px`;
|
||||
});
|
||||
|
||||
this.timing.totalScreens = forecastsElem.scrollHeight / this.pageHeight;
|
||||
this.calculateContentAwareTiming(templates);
|
||||
|
||||
this.calcNavTiming();
|
||||
|
||||
this.setStatus(STATUS.loaded);
|
||||
}
|
||||
|
||||
// get the unformatted data (also used by extended forecast)
|
||||
async getRawData(weatherParameters) {
|
||||
// request us or si units
|
||||
try {
|
||||
return await json(weatherParameters.forecast, {
|
||||
data: {
|
||||
units: settings.units.value,
|
||||
},
|
||||
retryCount: 3,
|
||||
stillWaiting: () => this.stillWaiting(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`GetWeatherForecast failed: ${weatherParameters.forecast}`);
|
||||
console.error(error.status, error.responseJSON);
|
||||
// request us or si units using centralized safe handling
|
||||
const data = await safeJson(weatherParameters.forecast, {
|
||||
data: {
|
||||
units: settings.units.value,
|
||||
},
|
||||
retryCount: 3,
|
||||
stillWaiting: () => this.stillWaiting(),
|
||||
});
|
||||
|
||||
if (!data) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
async drawCanvas() {
|
||||
@@ -84,14 +86,180 @@ class LocalForecast extends WeatherDisplay {
|
||||
|
||||
this.finishDraw();
|
||||
}
|
||||
|
||||
// calculate dynamic timing based on height measurement template approach
|
||||
calculateContentAwareTiming(templates) {
|
||||
if (!templates || templates.length === 0) {
|
||||
this.timing.delay = 1; // fallback to single delay if no templates
|
||||
return;
|
||||
}
|
||||
|
||||
// Use the original base duration constant for timing calculations
|
||||
const originalBaseDuration = LocalForecast.BASE_FORECAST_DURATION_MS;
|
||||
this.timing.baseDelay = 250; // use 250ms per count for precise timing control
|
||||
|
||||
// Get line height from CSS for accurate calculations
|
||||
const sampleForecast = templates[0];
|
||||
const computedStyle = window.getComputedStyle(sampleForecast);
|
||||
const lineHeight = parseInt(computedStyle.lineHeight, 10);
|
||||
|
||||
// Calculate the actual width that forecast text uses
|
||||
// Use the forecast container that's already been set up
|
||||
const forecastContainer = this.elem.querySelector('.local-forecast .container');
|
||||
let effectiveWidth;
|
||||
|
||||
if (!forecastContainer) {
|
||||
console.error('LocalForecast: Could not find forecast container for width calculation, using fallback width');
|
||||
effectiveWidth = 492; // "magic number" from manual calculations as fallback
|
||||
} else {
|
||||
const containerStyle = window.getComputedStyle(forecastContainer);
|
||||
const containerWidth = forecastContainer.offsetWidth;
|
||||
const paddingLeft = parseInt(containerStyle.paddingLeft, 10) || 0;
|
||||
const paddingRight = parseInt(containerStyle.paddingRight, 10) || 0;
|
||||
effectiveWidth = containerWidth - paddingLeft - paddingRight;
|
||||
|
||||
if (debugFlag('localforecast')) {
|
||||
console.log(`LocalForecast: Using measurement width of ${effectiveWidth}px (container=${containerWidth}px, padding=${paddingLeft}+${paddingRight}px)`);
|
||||
}
|
||||
}
|
||||
|
||||
// Measure each forecast period to get actual line counts
|
||||
const forecastLineCounts = [];
|
||||
templates.forEach((template, index) => {
|
||||
const currentHeight = template.offsetHeight;
|
||||
const currentLines = Math.round(currentHeight / lineHeight);
|
||||
|
||||
if (currentLines > 7) {
|
||||
// Multi-page forecasts measure correctly, so use the measurement directly
|
||||
forecastLineCounts.push(currentLines);
|
||||
|
||||
if (debugFlag('localforecast')) {
|
||||
console.log(`LocalForecast: Forecast ${index} measured ${currentLines} lines (${currentHeight}px direct measurement, ${lineHeight}px line-height)`);
|
||||
}
|
||||
} else {
|
||||
// If may be 7 lines or less, we need to pad the content to ensure proper height measurement
|
||||
// Short forecasts are capped by CSS min-height: 280px (7 lines)
|
||||
// Add 7 <br> tags to force height beyond the minimum, then subtract the padding
|
||||
const originalHTML = template.innerHTML;
|
||||
const paddingBRs = '<br/>'.repeat(7);
|
||||
template.innerHTML = originalHTML + paddingBRs;
|
||||
|
||||
// Measure the padded height
|
||||
const paddedHeight = template.offsetHeight;
|
||||
const paddedLines = Math.round(paddedHeight / lineHeight);
|
||||
|
||||
// Calculate actual content lines by subtracting the 7 BR lines we added
|
||||
const actualLines = Math.max(1, paddedLines - 7);
|
||||
|
||||
// Restore original content
|
||||
template.innerHTML = originalHTML;
|
||||
|
||||
forecastLineCounts.push(actualLines);
|
||||
|
||||
if (debugFlag('localforecast')) {
|
||||
console.log(`LocalForecast: Forecast ${index} measured ${actualLines} lines (${paddedHeight}px with padding - ${7 * lineHeight}px = ${actualLines * lineHeight}px actual, ${lineHeight}px line-height)`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Apply height padding for proper scrolling display (keep existing system working)
|
||||
templates.forEach((forecast) => {
|
||||
const newHeight = Math.ceil(forecast.offsetHeight / this.pageHeight) * this.pageHeight;
|
||||
forecast.style.height = `${newHeight}px`;
|
||||
});
|
||||
|
||||
// Calculate total screens based on padded height (for navigation system)
|
||||
const forecastsElem = templates[0].parentNode;
|
||||
const totalHeight = forecastsElem.scrollHeight;
|
||||
this.timing.totalScreens = Math.round(totalHeight / this.pageHeight);
|
||||
|
||||
// Now calculate timing based on actual measured line counts, ignoring padding
|
||||
const maxLinesPerScreen = 7; // 280px / 40px line height
|
||||
const screenTimings = []; forecastLineCounts.forEach((lines, forecastIndex) => {
|
||||
if (lines <= maxLinesPerScreen) {
|
||||
// Single screen for this forecast
|
||||
screenTimings.push({ forecastIndex, lines, type: 'single' });
|
||||
} else {
|
||||
// Multiple screens for this forecast
|
||||
let remainingLines = lines;
|
||||
let isFirst = true;
|
||||
|
||||
while (remainingLines > 0) {
|
||||
const linesThisScreen = Math.min(remainingLines, maxLinesPerScreen);
|
||||
const type = isFirst ? 'first-of-multi' : 'remainder';
|
||||
|
||||
screenTimings.push({ forecastIndex, lines: linesThisScreen, type });
|
||||
|
||||
remainingLines -= linesThisScreen;
|
||||
isFirst = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Create timing array based on measured line counts
|
||||
const screenDelays = screenTimings.map((screenInfo, screenIndex) => {
|
||||
const screenLines = screenInfo.lines;
|
||||
|
||||
// Apply timing rules based on actual screen content lines
|
||||
let timingMultiplier;
|
||||
if (screenLines === 1) {
|
||||
timingMultiplier = 0.6; // 1 line = shortest (3.0s at normal speed)
|
||||
} else if (screenLines === 2) {
|
||||
timingMultiplier = 0.8; // 2 lines = shorter (4.0s at normal speed)
|
||||
} else if (screenLines >= 6) {
|
||||
timingMultiplier = 1.4; // 6+ lines = longer (7.0s at normal speed)
|
||||
} else {
|
||||
timingMultiplier = 1.0; // 3-5 lines = normal (5.0s at normal speed)
|
||||
}
|
||||
|
||||
// Convert to base counts
|
||||
const desiredDurationMs = timingMultiplier * originalBaseDuration;
|
||||
const baseCounts = Math.round(desiredDurationMs / this.timing.baseDelay);
|
||||
|
||||
if (debugFlag('localforecast')) {
|
||||
console.log(`LocalForecast: Screen ${screenIndex}: ${screenLines} lines, ${timingMultiplier.toFixed(2)}x multiplier, ${desiredDurationMs}ms desired, ${baseCounts} counts (forecast ${screenInfo.forecastIndex}, ${screenInfo.type})`);
|
||||
}
|
||||
|
||||
return baseCounts;
|
||||
});
|
||||
|
||||
// Adjust timing array to match actual screen count if needed
|
||||
while (screenDelays.length < this.timing.totalScreens) {
|
||||
// Add fallback timing for extra screens
|
||||
const fallbackCounts = Math.round(originalBaseDuration / this.timing.baseDelay);
|
||||
screenDelays.push(fallbackCounts);
|
||||
console.warn(`LocalForecast: using fallback timing for Screen ${screenDelays.length - 1}: 5 lines, 1.00x multiplier, ${fallbackCounts} counts`);
|
||||
}
|
||||
|
||||
// Truncate if we have too many calculated screens
|
||||
if (screenDelays.length > this.timing.totalScreens) {
|
||||
const removed = screenDelays.splice(this.timing.totalScreens);
|
||||
console.warn(`LocalForecast: Truncated ${removed.length} excess screen timings`);
|
||||
}
|
||||
|
||||
// Set the timing array based on screen content
|
||||
this.timing.delay = screenDelays;
|
||||
|
||||
if (debugFlag('localforecast')) {
|
||||
console.log(`LocalForecast: Final screen count - calculated: ${screenTimings.length}, actual: ${this.timing.totalScreens}, timing array: ${screenDelays.length}`);
|
||||
const multipliers = screenDelays.map((counts) => counts * this.timing.baseDelay / originalBaseDuration);
|
||||
console.log('LocalForecast: Screen multipliers:', multipliers);
|
||||
console.log('LocalForecast: Expected durations (ms):', screenDelays.map((counts) => counts * this.timing.baseDelay));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// format the forecast
|
||||
// only use the first 6 lines
|
||||
const parse = (forecast) => forecast.properties.periods.slice(0, 6).map((text) => ({
|
||||
// format day and text
|
||||
DayName: text.name.toUpperCase(),
|
||||
Text: text.detailedForecast,
|
||||
}));
|
||||
// filter out expired periods, then use the first 6 forecasts
|
||||
const parse = (forecast, forecastUrl) => {
|
||||
const allPeriods = forecast.properties.periods;
|
||||
const activePeriods = filterExpiredPeriods(allPeriods, forecastUrl);
|
||||
|
||||
return activePeriods.slice(0, 6).map((text) => ({
|
||||
// format day and text
|
||||
DayName: text.name.toUpperCase(),
|
||||
Text: text.detailedForecast,
|
||||
}));
|
||||
};
|
||||
// register display
|
||||
registerDisplay(new LocalForecast(7, 'local-forecast'));
|
||||
|
||||
@@ -40,21 +40,32 @@ const scanMusicDirectory = async () => {
|
||||
};
|
||||
|
||||
const getMedia = async () => {
|
||||
let playlistSource = '';
|
||||
|
||||
try {
|
||||
const response = await fetch('playlist.json');
|
||||
if (response.ok) {
|
||||
playlist = await response.json();
|
||||
} else if (response.status === 404
|
||||
&& response.headers.get('X-Weatherstar') === 'true') {
|
||||
console.warn("Couldn't get playlist.json, falling back to directory scan");
|
||||
playlistSource = 'from server';
|
||||
} else if (response.status === 404 && response.headers.get('X-Weatherstar') === 'true') {
|
||||
// Expected behavior in static deployment mode
|
||||
playlist = await scanMusicDirectory();
|
||||
playlistSource = 'via directory scan (static deployment)';
|
||||
} else {
|
||||
console.warn(`Couldn't get playlist.json: ${response.status} ${response.statusText}`);
|
||||
playlist = { availableFiles: [] };
|
||||
playlistSource = `failed (${response.status} ${response.statusText})`;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Couldn't get playlist.json, falling back to directory scan");
|
||||
} catch (_e) {
|
||||
// Network error or other fetch failure - fall back to directory scanning
|
||||
playlist = await scanMusicDirectory();
|
||||
playlistSource = 'via directory scan (after fetch failed)';
|
||||
}
|
||||
|
||||
const fileCount = playlist?.availableFiles?.length || 0;
|
||||
if (fileCount > 0) {
|
||||
console.log(`Loaded playlist ${playlistSource} - found ${fileCount} music file${fileCount === 1 ? '' : 's'}`);
|
||||
} else {
|
||||
console.log(`No music files found ${playlistSource}`);
|
||||
}
|
||||
|
||||
enableMediaPlayer();
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
import noSleep from './utils/nosleep.mjs';
|
||||
import STATUS from './status.mjs';
|
||||
import { wrap } from './utils/calc.mjs';
|
||||
import { json } from './utils/fetch.mjs';
|
||||
import { safeJson } from './utils/fetch.mjs';
|
||||
import { getPoint } from './utils/weather.mjs';
|
||||
import { debugFlag } from './utils/debug.mjs';
|
||||
import settings from './settings.mjs';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
@@ -16,8 +17,36 @@ let progress;
|
||||
const weatherParameters = {};
|
||||
|
||||
const init = async () => {
|
||||
// set up resize handler
|
||||
window.addEventListener('resize', resize);
|
||||
// set up the resize handler with debounce logic to prevent rapid-fire calls
|
||||
let resizeTimeout;
|
||||
|
||||
// Handle fullscreen change events and trigger an immediate resize calculation
|
||||
const fullscreenEvents = ['fullscreenchange', 'webkitfullscreenchange', 'mozfullscreenchange', 'MSFullscreenChange'];
|
||||
fullscreenEvents.forEach((eventName) => {
|
||||
document.addEventListener(eventName, () => {
|
||||
if (debugFlag('fullscreen')) {
|
||||
console.log(`🖥️ ${eventName} event fired. fullscreenElement=${!!document.fullscreenElement}`);
|
||||
}
|
||||
resize(true);
|
||||
});
|
||||
});
|
||||
|
||||
// De-bounced resize handler to prevent rapid-fire resize calls
|
||||
window.addEventListener('resize', () => {
|
||||
clearTimeout(resizeTimeout);
|
||||
resizeTimeout = setTimeout(() => resize(), 100);
|
||||
});
|
||||
|
||||
// Handle orientation changes (Mobile Safari doesn't always fire resize events on orientation change)
|
||||
window.addEventListener('orientationchange', () => {
|
||||
if (debugFlag('resize')) {
|
||||
console.log('📱 Orientation change detected, forcing resize after short delay');
|
||||
}
|
||||
clearTimeout(resizeTimeout);
|
||||
// Use a slightly longer delay for orientation changes to allow the browser to settle
|
||||
resizeTimeout = setTimeout(() => resize(true), 200);
|
||||
});
|
||||
|
||||
resize();
|
||||
|
||||
generateCheckboxes();
|
||||
@@ -34,62 +63,87 @@ const getWeather = async (latLon, haveDataCallback) => {
|
||||
// get initial weather data
|
||||
const point = await getPoint(latLon.lat, latLon.lon);
|
||||
|
||||
// check if point data was successfully retrieved
|
||||
if (!point) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof haveDataCallback === 'function') haveDataCallback(point);
|
||||
|
||||
// get stations
|
||||
const stations = await json(point.properties.observationStations);
|
||||
try {
|
||||
// get stations using centralized safe handling
|
||||
const stations = await safeJson(point.properties.observationStations);
|
||||
|
||||
const StationId = stations.features[0].properties.stationIdentifier;
|
||||
if (!stations) {
|
||||
console.warn('Failed to get Observation Stations');
|
||||
return;
|
||||
}
|
||||
|
||||
let { city } = point.properties.relativeLocation.properties;
|
||||
const { state } = point.properties.relativeLocation.properties;
|
||||
// check if stations data is valid
|
||||
if (!stations || !stations.features || stations.features.length === 0) {
|
||||
console.warn('No Observation Stations found for this location');
|
||||
return;
|
||||
}
|
||||
|
||||
if (StationId in StationInfo) {
|
||||
city = StationInfo[StationId].city;
|
||||
[city] = city.split('/');
|
||||
city = city.replace(/\s+$/, '');
|
||||
const StationId = stations.features[0].properties.stationIdentifier;
|
||||
|
||||
let { city } = point.properties.relativeLocation.properties;
|
||||
const { state } = point.properties.relativeLocation.properties;
|
||||
|
||||
if (StationId in StationInfo) {
|
||||
city = StationInfo[StationId].city;
|
||||
[city] = city.split('/');
|
||||
city = city.replace(/\s+$/, '');
|
||||
}
|
||||
|
||||
// populate the weather parameters
|
||||
weatherParameters.latitude = latLon.lat;
|
||||
weatherParameters.longitude = latLon.lon;
|
||||
weatherParameters.zoneId = point.properties.forecastZone.substr(-6);
|
||||
weatherParameters.radarId = point.properties.radarStation.substr(-3);
|
||||
weatherParameters.stationId = StationId;
|
||||
weatherParameters.weatherOffice = point.properties.cwa;
|
||||
weatherParameters.city = city;
|
||||
weatherParameters.state = state;
|
||||
weatherParameters.timeZone = point.properties.timeZone;
|
||||
weatherParameters.forecast = point.properties.forecast;
|
||||
weatherParameters.forecastGridData = point.properties.forecastGridData;
|
||||
weatherParameters.stations = stations.features;
|
||||
|
||||
// update the main process for display purposes
|
||||
populateWeatherParameters(weatherParameters);
|
||||
|
||||
// reset the scroll
|
||||
postMessage({ type: 'current-weather-scroll', method: 'reload' });
|
||||
|
||||
// draw the progress canvas and hide others
|
||||
hideAllCanvases();
|
||||
if (!settings?.kiosk?.value) {
|
||||
// In normal mode, hide loading screen and show progress
|
||||
// (In kiosk mode, keep the loading screen visible until autoplay starts)
|
||||
document.querySelector('#loading').style.display = 'none';
|
||||
if (progress) {
|
||||
await progress.drawCanvas();
|
||||
progress.showCanvas();
|
||||
}
|
||||
}
|
||||
|
||||
// call for new data on each display
|
||||
displays.forEach((display) => display.getData(weatherParameters));
|
||||
} catch (error) {
|
||||
console.error(`Failed to get weather data: ${error.message}`);
|
||||
}
|
||||
|
||||
// populate the weather parameters
|
||||
weatherParameters.latitude = latLon.lat;
|
||||
weatherParameters.longitude = latLon.lon;
|
||||
weatherParameters.zoneId = point.properties.forecastZone.substr(-6);
|
||||
weatherParameters.radarId = point.properties.radarStation.substr(-3);
|
||||
weatherParameters.stationId = StationId;
|
||||
weatherParameters.weatherOffice = point.properties.cwa;
|
||||
weatherParameters.city = city;
|
||||
weatherParameters.state = state;
|
||||
weatherParameters.timeZone = point.properties.timeZone;
|
||||
weatherParameters.forecast = point.properties.forecast;
|
||||
weatherParameters.forecastGridData = point.properties.forecastGridData;
|
||||
weatherParameters.stations = stations.features;
|
||||
|
||||
// update the main process for display purposes
|
||||
populateWeatherParameters(weatherParameters);
|
||||
|
||||
// reset the scroll
|
||||
postMessage({ type: 'current-weather-scroll', method: 'reload' });
|
||||
|
||||
// draw the progress canvas and hide others
|
||||
hideAllCanvases();
|
||||
document.querySelector('#loading').style.display = 'none';
|
||||
if (progress) {
|
||||
await progress.drawCanvas();
|
||||
progress.showCanvas();
|
||||
}
|
||||
|
||||
// call for new data on each display
|
||||
displays.forEach((display) => display.getData(weatherParameters));
|
||||
};
|
||||
|
||||
// receive a status update from a module {id, value}
|
||||
const updateStatus = (value) => {
|
||||
if (value.id < 0) return;
|
||||
if (!progress) return;
|
||||
progress.drawCanvas(displays, countLoadedDisplays());
|
||||
if (!progress && !settings?.kiosk?.value) return;
|
||||
|
||||
if (progress) progress.drawCanvas(displays, countLoadedDisplays());
|
||||
|
||||
// first display is hazards and it must load before evaluating the first display
|
||||
if (displays[0].status === STATUS.loading) return;
|
||||
if (!displays[0] || displays[0].status === STATUS.loading) return;
|
||||
|
||||
// calculate first enabled display
|
||||
const firstDisplayIndex = displays.findIndex((display) => display?.enabled && display?.timing?.totalScreens > 0);
|
||||
@@ -102,7 +156,7 @@ const updateStatus = (value) => {
|
||||
}
|
||||
|
||||
// if hazards data arrives after the firstDisplayIndex loads, then we need to hot wire this to the first display
|
||||
if (value.id === 0 && value.status === STATUS.loaded && displays[0].timing.totalScreens === 0) {
|
||||
if (value.id === 0 && value.status === STATUS.loaded && displays[0] && displays[0].timing && displays[0].timing.totalScreens === 0) {
|
||||
value.id = firstDisplayIndex;
|
||||
value.status = displays[firstDisplayIndex].status;
|
||||
}
|
||||
@@ -153,19 +207,30 @@ const displayNavMessage = (myMessage) => {
|
||||
const navTo = (direction) => {
|
||||
// test for a current display
|
||||
const current = currentDisplay();
|
||||
progress.hideCanvas();
|
||||
if (progress) progress.hideCanvas();
|
||||
if (!current) {
|
||||
// special case for no active displays (typically on progress screen)
|
||||
// find the first ready display
|
||||
let firstDisplay;
|
||||
let displayCount = 0;
|
||||
do {
|
||||
if (displays[displayCount].status === STATUS.loaded && displays[displayCount].timing.totalScreens > 0) firstDisplay = displays[displayCount];
|
||||
// Check if displayCount is within bounds and the display exists
|
||||
if (displayCount < displays.length && displays[displayCount]) {
|
||||
const display = displays[displayCount];
|
||||
if (display.status === STATUS.loaded && display.timing?.totalScreens > 0) {
|
||||
firstDisplay = display;
|
||||
}
|
||||
}
|
||||
displayCount += 1;
|
||||
} while (!firstDisplay && displayCount < displays.length);
|
||||
|
||||
if (!firstDisplay) return;
|
||||
|
||||
// In kiosk mode, hide the loading screen when we start showing the first display
|
||||
if (settings?.kiosk?.value) {
|
||||
document.querySelector('#loading').style.display = 'none';
|
||||
}
|
||||
|
||||
firstDisplay.navNext(msg.command.firstFrame);
|
||||
firstDisplay.showCanvas();
|
||||
return;
|
||||
@@ -179,11 +244,32 @@ const loadDisplay = (direction) => {
|
||||
const totalDisplays = displays.length;
|
||||
const curIdx = currentDisplayIndex();
|
||||
let idx;
|
||||
let foundSuitableDisplay = false;
|
||||
|
||||
for (let i = 0; i < totalDisplays; i += 1) {
|
||||
// convert form simple 0-10 to start at current display index +/-1 and wrap
|
||||
idx = wrap(curIdx + (i + 1) * direction, totalDisplays);
|
||||
if (displays[idx].status === STATUS.loaded && displays[idx].timing.totalScreens > 0) break;
|
||||
if (displays[idx].status === STATUS.loaded && displays[idx].timing.totalScreens > 0) {
|
||||
// Prevent infinite recursion by ensuring we don't select the same display
|
||||
if (idx !== curIdx) {
|
||||
foundSuitableDisplay = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no other suitable display was found, but current display is still suitable (e.g. user only enabled one display), stay on it
|
||||
if (!foundSuitableDisplay && displays[curIdx] && displays[curIdx].status === STATUS.loaded && displays[curIdx].timing.totalScreens > 0) {
|
||||
idx = curIdx;
|
||||
foundSuitableDisplay = true;
|
||||
}
|
||||
|
||||
// if no suitable display was found at all, do NOT proceed to avoid infinite recursion
|
||||
if (!foundSuitableDisplay) {
|
||||
console.warn('No suitable display found for navigation');
|
||||
return;
|
||||
}
|
||||
|
||||
const newDisplay = displays[idx];
|
||||
// hide all displays
|
||||
hideAllCanvases();
|
||||
@@ -202,17 +288,24 @@ const setPlaying = (newValue) => {
|
||||
localStorage.setItem('play', playing);
|
||||
|
||||
if (playing) {
|
||||
noSleep(true);
|
||||
noSleep(true).catch(() => {
|
||||
// Wake lock failed, but continue normally
|
||||
});
|
||||
playButton.title = 'Pause';
|
||||
playButton.src = 'images/nav/ic_pause_white_24dp_2x.png';
|
||||
} else {
|
||||
noSleep(false);
|
||||
noSleep(false).catch(() => {
|
||||
// Wake lock disable failed, but continue normally
|
||||
});
|
||||
playButton.title = 'Play';
|
||||
playButton.src = 'images/nav/ic_play_arrow_white_24dp_2x.png';
|
||||
}
|
||||
// if we're playing and on the progress screen jump to the next screen
|
||||
if (!progress) return;
|
||||
if (playing && !currentDisplay()) navTo(msg.command.firstFrame);
|
||||
// if we're playing and on the progress screen (or in kiosk mode), jump to the next screen
|
||||
if (playing && !currentDisplay()) {
|
||||
if (progress || settings?.kiosk?.value) {
|
||||
navTo(msg.command.firstFrame);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// handle all navigation buttons
|
||||
@@ -237,7 +330,12 @@ const handleNavButton = (button) => {
|
||||
break;
|
||||
case 'menu':
|
||||
setPlaying(false);
|
||||
progress.showCanvas();
|
||||
if (progress) {
|
||||
progress.showCanvas();
|
||||
} else if (settings?.kiosk?.value) {
|
||||
// In kiosk mode without progress, show the loading screen
|
||||
document.querySelector('#loading').style.display = 'flex';
|
||||
}
|
||||
hideAllCanvases();
|
||||
break;
|
||||
default:
|
||||
@@ -248,18 +346,222 @@ const handleNavButton = (button) => {
|
||||
// return the specificed display
|
||||
const getDisplay = (index) => displays[index];
|
||||
|
||||
// resize the container on a page resize
|
||||
const resize = () => {
|
||||
const targetWidth = settings.wide.value ? 640 + 107 + 107 : 640;
|
||||
const widthZoomPercent = (document.querySelector('#divTwcBottom').getBoundingClientRect().width) / targetWidth;
|
||||
const heightZoomPercent = (window.innerHeight) / 480;
|
||||
// Helper function to detect iOS (using technique from nosleep.js)
|
||||
const isIOS = () => {
|
||||
const { userAgent } = navigator;
|
||||
const iOSRegex = /CPU.*OS ([0-9_]{1,})[0-9_]{0,}|(CPU like).*AppleWebKit.*Mobile/i;
|
||||
return iOSRegex.test(userAgent) && !window.MSStream;
|
||||
};
|
||||
|
||||
const scale = Math.min(widthZoomPercent, heightZoomPercent);
|
||||
if (scale < 1.0 || document.fullscreenElement || settings.kiosk) {
|
||||
document.querySelector('#container').style.zoom = scale;
|
||||
} else {
|
||||
document.querySelector('#container').style.zoom = 'unset';
|
||||
// Track the last applied scale to avoid redundant operations
|
||||
let lastAppliedScale = null;
|
||||
let lastAppliedKioskMode = null;
|
||||
|
||||
// resize the container on a page resize
|
||||
const resize = (force = false) => {
|
||||
// Ignore resize events caused by pinch-to-zoom on mobile
|
||||
if (window.visualViewport && Math.abs(window.visualViewport.scale - 1) > 0.01) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isFullscreen = !!document.fullscreenElement;
|
||||
const isKioskMode = settings.kiosk?.value || false;
|
||||
const isMobileSafariKiosk = isIOS() && isKioskMode; // Detect Mobile Safari in kiosk mode (regardless of standalone status)
|
||||
const targetWidth = settings.wide.value ? 640 + 107 + 107 : 640;
|
||||
|
||||
// Use window width instead of bottom container width to avoid zero-dimension issues
|
||||
const widthZoomPercent = window.innerWidth / targetWidth;
|
||||
const heightZoomPercent = window.innerHeight / 480;
|
||||
|
||||
// Standard scaling: fit within both dimensions
|
||||
const scale = Math.min(widthZoomPercent, heightZoomPercent);
|
||||
|
||||
// For Mobile Safari in kiosk mode, always use centering behavior regardless of scale
|
||||
// For other platforms, only use fullscreen/centering behavior for actual fullscreen or kiosk mode where content fits naturally
|
||||
const isKioskLike = isFullscreen || (isKioskMode && scale >= 1.0) || isMobileSafariKiosk;
|
||||
|
||||
if (debugFlag('resize') || debugFlag('fullscreen')) {
|
||||
console.log(`🖥️ Resize: force=${force} isKioskLike=${isKioskLike} window=${window.innerWidth}x${window.innerHeight} targetWidth=${targetWidth} widthZoom=${widthZoomPercent.toFixed(3)} heightZoom=${heightZoomPercent.toFixed(3)} finalScale=${scale.toFixed(3)} fullscreenElement=${!!document.fullscreenElement} isIOS=${isIOS()} standalone=${window.navigator.standalone} isMobileSafariKiosk=${isMobileSafariKiosk} kioskMode=${settings.kiosk?.value} wideMode=${settings.wide.value}`);
|
||||
}
|
||||
|
||||
// Prevent zero or negative scale values
|
||||
if (scale <= 0) {
|
||||
console.warn('Invalid scale calculated, skipping resize');
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip redundant resize operations if scale and mode haven't changed (unless forced)
|
||||
const scaleChanged = Math.abs((lastAppliedScale || 0) - scale) > 0.001;
|
||||
const modeChanged = lastAppliedKioskMode !== isKioskLike;
|
||||
|
||||
if (!force && !scaleChanged && !modeChanged) {
|
||||
return; // No meaningful change, skip resize operation
|
||||
}
|
||||
|
||||
// Update tracking variables
|
||||
lastAppliedScale = scale;
|
||||
lastAppliedKioskMode = isKioskLike;
|
||||
window.currentScale = scale; // Make scale available to settings module
|
||||
|
||||
const wrapper = document.querySelector('#divTwc');
|
||||
const mainContainer = document.querySelector('#divTwcMain');
|
||||
|
||||
// BASELINE: content fits naturally, no scaling needed
|
||||
if (!isKioskLike && scale >= 1.0 && !isKioskMode) {
|
||||
if (debugFlag('fullscreen')) {
|
||||
console.log('🖥️ Resetting fullscreen/kiosk styles to normal');
|
||||
}
|
||||
|
||||
// Reset wrapper styles (only properties that are actually set in fullscreen/scaling modes)
|
||||
wrapper.style.removeProperty('width');
|
||||
wrapper.style.removeProperty('height');
|
||||
wrapper.style.removeProperty('overflow');
|
||||
wrapper.style.removeProperty('transform');
|
||||
wrapper.style.removeProperty('transform-origin');
|
||||
|
||||
// Reset container styles that might have been applied during fullscreen
|
||||
mainContainer.style.removeProperty('transform');
|
||||
mainContainer.style.removeProperty('transform-origin');
|
||||
mainContainer.style.removeProperty('width');
|
||||
mainContainer.style.removeProperty('height');
|
||||
mainContainer.style.removeProperty('position');
|
||||
mainContainer.style.removeProperty('left');
|
||||
mainContainer.style.removeProperty('top');
|
||||
mainContainer.style.removeProperty('margin-left');
|
||||
mainContainer.style.removeProperty('margin-top');
|
||||
|
||||
applyScanlineScaling(1.0);
|
||||
return;
|
||||
}
|
||||
|
||||
// MOBILE SCALING: Use wrapper scaling for mobile devices (but not Mobile Safari kiosk mode)
|
||||
if ((scale < 1.0 || (isKioskMode && !isKioskLike)) && !isMobileSafariKiosk) {
|
||||
/*
|
||||
* MOBILE SCALING (Wrapper Scaling)
|
||||
*
|
||||
* Why scale the wrapper instead of mainContainer?
|
||||
* - For mobile devices where content is larger than viewport, we need to scale the entire layout
|
||||
* - The wrapper (#divTwc) contains both the main content AND the bottom navigation bar
|
||||
* - Scaling the wrapper ensures both elements are scaled together as a unit
|
||||
* - No centering is applied - content aligns to top-left for typical mobile behavior
|
||||
* - Uses explicit dimensions to prevent layout issues and eliminate gaps after scaling
|
||||
*/
|
||||
wrapper.style.setProperty('transform', `scale(${scale})`);
|
||||
wrapper.style.setProperty('transform-origin', 'top left'); // Scale from top-left corner
|
||||
|
||||
// Set explicit dimensions to prevent layout issues on mobile
|
||||
const wrapperWidth = settings.wide.value ? 854 : 640;
|
||||
// Calculate total height: main content (480px) + bottom navigation bar
|
||||
const bottomBar = document.querySelector('#divTwcBottom');
|
||||
const bottomBarHeight = bottomBar ? bottomBar.offsetHeight : 40; // fallback to ~40px
|
||||
const totalHeight = 480 + bottomBarHeight;
|
||||
const scaledHeight = totalHeight * scale; // Height after scaling
|
||||
|
||||
wrapper.style.setProperty('width', `${wrapperWidth}px`);
|
||||
wrapper.style.setProperty('height', `${scaledHeight}px`); // Use scaled height to eliminate gap
|
||||
applyScanlineScaling(scale);
|
||||
return;
|
||||
}
|
||||
|
||||
// KIOSK/FULLSCREEN SCALING: Two different positioning approaches for different platforms
|
||||
const wrapperWidth = settings.wide.value ? 854 : 640;
|
||||
const wrapperHeight = 480;
|
||||
|
||||
// Reset wrapper styles to avoid double scaling (wrapper remains unstyled)
|
||||
wrapper.style.removeProperty('width');
|
||||
wrapper.style.removeProperty('height');
|
||||
wrapper.style.removeProperty('transform');
|
||||
wrapper.style.removeProperty('transform-origin');
|
||||
|
||||
// Platform-specific positioning logic
|
||||
let transformOrigin;
|
||||
let leftPosition;
|
||||
let topPosition;
|
||||
let marginLeft;
|
||||
let marginTop;
|
||||
|
||||
if (isMobileSafariKiosk) {
|
||||
/*
|
||||
* MOBILE SAFARI KIOSK MODE (Manual offset calculation)
|
||||
*
|
||||
* Why this approach?
|
||||
* - Mobile Safari in kiosk mode has unique viewport behaviors that don't work well with standard CSS centering
|
||||
* - We want orientation-specific centering: vertical in portrait, horizontal in landscape
|
||||
* - The standard CSS centering method can cause layout issues in Mobile Safari's constrained environment
|
||||
*/
|
||||
const scaledWidth = wrapperWidth * scale;
|
||||
const scaledHeight = wrapperHeight * scale;
|
||||
|
||||
// Determine if we're in portrait or landscape
|
||||
const isPortrait = window.innerHeight > window.innerWidth;
|
||||
|
||||
let offsetX = 0;
|
||||
let offsetY = 0;
|
||||
|
||||
if (isPortrait) {
|
||||
offsetY = (window.innerHeight - scaledHeight) / 2; // center vertically, align to left edge
|
||||
} else {
|
||||
offsetX = (window.innerWidth - scaledWidth) / 2; // center horizontally, align to top edge
|
||||
}
|
||||
|
||||
if (debugFlag('fullscreen')) {
|
||||
console.log(`📱 Mobile Safari kiosk centering: ${isPortrait ? 'portrait' : 'landscape'} wrapper=${wrapperWidth}x${wrapperHeight} scale=${scale.toFixed(3)} offset=${offsetX.toFixed(1)},${offsetY.toFixed(1)}`);
|
||||
}
|
||||
|
||||
// Set positioning values for manual offset calculation
|
||||
transformOrigin = 'top left'; // Scale from top-left corner
|
||||
leftPosition = `${offsetX}px`; // Exact pixel positioning
|
||||
topPosition = `${offsetY}px`; // Exact pixel positioning
|
||||
marginLeft = null; // Clear any previous centering margins
|
||||
marginTop = null; // Clear any previous centering margins
|
||||
} else {
|
||||
/*
|
||||
* STANDARD FULLSCREEN/KIOSK MODE (CSS-based Centering)
|
||||
*
|
||||
* Why this approach?
|
||||
* - Should work reliably across all other browsers and scenarios (desktop, non-Safari mobile, etc.)
|
||||
* - Uses standard CSS centering techniques that browsers handle efficiently
|
||||
* - Always centers both horizontally and vertically
|
||||
*/
|
||||
const scaledWidth = wrapperWidth * scale;
|
||||
const scaledHeight = wrapperHeight * scale;
|
||||
const offsetX = (window.innerWidth - scaledWidth) / 2;
|
||||
const offsetY = (window.innerHeight - scaledHeight) / 2;
|
||||
|
||||
if (debugFlag('fullscreen')) {
|
||||
console.log(`🖥️ Applying fullscreen/kiosk scaling: wrapper=${wrapperWidth}x${wrapperHeight} scale=${scale.toFixed(3)} offset=${offsetX.toFixed(1)},${offsetY.toFixed(1)} transform: scale(${scale}) translate(${offsetX / scale}px, ${offsetY / scale}px)`);
|
||||
}
|
||||
|
||||
// Set positioning values for CSS-based centering
|
||||
transformOrigin = 'center center'; // Scale from center point
|
||||
leftPosition = '50%'; // Position at 50% from left
|
||||
topPosition = '50%'; // Position at 50% from top
|
||||
marginLeft = `-${wrapperWidth / 2}px`; // Pull back by half width
|
||||
marginTop = `-${wrapperHeight / 2}px`; // Pull back by half height
|
||||
}
|
||||
|
||||
// Apply shared mainContainer properties (same for both kiosk modes)
|
||||
mainContainer.style.setProperty('transform', `scale(${scale})`, 'important');
|
||||
mainContainer.style.setProperty('transform-origin', transformOrigin, 'important');
|
||||
mainContainer.style.setProperty('width', `${wrapperWidth}px`, 'important');
|
||||
mainContainer.style.setProperty('height', `${wrapperHeight}px`, 'important');
|
||||
mainContainer.style.setProperty('position', 'absolute', 'important');
|
||||
mainContainer.style.setProperty('left', leftPosition, 'important');
|
||||
mainContainer.style.setProperty('top', topPosition, 'important');
|
||||
|
||||
// Apply or clear margin properties based on positioning method
|
||||
if (marginLeft !== null) {
|
||||
mainContainer.style.setProperty('margin-left', marginLeft, 'important');
|
||||
} else {
|
||||
mainContainer.style.removeProperty('margin-left');
|
||||
}
|
||||
if (marginTop !== null) {
|
||||
mainContainer.style.setProperty('margin-top', marginTop, 'important');
|
||||
} else {
|
||||
mainContainer.style.removeProperty('margin-top');
|
||||
}
|
||||
|
||||
applyScanlineScaling(scale);
|
||||
};
|
||||
|
||||
// reset all statuses to loading on all displays, used to keep the progress bar accurate during refresh
|
||||
@@ -267,6 +569,164 @@ const resetStatuses = () => {
|
||||
displays.forEach((display) => { display.status = STATUS.loading; });
|
||||
};
|
||||
|
||||
// Apply scanline scaling to try and prevent banding by avoiding fractional scaling
|
||||
const applyScanlineScaling = (scale) => {
|
||||
const container = document.querySelector('#container');
|
||||
if (!container || !container.classList.contains('scanlines')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
const devicePixelRatio = window.devicePixelRatio || 1;
|
||||
const currentMode = settings?.scanLineMode?.value || 'auto';
|
||||
let cssThickness;
|
||||
let scanlineDebugInfo = null;
|
||||
|
||||
// Helper function to round CSS values intelligently based on scale and DPR
|
||||
// At high scales, precise fractional pixels render fine; at low scales, alignment matters more
|
||||
const roundCSSValue = (value) => {
|
||||
// On 1x DPI displays, use exact calculated values
|
||||
if (devicePixelRatio === 1) {
|
||||
return value;
|
||||
}
|
||||
|
||||
// At high scales (>2x), the browser scaling dominates and fractional pixels render well
|
||||
// Prioritize nice fractions for better visual consistency
|
||||
if (scale > 2.0) {
|
||||
// Try quarter-pixel boundaries first (0.25, 0.5, 0.75, 1.0, etc.)
|
||||
const quarterRounded = Math.round(value * 4) / 4;
|
||||
if (Math.abs(quarterRounded - value) <= 0.125) { // Within 0.125px tolerance
|
||||
return quarterRounded;
|
||||
}
|
||||
// Fall through to half-pixel boundaries for high scale fallback
|
||||
}
|
||||
|
||||
// At lower scales (and high scale fallback), pixel alignment matters more for crisp rendering
|
||||
// Round UP to the next half-pixel to ensure scanlines are never thinner than intended
|
||||
const halfPixelRounded = Math.ceil(value * 2) / 2;
|
||||
return halfPixelRounded;
|
||||
};
|
||||
|
||||
// Manual modes: use smart rounding in scaled scenarios to avoid banding
|
||||
if (currentMode === 'thin') {
|
||||
const rawValue = 1 / scale;
|
||||
const cssValue = scale === 1.0 ? rawValue : roundCSSValue(rawValue);
|
||||
cssThickness = `${cssValue}px`;
|
||||
scanlineDebugInfo = {
|
||||
css: cssValue,
|
||||
visual: 1,
|
||||
target: '1px visual thickness',
|
||||
reason: scale === 1.0 ? 'Thin: 1px visual user override (exact)' : 'Thin: 1px visual user override (rounded)',
|
||||
isManual: true,
|
||||
};
|
||||
} else if (currentMode === 'medium') {
|
||||
const rawValue = 2 / scale;
|
||||
const cssValue = scale === 1.0 ? rawValue : roundCSSValue(rawValue);
|
||||
cssThickness = `${cssValue}px`;
|
||||
scanlineDebugInfo = {
|
||||
css: cssValue,
|
||||
visual: 2,
|
||||
target: '2px visual thickness',
|
||||
reason: scale === 1.0 ? 'Medium: 2px visual user override (exact)' : 'Medium: 2px visual user override (rounded)',
|
||||
isManual: true,
|
||||
};
|
||||
} else if (currentMode === 'thick') {
|
||||
const rawValue = 3 / scale;
|
||||
const cssValue = scale === 1.0 ? rawValue : roundCSSValue(rawValue);
|
||||
cssThickness = `${cssValue}px`;
|
||||
scanlineDebugInfo = {
|
||||
css: cssValue,
|
||||
visual: 3,
|
||||
target: '3px visual thickness',
|
||||
reason: scale === 1.0 ? 'Thick: 3px visual user override (exact)' : 'Thick: 3px visual user override (rounded)',
|
||||
isManual: true,
|
||||
};
|
||||
} else {
|
||||
// Auto mode: choose thickness based on scaling behavior
|
||||
|
||||
let visualThickness;
|
||||
let reason;
|
||||
|
||||
if (scale === 1.0) {
|
||||
// Unscaled mode: use reasonable thickness based on device characteristics
|
||||
const isHighDPIMobile = devicePixelRatio >= 2 && viewportWidth <= 768 && viewportHeight <= 768;
|
||||
const isHighDPITablet = devicePixelRatio >= 2 && viewportWidth <= 1024 && viewportHeight <= 1024;
|
||||
|
||||
if (isHighDPIMobile) {
|
||||
// High-DPI mobile: use thin scanlines but not too thin
|
||||
const cssValue = roundCSSValue(1.5 / devicePixelRatio);
|
||||
cssThickness = `${cssValue}px`;
|
||||
reason = `Auto: ${cssValue}px unscaled (high-DPI mobile, DPR=${devicePixelRatio})`;
|
||||
} else if (isHighDPITablet) {
|
||||
// High-DPI tablets: use slightly thicker scanlines for better visibility
|
||||
const cssValue = roundCSSValue(1.5 / devicePixelRatio);
|
||||
cssThickness = `${cssValue}px`;
|
||||
reason = `Auto: ${cssValue}px unscaled (high-DPI tablet, DPR=${devicePixelRatio})`;
|
||||
} else if (devicePixelRatio >= 2) {
|
||||
// High-DPI desktop: use scanlines that look similar to scaled mode
|
||||
const cssValue = roundCSSValue(1.5 / devicePixelRatio);
|
||||
cssThickness = `${cssValue}px`;
|
||||
reason = `Auto: ${cssValue}px unscaled (high-DPI desktop, DPR=${devicePixelRatio})`;
|
||||
} else {
|
||||
// Standard DPI desktop: use 2px for better visibility
|
||||
cssThickness = '2px';
|
||||
reason = 'Auto: 2px unscaled (standard DPI desktop)';
|
||||
}
|
||||
} else if (scale < 1.0) {
|
||||
// Mobile scaling: use thinner scanlines for small displays
|
||||
visualThickness = 1;
|
||||
const cssValue = roundCSSValue(visualThickness / scale);
|
||||
cssThickness = `${cssValue}px`;
|
||||
reason = `Auto: ${cssValue}px scaled (mobile, scale=${scale})`;
|
||||
} else if (scale >= 3.0) {
|
||||
// Very high scale (large displays/high DPI): use thick scanlines for visibility
|
||||
visualThickness = 3;
|
||||
const cssValue = roundCSSValue(visualThickness / scale);
|
||||
cssThickness = `${cssValue}px`;
|
||||
reason = `Auto: ${cssValue}px scaled (large display/high scale, scale=${scale})`;
|
||||
} else {
|
||||
// Medium scale kiosk/fullscreen: use medium scanlines with smart rounding
|
||||
visualThickness = 2;
|
||||
const rawValue = visualThickness / scale;
|
||||
const cssValue = roundCSSValue(rawValue);
|
||||
cssThickness = `${cssValue}px`;
|
||||
reason = `Auto: ${cssValue}px scaled (kiosk/fullscreen, scale=${scale})`;
|
||||
|
||||
if (debugFlag('scanlines')) {
|
||||
console.log(`↕️ Kiosk/fullscreen rounding: raw=${rawValue}, rounded=${cssValue}, DPR=${devicePixelRatio}, scale=${scale}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract numeric value from cssThickness for debug info
|
||||
const cssNumericValue = parseFloat(cssThickness);
|
||||
|
||||
scanlineDebugInfo = {
|
||||
css: cssNumericValue,
|
||||
visual: scale === 1.0 ? cssNumericValue : visualThickness, // For unscaled mode, visual thickness equals CSS thickness
|
||||
target: scale === 1.0 ? `${cssNumericValue}px CSS (unscaled)` : `${visualThickness}px visual thickness`,
|
||||
reason,
|
||||
isManual: false,
|
||||
};
|
||||
}
|
||||
|
||||
container.style.setProperty('--scanline-thickness', cssThickness);
|
||||
|
||||
// Output debug information if enabled
|
||||
if (debugFlag('scanlines')) {
|
||||
const actualRendered = scanlineDebugInfo.css * scale;
|
||||
const physicalRendered = actualRendered * devicePixelRatio;
|
||||
const visualThickness = scanlineDebugInfo.visual || actualRendered; // Use visual thickness if available
|
||||
|
||||
console.log(`↕️ Scanline optimization: ${cssThickness} CSS × ${scale.toFixed(3)} scale = ${actualRendered.toFixed(3)}px rendered (${visualThickness}px visual target) × ${devicePixelRatio}x DPI = ${physicalRendered.toFixed(3)}px physical - ${scanlineDebugInfo.reason}`);
|
||||
console.log(`↕️ Display: ${viewportWidth}×${viewportHeight}, Scale factors: width=${(window.innerWidth / (settings.wide.value ? 854 : 640)).toFixed(3)}, height=${(window.innerHeight / 480).toFixed(3)}, DPR=${devicePixelRatio}`);
|
||||
console.log(`↕️ Thickness: CSS=${cssThickness}, Visual=${visualThickness.toFixed(1)}px, Rendered=${actualRendered.toFixed(3)}px, Physical=${physicalRendered.toFixed(3)}px`);
|
||||
}
|
||||
};
|
||||
|
||||
// Make applyScanlineScaling available for direct calls from Settings
|
||||
window.applyScanlineScaling = applyScanlineScaling;
|
||||
|
||||
// allow displays to register themselves
|
||||
const registerDisplay = (display) => {
|
||||
if (displays[display.navId]) console.warn(`Display nav ID ${display.navId} already in use`);
|
||||
@@ -321,4 +781,5 @@ export {
|
||||
message,
|
||||
latLonReceived,
|
||||
timeZone,
|
||||
isIOS,
|
||||
};
|
||||
|
||||
@@ -33,91 +33,108 @@ const getXYFromLatitudeLongitudeDoppler = (pos, offsetX, offsetY) => {
|
||||
};
|
||||
|
||||
const removeDopplerRadarImageNoise = (RadarContext) => {
|
||||
const RadarImageData = RadarContext.getImageData(0, 0, RadarContext.canvas.width, RadarContext.canvas.height);
|
||||
|
||||
// examine every pixel,
|
||||
// change any old rgb to the new-rgb
|
||||
for (let i = 0; i < RadarImageData.data.length; i += 4) {
|
||||
// i + 0 = red
|
||||
// i + 1 = green
|
||||
// i + 2 = blue
|
||||
// i + 3 = alpha (0 = transparent, 255 = opaque)
|
||||
let R = RadarImageData.data[i];
|
||||
let G = RadarImageData.data[i + 1];
|
||||
let B = RadarImageData.data[i + 2];
|
||||
let A = RadarImageData.data[i + 3];
|
||||
|
||||
// is this pixel the old rgb?
|
||||
if ((R === 0 && G === 0 && B === 0)
|
||||
|| (R === 0 && G === 236 && B === 236)
|
||||
|| (R === 1 && G === 160 && B === 246)
|
||||
|| (R === 0 && G === 0 && B === 246)) {
|
||||
// change to your new rgb
|
||||
|
||||
// Transparent
|
||||
R = 0;
|
||||
G = 0;
|
||||
B = 0;
|
||||
A = 0;
|
||||
} else if ((R === 0 && G === 255 && B === 0)) {
|
||||
// Light Green 1
|
||||
R = 49;
|
||||
G = 210;
|
||||
B = 22;
|
||||
A = 255;
|
||||
} else if ((R === 0 && G === 200 && B === 0)) {
|
||||
// Light Green 2
|
||||
R = 0;
|
||||
G = 142;
|
||||
B = 0;
|
||||
A = 255;
|
||||
} else if ((R === 0 && G === 144 && B === 0)) {
|
||||
// Dark Green 1
|
||||
R = 20;
|
||||
G = 90;
|
||||
B = 15;
|
||||
A = 255;
|
||||
} else if ((R === 255 && G === 255 && B === 0)) {
|
||||
// Dark Green 2
|
||||
R = 10;
|
||||
G = 40;
|
||||
B = 10;
|
||||
A = 255;
|
||||
} else if ((R === 231 && G === 192 && B === 0)) {
|
||||
// Yellow
|
||||
R = 196;
|
||||
G = 179;
|
||||
B = 70;
|
||||
A = 255;
|
||||
} else if ((R === 255 && G === 144 && B === 0)) {
|
||||
// Orange
|
||||
R = 190;
|
||||
G = 72;
|
||||
B = 19;
|
||||
A = 255;
|
||||
} else if ((R === 214 && G === 0 && B === 0)
|
||||
|| (R === 255 && G === 0 && B === 0)) {
|
||||
// Red
|
||||
R = 171;
|
||||
G = 14;
|
||||
B = 14;
|
||||
A = 255;
|
||||
} else if ((R === 192 && G === 0 && B === 0)
|
||||
|| (R === 255 && G === 0 && B === 255)) {
|
||||
// Brown
|
||||
R = 115;
|
||||
G = 31;
|
||||
B = 4;
|
||||
A = 255;
|
||||
}
|
||||
|
||||
RadarImageData.data[i] = R;
|
||||
RadarImageData.data[i + 1] = G;
|
||||
RadarImageData.data[i + 2] = B;
|
||||
RadarImageData.data[i + 3] = A;
|
||||
// Validate canvas context and dimensions before calling getImageData
|
||||
if (!RadarContext || !RadarContext.canvas) {
|
||||
console.error('Invalid radar context provided to removeDopplerRadarImageNoise');
|
||||
return;
|
||||
}
|
||||
|
||||
RadarContext.putImageData(RadarImageData, 0, 0);
|
||||
const { canvas } = RadarContext;
|
||||
if (canvas.width <= 0 || canvas.height <= 0) {
|
||||
console.error(`Invalid canvas dimensions in removeDopplerRadarImageNoise: ${canvas.width}x${canvas.height}`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const RadarImageData = RadarContext.getImageData(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// examine every pixel,
|
||||
// change any old rgb to the new-rgb
|
||||
for (let i = 0; i < RadarImageData.data.length; i += 4) {
|
||||
// i + 0 = red
|
||||
// i + 1 = green
|
||||
// i + 2 = blue
|
||||
// i + 3 = alpha (0 = transparent, 255 = opaque)
|
||||
let R = RadarImageData.data[i];
|
||||
let G = RadarImageData.data[i + 1];
|
||||
let B = RadarImageData.data[i + 2];
|
||||
let A = RadarImageData.data[i + 3];
|
||||
|
||||
// is this pixel the old rgb?
|
||||
if ((R === 0 && G === 0 && B === 0)
|
||||
|| (R === 0 && G === 236 && B === 236)
|
||||
|| (R === 1 && G === 160 && B === 246)
|
||||
|| (R === 0 && G === 0 && B === 246)) {
|
||||
// change to your new rgb
|
||||
|
||||
// Transparent
|
||||
R = 0;
|
||||
G = 0;
|
||||
B = 0;
|
||||
A = 0;
|
||||
} else if ((R === 0 && G === 255 && B === 0)) {
|
||||
// Light Green 1
|
||||
R = 49;
|
||||
G = 210;
|
||||
B = 22;
|
||||
A = 255;
|
||||
} else if ((R === 0 && G === 200 && B === 0)) {
|
||||
// Light Green 2
|
||||
R = 0;
|
||||
G = 142;
|
||||
B = 0;
|
||||
A = 255;
|
||||
} else if ((R === 0 && G === 144 && B === 0)) {
|
||||
// Dark Green 1
|
||||
R = 20;
|
||||
G = 90;
|
||||
B = 15;
|
||||
A = 255;
|
||||
} else if ((R === 255 && G === 255 && B === 0)) {
|
||||
// Dark Green 2
|
||||
R = 10;
|
||||
G = 40;
|
||||
B = 10;
|
||||
A = 255;
|
||||
} else if ((R === 231 && G === 192 && B === 0)) {
|
||||
// Yellow
|
||||
R = 196;
|
||||
G = 179;
|
||||
B = 70;
|
||||
A = 255;
|
||||
} else if ((R === 255 && G === 144 && B === 0)) {
|
||||
// Orange
|
||||
R = 190;
|
||||
G = 72;
|
||||
B = 19;
|
||||
A = 255;
|
||||
} else if ((R === 214 && G === 0 && B === 0)
|
||||
|| (R === 255 && G === 0 && B === 0)) {
|
||||
// Red
|
||||
R = 171;
|
||||
G = 14;
|
||||
B = 14;
|
||||
A = 255;
|
||||
} else if ((R === 192 && G === 0 && B === 0)
|
||||
|| (R === 255 && G === 0 && B === 255)) {
|
||||
// Brown
|
||||
R = 115;
|
||||
G = 31;
|
||||
B = 4;
|
||||
A = 255;
|
||||
}
|
||||
|
||||
RadarImageData.data[i] = R;
|
||||
RadarImageData.data[i + 1] = G;
|
||||
RadarImageData.data[i + 2] = B;
|
||||
RadarImageData.data[i + 3] = A;
|
||||
}
|
||||
|
||||
RadarContext.putImageData(RadarImageData, 0, 0);
|
||||
} catch (error) {
|
||||
console.error(`Error in removeDopplerRadarImageNoise: ${error.message}. Canvas size: ${canvas.width}x${canvas.height}`);
|
||||
// Don't re-throw the error, just log it and continue processing
|
||||
}
|
||||
};
|
||||
|
||||
export {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// current weather conditions display
|
||||
import STATUS from './status.mjs';
|
||||
import { DateTime } from '../vendor/auto/luxon.mjs';
|
||||
import { text } from './utils/fetch.mjs';
|
||||
import { safeText } from './utils/fetch.mjs';
|
||||
import WeatherDisplay from './weatherdisplay.mjs';
|
||||
import { registerDisplay, timeZone } from './navigation.mjs';
|
||||
import * as utils from './radar-utils.mjs';
|
||||
@@ -57,35 +57,60 @@ class Radar extends WeatherDisplay {
|
||||
}
|
||||
|
||||
const baseUrl = `https://${RADAR_HOST}/archive/data/`;
|
||||
const baseUrlEnd = '/GIS/uscomp/?F=0&P=n0r*.png';
|
||||
const baseUrls = [];
|
||||
let date = DateTime.utc().minus({ days: 1 }).startOf('day');
|
||||
const baseUrlEnd = '/GIS/uscomp/?F=0&P=n0r*.png'; // This URL returns an index of .png files for the given date
|
||||
|
||||
// make urls for yesterday and today
|
||||
while (date <= DateTime.utc().startOf('day')) {
|
||||
baseUrls.push(`${baseUrl}${date.toFormat('yyyy/LL/dd')}${baseUrlEnd}`);
|
||||
date = date.plus({ days: 1 });
|
||||
// Always get today's data
|
||||
const today = DateTime.utc().startOf('day');
|
||||
const todayStr = today.toFormat('yyyy/LL/dd');
|
||||
const yesterday = today.minus({ days: 1 });
|
||||
const yesterdayStr = yesterday.toFormat('yyyy/LL/dd');
|
||||
const todayUrl = `${baseUrl}${todayStr}${baseUrlEnd}`;
|
||||
|
||||
// Get today's data, then we'll see if we need yesterday's
|
||||
const todayList = await safeText(todayUrl);
|
||||
|
||||
// Count available images from today
|
||||
let todayImageCount = 0;
|
||||
if (todayList) {
|
||||
const parser = new DOMParser();
|
||||
const xmlDoc = parser.parseFromString(todayList, 'text/html');
|
||||
const anchors = xmlDoc.querySelectorAll('a');
|
||||
todayImageCount = Array.from(anchors).filter((elem) => elem.innerHTML?.match(/n0r_\d{12}\.png/)).length;
|
||||
}
|
||||
|
||||
const lists = (await Promise.all(baseUrls.map(async (url) => {
|
||||
try {
|
||||
// get a list of available radars
|
||||
return text(url);
|
||||
} catch (error) {
|
||||
console.log('Unable to get list of radars');
|
||||
console.error(error);
|
||||
this.setStatus(STATUS.failed);
|
||||
return false;
|
||||
}
|
||||
}))).filter((d) => d);
|
||||
// Only fetch yesterday's data if we don't have enough images from today
|
||||
// or if it's very early in the day when recent images might still be from yesterday
|
||||
const currentTimeUTC = DateTime.utc();
|
||||
const minutesSinceMidnight = currentTimeUTC.hour * 60 + currentTimeUTC.minute;
|
||||
const requiredTimeWindow = this.dopplerRadarImageMax * 5; // 5 minutes per image
|
||||
const needYesterday = todayImageCount < this.dopplerRadarImageMax || minutesSinceMidnight < requiredTimeWindow;
|
||||
|
||||
// convert to an array of gif urls
|
||||
// Build the final lists array
|
||||
const lists = [];
|
||||
if (needYesterday) {
|
||||
const yesterdayUrl = `${baseUrl}${yesterdayStr}${baseUrlEnd}`;
|
||||
const yesterdayList = await safeText(yesterdayUrl);
|
||||
if (yesterdayList) {
|
||||
lists.push(yesterdayList); // Add yesterday's data first
|
||||
}
|
||||
}
|
||||
if (todayList) {
|
||||
lists.push(todayList); // Add today's data
|
||||
}
|
||||
|
||||
// convert to an array of png urls
|
||||
const pngs = lists.flatMap((html, htmlIdx) => {
|
||||
const parser = new DOMParser();
|
||||
const xmlDoc = parser.parseFromString(html, 'text/html');
|
||||
// add the base url
|
||||
// add the base url - reconstruct the URL for each list
|
||||
const base = xmlDoc.createElement('base');
|
||||
base.href = baseUrls[htmlIdx];
|
||||
if (htmlIdx === 0 && needYesterday) {
|
||||
// First item is yesterday's data when we fetched it
|
||||
base.href = `${baseUrl}${yesterdayStr}${baseUrlEnd}`;
|
||||
} else {
|
||||
// This is today's data (or the only data if yesterday wasn't fetched)
|
||||
base.href = `${baseUrl}${todayStr}${baseUrlEnd}`;
|
||||
}
|
||||
xmlDoc.head.append(base);
|
||||
const anchors = xmlDoc.querySelectorAll('a');
|
||||
const urls = [];
|
||||
@@ -119,69 +144,73 @@ class Radar extends WeatherDisplay {
|
||||
// reset the "used" flag on pre-processed radars
|
||||
// items that were not used during this process are deleted (either expired via time or change of location)
|
||||
processedRadars.forEach((radar) => { radar.used = false; });
|
||||
// remove any radars that aren't
|
||||
|
||||
// Load the most recent doppler radar images.
|
||||
const radarInfo = await Promise.all(urls.map(async (url) => {
|
||||
// store the time
|
||||
const timeMatch = url.match(/_(\d{4})(\d\d)(\d\d)(\d\d)(\d\d)\./);
|
||||
const [, year, month, day, hour, minute] = timeMatch;
|
||||
try {
|
||||
const radarInfo = await Promise.all(urls.map(async (url) => {
|
||||
// store the time
|
||||
const timeMatch = url.match(/_(\d{4})(\d\d)(\d\d)(\d\d)(\d\d)\./);
|
||||
const [, year, month, day, hour, minute] = timeMatch;
|
||||
|
||||
const radarKeyedTimestamp = `${radarKey}:${year}${month}${day}${hour}${minute}`;
|
||||
const radarKeyedTimestamp = `${radarKey}:${year}${month}${day}${hour}${minute}`;
|
||||
|
||||
// check for a pre-processed radar
|
||||
const preProcessed = processedRadars.find((radar) => radar.key === radarKeyedTimestamp);
|
||||
// check for a pre-processed radar
|
||||
const preProcessed = processedRadars.find((radar) => radar.key === radarKeyedTimestamp);
|
||||
|
||||
// use the pre-processed radar, or get a new one
|
||||
const processedRadar = preProcessed?.dataURL ?? await processRadar({
|
||||
url,
|
||||
RADAR_HOST,
|
||||
OVERRIDES,
|
||||
radarSourceXY,
|
||||
});
|
||||
|
||||
// store the radar
|
||||
if (!preProcessed) {
|
||||
processedRadars.push({
|
||||
key: radarKeyedTimestamp,
|
||||
dataURL: processedRadar,
|
||||
used: true,
|
||||
// use the pre-processed radar, or get a new one
|
||||
const processedRadar = preProcessed?.dataURL ?? await processRadar({
|
||||
url,
|
||||
RADAR_HOST,
|
||||
OVERRIDES,
|
||||
radarSourceXY,
|
||||
});
|
||||
} else {
|
||||
// set used flag
|
||||
preProcessed.used = true;
|
||||
}
|
||||
|
||||
const time = DateTime.fromObject({
|
||||
year,
|
||||
month,
|
||||
day,
|
||||
hour,
|
||||
minute,
|
||||
}, {
|
||||
zone: 'UTC',
|
||||
}).setZone(timeZone());
|
||||
// store the radar
|
||||
if (!preProcessed) {
|
||||
processedRadars.push({
|
||||
key: radarKeyedTimestamp,
|
||||
dataURL: processedRadar,
|
||||
used: true,
|
||||
});
|
||||
} else {
|
||||
// set used flag
|
||||
preProcessed.used = true;
|
||||
}
|
||||
|
||||
const elem = this.fillTemplate('frame', { map: { type: 'img', src: processedRadar } });
|
||||
return {
|
||||
time,
|
||||
elem,
|
||||
};
|
||||
}));
|
||||
const time = DateTime.fromObject({
|
||||
year,
|
||||
month,
|
||||
day,
|
||||
hour,
|
||||
minute,
|
||||
}, {
|
||||
zone: 'UTC',
|
||||
}).setZone(timeZone());
|
||||
|
||||
// put the elements in the container
|
||||
const scrollArea = this.elem.querySelector('.scroll-area');
|
||||
scrollArea.innerHTML = '';
|
||||
scrollArea.append(...radarInfo.map((r) => r.elem));
|
||||
const elem = this.fillTemplate('frame', { map: { type: 'img', src: processedRadar } });
|
||||
return {
|
||||
time,
|
||||
elem,
|
||||
};
|
||||
}));
|
||||
|
||||
// set max length
|
||||
this.timing.totalScreens = radarInfo.length;
|
||||
// put the elements in the container
|
||||
const scrollArea = this.elem.querySelector('.scroll-area');
|
||||
scrollArea.innerHTML = '';
|
||||
scrollArea.append(...radarInfo.map((r) => r.elem));
|
||||
|
||||
this.times = radarInfo.map((radar) => radar.time);
|
||||
this.setStatus(STATUS.loaded);
|
||||
// set max length
|
||||
this.timing.totalScreens = radarInfo.length;
|
||||
|
||||
// clean up any unused stored radars
|
||||
processedRadars = processedRadars.filter((radar) => radar.used);
|
||||
this.times = radarInfo.map((radar) => radar.time);
|
||||
this.setStatus(STATUS.loaded);
|
||||
|
||||
// clean up any unused stored radars
|
||||
processedRadars = processedRadars.filter((radar) => radar.used);
|
||||
} catch (_error) {
|
||||
// Radar fetch failed - skip this display in animation by setting totalScreens = 0
|
||||
this.timing.totalScreens = 0;
|
||||
if (this.isEnabled) this.setStatus(STATUS.failed);
|
||||
}
|
||||
}
|
||||
|
||||
async drawCanvas() {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { getSmallIcon } from './icons.mjs';
|
||||
import { preloadImg } from './utils/image.mjs';
|
||||
import { json } from './utils/fetch.mjs';
|
||||
import { safeJson } from './utils/fetch.mjs';
|
||||
import { temperature as temperatureUnit } from './utils/units.mjs';
|
||||
import augmentObservationWithMetar from './utils/metar.mjs';
|
||||
import { debugFlag } from './utils/debug.mjs';
|
||||
import { enhanceObservationWithMapClick } from './utils/mapclick.mjs';
|
||||
|
||||
const buildForecast = (forecast, city, cityXY) => {
|
||||
// get a unit converter
|
||||
@@ -19,23 +22,66 @@ const buildForecast = (forecast, city, cityXY) => {
|
||||
|
||||
const getRegionalObservation = async (point, city) => {
|
||||
try {
|
||||
// get stations
|
||||
const stations = await json(`https://api.weather.gov/gridpoints/${point.wfo}/${point.x},${point.y}/stations?limit=1`);
|
||||
// get stations using centralized safe handling
|
||||
const stations = await safeJson(`https://api.weather.gov/gridpoints/${point.wfo}/${point.x},${point.y}/stations?limit=1`);
|
||||
|
||||
if (!stations || !stations.features || stations.features.length === 0) {
|
||||
if (debugFlag('verbose-failures')) {
|
||||
console.warn(`Unable to get regional stations for ${city.city}`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// get the first station
|
||||
const station = stations.features[0].id;
|
||||
// get the observation data
|
||||
const observation = await json(`${station}/observations/latest`);
|
||||
const stationId = stations.features[0].properties.stationIdentifier;
|
||||
// get the observation data using centralized safe handling
|
||||
const observation = await safeJson(`${station}/observations/latest`);
|
||||
|
||||
if (!observation) {
|
||||
if (debugFlag('verbose-failures')) {
|
||||
console.warn(`Unable to get regional observations for station ${stationId}`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Enhance observation data with METAR parsing for missing fields
|
||||
let augmentedObservation = augmentObservationWithMetar(observation.properties);
|
||||
|
||||
// Define required fields for regional observations (more lenient than current weather)
|
||||
const requiredFields = [
|
||||
{ name: 'temperature', check: (props) => props.temperature?.value === null },
|
||||
{ name: 'textDescription', check: (props) => props.textDescription === null || props.textDescription === '' },
|
||||
{ name: 'icon', check: (props) => props.icon === null },
|
||||
];
|
||||
|
||||
// Use enhanced observation with MapClick fallback
|
||||
const enhancedResult = await enhanceObservationWithMapClick(augmentedObservation, {
|
||||
requiredFields,
|
||||
stationId,
|
||||
debugContext: 'regionalforecast',
|
||||
});
|
||||
|
||||
augmentedObservation = enhancedResult.data;
|
||||
const { missingFields } = enhancedResult;
|
||||
|
||||
// Check final data quality
|
||||
if (missingFields.length > 0) {
|
||||
if (debugFlag('regionalforecast')) {
|
||||
console.log(`Regional Observations for station ${stationId} is missing fields: ${missingFields.join(', ')} (skipping)`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// preload the image
|
||||
if (!observation.properties.icon) return false;
|
||||
const icon = getSmallIcon(observation.properties.icon, !observation.properties.daytime);
|
||||
if (!augmentedObservation.icon) return false;
|
||||
const icon = getSmallIcon(augmentedObservation.icon, !augmentedObservation.daytime);
|
||||
if (!icon) return false;
|
||||
preloadImg(icon);
|
||||
// return the observation
|
||||
return observation.properties;
|
||||
return augmentedObservation;
|
||||
} catch (error) {
|
||||
console.log(`Unable to get regional observations for ${city.Name ?? city.city}`);
|
||||
console.error(error.status, error.responseJSON);
|
||||
console.error(`Unexpected error getting Regional Observation for ${city.city}: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3,15 +3,17 @@
|
||||
|
||||
import STATUS from './status.mjs';
|
||||
import { distance as calcDistance } from './utils/calc.mjs';
|
||||
import { json } from './utils/fetch.mjs';
|
||||
import { safeJson, safePromiseAll } from './utils/fetch.mjs';
|
||||
import { temperature as temperatureUnit } from './utils/units.mjs';
|
||||
import { getSmallIcon } from './icons.mjs';
|
||||
import { preloadImg } from './utils/image.mjs';
|
||||
import { DateTime, Interval } from '../vendor/auto/luxon.mjs';
|
||||
import { DateTime } from '../vendor/auto/luxon.mjs';
|
||||
import WeatherDisplay from './weatherdisplay.mjs';
|
||||
import { registerDisplay } from './navigation.mjs';
|
||||
import * as utils from './regionalforecast-utils.mjs';
|
||||
import { getPoint } from './utils/weather.mjs';
|
||||
import { debugFlag } from './utils/debug.mjs';
|
||||
import filterExpiredPeriods from './utils/forecast-utils.mjs';
|
||||
|
||||
// map offset
|
||||
const mapOffsetXY = {
|
||||
@@ -77,19 +79,27 @@ class RegionalForecast extends WeatherDisplay {
|
||||
// get a unit converter
|
||||
const temperatureConverter = temperatureUnit();
|
||||
|
||||
// get now as DateTime for calculations below
|
||||
const now = DateTime.now();
|
||||
|
||||
// get regional forecasts and observations (the two are intertwined due to the design of api.weather.gov)
|
||||
const regionalDataAll = await Promise.all(regionalCities.map(async (city) => {
|
||||
// get regional forecasts and observations using centralized safe Promise handling
|
||||
const regionalDataAll = await safePromiseAll(regionalCities.map(async (city) => {
|
||||
try {
|
||||
const point = city?.point ?? (await getAndFormatPoint(city.lat, city.lon));
|
||||
if (!point) throw new Error('No pre-loaded point');
|
||||
if (!point) {
|
||||
if (debugFlag('verbose-failures')) {
|
||||
console.warn(`Unable to get Points for '${city.Name ?? city.city}'`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// start off the observation task
|
||||
const observationPromise = utils.getRegionalObservation(point, city);
|
||||
|
||||
const forecast = await json(`https://api.weather.gov/gridpoints/${point.wfo}/${point.x},${point.y}/forecast`);
|
||||
const forecast = await safeJson(`https://api.weather.gov/gridpoints/${point.wfo}/${point.x},${point.y}/forecast`);
|
||||
if (!forecast) {
|
||||
if (debugFlag('verbose-failures')) {
|
||||
console.warn(`Regional Forecast request for ${city.Name ?? city.city} failed`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// get XY on map for city
|
||||
const cityXY = utils.getXYForCity(city, minMaxLatLon.maxLat, minMaxLatLon.minLon, this.weatherParameters.state);
|
||||
@@ -112,29 +122,23 @@ class RegionalForecast extends WeatherDisplay {
|
||||
// preload the icon
|
||||
preloadImg(getSmallIcon(regionalObservation.icon, !regionalObservation.daytime));
|
||||
|
||||
// return a pared-down forecast
|
||||
// 0th object should contain the current conditions, but when WFOs go offline or otherwise don't post
|
||||
// an updated forecast it's possible that the 0th object is in the past.
|
||||
// so we go on a search for the current time in the start/end times provided in the forecast periods
|
||||
const { periods } = forecast.properties;
|
||||
const currentPeriod = periods.reduce((prev, period, index) => {
|
||||
const start = DateTime.fromISO(period.startTime);
|
||||
const end = DateTime.fromISO(period.endTime);
|
||||
const interval = Interval.fromDateTimes(start, end);
|
||||
if (interval.contains(now)) {
|
||||
return index;
|
||||
}
|
||||
return prev;
|
||||
}, 0);
|
||||
// filter out expired periods first, then use the next two periods for forecast
|
||||
const activePeriods = filterExpiredPeriods(forecast.properties.periods);
|
||||
|
||||
// ensure we have enough periods for forecast
|
||||
if (activePeriods.length < 3) {
|
||||
console.warn(`Insufficient active periods for ${city.Name ?? city.city}: only ${activePeriods.length} periods available`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// group together the current observation and next two periods
|
||||
return [
|
||||
regionalObservation,
|
||||
utils.buildForecast(forecast.properties.periods[currentPeriod + 1], city, cityXY),
|
||||
utils.buildForecast(forecast.properties.periods[currentPeriod + 2], city, cityXY),
|
||||
utils.buildForecast(activePeriods[1], city, cityXY),
|
||||
utils.buildForecast(activePeriods[2], city, cityXY),
|
||||
];
|
||||
} catch (error) {
|
||||
console.log(`No regional forecast data for '${city.name ?? city.city}'`);
|
||||
console.log(error);
|
||||
console.error(`Unexpected error getting Regional Forecast data for '${city.name ?? city.city}': ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}));
|
||||
@@ -215,12 +219,19 @@ class RegionalForecast extends WeatherDisplay {
|
||||
}
|
||||
|
||||
const getAndFormatPoint = async (lat, lon) => {
|
||||
const point = await getPoint(lat, lon);
|
||||
return {
|
||||
x: point.properties.gridX,
|
||||
y: point.properties.gridY,
|
||||
wfo: point.properties.gridId,
|
||||
};
|
||||
try {
|
||||
const point = await getPoint(lat, lon);
|
||||
if (!point) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
x: point.properties.gridX,
|
||||
y: point.properties.gridY,
|
||||
wfo: point.properties.gridId,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(`Unexpected error getting point for ${lat},${lon}: ${error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// register display
|
||||
|
||||
@@ -1,12 +1,115 @@
|
||||
import Setting from './utils/setting.mjs';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
init();
|
||||
});
|
||||
|
||||
// default speed
|
||||
// Initialize settings immediately so other modules can access them
|
||||
const settings = { speed: { value: 1.0 } };
|
||||
|
||||
// Track settings that need DOM changes after early initialization
|
||||
const deferredDomSettings = new Set();
|
||||
|
||||
// Declare change functions first, before they're referenced in init() to avoid the Temporal Dead Zone (TDZ)
|
||||
const wideScreenChange = (value) => {
|
||||
const container = document.querySelector('#divTwc');
|
||||
if (!container) {
|
||||
// DOM not ready; defer enabling if set
|
||||
if (value) {
|
||||
deferredDomSettings.add('wide');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (value) {
|
||||
container.classList.add('wide');
|
||||
} else {
|
||||
container.classList.remove('wide');
|
||||
}
|
||||
// Trigger resize to recalculate scaling for new width
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
};
|
||||
|
||||
const kioskChange = (value) => {
|
||||
const body = document.querySelector('body');
|
||||
if (!body) {
|
||||
// DOM not ready; defer enabling if set
|
||||
if (value) {
|
||||
deferredDomSettings.add('kiosk');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (value) {
|
||||
body.classList.add('kiosk');
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
} else {
|
||||
body.classList.remove('kiosk');
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
}
|
||||
|
||||
// Conditionally store the kiosk setting based on the "Sticky Kiosk" setting
|
||||
// (Need to check if the method exists to handle initialization race condition)
|
||||
if (settings.kiosk?.conditionalStoreToLocalStorage) {
|
||||
settings.kiosk.conditionalStoreToLocalStorage(value, settings.stickyKiosk?.value);
|
||||
}
|
||||
};
|
||||
|
||||
const scanLineChange = (value) => {
|
||||
const container = document.getElementById('container');
|
||||
const navIcons = document.getElementById('ToggleScanlines');
|
||||
|
||||
if (!container || !navIcons) {
|
||||
// DOM not ready; defer enabling if set
|
||||
if (value) {
|
||||
deferredDomSettings.add('scanLines');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (value) {
|
||||
container.classList.add('scanlines');
|
||||
navIcons.classList.add('on');
|
||||
} else {
|
||||
// Remove all scanline classes
|
||||
container.classList.remove('scanlines', 'scanlines-auto', 'scanlines-fine', 'scanlines-normal', 'scanlines-thick', 'scanlines-classic', 'scanlines-retro');
|
||||
navIcons.classList.remove('on');
|
||||
}
|
||||
};
|
||||
|
||||
const scanLineModeChange = (_value) => {
|
||||
// Only apply if scanlines are currently enabled
|
||||
if (settings.scanLines?.value) {
|
||||
// Call the scanline update function directly with current scale
|
||||
if (typeof window.applyScanlineScaling === 'function') {
|
||||
// Get current scale from navigation module or use 1.0 as fallback
|
||||
const scale = window.currentScale || 1.0;
|
||||
window.applyScanlineScaling(scale);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Simple global helper to change scanline mode when remote debugging or in kiosk mode
|
||||
window.changeScanlineMode = (mode) => {
|
||||
if (typeof settings === 'undefined' || !settings.scanLineMode) {
|
||||
console.error('Settings system not available');
|
||||
return false;
|
||||
}
|
||||
|
||||
const validModes = ['auto', 'thin', 'medium', 'thick'];
|
||||
if (!validModes.includes(mode)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
settings.scanLineMode.value = mode;
|
||||
return true;
|
||||
};
|
||||
|
||||
const unitChange = () => {
|
||||
// reload the data at the top level to refresh units
|
||||
// after the initial load
|
||||
if (unitChange.firstRunDone) {
|
||||
window.location.reload();
|
||||
}
|
||||
unitChange.firstRunDone = true;
|
||||
};
|
||||
|
||||
const init = () => {
|
||||
// create settings see setting.mjs for defaults
|
||||
settings.wide = new Setting('wide', {
|
||||
@@ -20,6 +123,12 @@ const init = () => {
|
||||
defaultValue: false,
|
||||
changeAction: kioskChange,
|
||||
sticky: false,
|
||||
stickyRead: true,
|
||||
});
|
||||
settings.stickyKiosk = new Setting('stickyKiosk', {
|
||||
name: 'Sticky Kiosk',
|
||||
defaultValue: false,
|
||||
sticky: true,
|
||||
});
|
||||
settings.speed = new Setting('speed', {
|
||||
name: 'Speed',
|
||||
@@ -39,6 +148,19 @@ const init = () => {
|
||||
changeAction: scanLineChange,
|
||||
sticky: true,
|
||||
});
|
||||
settings.scanLineMode = new Setting('scanLineMode', {
|
||||
name: 'Scan Line Style',
|
||||
type: 'select',
|
||||
defaultValue: 'auto',
|
||||
changeAction: scanLineModeChange,
|
||||
sticky: true,
|
||||
values: [
|
||||
['auto', 'Auto (Adaptive)'],
|
||||
['thin', 'Thin (1x)'],
|
||||
['medium', 'Medium (2x)'],
|
||||
['thick', 'Thick (3x)'],
|
||||
],
|
||||
});
|
||||
settings.units = new Setting('units', {
|
||||
name: 'Units',
|
||||
type: 'select',
|
||||
@@ -62,54 +184,32 @@ const init = () => {
|
||||
],
|
||||
visible: false,
|
||||
});
|
||||
};
|
||||
|
||||
// generate html objects
|
||||
init();
|
||||
|
||||
// generate html objects
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Apply any settings that were deferred due to the DOM not being ready when setting were read
|
||||
if (deferredDomSettings.size > 0) {
|
||||
console.log('Applying deferred DOM settings:', Array.from(deferredDomSettings));
|
||||
|
||||
// Re-apply each pending setting by calling its changeAction with current value
|
||||
deferredDomSettings.forEach((settingName) => {
|
||||
const setting = settings[settingName];
|
||||
if (setting && setting.changeAction && typeof setting.changeAction === 'function') {
|
||||
setting.changeAction(setting.value);
|
||||
}
|
||||
});
|
||||
|
||||
deferredDomSettings.clear();
|
||||
}
|
||||
|
||||
// Then generate the settings UI
|
||||
const settingHtml = Object.values(settings).map((d) => d.generate());
|
||||
|
||||
// write to page
|
||||
const settingsSection = document.querySelector('#settings');
|
||||
settingsSection.innerHTML = '';
|
||||
settingsSection.append(...settingHtml);
|
||||
};
|
||||
|
||||
const wideScreenChange = (value) => {
|
||||
const container = document.querySelector('#divTwc');
|
||||
if (value) {
|
||||
container.classList.add('wide');
|
||||
} else {
|
||||
container.classList.remove('wide');
|
||||
}
|
||||
};
|
||||
|
||||
const kioskChange = (value) => {
|
||||
const body = document.querySelector('body');
|
||||
if (value) {
|
||||
body.classList.add('kiosk');
|
||||
window.dispatchEvent(new Event('resize'));
|
||||
} else {
|
||||
body.classList.remove('kiosk');
|
||||
}
|
||||
};
|
||||
|
||||
const scanLineChange = (value) => {
|
||||
const container = document.getElementById('container');
|
||||
const navIcons = document.getElementById('ToggleScanlines');
|
||||
if (value) {
|
||||
container.classList.add('scanlines');
|
||||
navIcons.classList.add('on');
|
||||
} else {
|
||||
container.classList.remove('scanlines');
|
||||
navIcons.classList.remove('on');
|
||||
}
|
||||
};
|
||||
|
||||
const unitChange = () => {
|
||||
// reload the data at the top level to refresh units
|
||||
// after the initial load
|
||||
if (unitChange.firstRunDone) {
|
||||
window.location.reload();
|
||||
}
|
||||
unitChange.firstRunDone = true;
|
||||
};
|
||||
});
|
||||
|
||||
export default settings;
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
// display spc outlook in a bar graph
|
||||
|
||||
import STATUS from './status.mjs';
|
||||
import { json } from './utils/fetch.mjs';
|
||||
import { safeJson, safePromiseAll } from './utils/fetch.mjs';
|
||||
import { DateTime } from '../vendor/auto/luxon.mjs';
|
||||
import WeatherDisplay from './weatherdisplay.mjs';
|
||||
import { registerDisplay } from './navigation.mjs';
|
||||
import testPolygon from './utils/polygon.mjs';
|
||||
import { debugFlag } from './utils/debug.mjs';
|
||||
|
||||
// list of interesting files ordered [0] = today, [1] = tomorrow...
|
||||
const urlPattern = (day) => `https://www.spc.noaa.gov/products/outlook/day${day}otlk_cat.nolyr.geojson`;
|
||||
@@ -18,8 +19,10 @@ const testAllPoints = (point, data) => {
|
||||
data.forEach((day, index) => {
|
||||
// initialize the result
|
||||
result[index] = false;
|
||||
// if there's no data (file didn't load), exit early
|
||||
if (day === undefined) return;
|
||||
// ensure day exists and has features array
|
||||
if (!day || !day.features || !Array.isArray(day.features)) {
|
||||
return;
|
||||
}
|
||||
// loop through each category
|
||||
day.features.forEach((feature) => {
|
||||
if (!feature.geometry.coordinates) return;
|
||||
@@ -46,7 +49,7 @@ class SpcOutlook extends WeatherDisplay {
|
||||
// don't display on progress/navigation screen
|
||||
this.showOnProgress = false;
|
||||
|
||||
// calculate file names
|
||||
// calculate file names, one for each day
|
||||
this.files = [null, null, null].map((v, i) => urlPattern(i + 1));
|
||||
|
||||
// set timings
|
||||
@@ -56,27 +59,43 @@ class SpcOutlook extends WeatherDisplay {
|
||||
async getData(weatherParameters, refresh) {
|
||||
if (!super.getData(weatherParameters, refresh)) return;
|
||||
|
||||
// initial data does not need to be reloaded on a location change, only during silent refresh
|
||||
if (!this.initialData || refresh) {
|
||||
// SPC outlook data does not need to be reloaded on a location change, only during silent refresh
|
||||
if (!this.rawOutlookData || refresh) {
|
||||
try {
|
||||
// get the three categorical files to get started
|
||||
const filePromises = await Promise.allSettled(this.files.map((file) => json(file)));
|
||||
// store the data, promise will always be fulfilled
|
||||
this.initialData = filePromises.map((outlookDay) => outlookDay.value);
|
||||
} catch (error) {
|
||||
console.error('Unable to get spc outlook');
|
||||
console.error(error.status, error.responseJSON);
|
||||
// if there's no previous data, fail
|
||||
if (!this.initialData) {
|
||||
this.setStatus(STATUS.failed);
|
||||
// get the data for today, tomorrow, and the day after
|
||||
const filePromises = this.files.map((file) => safeJson(file, {
|
||||
retryCount: 1, // Retry one time
|
||||
timeout: 10000, // 10 second timeout for SPC outlook data
|
||||
}));
|
||||
// wait for all the data to be fetched; always returns an array of (potentially null) results
|
||||
this.rawOutlookData = await safePromiseAll(filePromises);
|
||||
|
||||
// Filter out null results (like failed requests) and ensure the response has GeoJSON-looking data
|
||||
this.rawOutlookData = this.rawOutlookData.filter((value) => value && value.features);
|
||||
|
||||
if (this.rawOutlookData.length === 0) {
|
||||
if (debugFlag('verbose-failures')) {
|
||||
console.warn('SPC Outlook has zero days of data');
|
||||
}
|
||||
if (this.isEnabled) this.setStatus(STATUS.failed);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.rawOutlookData.length < this.files.length) {
|
||||
if (debugFlag('verbose-failures')) {
|
||||
console.warn(`SPC Outlook only loaded ${this.rawOutlookData.length} of ${this.files.length} days successfully`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Unexpected error getting SPC Outlook data: ${error.message}`);
|
||||
if (this.isEnabled) this.setStatus(STATUS.failed);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// do the initial parsing of the data
|
||||
this.data = testAllPoints([weatherParameters.longitude, weatherParameters.latitude], this.initialData);
|
||||
// parse the data
|
||||
this.data = testAllPoints([weatherParameters.longitude, weatherParameters.latitude], this.rawOutlookData);
|
||||
|
||||
// if all the data returns false the there's nothing to do, skip this screen
|
||||
// check if there's a "risk" for any of the three days, otherwise skip the SPC Outlook screen
|
||||
if (this.data.reduce((prev, cur) => prev || !!cur, false)) {
|
||||
this.timing.totalScreens = 1;
|
||||
} else {
|
||||
|
||||
@@ -1,34 +1,34 @@
|
||||
// travel forecast display
|
||||
import STATUS from './status.mjs';
|
||||
import { json } from './utils/fetch.mjs';
|
||||
import { safeJson, safePromiseAll } from './utils/fetch.mjs';
|
||||
import { getSmallIcon } from './icons.mjs';
|
||||
import { DateTime } from '../vendor/auto/luxon.mjs';
|
||||
import WeatherDisplay from './weatherdisplay.mjs';
|
||||
import { registerDisplay } from './navigation.mjs';
|
||||
import settings from './settings.mjs';
|
||||
import calculateScrollTiming from './utils/scroll-timing.mjs';
|
||||
import { debugFlag } from './utils/debug.mjs';
|
||||
|
||||
class TravelForecast extends WeatherDisplay {
|
||||
constructor(navId, elemId, defaultActive) {
|
||||
// special height and width for scrolling
|
||||
super(navId, elemId, 'Travel Forecast', defaultActive);
|
||||
|
||||
// set up the timing
|
||||
this.timing.baseDelay = 20;
|
||||
// page sizes are 4 cities, calculate the number of pages necessary plus overflow
|
||||
const pagesFloat = TravelCities.length / 4;
|
||||
const pages = Math.floor(pagesFloat) - 2; // first page is already displayed, last page doesn't happen
|
||||
const extra = pages % 1;
|
||||
const timingStep = 75 * 4;
|
||||
this.timing.delay = [150 + timingStep];
|
||||
// add additional pages
|
||||
for (let i = 0; i < pages; i += 1) this.timing.delay.push(timingStep);
|
||||
// add the extra (not exactly 4 pages portion)
|
||||
if (extra !== 0) this.timing.delay.push(Math.round(this.extra * this.cityHeight));
|
||||
// add the final 3 second delay
|
||||
this.timing.delay.push(150);
|
||||
|
||||
// add previous data cache
|
||||
this.previousData = [];
|
||||
|
||||
// cache for scroll calculations
|
||||
// This cache is essential because baseCountChange() is called 25 times per second (every 40ms)
|
||||
// during scrolling. Travel forecast scroll duration varies based on the number of cities configured.
|
||||
// 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,
|
||||
travelLines: null,
|
||||
};
|
||||
}
|
||||
|
||||
async getData(weatherParameters, refresh) {
|
||||
@@ -45,22 +45,27 @@ class TravelForecast extends WeatherDisplay {
|
||||
// get point then forecast
|
||||
if (!city.point) throw new Error('No pre-loaded point');
|
||||
let forecast;
|
||||
try {
|
||||
forecast = await json(`https://api.weather.gov/gridpoints/${city.point.wfo}/${city.point.x},${city.point.y}/forecast`, {
|
||||
data: {
|
||||
units: settings.units.value,
|
||||
},
|
||||
});
|
||||
forecast = await safeJson(`https://api.weather.gov/gridpoints/${city.point.wfo}/${city.point.x},${city.point.y}/forecast`, {
|
||||
data: {
|
||||
units: settings.units.value,
|
||||
},
|
||||
});
|
||||
|
||||
if (forecast) {
|
||||
// store for the next run
|
||||
this.previousData[index] = forecast;
|
||||
} catch (e) {
|
||||
} else if (this.previousData?.[index]) {
|
||||
// if there's previous data use it
|
||||
if (this.previousData?.[index]) {
|
||||
forecast = this.previousData?.[index];
|
||||
} else {
|
||||
// otherwise re-throw for the standard error handling
|
||||
throw (e);
|
||||
if (debugFlag('travelforecast')) {
|
||||
console.warn(`Using previous forecast data for ${city.Name} travel forecast`);
|
||||
}
|
||||
forecast = this.previousData?.[index];
|
||||
} else {
|
||||
// no current data and no previous data available
|
||||
if (debugFlag('verbose-failures')) {
|
||||
console.warn(`No travel forecast for ${city.Name} available`);
|
||||
}
|
||||
return { name: city.Name, error: true };
|
||||
}
|
||||
// determine today or tomorrow (shift periods by 1 if tomorrow)
|
||||
const todayShift = forecast.properties.periods[0].isDaytime ? 0 : 1;
|
||||
@@ -73,14 +78,13 @@ class TravelForecast extends WeatherDisplay {
|
||||
icon: getSmallIcon(forecast.properties.periods[todayShift].icon),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`GetTravelWeather for ${city.Name} failed`);
|
||||
console.error(error.status, error.responseJSON);
|
||||
console.error(`Unexpected error getting Travel Forecast for ${city.Name}: ${error.message}`);
|
||||
return { name: city.Name, error: true };
|
||||
}
|
||||
});
|
||||
|
||||
// wait for all forecasts
|
||||
const forecasts = await Promise.all(forecastPromises);
|
||||
// wait for all forecasts using centralized safe Promise handling
|
||||
const forecasts = await safePromiseAll(forecastPromises);
|
||||
this.data = forecasts;
|
||||
|
||||
// test for some data available in at least one forecast
|
||||
@@ -129,6 +133,9 @@ class TravelForecast extends WeatherDisplay {
|
||||
return this.fillTemplate('travel-row', fillValues);
|
||||
}).filter((d) => d);
|
||||
list.append(...lines);
|
||||
|
||||
// update timing based on actual content
|
||||
this.setTiming(list);
|
||||
}
|
||||
|
||||
async drawCanvas() {
|
||||
@@ -157,20 +164,50 @@ class TravelForecast extends WeatherDisplay {
|
||||
|
||||
// base count change callback
|
||||
baseCountChange(count) {
|
||||
// get the travel lines element and cache measurements if needed
|
||||
const travelLines = this.elem.querySelector('.travel-lines');
|
||||
if (!travelLines) return;
|
||||
|
||||
// update cache if needed (when content changes or first run)
|
||||
if (this.scrollCache.travelLines !== travelLines || this.scrollCache.displayHeight === 0) {
|
||||
this.scrollCache.displayHeight = this.elem.querySelector('.main').offsetHeight;
|
||||
this.scrollCache.contentHeight = travelLines.offsetHeight;
|
||||
this.scrollCache.maxOffset = Math.max(0, this.scrollCache.contentHeight - this.scrollCache.displayHeight);
|
||||
this.scrollCache.travelLines = travelLines;
|
||||
|
||||
// Set up hardware acceleration on the travel lines element
|
||||
travelLines.style.willChange = 'transform';
|
||||
travelLines.style.backfaceVisibility = 'hidden';
|
||||
}
|
||||
|
||||
// calculate scroll offset and don't go past end
|
||||
let offsetY = Math.min(this.elem.querySelector('.travel-lines').offsetHeight - 289, (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;
|
||||
|
||||
// copy the scrolled portion of the canvas
|
||||
this.elem.querySelector('.main').scrollTo(0, offsetY);
|
||||
// use transform instead of scrollTo for hardware acceleration
|
||||
travelLines.style.transform = `translateY(-${Math.round(offsetY)}px)`;
|
||||
}
|
||||
|
||||
// necessary to get the lastest long canvas when scrolling
|
||||
getLongCanvas() {
|
||||
return this.longCanvas;
|
||||
}
|
||||
|
||||
setTiming(list) {
|
||||
const container = this.elem.querySelector('.main');
|
||||
const timingConfig = calculateScrollTiming(list, container, {
|
||||
staticDisplay: 5.0, // special static display time for travel forecast
|
||||
});
|
||||
|
||||
// Apply the calculated timing
|
||||
this.timing.baseDelay = timingConfig.baseDelay;
|
||||
this.timing.delay = timingConfig.delay;
|
||||
this.scrollTiming = timingConfig.scrollTiming;
|
||||
|
||||
this.calcNavTiming();
|
||||
}
|
||||
}
|
||||
|
||||
// effectively returns early on the first found date
|
||||
|
||||
40
server/scripts/modules/utils/cache.mjs
Normal file
40
server/scripts/modules/utils/cache.mjs
Normal file
@@ -0,0 +1,40 @@
|
||||
import { rewriteUrl } from './url-rewrite.mjs';
|
||||
|
||||
// Clear cache utility for client-side use
|
||||
const clearCacheEntry = async (url, baseUrl = '') => {
|
||||
try {
|
||||
// Rewrite the URL to get the local proxy path
|
||||
const rewrittenUrl = rewriteUrl(url);
|
||||
const urlObj = typeof rewrittenUrl === 'string' ? new URL(rewrittenUrl, baseUrl || window.location.origin) : rewrittenUrl;
|
||||
let cachePath = urlObj.pathname + urlObj.search;
|
||||
|
||||
// Strip the route designator (first path segment) to match actual cache keys
|
||||
const firstSlashIndex = cachePath.indexOf('/', 1); // Find second slash
|
||||
if (firstSlashIndex > 0) {
|
||||
cachePath = cachePath.substring(firstSlashIndex);
|
||||
}
|
||||
|
||||
// Call the cache clear endpoint
|
||||
const fetchUrl = baseUrl ? `${baseUrl}/cache${cachePath}` : `/cache${cachePath}`;
|
||||
const response = await fetch(fetchUrl, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
if (result.cleared) {
|
||||
console.log(`🗑️ Cleared cache entry: ${cachePath}`);
|
||||
return true;
|
||||
}
|
||||
console.log(`🔍 Cache entry not found: ${cachePath}`);
|
||||
return false;
|
||||
}
|
||||
console.warn(`⚠️ Failed to clear cache entry: ${response.status} ${response.statusText}`);
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error(`❌ Error clearing cache entry for ${url}:`, error.message);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export default clearCacheEntry;
|
||||
@@ -1,5 +1,9 @@
|
||||
// wind direction
|
||||
const directionToNSEW = (Direction) => {
|
||||
// Handle null, undefined, or invalid direction values
|
||||
if (Direction === null || Direction === undefined || typeof Direction !== 'number' || Number.isNaN(Direction)) {
|
||||
return 'VAR'; // Variable (or unknown) direction
|
||||
}
|
||||
const val = Math.floor((Direction / 22.5) + 0.5);
|
||||
const arr = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'];
|
||||
return arr[(val % 16)];
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
// rewrite some urls for local server
|
||||
const rewriteUrl = (_url) => {
|
||||
let url = _url;
|
||||
url = url.replace('https://api.weather.gov/', `${window.location.protocol}//${window.location.host}/`);
|
||||
url = url.replace('https://www.cpc.ncep.noaa.gov/', `${window.location.protocol}//${window.location.host}/`);
|
||||
return url;
|
||||
};
|
||||
|
||||
export {
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
rewriteUrl,
|
||||
};
|
||||
53
server/scripts/modules/utils/data-loader.mjs
Normal file
53
server/scripts/modules/utils/data-loader.mjs
Normal file
@@ -0,0 +1,53 @@
|
||||
// Data loader utility for fetching JSON data with cache-busting
|
||||
|
||||
let dataCache = {};
|
||||
|
||||
// Load data with version-based cache busting
|
||||
const loadData = async (dataType, version = '') => {
|
||||
if (dataCache[dataType]) {
|
||||
return dataCache[dataType];
|
||||
}
|
||||
|
||||
try {
|
||||
const url = `/data/${dataType}.json${version ? `?_=${version}` : ''}`;
|
||||
const response = await fetch(url);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load ${dataType}: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
dataCache[dataType] = data;
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error(`Error loading ${dataType}:`, error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Load all data types
|
||||
const loadAllData = async (version = '') => {
|
||||
const [travelCities, regionalCities, stationInfo] = await Promise.all([
|
||||
loadData('travelcities', version),
|
||||
loadData('regionalcities', version),
|
||||
loadData('stations', version),
|
||||
]);
|
||||
|
||||
// Set global variables for backward compatibility
|
||||
window.TravelCities = travelCities;
|
||||
window.RegionalCities = regionalCities;
|
||||
window.StationInfo = stationInfo;
|
||||
|
||||
return { travelCities, regionalCities, stationInfo };
|
||||
};
|
||||
|
||||
// Clear cache (useful for development)
|
||||
const clearDataCache = () => {
|
||||
dataCache = {};
|
||||
};
|
||||
|
||||
export {
|
||||
loadData,
|
||||
loadAllData,
|
||||
clearDataCache,
|
||||
};
|
||||
148
server/scripts/modules/utils/debug.mjs
Normal file
148
server/scripts/modules/utils/debug.mjs
Normal file
@@ -0,0 +1,148 @@
|
||||
// Debug flag management system
|
||||
// Supports comma-separated debug flags or "all" for everything
|
||||
// URL parameter takes priority over OVERRIDES.DEBUG
|
||||
|
||||
let debugFlags = null; // memoized parsed flags
|
||||
let runtimeFlags = null; // runtime modifications via debugEnable/debugDisable/debugSet
|
||||
|
||||
/**
|
||||
* Parse debug flags from URL parameter or environment variable
|
||||
* @returns {Set<string>} Set of enabled debug flags
|
||||
*/
|
||||
const parseDebugFlags = () => {
|
||||
if (debugFlags !== null) return debugFlags;
|
||||
|
||||
let debugString = '';
|
||||
|
||||
// Check URL parameter first
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const urlDebug = urlParams.get('debug');
|
||||
|
||||
if (urlDebug) {
|
||||
debugString = urlDebug;
|
||||
} else {
|
||||
// Fall back to OVERRIDES.DEBUG
|
||||
debugString = (typeof OVERRIDES !== 'undefined' ? OVERRIDES?.DEBUG : '') || '';
|
||||
}
|
||||
|
||||
// Parse comma-separated values into a Set
|
||||
if (debugString.trim()) {
|
||||
debugFlags = new Set(
|
||||
debugString
|
||||
.split(',')
|
||||
.map((flag) => flag.trim().toLowerCase())
|
||||
.filter((flag) => flag.length > 0),
|
||||
);
|
||||
} else {
|
||||
debugFlags = new Set();
|
||||
}
|
||||
|
||||
return debugFlags;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the current active debug flags (including runtime modifications)
|
||||
* @returns {Set<string>} Set of currently active debug flags
|
||||
*/
|
||||
const getActiveFlags = () => {
|
||||
if (runtimeFlags !== null) {
|
||||
return runtimeFlags;
|
||||
}
|
||||
return parseDebugFlags();
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a debug flag is enabled
|
||||
* @param {string} flag - The debug flag to check
|
||||
* @returns {boolean} True if the flag is enabled
|
||||
*/
|
||||
const debugFlag = (flag) => {
|
||||
const activeFlags = getActiveFlags();
|
||||
|
||||
// "all" enables everything
|
||||
if (activeFlags.has('all')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for specific flag
|
||||
return activeFlags.has(flag.toLowerCase());
|
||||
};
|
||||
|
||||
/**
|
||||
* Enable one or more debug flags at runtime
|
||||
* @param {...string} flags - Debug flags to enable
|
||||
* @returns {string[]} Array of currently active debug flags after enabling
|
||||
*/
|
||||
const debugEnable = (...flags) => {
|
||||
// Initialize runtime flags from current state if not already done
|
||||
if (runtimeFlags === null) {
|
||||
runtimeFlags = new Set(getActiveFlags());
|
||||
}
|
||||
|
||||
// Add new flags
|
||||
flags.forEach((flag) => {
|
||||
runtimeFlags.add(flag.toLowerCase());
|
||||
});
|
||||
|
||||
return debugList();
|
||||
};
|
||||
|
||||
/**
|
||||
* Disable one or more debug flags at runtime
|
||||
* @param {...string} flags - Debug flags to disable
|
||||
* @returns {string[]} Array of currently active debug flags after disabling
|
||||
*/
|
||||
const debugDisable = (...flags) => {
|
||||
// Initialize runtime flags from current state if not already done
|
||||
if (runtimeFlags === null) {
|
||||
runtimeFlags = new Set(getActiveFlags());
|
||||
}
|
||||
|
||||
flags.forEach((flag) => {
|
||||
const lowerFlag = flag.toLowerCase();
|
||||
if (lowerFlag === 'all') {
|
||||
// Special case: disable all flags
|
||||
runtimeFlags.clear();
|
||||
} else {
|
||||
runtimeFlags.delete(lowerFlag);
|
||||
}
|
||||
});
|
||||
|
||||
return debugList();
|
||||
};
|
||||
|
||||
/**
|
||||
* Set debug flags at runtime (overwrites existing flags)
|
||||
* @param {...string} flags - Debug flags to set (replaces all current flags)
|
||||
* @returns {string[]} Array of currently active debug flags after setting
|
||||
*/
|
||||
const debugSet = (...flags) => {
|
||||
runtimeFlags = new Set(
|
||||
flags.map((flag) => flag.toLowerCase()),
|
||||
);
|
||||
|
||||
return debugList();
|
||||
};
|
||||
|
||||
/**
|
||||
* Get current debug flags for inspection
|
||||
* @returns {string[]} Array of currently active debug flags
|
||||
*/
|
||||
const debugList = () => Array.from(getActiveFlags()).sort();
|
||||
|
||||
// Make debug functions globally accessible in development for console use
|
||||
if (typeof window !== 'undefined') {
|
||||
window.debugFlag = debugFlag;
|
||||
window.debugEnable = debugEnable;
|
||||
window.debugDisable = debugDisable;
|
||||
window.debugSet = debugSet;
|
||||
window.debugList = debugList;
|
||||
}
|
||||
|
||||
export {
|
||||
debugFlag,
|
||||
debugEnable,
|
||||
debugDisable,
|
||||
debugSet,
|
||||
debugList,
|
||||
};
|
||||
@@ -1,31 +1,111 @@
|
||||
import { rewriteUrl } from './cors.mjs';
|
||||
import { rewriteUrl } from './url-rewrite.mjs';
|
||||
|
||||
const DEFAULT_REQUEST_TIMEOUT = 15000; // For example, with 3 retries: 15s+1s+15s+2s+15s+5s+15s = 68s
|
||||
|
||||
// Centralized utilities for handling errors in Promise contexts
|
||||
const safeJson = async (url, params) => {
|
||||
try {
|
||||
const result = await json(url, params);
|
||||
// Return an object with both data and url if params.returnUrl is true
|
||||
if (params?.returnUrl) {
|
||||
return result;
|
||||
}
|
||||
// If caller didn't specify returnUrl, result is the raw API response
|
||||
return result;
|
||||
} catch (_error) {
|
||||
// Error already logged in fetchAsync; return null to be "safe"
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const safeText = async (url, params) => {
|
||||
try {
|
||||
const result = await text(url, params);
|
||||
// Return an object with both data and url if params.returnUrl is true
|
||||
if (params?.returnUrl) {
|
||||
return result;
|
||||
}
|
||||
// If caller didn't specify returnUrl, result is the raw API response
|
||||
return result;
|
||||
} catch (_error) {
|
||||
// Error already logged in fetchAsync; return null to be "safe"
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const safeBlob = async (url, params) => {
|
||||
try {
|
||||
const result = await blob(url, params);
|
||||
// Return an object with both data and url if params.returnUrl is true
|
||||
if (params?.returnUrl) {
|
||||
return result;
|
||||
}
|
||||
// If caller didn't specify returnUrl, result is the raw API response
|
||||
return result;
|
||||
} catch (_error) {
|
||||
// Error already logged in fetchAsync; return null to be "safe"
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const safePromiseAll = async (promises) => {
|
||||
try {
|
||||
const results = await Promise.allSettled(promises);
|
||||
|
||||
return results.map((result, index) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
return result.value;
|
||||
}
|
||||
// Log rejected promises for debugging (except AbortErrors which are expected)
|
||||
if (result.reason?.name !== 'AbortError') {
|
||||
console.warn(`Promise ${index} rejected:`, result.reason?.message || result.reason);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('safePromiseAll encountered an unexpected error:', error);
|
||||
// Return array of nulls matching the input length
|
||||
return new Array(promises.length).fill(null);
|
||||
}
|
||||
};
|
||||
|
||||
const json = (url, params) => fetchAsync(url, 'json', params);
|
||||
const text = (url, params) => fetchAsync(url, 'text', params);
|
||||
const blob = (url, params) => fetchAsync(url, 'blob', params);
|
||||
|
||||
// Hosts that don't allow custom User-Agent headers due to CORS restrictions
|
||||
const USER_AGENT_EXCLUDED_HOSTS = [
|
||||
'geocode.arcgis.com',
|
||||
'services.arcgis.com',
|
||||
];
|
||||
|
||||
const fetchAsync = async (_url, responseType, _params = {}) => {
|
||||
// add user agent header to json request at api.weather.gov
|
||||
const headers = {};
|
||||
if (_url.toString().match(/api\.weather\.gov/)) {
|
||||
|
||||
const checkUrl = new URL(_url, window.location.origin);
|
||||
const shouldExcludeUserAgent = USER_AGENT_EXCLUDED_HOSTS.some((host) => checkUrl.hostname.includes(host));
|
||||
|
||||
// User-Agent handling:
|
||||
// - Server mode (with caching proxy): Add User-Agent for all requests except excluded hosts
|
||||
// - Static mode (direct requests): Only add User-Agent for api.weather.gov, avoiding CORS preflight issues with other services
|
||||
const shouldAddUserAgent = !shouldExcludeUserAgent && (window.WS4KP_SERVER_AVAILABLE || _url.toString().match(/api\.weather\.gov/));
|
||||
if (shouldAddUserAgent) {
|
||||
headers['user-agent'] = 'Weatherstar 4000+; weatherstar@netbymatt.com';
|
||||
}
|
||||
|
||||
// combine default and provided parameters
|
||||
const params = {
|
||||
method: 'GET',
|
||||
mode: 'cors',
|
||||
type: 'GET',
|
||||
retryCount: 0,
|
||||
retryCount: 3, // Default to 3 retries for any failed requests (timeout or 5xx server errors)
|
||||
timeout: DEFAULT_REQUEST_TIMEOUT,
|
||||
..._params,
|
||||
headers,
|
||||
};
|
||||
// store original number of retries
|
||||
params.originalRetries = params.retryCount;
|
||||
|
||||
// build a url, including the rewrite for cors if necessary
|
||||
let corsUrl = _url;
|
||||
if (params.cors === true) corsUrl = rewriteUrl(_url);
|
||||
const url = new URL(corsUrl, `${window.location.origin}/`);
|
||||
// rewrite URLs for various services to use the backend proxy server for proper caching (and request logging)
|
||||
const url = rewriteUrl(_url);
|
||||
// match the security protocol when not on localhost
|
||||
// url.protocol = window.location.hostname === 'localhost' ? url.protocol : window.location.protocol;
|
||||
// add parameters if necessary
|
||||
@@ -39,53 +119,174 @@ const fetchAsync = async (_url, responseType, _params = {}) => {
|
||||
}
|
||||
|
||||
// make the request
|
||||
const response = await doFetch(url, params);
|
||||
try {
|
||||
const response = await doFetch(url, params);
|
||||
|
||||
// check for ok response
|
||||
if (!response.ok) throw new Error(`Fetch error ${response.status} ${response.statusText} while fetching ${response.url}`);
|
||||
// return the requested response
|
||||
switch (responseType) {
|
||||
case 'json':
|
||||
return response.json();
|
||||
case 'text':
|
||||
return response.text();
|
||||
case 'blob':
|
||||
return response.blob();
|
||||
default:
|
||||
return response;
|
||||
// check for ok response
|
||||
if (!response.ok) throw new Error(`Fetch error ${response.status} ${response.statusText} while fetching ${response.url}`);
|
||||
// process the response based on type
|
||||
let result;
|
||||
switch (responseType) {
|
||||
case 'json':
|
||||
result = await response.json();
|
||||
break;
|
||||
case 'text':
|
||||
result = await response.text();
|
||||
break;
|
||||
case 'blob':
|
||||
result = await response.blob();
|
||||
break;
|
||||
default:
|
||||
result = response;
|
||||
}
|
||||
|
||||
// Return both data and URL if requested
|
||||
if (params.returnUrl) {
|
||||
return {
|
||||
data: result,
|
||||
url: response.url,
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
// Enhanced error handling for different error types
|
||||
if (error.name === 'AbortError') {
|
||||
// AbortError always happens in the browser, regardless of server vs static mode
|
||||
// Most likely causes include background tab throttling, user navigation, or client timeout
|
||||
console.log(`🛑 Fetch aborted for ${_url} (background tab throttling?)`);
|
||||
return null; // Always return null for AbortError instead of throwing
|
||||
} if (error.name === 'TimeoutError') {
|
||||
console.warn(`⏱️ Request timeout for ${_url} (${error.message})`);
|
||||
} else if (error.message.includes('502')) {
|
||||
console.warn(`🚪 Bad Gateway error for ${_url}`);
|
||||
} else if (error.message.includes('503')) {
|
||||
console.warn(`⌛ Temporarily unavailable for ${_url}`);
|
||||
} else if (error.message.includes('504')) {
|
||||
console.warn(`⏱️ Gateway Timeout for ${_url}`);
|
||||
} else if (error.message.includes('500')) {
|
||||
console.warn(`💥 Internal Server Error for ${_url}`);
|
||||
} else if (error.message.includes('CORS') || error.message.includes('Access-Control')) {
|
||||
console.warn(`🔒 CORS or Access Control error for ${_url}`);
|
||||
} else {
|
||||
console.warn(`❌ Fetch failed for ${_url} (${error.message})`);
|
||||
}
|
||||
|
||||
// Add standard error properties that calling code expects
|
||||
if (!error.status) error.status = 0;
|
||||
if (!error.responseJSON) error.responseJSON = null;
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// fetch with retry and back-off
|
||||
const doFetch = (url, params) => new Promise((resolve, reject) => {
|
||||
fetch(url, params).then((response) => {
|
||||
if (params.retryCount > 0) {
|
||||
// 500 status codes should be retried after a short backoff
|
||||
if (response.status >= 500 && response.status <= 599 && params.retryCount > 0) {
|
||||
// call the "still waiting" function
|
||||
if (typeof params.stillWaiting === 'function' && params.retryCount === params.originalRetries) {
|
||||
params.stillWaiting();
|
||||
}
|
||||
// decrement and retry
|
||||
const newParams = {
|
||||
...params,
|
||||
retryCount: params.retryCount - 1,
|
||||
};
|
||||
return resolve(delay(retryDelay(params.originalRetries - newParams.retryCount), doFetch, url, newParams));
|
||||
}
|
||||
// not 500 status
|
||||
return resolve(response);
|
||||
}
|
||||
// out of retries
|
||||
return resolve(response);
|
||||
})
|
||||
.catch(reject);
|
||||
});
|
||||
const doFetch = (url, params, originalRetryCount = null) => new Promise((resolve, reject) => {
|
||||
// On the first call, store the retry count for later logging
|
||||
const initialRetryCount = originalRetryCount ?? params.retryCount;
|
||||
|
||||
const delay = (time, func, ...args) => new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve(func(...args));
|
||||
}, time);
|
||||
// Create AbortController for timeout
|
||||
const controller = new AbortController();
|
||||
const startTime = Date.now();
|
||||
const timeoutId = setTimeout(() => {
|
||||
controller.abort();
|
||||
}, params.timeout);
|
||||
|
||||
// Add signal to fetch params
|
||||
const fetchParams = {
|
||||
...params,
|
||||
signal: controller.signal,
|
||||
};
|
||||
|
||||
// Shared retry logic to avoid duplication
|
||||
const attemptRetry = (reason) => {
|
||||
// Safety check for params
|
||||
if (!params || typeof params.retryCount !== 'number') {
|
||||
console.error(`❌ Invalid params for retry: ${url}`);
|
||||
return reject(new Error('Invalid retry parameters'));
|
||||
}
|
||||
|
||||
const retryAttempt = initialRetryCount - params.retryCount + 1;
|
||||
const remainingRetries = params.retryCount - 1;
|
||||
const delayMs = retryDelay(retryAttempt);
|
||||
|
||||
console.warn(`🔄 Retry ${retryAttempt}/${initialRetryCount} for ${url} - ${reason} (retrying in ${delayMs}ms, ${remainingRetries} retr${remainingRetries === 1 ? 'y' : 'ies'} left)`);
|
||||
|
||||
// call the "still waiting" function on first retry
|
||||
if (params && params.stillWaiting && typeof params.stillWaiting === 'function' && retryAttempt === 1) {
|
||||
try {
|
||||
params.stillWaiting();
|
||||
} catch (callbackError) {
|
||||
console.warn(`⚠️ stillWaiting callback error for ${url}:`, callbackError.message);
|
||||
}
|
||||
}
|
||||
// decrement and retry with safe parameter copying
|
||||
const newParams = {
|
||||
...params,
|
||||
retryCount: Math.max(0, params.retryCount - 1), // Ensure retryCount doesn't go negative
|
||||
};
|
||||
// Use setTimeout directly instead of the delay wrapper to avoid Promise resolution issues
|
||||
setTimeout(() => {
|
||||
doFetch(url, newParams, initialRetryCount).then(resolve).catch(reject);
|
||||
}, delayMs);
|
||||
return undefined; // Explicit return for linter
|
||||
};
|
||||
|
||||
fetch(url, fetchParams).then((response) => {
|
||||
clearTimeout(timeoutId); // Clear timeout on successful response
|
||||
|
||||
// Retry 500 status codes if we have retries left
|
||||
if (params && params.retryCount > 0 && response.status >= 500 && response.status <= 599) {
|
||||
let errorType = 'Server error';
|
||||
if (response.status === 502) {
|
||||
errorType = 'Bad Gateway';
|
||||
} else if (response.status === 503) {
|
||||
errorType = 'Service Unavailable';
|
||||
} else if (response.status === 504) {
|
||||
errorType = 'Gateway Timeout';
|
||||
}
|
||||
return attemptRetry(`${errorType} ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
// Log when we're out of retries for server errors
|
||||
// if (response.status >= 500 && response.status <= 599) {
|
||||
// console.warn(`⚠️ Server error ${response.status} ${response.statusText} for ${url} - no retries remaining`);
|
||||
// }
|
||||
|
||||
// successful response or out of retries
|
||||
return resolve(response);
|
||||
}).catch((error) => {
|
||||
clearTimeout(timeoutId); // Clear timeout on error
|
||||
|
||||
// Enhance AbortError detection by checking if we're near the timeout duration
|
||||
if (error.name === 'AbortError') {
|
||||
const duration = Date.now() - startTime;
|
||||
const isLikelyTimeout = duration >= (params.timeout - 1000); // Within 1 second of timeout
|
||||
|
||||
// Convert likely timeouts to TimeoutError for better error reporting
|
||||
if (isLikelyTimeout) {
|
||||
const reason = `Request timeout after ${Math.round(duration / 1000)}s`;
|
||||
if (params && params.retryCount > 0) {
|
||||
return attemptRetry(reason);
|
||||
}
|
||||
// Convert to a timeout error for better error reporting
|
||||
const timeoutError = new Error(`Request timeout after ${Math.round(duration / 1000)}s`);
|
||||
timeoutError.name = 'TimeoutError';
|
||||
reject(timeoutError);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Retry network errors if we have retries left
|
||||
if (params && params.retryCount > 0 && error.name !== 'AbortError') {
|
||||
const reason = error.name === 'TimeoutError' ? 'Request timeout' : `Network error: ${error.message}`;
|
||||
return attemptRetry(reason);
|
||||
}
|
||||
|
||||
// out of retries or AbortError - reject
|
||||
reject(error);
|
||||
return undefined; // Explicit return for linter
|
||||
});
|
||||
});
|
||||
|
||||
const retryDelay = (retryNumber) => {
|
||||
@@ -102,4 +303,8 @@ export {
|
||||
json,
|
||||
text,
|
||||
blob,
|
||||
safeJson,
|
||||
safeText,
|
||||
safeBlob,
|
||||
safePromiseAll,
|
||||
};
|
||||
|
||||
30
server/scripts/modules/utils/forecast-utils.mjs
Normal file
30
server/scripts/modules/utils/forecast-utils.mjs
Normal file
@@ -0,0 +1,30 @@
|
||||
// shared utility functions for forecast processing
|
||||
|
||||
/**
|
||||
* Filter out expired periods from forecast data
|
||||
* @param {Array} periods - Array of forecast periods
|
||||
* @param {string} forecastUrl - URL used for logging (optional)
|
||||
* @returns {Array} - Array of active (non-expired) periods
|
||||
*/
|
||||
const filterExpiredPeriods = (periods, forecastUrl = '') => {
|
||||
const now = new Date();
|
||||
|
||||
const { activePeriods, removedPeriods } = periods.reduce((acc, period) => {
|
||||
const endTime = new Date(period.endTime);
|
||||
if (endTime > now) {
|
||||
acc.activePeriods.push(period);
|
||||
} else {
|
||||
acc.removedPeriods.push(period);
|
||||
}
|
||||
return acc;
|
||||
}, { activePeriods: [], removedPeriods: [] });
|
||||
|
||||
if (removedPeriods.length > 0) {
|
||||
const source = forecastUrl ? ` from ${forecastUrl}` : '';
|
||||
console.log(`🚮 Forecast: Removed expired periods${source}: ${removedPeriods.map((p) => `${p.name} (ended ${p.endTime})`).join(', ')}`);
|
||||
}
|
||||
|
||||
return activePeriods;
|
||||
};
|
||||
|
||||
export default filterExpiredPeriods;
|
||||
@@ -5,6 +5,11 @@ import { blob } from './fetch.mjs';
|
||||
// a list of cached icons is used to avoid hitting the cache multiple times
|
||||
const cachedImages = [];
|
||||
const preloadImg = (src) => {
|
||||
if (!src || typeof src !== 'string') {
|
||||
console.warn(`preloadImg expects a URL string, received: '${src}' (${typeof src})`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (cachedImages.includes(src)) return false;
|
||||
blob(src);
|
||||
cachedImages.push(src);
|
||||
|
||||
669
server/scripts/modules/utils/mapclick.mjs
Normal file
669
server/scripts/modules/utils/mapclick.mjs
Normal file
@@ -0,0 +1,669 @@
|
||||
/**
|
||||
* MapClick API Fallback Utility
|
||||
*
|
||||
* Provides fallback functionality to fetch weather data from forecast.weather.gov's MapClick API
|
||||
* when the primary api.weather.gov data is stale or incomplete.
|
||||
*
|
||||
* MapClick uses the SBN feed which typically has faster METAR (airport) station updates
|
||||
* but is limited to airport stations only. The primary API uses MADIS which is more
|
||||
* comprehensive but can have delayed ingestion.
|
||||
*/
|
||||
|
||||
import { safeJson } from './fetch.mjs';
|
||||
import { debugFlag } from './debug.mjs';
|
||||
|
||||
/**
|
||||
* Parse MapClick date format to JavaScript Date
|
||||
* @param {string} dateString - Format: "18 Jun 23:53 pm EDT"
|
||||
* @returns {Date|null} - Parsed date or null if invalid
|
||||
*/
|
||||
export const parseMapClickDate = (dateString) => {
|
||||
try {
|
||||
// Extract components using regex
|
||||
const match = dateString.match(/(\d{1,2})\s+(\w{3})\s+(\d{1,2}):(\d{2})\s+(am|pm)\s+(\w{3})/i);
|
||||
if (!match) return null;
|
||||
|
||||
const [, day, month, hour, minute, ampm, timezone] = match;
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
// Convert to 12-hour format since we have AM/PM
|
||||
let hour12 = parseInt(hour, 10);
|
||||
// If it's in 24-hour format but we have AM/PM, convert it
|
||||
if (hour12 > 12) {
|
||||
hour12 -= 12;
|
||||
}
|
||||
|
||||
// Reconstruct in a format that Date.parse understands (12-hour format with AM/PM)
|
||||
const standardFormat = `${month} ${day}, ${currentYear} ${hour12}:${minute}:00 ${ampm.toUpperCase()} ${timezone}`;
|
||||
|
||||
const parsedDate = new Date(standardFormat);
|
||||
|
||||
// Check if the date is valid
|
||||
if (Number.isNaN(parsedDate.getTime())) {
|
||||
console.warn(`MapClick: Invalid date parsed from: ${dateString} -> ${standardFormat}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsedDate;
|
||||
} catch (error) {
|
||||
console.warn(`MapClick: Failed to parse date: ${dateString}`, error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalize icon name to determine if it's night and get base name for mapping
|
||||
* @param {string} iconName - Icon name without extension
|
||||
* @returns {Object} - { isNightTime: boolean, baseIconName: string }
|
||||
*/
|
||||
const normalizeIconName = (iconName) => {
|
||||
// Handle special cases where 'n' is not a prefix (hi_nshwrs, hi_ntsra)
|
||||
const hiNightMatch = iconName.match(/^hi_n(.+)/);
|
||||
if (hiNightMatch) {
|
||||
return {
|
||||
isNightTime: true,
|
||||
baseIconName: `hi_${hiNightMatch[1]}`, // Reconstruct as hi_[condition]
|
||||
};
|
||||
}
|
||||
|
||||
// Handle the general 'n' prefix rule (including nra, nwind_skc, etc.)
|
||||
if (iconName.startsWith('n')) {
|
||||
return {
|
||||
isNightTime: true,
|
||||
baseIconName: iconName.substring(1), // Strip the 'n' prefix
|
||||
};
|
||||
}
|
||||
|
||||
// Not a night icon
|
||||
return {
|
||||
isNightTime: false,
|
||||
baseIconName: iconName,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert MapClick weather image filename to weather.gov API icon format
|
||||
* @param {string} weatherImage - MapClick weather image filename (e.g., 'bkn.png')
|
||||
* @returns {string|null} - Weather.gov API icon URL or null if invalid/missing
|
||||
*/
|
||||
const convertMapClickIcon = (weatherImage) => {
|
||||
// Return null for missing, invalid, or NULL values - let caller handle defaults
|
||||
if (!weatherImage || weatherImage === 'NULL' || weatherImage === 'NA') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Remove .png extension if present
|
||||
const iconName = weatherImage.replace('.png', '');
|
||||
|
||||
// Determine if this is a night icon and get the base name for mapping
|
||||
const { isNightTime, baseIconName } = normalizeIconName(iconName);
|
||||
const timeOfDay = isNightTime ? 'night' : 'day';
|
||||
|
||||
// MapClick icon filename to weather.gov API condition mapping
|
||||
// This maps MapClick specific icon names to standard API icon names
|
||||
// Night variants are handled by stripping 'n' prefix before lookup
|
||||
// based on https://www.weather.gov/forecast-icons/
|
||||
const iconMapping = {
|
||||
// Clear/Fair conditions
|
||||
skc: 'skc', // Clear sky condition
|
||||
|
||||
// Cloud coverage
|
||||
few: 'few', // A few clouds
|
||||
sct: 'sct', // Scattered clouds / Partly cloudy
|
||||
bkn: 'bkn', // Broken clouds / Mostly cloudy
|
||||
ovc: 'ovc', // Overcast
|
||||
|
||||
// Light Rain + Drizzle
|
||||
minus_ra: 'rain', // Light rain -> rain
|
||||
ra: 'rain', // Rain
|
||||
// Note: nra.png is used for both light rain and rain at night
|
||||
// but normalizeIconName strips the 'n' to get 'ra' which maps to 'rain'
|
||||
|
||||
// Snow variants
|
||||
sn: 'snow', // Snow
|
||||
|
||||
// Rain + Snow combinations
|
||||
ra_sn: 'rain_snow', // Rain snow
|
||||
rasn: 'rain_snow', // Standard rain snow
|
||||
|
||||
// Ice Pellets/Sleet
|
||||
raip: 'rain_sleet', // Rain ice pellets -> rain_sleet
|
||||
ip: 'sleet', // Ice pellets
|
||||
|
||||
// Freezing Rain
|
||||
ra_fzra: 'rain_fzra', // Rain freezing rain -> rain_fzra
|
||||
fzra: 'fzra', // Freezing rain
|
||||
|
||||
// Freezing Rain + Snow
|
||||
fzra_sn: 'snow_fzra', // Freezing rain snow -> snow_fzra
|
||||
|
||||
// Snow + Ice Pellets
|
||||
snip: 'snow_sleet', // Snow ice pellets -> snow_sleet
|
||||
|
||||
// Showers
|
||||
hi_shwrs: 'rain_showers_hi', // Isolated showers -> rain_showers_hi
|
||||
shra: 'rain_showers', // Showers -> rain_showers
|
||||
|
||||
// Thunderstorms
|
||||
tsra: 'tsra', // Thunderstorm
|
||||
scttsra: 'tsra_sct', // Scattered thunderstorm -> tsra_sct
|
||||
hi_tsra: 'tsra_hi', // Isolated thunderstorm -> tsra_hi
|
||||
|
||||
// Fog
|
||||
fg: 'fog', // Fog
|
||||
|
||||
// Wind conditions
|
||||
wind_skc: 'wind_skc', // Clear and windy
|
||||
wind_few: 'wind_few', // Few clouds and windy
|
||||
wind_sct: 'wind_sct', // Scattered clouds and windy
|
||||
wind_bkn: 'wind_bkn', // Broken clouds and windy
|
||||
wind_ovc: 'wind_ovc', // Overcast and windy
|
||||
|
||||
// Extreme weather
|
||||
blizzard: 'blizzard', // Blizzard
|
||||
cold: 'cold', // Cold
|
||||
hot: 'hot', // Hot
|
||||
du: 'dust', // Dust
|
||||
fu: 'smoke', // Smoke
|
||||
hz: 'haze', // Haze
|
||||
|
||||
// Tornadoes
|
||||
fc: 'tornado', // Funnel cloud
|
||||
tor: 'tornado', // Tornado
|
||||
};
|
||||
|
||||
// Get the mapped condition, return null if not found in the mapping
|
||||
const condition = iconMapping[baseIconName];
|
||||
if (!condition) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return `/icons/land/${timeOfDay}/${condition}?size=medium`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert MapClick observation data to match the standard API format
|
||||
*
|
||||
* This is NOT intended to be a full replacment process, but rather a minimal
|
||||
* fallback for the data used in WS4KP.
|
||||
*
|
||||
* @param {Object} mapClickObs - MapClick observation data
|
||||
* @returns {Object} - Data formatted to match api.weather.gov structure
|
||||
*/
|
||||
export const convertMapClickObservationsToApiFormat = (mapClickObs) => {
|
||||
// Convert temperature from Fahrenheit to Celsius (only if valid)
|
||||
const tempF = parseFloat(mapClickObs.Temp);
|
||||
const tempC = !Number.isNaN(tempF) ? (tempF - 32) * 5 / 9 : null;
|
||||
|
||||
const dewpF = parseFloat(mapClickObs.Dewp);
|
||||
const dewpC = !Number.isNaN(dewpF) ? (dewpF - 32) * 5 / 9 : null;
|
||||
|
||||
// Convert wind speed from mph to km/h (only if valid)
|
||||
const windMph = parseFloat(mapClickObs.Winds);
|
||||
const windKmh = !Number.isNaN(windMph) ? windMph * 1.60934 : null;
|
||||
|
||||
// Convert wind gust from mph to km/h (only if valid and not "NA")
|
||||
const gustMph = mapClickObs.Gust !== 'NA' ? parseFloat(mapClickObs.Gust) : NaN;
|
||||
const windGust = !Number.isNaN(gustMph) ? gustMph * 1.60934 : null;
|
||||
|
||||
// Convert wind direction (only if valid)
|
||||
const windDir = parseFloat(mapClickObs.Windd);
|
||||
const windDirection = !Number.isNaN(windDir) ? windDir : null;
|
||||
|
||||
// Convert pressure from inHg to Pa (only if valid)
|
||||
const pressureInHg = parseFloat(mapClickObs.SLP);
|
||||
const pressurePa = !Number.isNaN(pressureInHg) ? pressureInHg * 3386.39 : null;
|
||||
|
||||
// Convert visibility from miles to meters (only if valid)
|
||||
const visibilityMiles = parseFloat(mapClickObs.Visibility);
|
||||
const visibilityMeters = !Number.isNaN(visibilityMiles) ? visibilityMiles * 1609.34 : null;
|
||||
|
||||
// Convert relative humidity (only if valid)
|
||||
const relh = parseFloat(mapClickObs.Relh);
|
||||
const relativeHumidity = !Number.isNaN(relh) ? relh : null;
|
||||
|
||||
// Convert wind chill from Fahrenheit to Celsius (only if valid and not "NA")
|
||||
const windChillF = mapClickObs.WindChill !== 'NA' ? parseFloat(mapClickObs.WindChill) : NaN;
|
||||
const windChill = !Number.isNaN(windChillF) ? (windChillF - 32) * 5 / 9 : null;
|
||||
|
||||
// Convert MapClick weather image to weather.gov API icon format
|
||||
const iconUrl = convertMapClickIcon(mapClickObs.Weatherimage);
|
||||
|
||||
return {
|
||||
features: [
|
||||
{
|
||||
properties: {
|
||||
timestamp: parseMapClickDate(mapClickObs.Date)?.toISOString() || new Date().toISOString(),
|
||||
temperature: { value: tempC, unitCode: 'wmoUnit:degC' },
|
||||
dewpoint: { value: dewpC, unitCode: 'wmoUnit:degC' },
|
||||
windDirection: { value: windDirection, unitCode: 'wmoUnit:degree_(angle)' },
|
||||
windSpeed: { value: windKmh, unitCode: 'wmoUnit:km_h-1' },
|
||||
windGust: { value: windGust, unitCode: 'wmoUnit:km_h-1' },
|
||||
barometricPressure: { value: pressurePa, unitCode: 'wmoUnit:Pa' },
|
||||
visibility: { value: visibilityMeters, unitCode: 'wmoUnit:m' },
|
||||
relativeHumidity: { value: relativeHumidity, unitCode: 'wmoUnit:percent' },
|
||||
textDescription: mapClickObs.Weather || null,
|
||||
icon: iconUrl, // Can be null if no valid icon available
|
||||
heatIndex: { value: null },
|
||||
windChill: { value: windChill },
|
||||
cloudLayers: [], // no cloud layer data available from MapClick
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert MapClick forecast data to weather.gov API forecast format
|
||||
* @param {Object} mapClickData - Raw MapClick response data
|
||||
* @returns {Object|null} - Forecast data in API format or null if invalid
|
||||
*/
|
||||
export const convertMapClickForecastToApiFormat = (mapClickData) => {
|
||||
if (!mapClickData?.data || !mapClickData?.time) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { data, time } = mapClickData;
|
||||
const {
|
||||
temperature, weather, iconLink, text, pop,
|
||||
} = data;
|
||||
|
||||
if (!temperature || !weather || !iconLink || !text || !time.startValidTime || !time.startPeriodName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Convert each forecast period
|
||||
const periods = temperature.map((temp, index) => {
|
||||
if (index >= weather.length || index >= iconLink.length || index >= text.length || index >= time.startValidTime.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Determine if this is a daytime period based on the period name
|
||||
const periodName = time.startPeriodName[index] || '';
|
||||
const isDaytime = !periodName.toLowerCase().includes('night');
|
||||
|
||||
// Convert icon from MapClick format to API format
|
||||
let icon = iconLink[index];
|
||||
if (icon) {
|
||||
let filename = null;
|
||||
|
||||
// Handle DualImage.php URLs: extract from 'i' parameter
|
||||
if (icon.includes('DualImage.php')) {
|
||||
const iMatch = icon.match(/[?&]i=([^&]+)/);
|
||||
if (iMatch) {
|
||||
[, filename] = iMatch;
|
||||
}
|
||||
} else {
|
||||
// Handle regular image URLs: extract filename from path, removing percentage numbers
|
||||
const pathMatch = icon.match(/\/([^/]+?)(?:\d+)?(?:\.png)?$/);
|
||||
if (pathMatch) {
|
||||
[, filename] = pathMatch;
|
||||
}
|
||||
}
|
||||
|
||||
if (filename) {
|
||||
icon = convertMapClickIcon(filename);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
number: index + 1,
|
||||
name: periodName,
|
||||
startTime: time.startValidTime[index],
|
||||
endTime: index + 1 < time.startValidTime.length ? time.startValidTime[index + 1] : null,
|
||||
isDaytime,
|
||||
temperature: parseInt(temp, 10),
|
||||
temperatureUnit: 'F',
|
||||
temperatureTrend: null,
|
||||
probabilityOfPrecipitation: {
|
||||
unitCode: 'wmoUnit:percent',
|
||||
value: pop[index] ? parseInt(pop[index], 10) : null,
|
||||
},
|
||||
dewpoint: {
|
||||
unitCode: 'wmoUnit:degC',
|
||||
value: null, // MapClick doesn't provide dewpoint in forecast
|
||||
},
|
||||
relativeHumidity: {
|
||||
unitCode: 'wmoUnit:percent',
|
||||
value: null, // MapClick doesn't provide humidity in forecast
|
||||
},
|
||||
windSpeed: null, // MapClick doesn't provide wind speed in forecast
|
||||
windDirection: null, // MapClick doesn't provide wind direction in forecast
|
||||
icon,
|
||||
shortForecast: weather[index],
|
||||
detailedForecast: text[index],
|
||||
};
|
||||
}).filter((period) => period !== null);
|
||||
|
||||
// Return in API forecast format
|
||||
return {
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Point',
|
||||
coordinates: [mapClickData.location?.longitude, mapClickData.location?.latitude],
|
||||
},
|
||||
properties: {
|
||||
units: 'us',
|
||||
forecastGenerator: 'MapClick',
|
||||
generatedAt: new Date().toISOString(),
|
||||
updateTime: parseMapClickDate(mapClickData.creationDateLocal)?.toISOString() || new Date().toISOString(),
|
||||
validTimes: `${time.startValidTime[0]}/${time.startValidTime[time.startValidTime.length - 1]}`,
|
||||
elevation: {
|
||||
unitCode: 'wmoUnit:m',
|
||||
value: mapClickData.location?.elevation ? parseFloat(mapClickData.location.elevation) : null,
|
||||
},
|
||||
periods,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if API data is stale and should trigger a MapClick fallback
|
||||
* @param {string|Date} timestamp - ISO timestamp string or Date object from API data
|
||||
* @param {number} maxAgeMinutes - Maximum age in minutes before considering stale (default: 60)
|
||||
* @returns {Object} - { isStale: boolean, ageInMinutes: number }
|
||||
*/
|
||||
export const isDataStale = (timestamp, maxAgeMinutes = 60) => {
|
||||
// Handle both Date objects and timestamp strings
|
||||
const observationTime = timestamp instanceof Date ? timestamp : new Date(timestamp);
|
||||
const now = new Date();
|
||||
const ageInMinutes = (now - observationTime) / (1000 * 60);
|
||||
|
||||
return {
|
||||
isStale: ageInMinutes > maxAgeMinutes,
|
||||
ageInMinutes,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch MapClick data from the MapClick API
|
||||
* @param {number} latitude - Latitude coordinate
|
||||
* @param {number} longitude - Longitude coordinate
|
||||
* @param {Object} options - Optional parameters
|
||||
* @param {string} stationId - Station identifier (used for URL logging)
|
||||
* @param {Function} options.stillWaiting - Callback for loading status
|
||||
* @param {number} options.retryCount - Number of retries (default: 3)
|
||||
* @returns {Object|null} - MapClick data or null if failed
|
||||
*/
|
||||
export const getMapClickData = async (latitude, longitude, stationId, options = {}) => {
|
||||
const { stillWaiting, retryCount = 3 } = options;
|
||||
|
||||
// Round coordinates to 4 decimal places to match weather.gov API precision
|
||||
const lat = latitude.toFixed(4);
|
||||
const lon = longitude.toFixed(4);
|
||||
|
||||
// &unit=0&lg=english are default parameters for MapClick API
|
||||
const mapClickUrl = `https://forecast.weather.gov/MapClick.php?FcstType=json&lat=${lat}&lon=${lon}&station=${stationId}`;
|
||||
|
||||
try {
|
||||
const mapClickData = await safeJson(mapClickUrl, {
|
||||
retryCount,
|
||||
stillWaiting,
|
||||
});
|
||||
|
||||
if (mapClickData) {
|
||||
return mapClickData;
|
||||
}
|
||||
|
||||
if (debugFlag('verbose-failures')) {
|
||||
console.log(`MapClick: No data available for ${lat},${lon}`);
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error(`Unexpected error fetching MapClick data for ${lat},${lon}: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get current observation from MapClick API in weather.gov API format
|
||||
* @param {number} latitude - Latitude coordinate
|
||||
* @param {number} longitude - Longitude coordinate
|
||||
* @param {string} stationId - Station identifier (used for URL logging)
|
||||
* @param {Object} options - Optional parameters
|
||||
* @param {Function} options.stillWaiting - Callback for loading status
|
||||
* @param {number} options.retryCount - Number of retries (default: 3)
|
||||
* @returns {Object|null} - Current observation in API format or null if failed
|
||||
*/
|
||||
export const getMapClickCurrentObservation = async (latitude, longitude, stationId, options = {}) => {
|
||||
const { stillWaiting, retryCount = 3 } = options;
|
||||
|
||||
const mapClickData = await getMapClickData(latitude, longitude, stationId, { stillWaiting, retryCount });
|
||||
|
||||
if (!mapClickData?.currentobservation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Convert to API format
|
||||
return convertMapClickObservationsToApiFormat(mapClickData.currentobservation);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get forecast data from MapClick API in weather.gov API format
|
||||
* @param {number} latitude - Latitude coordinate
|
||||
* @param {number} longitude - Longitude coordinate
|
||||
* @param {string} stationId - Station identifier (used for URL logging)
|
||||
* @param {Object} options - Optional parameters
|
||||
* @param {Function} options.stillWaiting - Callback for loading status
|
||||
* @param {number} options.retryCount - Number of retries (default: 3)
|
||||
* @returns {Object|null} - Forecast data in API format or null if failed
|
||||
*/
|
||||
export const getMapClickForecast = async (latitude, longitude, stationId, options = {}) => {
|
||||
const { stillWaiting, retryCount = 3 } = options;
|
||||
|
||||
const mapClickData = await getMapClickData(latitude, longitude, stationId, { stillWaiting, retryCount });
|
||||
|
||||
if (!mapClickData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Convert to API format
|
||||
return convertMapClickForecastToApiFormat(mapClickData);
|
||||
};
|
||||
|
||||
/**
|
||||
* Enhanced observation fetcher with MapClick fallback
|
||||
* Centralized logic for checking data quality and falling back to MapClick when needed
|
||||
* @param {Object} observationData - Original API observation data
|
||||
* @param {Object} options - Configuration options
|
||||
* @param {Array} options.requiredFields - Array of field definitions with { name, check, required? }
|
||||
* @param {number} options.maxOptionalMissing - Max missing optional fields allowed (default: 0)
|
||||
* @param {string} options.stationId - Station identifier for looking up coordinates (e.g., 'KORD')
|
||||
* @param {Function} options.stillWaiting - Loading callback
|
||||
* @param {string} options.debugContext - Debug logging context name
|
||||
* @param {number} options.maxAgeMinutes - Max age before considering stale (default: 60)
|
||||
* @returns {Object} - { data, wasImproved, improvements, missingFields }
|
||||
*/
|
||||
export const enhanceObservationWithMapClick = async (observationData, options = {}) => {
|
||||
const {
|
||||
requiredFields = [],
|
||||
maxOptionalMissing = 0,
|
||||
stationId,
|
||||
stillWaiting,
|
||||
debugContext = 'mapclick',
|
||||
maxAgeMinutes = 80, // hourly observation plus 20 minute ingestion delay
|
||||
} = options;
|
||||
|
||||
// Helper function to return original data with consistent logging
|
||||
const returnOriginalData = (reason, missingRequired = [], missingOptional = [], isStale = false, ageInMinutes = 0) => {
|
||||
if (debugFlag(debugContext)) {
|
||||
const issues = [];
|
||||
if (isStale) issues.push(`API data is stale: ${ageInMinutes.toFixed(0)} minutes old`);
|
||||
if (missingRequired.length > 0) issues.push(`API data missing required: ${missingRequired.join(', ')}`);
|
||||
if (missingOptional.length > maxOptionalMissing) issues.push(`API data missing optional: ${missingOptional.join(', ')}`);
|
||||
|
||||
if (reason) {
|
||||
if (issues.length > 0) {
|
||||
console.log(`🚫 ${debugContext}: Station ${stationId} ${reason} (${issues.join(', ')})`);
|
||||
} else {
|
||||
console.log(`🚫 ${debugContext}: Station ${stationId} ${reason}`);
|
||||
}
|
||||
} else if (issues.length > 0) {
|
||||
console.log(`🚫 ${debugContext}: Station ${stationId} ${issues.join('; ')}`);
|
||||
}
|
||||
}
|
||||
return {
|
||||
data: observationData,
|
||||
wasImproved: false,
|
||||
improvements: [],
|
||||
missingFields: [...missingRequired, ...missingOptional],
|
||||
};
|
||||
};
|
||||
|
||||
if (!observationData) {
|
||||
return returnOriginalData('no original observation data');
|
||||
}
|
||||
|
||||
// Look up station coordinates from global StationInfo
|
||||
if (!stationId || typeof window === 'undefined' || !window.StationInfo) {
|
||||
return returnOriginalData('no station ID');
|
||||
}
|
||||
|
||||
const stationLookup = Object.values(window.StationInfo).find((s) => s.id === stationId);
|
||||
if (!stationLookup) {
|
||||
let reason = null;
|
||||
if (stationId.length === 4) { // MapClick only supports 4-letter station IDs, so other failures are "expected"
|
||||
reason = `station ${stationId} not found in StationInfo`;
|
||||
}
|
||||
return returnOriginalData(reason);
|
||||
}
|
||||
|
||||
// Check data staleness
|
||||
const observationTime = new Date(observationData.timestamp);
|
||||
const { isStale, ageInMinutes } = isDataStale(observationTime, maxAgeMinutes);
|
||||
|
||||
// Categorize fields by required/optional
|
||||
const requiredFieldDefs = requiredFields.filter((field) => field.required !== false);
|
||||
const optionalFieldDefs = requiredFields.filter((field) => field.required === false);
|
||||
|
||||
// Check current data quality
|
||||
const missingRequired = requiredFieldDefs.filter((field) => field.check(observationData)).map((field) => field.name);
|
||||
const missingOptional = optionalFieldDefs.filter((field) => field.check(observationData)).map((field) => field.name);
|
||||
const missingOptionalCount = missingOptional.length;
|
||||
|
||||
// Determine if we should try MapClick
|
||||
const shouldTryMapClick = isStale || missingRequired.length > 0 || missingOptionalCount > maxOptionalMissing;
|
||||
|
||||
if (!shouldTryMapClick) {
|
||||
return returnOriginalData(null, missingRequired, missingOptional, isStale, ageInMinutes);
|
||||
}
|
||||
|
||||
// Try MapClick API
|
||||
const mapClickData = await getMapClickCurrentObservation(stationLookup.lat, stationLookup.lon, stationId, {
|
||||
stillWaiting,
|
||||
retryCount: 1,
|
||||
});
|
||||
|
||||
if (!mapClickData) {
|
||||
return returnOriginalData('MapClick fetch failed', missingRequired, missingOptional, isStale, ageInMinutes);
|
||||
}
|
||||
|
||||
// Evaluate MapClick data quality
|
||||
const mapClickProps = mapClickData.features[0].properties;
|
||||
const mapClickTimestamp = new Date(mapClickProps.timestamp);
|
||||
const isFresher = mapClickTimestamp > observationTime;
|
||||
|
||||
const mapClickMissingRequired = requiredFieldDefs.filter((field) => field.check(mapClickProps)).map((field) => field.name);
|
||||
const mapClickMissingOptional = optionalFieldDefs.filter((field) => field.check(mapClickProps)).map((field) => field.name);
|
||||
const mapClickMissingOptionalCount = mapClickMissingOptional.length;
|
||||
|
||||
// Determine if MapClick data is better
|
||||
let hasBetterQuality = false;
|
||||
if (optionalFieldDefs.length > 0) {
|
||||
// For modules with optional fields (like currentweather)
|
||||
hasBetterQuality = (mapClickMissingRequired.length < missingRequired.length)
|
||||
|| (missingOptionalCount > maxOptionalMissing && mapClickMissingOptionalCount <= maxOptionalMissing);
|
||||
} else {
|
||||
// For modules with only required fields (like latestobservations, regionalforecast)
|
||||
hasBetterQuality = mapClickMissingRequired.length < missingRequired.length;
|
||||
}
|
||||
|
||||
// Only use MapClick if:
|
||||
// 1. It doesn't make required fields worse AND
|
||||
// 2. It's either fresher OR has better quality
|
||||
const doesNotWorsenRequired = mapClickMissingRequired.length <= missingRequired.length;
|
||||
const shouldUseMapClick = doesNotWorsenRequired && (isFresher || hasBetterQuality);
|
||||
if (!shouldUseMapClick) {
|
||||
// Build brief rejection reason only when debugging is enabled
|
||||
let rejectionReason = 'MapClick data rejected';
|
||||
if (debugFlag(debugContext)) {
|
||||
const rejectionDetails = [];
|
||||
|
||||
if (!doesNotWorsenRequired) {
|
||||
rejectionDetails.push(`has ${mapClickMissingRequired.length - missingRequired.length} missing fields`);
|
||||
if (mapClickMissingRequired.length > 0) {
|
||||
rejectionDetails.push(`required: ${mapClickMissingRequired.join(', ')}`);
|
||||
}
|
||||
} else {
|
||||
// MapClick doesn't worsen required fields, but wasn't good enough
|
||||
if (!hasBetterQuality) {
|
||||
if (optionalFieldDefs.length > 0 && mapClickMissingOptional.length > missingOptional.length) {
|
||||
rejectionDetails.push(`optional: ${mapClickMissingOptional.length} vs ${missingOptional.length}`);
|
||||
}
|
||||
}
|
||||
if (!isFresher) {
|
||||
const mapClickAgeInMinutes = Math.round((Date.now() - mapClickTimestamp) / (1000 * 60));
|
||||
rejectionDetails.push(`older: ${mapClickAgeInMinutes}min`);
|
||||
}
|
||||
}
|
||||
|
||||
if (rejectionDetails.length > 0) {
|
||||
rejectionReason += `: ${rejectionDetails.join('; ')}`;
|
||||
}
|
||||
}
|
||||
|
||||
return returnOriginalData(rejectionReason, missingRequired, missingOptional, isStale, ageInMinutes);
|
||||
}
|
||||
|
||||
// Build improvements list for logging
|
||||
const improvements = [];
|
||||
if (isFresher) {
|
||||
// NOTE: for the forecast version, we'd want to use the `updateTime` property instead of `timestamp`
|
||||
const mapClickAgeInMinutes = Math.round((Date.now() - mapClickTimestamp) / (1000 * 60));
|
||||
improvements.push(`${mapClickAgeInMinutes} minutes old vs. ${ageInMinutes.toFixed(0)} minutes old`);
|
||||
}
|
||||
|
||||
if (hasBetterQuality) {
|
||||
const nowPresentRequired = missingRequired.filter((fieldName) => {
|
||||
const field = requiredFieldDefs.find((f) => f.name === fieldName);
|
||||
return field && !field.check(mapClickProps);
|
||||
});
|
||||
const nowPresentOptional = missingOptional.filter((fieldName) => {
|
||||
const field = optionalFieldDefs.find((f) => f.name === fieldName);
|
||||
return field && !field.check(mapClickProps);
|
||||
});
|
||||
|
||||
if (nowPresentRequired.length > 0) {
|
||||
improvements.push(`provides missing required: ${nowPresentRequired.join(', ')}`);
|
||||
}
|
||||
if (nowPresentOptional.length > 0) {
|
||||
improvements.push(`provides missing optional: ${nowPresentOptional.join(', ')}`);
|
||||
}
|
||||
if (nowPresentRequired.length === 0 && nowPresentOptional.length === 0 && mapClickMissingRequired.length < missingRequired.length) {
|
||||
improvements.push('better data quality');
|
||||
}
|
||||
}
|
||||
|
||||
// Log the improvements
|
||||
if (debugFlag(debugContext)) {
|
||||
console.log(`🗺️ ${debugContext}: preferring MapClick data for station ${stationId} (${improvements.join('; ')})`);
|
||||
}
|
||||
|
||||
return {
|
||||
data: mapClickProps,
|
||||
wasImproved: true,
|
||||
improvements,
|
||||
missingFields: [...mapClickMissingRequired, ...mapClickMissingOptional],
|
||||
};
|
||||
};
|
||||
|
||||
export default {
|
||||
parseMapClickDate,
|
||||
convertMapClickObservationsToApiFormat,
|
||||
convertMapClickForecastToApiFormat,
|
||||
isDataStale,
|
||||
getMapClickData,
|
||||
getMapClickCurrentObservation,
|
||||
getMapClickForecast,
|
||||
enhanceObservationWithMapClick,
|
||||
};
|
||||
153
server/scripts/modules/utils/metar.mjs
Normal file
153
server/scripts/modules/utils/metar.mjs
Normal file
@@ -0,0 +1,153 @@
|
||||
// METAR parsing utilities using metar-taf-parser library
|
||||
import { parseMetar } from '../../vendor/auto/metar-taf-parser.mjs';
|
||||
|
||||
/**
|
||||
* Augment observation data by parsing METAR when API fields are missing
|
||||
* @param {Object} observation - The observation object from the API
|
||||
* @returns {Object} - Augmented observation with parsed METAR data filled in
|
||||
*/
|
||||
const augmentObservationWithMetar = (observation) => {
|
||||
if (!observation?.rawMessage) {
|
||||
return observation;
|
||||
}
|
||||
|
||||
const metar = { ...observation };
|
||||
|
||||
try {
|
||||
const metarData = parseMetar(observation.rawMessage);
|
||||
|
||||
if (observation.windSpeed?.value === null && metarData.wind?.speed !== undefined) {
|
||||
metar.windSpeed = {
|
||||
...observation.windSpeed,
|
||||
value: metarData.wind.speed * 1.852, // Convert knots to km/h (API uses km/h)
|
||||
qualityControl: 'M', // M for METAR-derived
|
||||
};
|
||||
}
|
||||
|
||||
if (observation.windDirection?.value === null && metarData.wind?.degrees !== undefined) {
|
||||
metar.windDirection = {
|
||||
...observation.windDirection,
|
||||
value: metarData.wind.degrees,
|
||||
qualityControl: 'M',
|
||||
};
|
||||
}
|
||||
|
||||
if (observation.windGust?.value === null && metarData.wind?.gust !== undefined) {
|
||||
metar.windGust = {
|
||||
...observation.windGust,
|
||||
value: metarData.wind.gust * 1.852, // Convert knots to km/h
|
||||
qualityControl: 'M',
|
||||
};
|
||||
}
|
||||
|
||||
if (observation.temperature?.value === null && metarData.temperature !== undefined) {
|
||||
metar.temperature = {
|
||||
...observation.temperature,
|
||||
value: metarData.temperature,
|
||||
qualityControl: 'M',
|
||||
};
|
||||
}
|
||||
|
||||
if (observation.dewpoint?.value === null && metarData.dewPoint !== undefined) {
|
||||
metar.dewpoint = {
|
||||
...observation.dewpoint,
|
||||
value: metarData.dewPoint,
|
||||
qualityControl: 'M',
|
||||
};
|
||||
}
|
||||
|
||||
if (observation.barometricPressure?.value === null && metarData.altimeter !== undefined) {
|
||||
// Convert inHg to Pascals
|
||||
const pascals = Math.round(metarData.altimeter * 3386.39);
|
||||
metar.barometricPressure = {
|
||||
...observation.barometricPressure,
|
||||
value: pascals,
|
||||
qualityControl: 'M',
|
||||
};
|
||||
}
|
||||
|
||||
// Calculate relative humidity if missing from API but we have temp and dewpoint
|
||||
if (observation.relativeHumidity?.value === null && metar.temperature?.value !== null && metar.dewpoint?.value !== null) {
|
||||
const humidity = calculateRelativeHumidity(metar.temperature.value, metar.dewpoint.value);
|
||||
metar.relativeHumidity = {
|
||||
...observation.relativeHumidity,
|
||||
value: humidity,
|
||||
qualityControl: 'M', // M for METAR-derived
|
||||
};
|
||||
}
|
||||
|
||||
if (observation.visibility?.value === null && metarData.visibility?.value !== undefined) {
|
||||
let visibilityKm;
|
||||
if (metarData.visibility.unit === 'SM') {
|
||||
// Convert statute miles to kilometers
|
||||
visibilityKm = metarData.visibility.value * 1.609344;
|
||||
} else if (metarData.visibility.unit === 'm') {
|
||||
// Convert meters to kilometers
|
||||
visibilityKm = metarData.visibility.value / 1000;
|
||||
} else {
|
||||
// Assume it's already in the right unit
|
||||
visibilityKm = metarData.visibility.value;
|
||||
}
|
||||
|
||||
metar.visibility = {
|
||||
...observation.visibility,
|
||||
value: Math.round(visibilityKm * 10) / 10, // Round to 1 decimal place
|
||||
qualityControl: 'M',
|
||||
};
|
||||
}
|
||||
|
||||
if (observation.cloudLayers?.[0]?.base?.value === null && metarData.clouds?.length > 0) {
|
||||
// Find the lowest broken (BKN) or overcast (OVC) layer for ceiling
|
||||
const ceilingLayer = metarData.clouds
|
||||
.filter((cloud) => cloud.type === 'BKN' || cloud.type === 'OVC')
|
||||
.sort((a, b) => a.height - b.height)[0];
|
||||
|
||||
if (ceilingLayer) {
|
||||
// Convert feet to meters
|
||||
const heightMeters = Math.round(ceilingLayer.height * 0.3048);
|
||||
|
||||
// Create cloud layer structure if it doesn't exist
|
||||
if (!metar.cloudLayers || !metar.cloudLayers[0]) {
|
||||
metar.cloudLayers = [{
|
||||
base: {
|
||||
value: heightMeters,
|
||||
qualityControl: 'M',
|
||||
},
|
||||
}];
|
||||
} else {
|
||||
metar.cloudLayers[0].base = {
|
||||
...observation.cloudLayers[0].base,
|
||||
value: heightMeters,
|
||||
qualityControl: 'M',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// If METAR parsing fails, just return the original observation
|
||||
console.warn(`Failed to parse METAR: ${error.message}`);
|
||||
return observation;
|
||||
}
|
||||
|
||||
return metar;
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculate relative humidity from temperature and dewpoint
|
||||
* @param {number} temperature - Temperature in Celsius
|
||||
* @param {number} dewpoint - Dewpoint in Celsius
|
||||
* @returns {number} Relative humidity as a percentage (0-100)
|
||||
*/
|
||||
const calculateRelativeHumidity = (temperature, dewpoint) => {
|
||||
// Using the Magnus formula approximation
|
||||
const a = 17.625;
|
||||
const b = 243.04;
|
||||
|
||||
const alpha = Math.log(Math.exp((a * dewpoint) / (b + dewpoint)) / Math.exp((a * temperature) / (b + temperature)));
|
||||
const relativeHumidity = Math.exp(alpha) * 100;
|
||||
|
||||
// Clamp between 0 and 100 and round to nearest integer
|
||||
return Math.round(Math.max(0, Math.min(100, relativeHumidity)));
|
||||
};
|
||||
|
||||
export default augmentObservationWithMetar;
|
||||
@@ -7,12 +7,19 @@ const noSleep = (enable = false) => {
|
||||
// get a nosleep controller
|
||||
if (!noSleep.controller) noSleep.controller = new NoSleep();
|
||||
// don't call anything if the states match
|
||||
if (wakeLock === enable) return false;
|
||||
if (wakeLock === enable) return Promise.resolve(false);
|
||||
// store the value
|
||||
wakeLock = enable;
|
||||
// call the function
|
||||
if (enable) return noSleep.controller.enable();
|
||||
return noSleep.controller.disable();
|
||||
if (enable) {
|
||||
return noSleep.controller.enable().catch((error) => {
|
||||
// Handle wake lock request failures gracefully
|
||||
console.warn('Wake lock request failed:', error.message);
|
||||
wakeLock = false;
|
||||
return false;
|
||||
});
|
||||
}
|
||||
return Promise.resolve(noSleep.controller.disable());
|
||||
};
|
||||
|
||||
export default noSleep;
|
||||
|
||||
66
server/scripts/modules/utils/scroll-timing.mjs
Normal file
66
server/scripts/modules/utils/scroll-timing.mjs
Normal file
@@ -0,0 +1,66 @@
|
||||
// Utility functions for dynamic scroll timing calculations
|
||||
|
||||
/**
|
||||
* Calculate dynamic scroll timing based on actual content dimensions
|
||||
* @param {HTMLElement} list - The scrollable content element
|
||||
* @param {HTMLElement} container - The container element (for measuring display height)
|
||||
* @param {Object} options - Timing configuration options
|
||||
* @param {number} options.scrollSpeed - Pixels per second scroll speed (default: 50)
|
||||
* @param {number} options.initialDelay - Seconds before scrolling starts (default: 3.0)
|
||||
* @param {number} options.finalPause - Seconds after scrolling ends (default: 3.0)
|
||||
* @param {number} options.staticDisplay - Seconds for static display when no scrolling needed (default: same as initialDelay + finalPause)
|
||||
* @param {number} options.baseDelay - Milliseconds per timing count (default: 40)
|
||||
* @returns {Object} Timing configuration object with delay array, scrollTiming, and baseDelay
|
||||
*/
|
||||
const calculateScrollTiming = (list, container, options = {}) => {
|
||||
const {
|
||||
scrollSpeed = 50,
|
||||
initialDelay = 3.0,
|
||||
finalPause = 3.0,
|
||||
staticDisplay = initialDelay + finalPause,
|
||||
baseDelay = 40,
|
||||
} = options;
|
||||
|
||||
// timing conversion helper
|
||||
const secondsToTimingCounts = (seconds) => Math.ceil(seconds * 1000 / baseDelay);
|
||||
|
||||
// calculate actual scroll distance needed
|
||||
const displayHeight = container.offsetHeight;
|
||||
const contentHeight = list.scrollHeight;
|
||||
const scrollableHeight = Math.max(0, contentHeight - displayHeight);
|
||||
|
||||
// calculate scroll time based on actual distance and speed
|
||||
const scrollTimeSeconds = scrollableHeight > 0 ? scrollableHeight / scrollSpeed : 0;
|
||||
|
||||
// convert seconds to timing counts
|
||||
const initialCounts = secondsToTimingCounts(initialDelay);
|
||||
const scrollCounts = secondsToTimingCounts(scrollTimeSeconds);
|
||||
const finalCounts = secondsToTimingCounts(finalPause);
|
||||
const staticCounts = secondsToTimingCounts(staticDisplay);
|
||||
|
||||
// calculate pixels per count based on our actual scroll distance and time
|
||||
// This ensures the scroll animation matches our timing perfectly
|
||||
const pixelsPerCount = scrollCounts > 0 ? scrollableHeight / scrollCounts : 0;
|
||||
|
||||
// Build timing array - simple approach
|
||||
const delay = [];
|
||||
|
||||
if (scrollableHeight === 0) {
|
||||
// No scrolling needed - just show static content
|
||||
delay.push(staticCounts);
|
||||
} else {
|
||||
// Initial delay + scroll time + final pause
|
||||
delay.push(initialCounts + scrollCounts + finalCounts);
|
||||
}
|
||||
|
||||
return {
|
||||
baseDelay,
|
||||
delay,
|
||||
scrollTiming: {
|
||||
initialCounts,
|
||||
pixelsPerCount,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default calculateScrollTiming;
|
||||
@@ -9,6 +9,7 @@ const DEFAULTS = {
|
||||
defaultValue: undefined,
|
||||
changeAction: () => { },
|
||||
sticky: true,
|
||||
stickyRead: false,
|
||||
values: [],
|
||||
visible: true,
|
||||
placeholder: '',
|
||||
@@ -29,6 +30,7 @@ class Setting {
|
||||
this.myValue = this.defaultValue;
|
||||
this.type = options?.type;
|
||||
this.sticky = options.sticky;
|
||||
this.stickyRead = options.stickyRead;
|
||||
this.values = options.values;
|
||||
this.visible = options.visible;
|
||||
this.changeAction = options.changeAction;
|
||||
@@ -56,7 +58,7 @@ class Setting {
|
||||
|
||||
// get existing value if present
|
||||
const storedValue = urlState ?? this.getFromLocalStorage();
|
||||
if ((this.sticky || urlValue !== undefined) && storedValue !== null) {
|
||||
if ((this.sticky || this.stickyRead || urlValue !== undefined) && storedValue !== null) {
|
||||
this.myValue = storedValue;
|
||||
}
|
||||
|
||||
@@ -199,6 +201,20 @@ class Setting {
|
||||
localStorage?.setItem(SETTINGS_KEY, JSON.stringify(allSettings));
|
||||
}
|
||||
|
||||
// Conditional storage method for stickyRead settings
|
||||
conditionalStoreToLocalStorage(value, shouldStore) {
|
||||
if (!this.stickyRead) return;
|
||||
const allSettingsString = localStorage?.getItem(SETTINGS_KEY) ?? '{}';
|
||||
const allSettings = JSON.parse(allSettingsString);
|
||||
|
||||
if (shouldStore) {
|
||||
allSettings[this.shortName] = value;
|
||||
} else {
|
||||
delete allSettings[this.shortName];
|
||||
}
|
||||
localStorage?.setItem(SETTINGS_KEY, JSON.stringify(allSettings));
|
||||
}
|
||||
|
||||
getFromLocalStorage() {
|
||||
const allSettings = localStorage?.getItem(SETTINGS_KEY);
|
||||
try {
|
||||
@@ -216,8 +232,9 @@ class Setting {
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.warn(`Failed to parse settings from localStorage: ${error} - allSettings=${allSettings}`);
|
||||
localStorage?.removeItem(SETTINGS_KEY);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
58
server/scripts/modules/utils/url-rewrite.mjs
Normal file
58
server/scripts/modules/utils/url-rewrite.mjs
Normal file
@@ -0,0 +1,58 @@
|
||||
// rewrite URLs to use local proxy server
|
||||
const rewriteUrl = (_url) => {
|
||||
if (!_url) {
|
||||
throw new Error(`rewriteUrl called with invalid argument: '${_url}' (${typeof _url})`);
|
||||
}
|
||||
|
||||
// Handle relative URLs early: return them as-is since they don't need rewriting
|
||||
if (typeof _url === 'string' && !_url.startsWith('http')) {
|
||||
return _url;
|
||||
}
|
||||
|
||||
if (typeof _url !== 'string' && !(_url instanceof URL)) {
|
||||
throw new Error(`rewriteUrl expects a URL string or URL object, received: ${typeof _url}`);
|
||||
}
|
||||
|
||||
// Convert to URL object (for URL objects, creates a copy to avoid mutating the original)
|
||||
const url = new URL(_url);
|
||||
|
||||
if (!window.WS4KP_SERVER_AVAILABLE) {
|
||||
// If running standalone in the browser, simply return a URL object without rewriting
|
||||
return url;
|
||||
}
|
||||
|
||||
// Rewrite the origin to use local proxy server
|
||||
if (url.origin === 'https://api.weather.gov') {
|
||||
url.protocol = window.location.protocol;
|
||||
url.host = window.location.host;
|
||||
url.pathname = `/api${url.pathname}`;
|
||||
} else if (url.origin === 'https://forecast.weather.gov') {
|
||||
url.protocol = window.location.protocol;
|
||||
url.host = window.location.host;
|
||||
url.pathname = `/forecast${url.pathname}`;
|
||||
} else if (url.origin === 'https://www.spc.noaa.gov') {
|
||||
url.protocol = window.location.protocol;
|
||||
url.host = window.location.host;
|
||||
url.pathname = `/spc${url.pathname}`;
|
||||
} else if (url.origin === 'https://radar.weather.gov') {
|
||||
url.protocol = window.location.protocol;
|
||||
url.host = window.location.host;
|
||||
url.pathname = `/radar${url.pathname}`;
|
||||
} else if (url.origin === 'https://mesonet.agron.iastate.edu') {
|
||||
url.protocol = window.location.protocol;
|
||||
url.host = window.location.host;
|
||||
url.pathname = `/mesonet${url.pathname}`;
|
||||
} else if (typeof OVERRIDES !== 'undefined' && OVERRIDES?.RADAR_HOST && url.origin === `https://${OVERRIDES.RADAR_HOST}`) {
|
||||
// Handle override radar host
|
||||
url.protocol = window.location.protocol;
|
||||
url.host = window.location.host;
|
||||
url.pathname = `/mesonet${url.pathname}`;
|
||||
}
|
||||
|
||||
return url;
|
||||
};
|
||||
|
||||
export {
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
rewriteUrl,
|
||||
};
|
||||
@@ -1,13 +1,15 @@
|
||||
import { json } from './fetch.mjs';
|
||||
import { safeJson } from './fetch.mjs';
|
||||
import { debugFlag } from './debug.mjs';
|
||||
|
||||
const getPoint = async (lat, lon) => {
|
||||
try {
|
||||
return await json(`https://api.weather.gov/points/${lat.toFixed(4)},${lon.toFixed(4)}`);
|
||||
} catch (error) {
|
||||
console.log(`Unable to get point ${lat}, ${lon}`);
|
||||
console.error(error);
|
||||
const point = await safeJson(`https://api.weather.gov/points/${lat.toFixed(4)},${lon.toFixed(4)}`);
|
||||
if (!point) {
|
||||
if (debugFlag('verbose-failures')) {
|
||||
console.warn(`Unable to get points for ${lat},${lon}`);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return point;
|
||||
};
|
||||
|
||||
export {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import { parseQueryString } from './share.mjs';
|
||||
import settings from './settings.mjs';
|
||||
import { elemForEach } from './utils/elem.mjs';
|
||||
import { debugFlag } from './utils/debug.mjs';
|
||||
|
||||
class WeatherDisplay {
|
||||
constructor(navId, elemId, name, defaultEnabled) {
|
||||
@@ -28,7 +29,7 @@ class WeatherDisplay {
|
||||
// default navigation timing
|
||||
this.timing = {
|
||||
totalScreens: 1,
|
||||
baseDelay: 9000, // 5 seconds
|
||||
baseDelay: 9000, // 9 seconds
|
||||
delay: 1, // 1*1second = 1 second total display time
|
||||
};
|
||||
this.navBaseCount = 0;
|
||||
@@ -249,6 +250,23 @@ class WeatherDisplay {
|
||||
// increment the base count
|
||||
this.navBaseCount += 1;
|
||||
|
||||
if (debugFlag('weatherdisplay')) {
|
||||
const now = Date.now();
|
||||
if (!this.timingDebug) {
|
||||
this.timingDebug = { startTime: now, lastTransition: now, baseCountLog: [] };
|
||||
if (this.navBaseCount !== 1) {
|
||||
console.log(`⏱️ [${this.constructor.name}] Starting at baseCount ${this.navBaseCount}`);
|
||||
}
|
||||
}
|
||||
const elapsed = now - this.timingDebug.lastTransition;
|
||||
this.timingDebug.baseCountLog.push({
|
||||
baseCount: this.navBaseCount,
|
||||
timestamp: now,
|
||||
elapsedMs: elapsed,
|
||||
screenIndex: this.screenIndex,
|
||||
});
|
||||
}
|
||||
|
||||
// call base count change if available for this function
|
||||
if (this.baseCountChange) this.baseCountChange(this.navBaseCount);
|
||||
|
||||
@@ -270,6 +288,30 @@ class WeatherDisplay {
|
||||
// test for no change and exit early
|
||||
if (nextScreenIndex === this.screenIndex) return;
|
||||
|
||||
if (debugFlag('weatherdisplay') && this.timingDebug) {
|
||||
const now = Date.now();
|
||||
const elapsed = now - this.timingDebug.lastTransition;
|
||||
this.timingDebug.lastTransition = now;
|
||||
console.log(`⏱️ [${this.constructor.name}] Screen Transition: ${this.screenIndex} → ${nextScreenIndex === -1 ? 0 : nextScreenIndex}, baseCount=${this.navBaseCount}, duration=${elapsed}ms`);
|
||||
if (this.screenIndex !== -1 && this.timing && this.timing.delay !== undefined) { // Skip expected duration calculation for the first transition (screenIndex -1 → 0)
|
||||
let expectedMs;
|
||||
if (Array.isArray(this.timing.delay)) { // Array-based timing (different delay per screen/period)
|
||||
// Find the timing index for the screen we just LEFT (the one that just finished displaying)
|
||||
// For transition "X → Y", we want the timing for screen X (which is this.screenIndex before it gets updated)
|
||||
const timingIndex = this.screenIndex;
|
||||
if (timingIndex >= 0 && timingIndex < this.timing.delay.length) { // Handle both simple number delays and object delays with time property (radar)
|
||||
const delayValue = typeof this.timing.delay[timingIndex] === 'object' ? this.timing.delay[timingIndex].time : this.timing.delay[timingIndex];
|
||||
expectedMs = this.timing.baseDelay * delayValue * (settings?.speed?.value || 1);
|
||||
}
|
||||
} else if (typeof this.timing.delay === 'number') { // Simple number-based timing (same delay for all screens)
|
||||
expectedMs = this.timing.baseDelay * this.timing.delay * (settings?.speed?.value || 1);
|
||||
}
|
||||
if (expectedMs !== undefined) {
|
||||
console.log(`⏱️ [${this.constructor.name}] Expected duration: ${expectedMs}ms, Actual: ${elapsed}ms, Diff: ${elapsed - expectedMs}ms`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// test for -1 (no screen displayed yet)
|
||||
this.screenIndex = nextScreenIndex === -1 ? 0 : nextScreenIndex;
|
||||
|
||||
@@ -369,10 +411,29 @@ class WeatherDisplay {
|
||||
|
||||
// start and stop base counter
|
||||
startNavCount() {
|
||||
if (!this.navInterval) this.navInterval = setInterval(() => this.navBaseTime(), this.timing.baseDelay * settings.speed.value);
|
||||
if (!this.navInterval) {
|
||||
if (debugFlag('weatherdisplay')) {
|
||||
console.log(`⏱️ [${this.constructor.name}] Starting navigation:`, {
|
||||
baseDelay: this.timing.baseDelay,
|
||||
intervalMs: this.timing.baseDelay * (settings?.speed?.value || 1),
|
||||
totalScreens: this.timing.totalScreens,
|
||||
delayArray: this.timing.delay,
|
||||
fullDelayArray: this.timing.fullDelay,
|
||||
screenIndexes: this.timing.screenIndexes,
|
||||
});
|
||||
}
|
||||
this.navInterval = setInterval(() => this.navBaseTime(), this.timing.baseDelay * settings.speed.value);
|
||||
}
|
||||
}
|
||||
|
||||
resetNavBaseCount() {
|
||||
if (debugFlag('weatherdisplay') && this.timingDebug && this.timingDebug.baseCountLog.length > 1) {
|
||||
const totalDuration = this.timingDebug.baseCountLog[this.timingDebug.baseCountLog.length - 1].timestamp - this.timingDebug.baseCountLog[0].timestamp;
|
||||
const avgInterval = totalDuration / (this.timingDebug.baseCountLog.length - 1);
|
||||
console.log(`⏱️ [${this.constructor.name}] Total duration: ${totalDuration}ms, Avg base interval: ${avgInterval.toFixed(1)}ms, Base count range: ${this.timingDebug.baseCountLog[0].baseCount}-${this.timingDebug.baseCountLog[this.timingDebug.baseCountLog.length - 1].baseCount}`);
|
||||
this.timingDebug = null;
|
||||
}
|
||||
|
||||
this.navBaseCount = 0;
|
||||
this.screenIndex = -1;
|
||||
// reset the timing so we don't short-change the first screen
|
||||
@@ -446,7 +507,7 @@ class WeatherDisplay {
|
||||
setAutoReload() {
|
||||
// refresh time can be forced by the user (for hazards)
|
||||
const refreshTime = this.refreshTime ?? settings.refreshTime.value;
|
||||
this.autoRefreshHandle = this.autoRefreshHandle ?? setInterval(() => this.getData(false, true), refreshTime);
|
||||
this.autoRefreshHandle = this.autoRefreshHandle ?? setInterval(() => this.getData(this.weatherParameters, true), refreshTime);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
398
server/scripts/vendor/auto/locale/en.js
vendored
Normal file
398
server/scripts/vendor/auto/locale/en.js
vendored
Normal file
@@ -0,0 +1,398 @@
|
||||
var en = {
|
||||
CloudQuantity: {
|
||||
BKN: "broken",
|
||||
FEW: "few",
|
||||
NSC: "no significant clouds.",
|
||||
OVC: "overcast",
|
||||
SCT: "scattered",
|
||||
SKC: "sky clear",
|
||||
},
|
||||
CloudType: {
|
||||
AC: "Altocumulus",
|
||||
AS: "Altostratus",
|
||||
CB: "Cumulonimbus",
|
||||
CC: "CirroCumulus",
|
||||
CI: "Cirrus",
|
||||
CS: "Cirrostratus",
|
||||
CU: "Cumulus",
|
||||
NS: "Nimbostratus",
|
||||
SC: "Stratocumulus",
|
||||
ST: "Stratus",
|
||||
TCU: "Towering cumulus",
|
||||
},
|
||||
Converter: {
|
||||
D: "decreasing",
|
||||
E: "East",
|
||||
ENE: "East North East",
|
||||
ESE: "East South East",
|
||||
N: "North",
|
||||
NE: "North East",
|
||||
NNE: "North North East",
|
||||
NNW: "North North West",
|
||||
NSC: "no significant change",
|
||||
NW: "North West",
|
||||
S: "South",
|
||||
SE: "South East",
|
||||
SSE: "South South East",
|
||||
SSW: "South South West",
|
||||
SW: "South West",
|
||||
U: "up rising",
|
||||
VRB: "Variable",
|
||||
W: "West",
|
||||
WNW: "West North West",
|
||||
WSW: "West South West",
|
||||
},
|
||||
DepositBrakingCapacity: {
|
||||
GOOD: "good",
|
||||
MEDIUM: "medium",
|
||||
MEDIUM_GOOD: "medium/good",
|
||||
MEDIUM_POOR: "poor/medium",
|
||||
NOT_REPORTED: "not reported",
|
||||
POOR: "poor",
|
||||
UNRELIABLE: "figures unreliable",
|
||||
},
|
||||
DepositCoverage: {
|
||||
FROM_11_TO_25: "from 11% to 25%",
|
||||
FROM_26_TO_50: "from 26% to 50%",
|
||||
FROM_51_TO_100: "from 51% to 100%",
|
||||
LESS_10: "less than 10%",
|
||||
NOT_REPORTED: "not reported",
|
||||
},
|
||||
DepositThickness: {
|
||||
CLOSED: "closed",
|
||||
LESS_1_MM: "less than 1 mm",
|
||||
NOT_REPORTED: "not reported",
|
||||
THICKNESS_10: "10 cm",
|
||||
THICKNESS_15: "15 cm",
|
||||
THICKNESS_20: "20 cm",
|
||||
THICKNESS_25: "25 cm",
|
||||
THICKNESS_30: "30 cm",
|
||||
THICKNESS_35: "35 cm",
|
||||
THICKNESS_40: "40 cm or more",
|
||||
},
|
||||
DepositType: {
|
||||
CLEAR_DRY: "clear and dry",
|
||||
COMPACTED_SNOW: "compacted or rolled snow",
|
||||
DAMP: "damp",
|
||||
DRY_SNOW: "dry snow",
|
||||
FROZEN_RIDGES: "frozen ruts or ridges",
|
||||
ICE: "ice",
|
||||
NOT_REPORTED: "not reported",
|
||||
RIME_FROST_COVERED: "rime or frost covered",
|
||||
SLUSH: "slush",
|
||||
WET_SNOW: "wet snow",
|
||||
WET_WATER_PATCHES: "wet or water patches",
|
||||
},
|
||||
Descriptive: {
|
||||
BC: "patches",
|
||||
BL: "blowing",
|
||||
DR: "low drifting",
|
||||
FZ: "freezing",
|
||||
MI: "shallow",
|
||||
PR: "partial",
|
||||
SH: "showers of",
|
||||
TS: "thunderstorm",
|
||||
},
|
||||
Error: {
|
||||
prefix: "An error occured. Error code n°",
|
||||
},
|
||||
ErrorCode: {
|
||||
AirportNotFound: "The airport was not found for this message.",
|
||||
InvalidMessage: "The entered message is invalid.",
|
||||
},
|
||||
Indicator: {
|
||||
M: "less than",
|
||||
P: "greater than",
|
||||
},
|
||||
"intensity-plus": "Heavy",
|
||||
Intensity: {
|
||||
"-": "Light",
|
||||
VC: "In the vicinity",
|
||||
},
|
||||
MetarFacade: {
|
||||
InvalidIcao: "Icao code is invalid.",
|
||||
},
|
||||
Phenomenon: {
|
||||
BR: "mist",
|
||||
DS: "duststorm",
|
||||
DU: "widespread dust",
|
||||
DZ: "drizzle",
|
||||
FC: "funnel cloud",
|
||||
FG: "fog",
|
||||
FU: "smoke",
|
||||
GR: "hail",
|
||||
GS: "small hail and/or snow pellets",
|
||||
HZ: "haze",
|
||||
IC: "ice crystals",
|
||||
PL: "ice pellets",
|
||||
PO: "dust or sand whirls",
|
||||
PY: "spray",
|
||||
RA: "rain",
|
||||
SA: "sand",
|
||||
SG: "snow grains",
|
||||
SN: "snow",
|
||||
SQ: "squall",
|
||||
SS: "sandstorm",
|
||||
TS: "thunderstorm",
|
||||
UP: "unknown precipitation",
|
||||
VA: "volcanic ash",
|
||||
},
|
||||
Remark: {
|
||||
ALQDS: "all quadrants",
|
||||
AO1: "automated stations without a precipitation discriminator",
|
||||
AO2: "automated station with a precipitation discriminator",
|
||||
BASED: "based",
|
||||
Barometer: [
|
||||
"Increase, then decrease",
|
||||
"Increase, then steady, or increase then Increase more slowly",
|
||||
"steady or unsteady increase",
|
||||
"Decrease or steady, then increase; or increase then increase more rapidly",
|
||||
"Steady",
|
||||
"Decrease, then increase",
|
||||
"Decrease then steady; or decrease then decrease more slowly",
|
||||
"Steady or unsteady decrease",
|
||||
"Steady or increase, then decrease; or decrease then decrease more rapidly",
|
||||
],
|
||||
Ceiling: {
|
||||
Height: "ceiling varying between {0} and {1} feet",
|
||||
Second: {
|
||||
Location: "ceiling of {0} feet mesured by a second sensor located at {1}",
|
||||
},
|
||||
},
|
||||
DSNT: "distant",
|
||||
FCST: "forecast",
|
||||
FUNNELCLOUD: "funnel cloud",
|
||||
HVY: "heavy",
|
||||
Hail: {
|
||||
"0": "largest hailstones with a diameter of {0} inches",
|
||||
LesserThan: "largest hailstones with a diameter less than {0} inches",
|
||||
},
|
||||
Hourly: {
|
||||
Maximum: {
|
||||
Minimum: {
|
||||
Temperature: "24-hour maximum temperature of {0}°C and 24-hour minimum temperature of {1}°C",
|
||||
},
|
||||
Temperature: "6-hourly maximum temperature of {0}°C",
|
||||
},
|
||||
Minimum: {
|
||||
Temperature: "6-hourly minimum temperature of {0}°C",
|
||||
},
|
||||
Temperature: {
|
||||
"0": "hourly temperature of {0}°C",
|
||||
Dew: {
|
||||
Point: "hourly temperature of {0}°C and dew point of {1}°C",
|
||||
},
|
||||
},
|
||||
},
|
||||
Ice: {
|
||||
Accretion: {
|
||||
Amount: "{0}/100 of an inch of ice accretion in the past {1} hour(s)",
|
||||
},
|
||||
},
|
||||
LGT: "light",
|
||||
LTG: "lightning",
|
||||
MOD: "moderate",
|
||||
NXT: "next",
|
||||
ON: "on",
|
||||
Obscuration: "{0} layer at {1} feet composed of {2}",
|
||||
PRESFR: "pressure falling rapidly",
|
||||
PRESRR: "pressure rising rapidly",
|
||||
PeakWind: "peak wind of {1} knots from {0} degrees at {2}:{3}",
|
||||
Precipitation: {
|
||||
Amount: {
|
||||
"24": "{0} inches of precipitation fell in the last 24 hours",
|
||||
"3": {
|
||||
"6": "{1} inches of precipitation fell in the last {0} hours",
|
||||
},
|
||||
Hourly: "{0}/100 of an inch of precipitation fell in the last hour",
|
||||
},
|
||||
Beg: {
|
||||
"0": "{0} {1} beginning at {2}:{3}",
|
||||
End: "{0} {1} beginning at {2}:{3} ending at {4}:{5}",
|
||||
},
|
||||
End: "{0} {1} ending at {2}:{3}",
|
||||
},
|
||||
Pressure: {
|
||||
Tendency: "of {0} hectopascals in the past 3 hours",
|
||||
},
|
||||
SLPNO: "sea level pressure not available",
|
||||
Sea: {
|
||||
Level: {
|
||||
Pressure: "sea level pressure of {0} HPa",
|
||||
},
|
||||
},
|
||||
Second: {
|
||||
Location: {
|
||||
Visibility: "visibility of {0} SM mesured by a second sensor located at {1}",
|
||||
},
|
||||
},
|
||||
Sector: {
|
||||
Visibility: "visibility of {1} SM in the {0} direction",
|
||||
},
|
||||
Snow: {
|
||||
Depth: "snow depth of {0} inches",
|
||||
Increasing: {
|
||||
Rapidly: "snow depth increase of {0} inches in the past hour with a total depth on the ground of {1} inches",
|
||||
},
|
||||
Pellets: "{0} snow pellets",
|
||||
},
|
||||
Sunshine: {
|
||||
Duration: "{0} minutes of sunshine",
|
||||
},
|
||||
Surface: {
|
||||
Visibility: "surface visibility of {0} statute miles",
|
||||
},
|
||||
TORNADO: "tornado",
|
||||
Thunderstorm: {
|
||||
Location: {
|
||||
"0": "thunderstorm {0} of the station",
|
||||
Moving: "thunderstorm {0} of the station moving towards {1}",
|
||||
},
|
||||
},
|
||||
Tornadic: {
|
||||
Activity: {
|
||||
BegEnd: "{0} beginning at {1}:{2} ending at {3}:{4} {5} SM {6} of the station",
|
||||
Beginning: "{0} beginning at {1}:{2} {3} SM {4} of the station",
|
||||
Ending: "{0} ending at {1}:{2} {3} SM {4} of the station",
|
||||
},
|
||||
},
|
||||
Tower: {
|
||||
Visibility: "control tower visibility of {0} statute miles",
|
||||
},
|
||||
VIRGA: "virga",
|
||||
Variable: {
|
||||
Prevailing: {
|
||||
Visibility: "variable prevailing visibility between {0} and {1} SM",
|
||||
},
|
||||
Sky: {
|
||||
Condition: {
|
||||
"0": "cloud layer varying between {0} and {1}",
|
||||
Height: "cloud layer at {0} feet varying between {1} and {2}",
|
||||
},
|
||||
},
|
||||
},
|
||||
Virga: {
|
||||
Direction: "virga {0} from the station",
|
||||
},
|
||||
WATERSPOUT: "waterspout",
|
||||
Water: {
|
||||
Equivalent: {
|
||||
Snow: {
|
||||
Ground: "water equivalent of {0} inches of snow",
|
||||
},
|
||||
},
|
||||
},
|
||||
WindShift: {
|
||||
"0": "wind shift at {0}:{1}",
|
||||
FROPA: "wind shift accompanied by frontal passage at {0}:{1}",
|
||||
},
|
||||
},
|
||||
TimeIndicator: {
|
||||
AT: "at",
|
||||
FM: "From",
|
||||
TL: "until",
|
||||
},
|
||||
ToString: {
|
||||
airport: "airport",
|
||||
altimeter: "altimeter (hPa)",
|
||||
amendment: "amendment",
|
||||
auto: "auto",
|
||||
cavok: "cavok",
|
||||
clouds: "clouds",
|
||||
day: {
|
||||
hour: "hour of the day",
|
||||
month: "day of the month",
|
||||
},
|
||||
deposit: {
|
||||
braking: "braking capacity",
|
||||
coverage: "coverage",
|
||||
thickness: "thickness",
|
||||
type: "type of deposit",
|
||||
},
|
||||
descriptive: "descriptive",
|
||||
dew: {
|
||||
point: "dew point",
|
||||
},
|
||||
end: {
|
||||
day: {
|
||||
month: "end day of the month",
|
||||
},
|
||||
hour: {
|
||||
day: "end hour of the day",
|
||||
},
|
||||
},
|
||||
height: {
|
||||
feet: "height (ft)",
|
||||
meter: "height (m)",
|
||||
},
|
||||
indicator: "indicator",
|
||||
intensity: "intensity",
|
||||
message: "original message",
|
||||
name: "name",
|
||||
nosig: "nosig",
|
||||
phenomenons: "phenomenons",
|
||||
probability: "probability",
|
||||
quantity: "quantity",
|
||||
remark: "remarks",
|
||||
report: {
|
||||
time: "time of report",
|
||||
},
|
||||
runway: {
|
||||
info: "runways information",
|
||||
},
|
||||
start: {
|
||||
day: {
|
||||
month: "starting day of the month",
|
||||
},
|
||||
hour: {
|
||||
day: "starting hour of the day",
|
||||
},
|
||||
minute: "starting minute",
|
||||
},
|
||||
temperature: {
|
||||
"0": "temperature (°C)",
|
||||
max: "maximum temperature (°C)",
|
||||
min: "minimum temperature (°C)",
|
||||
},
|
||||
trend: "trend",
|
||||
trends: "trends",
|
||||
type: "type",
|
||||
vertical: {
|
||||
visibility: "vertical visibility (ft)",
|
||||
},
|
||||
visibility: {
|
||||
main: "main visibility",
|
||||
max: "maximum visibility",
|
||||
min: {
|
||||
"0": "minimum visibility",
|
||||
direction: "minimum visibility direction",
|
||||
},
|
||||
},
|
||||
weather: {
|
||||
conditions: "weather conditions",
|
||||
},
|
||||
wind: {
|
||||
direction: {
|
||||
"0": "direction",
|
||||
degrees: "direction (degrees)",
|
||||
},
|
||||
gusts: "gusts",
|
||||
max: {
|
||||
variation: "maximal wind variation",
|
||||
},
|
||||
min: {
|
||||
variation: "minimal wind variation",
|
||||
},
|
||||
speed: "speed",
|
||||
unit: "unit",
|
||||
},
|
||||
},
|
||||
WeatherChangeType: {
|
||||
BECMG: "Becoming",
|
||||
FM: "From",
|
||||
PROB: "Probability",
|
||||
TEMPO: "Temporary",
|
||||
},
|
||||
};
|
||||
|
||||
export { en as default };
|
||||
2840
server/scripts/vendor/auto/metar-taf-parser.mjs
vendored
Normal file
2840
server/scripts/vendor/auto/metar-taf-parser.mjs
vendored
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user