mirror of
https://github.com/netbymatt/ws4kp.git
synced 2026-04-14 07:39:29 -07:00
Merge remote-tracking branch 'eddyg/station-name-improvements' into code-refactor
This commit is contained in:
BIN
server/images/logos/app-icon-180.png
Normal file
BIN
server/images/logos/app-icon-180.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,5 +1,5 @@
|
||||
@use 'shared/_colors'as c;
|
||||
@use 'shared/_utils'as u;
|
||||
@use 'shared/_colors' as c;
|
||||
@use 'shared/_utils' as u;
|
||||
|
||||
#almanac-html.weather-display {
|
||||
background-image: url('../images/backgrounds/3.png');
|
||||
@@ -11,62 +11,57 @@
|
||||
@include u.text-shadow();
|
||||
|
||||
.sun {
|
||||
display: table;
|
||||
margin-left: 50px;
|
||||
height: 100px;
|
||||
// Use CSS Grid for cross-browser consistency
|
||||
// Grid is populated in reading order (left-to-right, top-to-bottom):
|
||||
display: grid;
|
||||
grid-template-columns: auto auto auto;
|
||||
grid-template-rows: auto auto auto;
|
||||
gap: 0px 90px;
|
||||
margin: 3px auto 5px auto; // align the bottom of the div with the background
|
||||
width: fit-content;
|
||||
line-height: 30px;
|
||||
|
||||
|
||||
&>div {
|
||||
display: table-row;
|
||||
.grid-item {
|
||||
// Reset inherited styles that interfere with grid layout
|
||||
width: auto;
|
||||
height: auto;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
position: relative;
|
||||
|
||||
&>div {
|
||||
display: table-cell;
|
||||
}
|
||||
}
|
||||
|
||||
.days {
|
||||
color: c.$column-header-text;
|
||||
text-align: right;
|
||||
top: -5px;
|
||||
|
||||
.day {
|
||||
padding-right: 10px;
|
||||
// Column headers (day names)
|
||||
&.header {
|
||||
color: c.$column-header-text;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.times {
|
||||
text-align: right;
|
||||
|
||||
.sun-time {
|
||||
width: 200px;
|
||||
// Row labels (Sunrise:, Sunset:)
|
||||
&.row-label {
|
||||
// color: c.$column-header-text; // screenshots show labels were white
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
&.times-1 {
|
||||
top: -10px;
|
||||
}
|
||||
|
||||
&.times-2 {
|
||||
top: -15px;
|
||||
// Time values (sunrise/sunset)
|
||||
&.time {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.moon {
|
||||
position: relative;
|
||||
top: -10px;
|
||||
|
||||
padding: 0px 60px;
|
||||
padding: 7px 50px;
|
||||
line-height: 36px;
|
||||
|
||||
.title {
|
||||
color: c.$column-header-text;
|
||||
padding-left: 13px;
|
||||
}
|
||||
|
||||
.day {
|
||||
display: inline-block;
|
||||
text-align: center;
|
||||
width: 130px;
|
||||
width: 132px;
|
||||
|
||||
.icon {
|
||||
// shadow in image make it look off center
|
||||
@@ -82,4 +77,4 @@
|
||||
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
@use 'shared/_colors'as c;
|
||||
@use 'shared/_utils'as u;
|
||||
@use 'shared/_colors' as c;
|
||||
@use 'shared/_utils' as u;
|
||||
|
||||
.weather-display .main.current-weather {
|
||||
&.main {
|
||||
@@ -58,27 +58,19 @@
|
||||
font-size: 24pt;
|
||||
}
|
||||
|
||||
.condition {}
|
||||
|
||||
.icon {
|
||||
height: 100px;
|
||||
|
||||
img {
|
||||
max-width: 126px;
|
||||
margin: 0 auto;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.wind-container {
|
||||
margin-bottom: 10px;
|
||||
margin-left: 10px;
|
||||
display: flex;
|
||||
|
||||
&>div {
|
||||
width: 45%;
|
||||
display: inline-block;
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
.wind-label {
|
||||
margin-left: 5px;
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
.wind {
|
||||
@@ -87,7 +79,8 @@
|
||||
}
|
||||
|
||||
.wind-gusts {
|
||||
margin-left: 5px;
|
||||
text-align: right;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.location {
|
||||
@@ -99,4 +92,4 @@
|
||||
text-wrap: nowrap;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
@use 'shared/_colors'as c;
|
||||
@use 'shared/_utils'as u;
|
||||
@use 'shared/_colors' as c;
|
||||
@use 'shared/_utils' as u;
|
||||
|
||||
.weather-display .main.hazards {
|
||||
&.main {
|
||||
overflow-y: hidden;
|
||||
height: 480px;
|
||||
background-color: rgb(112, 35, 35);
|
||||
|
||||
.hazard-lines {
|
||||
min-height: 400px;
|
||||
padding-top: 10px;
|
||||
|
||||
background-color: rgb(112, 35, 35);
|
||||
|
||||
.hazard {
|
||||
font-family: 'Star4000';
|
||||
font-size: 24pt;
|
||||
@@ -26,4 +25,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
@use 'shared/_utils'as u;
|
||||
@use 'shared/_colors'as c;
|
||||
@use 'shared/_utils' as u;
|
||||
@use 'shared/_colors' as c;
|
||||
|
||||
@font-face {
|
||||
font-family: "Star4000";
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
body {
|
||||
font-family: "Star4000";
|
||||
margin: 0;
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
background-color: #000000;
|
||||
@@ -23,13 +24,17 @@ body {
|
||||
|
||||
&.kiosk {
|
||||
margin: 0px;
|
||||
padding: 0px;
|
||||
overflow: hidden;
|
||||
width: 100vw;
|
||||
// Always use black background in kiosk mode, regardless of light/dark preference
|
||||
background-color: #000000 !important;
|
||||
}
|
||||
}
|
||||
|
||||
#divQuery {
|
||||
max-width: 640px;
|
||||
padding: 8px;
|
||||
|
||||
.buttons {
|
||||
display: inline-block;
|
||||
@@ -89,17 +94,22 @@ body {
|
||||
font-family: "Star4000";
|
||||
}
|
||||
|
||||
#txtAddress {
|
||||
#txtLocation {
|
||||
width: calc(100% - 170px);
|
||||
max-width: 490px;
|
||||
font-size: 16pt;
|
||||
min-width: 200px;
|
||||
display: inline-block;
|
||||
|
||||
// Ensure consistent styling across light and dark modes
|
||||
background-color: white;
|
||||
color: black;
|
||||
border: 2px inset #808080;
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
background-color: #000000;
|
||||
color: white;
|
||||
border: 1px solid darkgray;
|
||||
border: 2px inset #808080;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,12 +147,26 @@ body {
|
||||
color: #ffffff;
|
||||
width: 100%;
|
||||
max-width: 640px;
|
||||
margin: 0; // Ensure edge-to-edge display
|
||||
|
||||
&.wide {
|
||||
max-width: 854px;
|
||||
}
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
#divTwcMain {
|
||||
width: 640px;
|
||||
height: 480px;
|
||||
|
||||
.wide & {
|
||||
width: 854px;
|
||||
}
|
||||
}
|
||||
|
||||
.kiosk #divTwc {
|
||||
max-width: unset;
|
||||
}
|
||||
@@ -184,7 +208,11 @@ body {
|
||||
background-color: #000000;
|
||||
|
||||
color: #ffffff;
|
||||
width: 100%;
|
||||
width: 640px;
|
||||
|
||||
.wide & {
|
||||
width: 854px;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
background-color: rgb(48, 48, 48);
|
||||
@@ -196,25 +224,26 @@ body {
|
||||
padding-left: 6px;
|
||||
padding-right: 6px;
|
||||
|
||||
// scale down the buttons on narrower screens
|
||||
// Use font-size scaling instead of zoom/transform to avoid layout gaps and preserve icon tap targets.
|
||||
// While not semantically ideal, it works well for our fixed-layout design.
|
||||
@media (max-width: 550px) {
|
||||
zoom: 0.90;
|
||||
font-size: 0.90em;
|
||||
}
|
||||
|
||||
@media (max-width: 500px) {
|
||||
zoom: 0.80;
|
||||
font-size: 0.80em;
|
||||
}
|
||||
|
||||
@media (max-width: 450px) {
|
||||
zoom: 0.70;
|
||||
font-size: 0.70em;
|
||||
}
|
||||
|
||||
@media (max-width: 400px) {
|
||||
zoom: 0.60;
|
||||
font-size: 0.60em;
|
||||
}
|
||||
|
||||
@media (max-width: 350px) {
|
||||
zoom: 0.50;
|
||||
font-size: 0.50em;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -325,7 +354,6 @@ body {
|
||||
// background-image: none;
|
||||
width: unset;
|
||||
height: unset;
|
||||
transform-origin: unset;
|
||||
}
|
||||
|
||||
#loading {
|
||||
@@ -399,7 +427,8 @@ body {
|
||||
|
||||
label {
|
||||
display: block;
|
||||
max-width: 300px;
|
||||
max-width: fit-content;
|
||||
cursor: pointer;
|
||||
|
||||
.alert {
|
||||
display: none;
|
||||
@@ -414,6 +443,13 @@ body {
|
||||
|
||||
#divTwcBottom img {
|
||||
transform: scale(0.75);
|
||||
|
||||
// Make icons larger in widescreen mode on mobile
|
||||
@media (max-width: 550px) {
|
||||
.wide & {
|
||||
transform: scale(1.0); // Larger icons in widescreen
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#divTwc:fullscreen,
|
||||
@@ -446,9 +482,7 @@ body {
|
||||
|
||||
.kiosk {
|
||||
#divTwc #divTwcBottom {
|
||||
>div {
|
||||
display: none;
|
||||
}
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -768,15 +802,15 @@ body {
|
||||
display: none;
|
||||
}
|
||||
|
||||
// Hide instructions in kiosk mode (higher specificity than the show rule)
|
||||
body.kiosk #loading .instructions {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.kiosk {
|
||||
|
||||
#divQuery,
|
||||
>.info,
|
||||
>.related-links,
|
||||
>.heading,
|
||||
#enabledDisplays,
|
||||
#settings,
|
||||
#divInfo {
|
||||
display: none;
|
||||
// In kiosk mode, hide everything except the main weather display
|
||||
>*:not(#divTwc) {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
@use 'shared/_colors'as c;
|
||||
@use 'shared/_utils'as u;
|
||||
@use 'shared/_colors' as c;
|
||||
@use 'shared/_utils' as u;
|
||||
|
||||
.weather-display .progress {
|
||||
@include u.text-shadow();
|
||||
@@ -13,6 +13,7 @@
|
||||
box-sizing: border-box;
|
||||
height: 310px;
|
||||
overflow: hidden;
|
||||
line-height: 28px;
|
||||
|
||||
.item {
|
||||
position: relative;
|
||||
@@ -117,4 +118,4 @@
|
||||
transition: width 1s steps(6);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
@use 'shared/_colors'as c;
|
||||
@use 'shared/_utils'as u;
|
||||
@use 'shared/_colors' as c;
|
||||
@use 'shared/_utils' as u;
|
||||
|
||||
.weather-display .main.travel {
|
||||
&.main {
|
||||
@@ -8,14 +8,11 @@
|
||||
.column-headers {
|
||||
background-color: c.$column-header;
|
||||
height: 20px;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.column-headers {
|
||||
position: sticky;
|
||||
top: 0px;
|
||||
width: 100%;
|
||||
z-index: 5;
|
||||
overflow: hidden; // prevent thin gaps between header and content
|
||||
|
||||
div {
|
||||
display: inline-block;
|
||||
@@ -100,4 +97,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
@use 'shared/_colors'as c;
|
||||
@use 'shared/_utils'as u;
|
||||
@use 'shared/_colors' as c;
|
||||
@use 'shared/_utils' as u;
|
||||
|
||||
.weather-display {
|
||||
width: 640px;
|
||||
@@ -94,11 +94,13 @@
|
||||
|
||||
&.has-scroll {
|
||||
width: 640px;
|
||||
margin-top: 0;
|
||||
height: 310px;
|
||||
overflow: hidden;
|
||||
|
||||
&.no-header {
|
||||
height: 400px;
|
||||
margin-top: 0; // Reset for no-header case since the gap issue is header-related
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,6 +131,12 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
// Remove margins for hazard scrolls to maximize text space
|
||||
&.hazard .fixed {
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.scroll-header {
|
||||
height: 26px;
|
||||
font-family: "Star4000 Small";
|
||||
@@ -150,4 +158,4 @@
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/* REGULAR SCANLINES SETTINGS */
|
||||
|
||||
// width of 1 scanline (min.: 1px)
|
||||
// width of 1 scanline (responsive units to prevent banding)
|
||||
$scan-width: 1px;
|
||||
$scan-width-scaled: 0.15vh; // viewport-relative unit for better scaling
|
||||
|
||||
// emulates a damage-your-eyes bad pre-2000 CRT screen ♥ (true, false)
|
||||
$scan-crt: false;
|
||||
@@ -75,18 +76,41 @@ $scan-opacity: .75;
|
||||
@include scan-moving($scan-moving-line);
|
||||
}
|
||||
|
||||
// the scanlines, so!
|
||||
// the scanlines, so! - with responsive scaling for low-res displays
|
||||
&:after {
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: $scan-z-index;
|
||||
background: linear-gradient(to bottom,
|
||||
transparent 50%,
|
||||
$scan-color 51%);
|
||||
background-size: 100% $scan-width*2;
|
||||
// repeating-linear-gradient is more efficient than linear-gradient+background-size because it doesn't require the browser to calculate tiling
|
||||
background: repeating-linear-gradient(to bottom,
|
||||
transparent 0,
|
||||
transparent $scan-width,
|
||||
$scan-color $scan-width,
|
||||
$scan-color calc($scan-width * 2));
|
||||
@include scan-crt($scan-crt);
|
||||
|
||||
// Prevent sub-pixel aliasing on scaled displays
|
||||
image-rendering: crisp-edges;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
// Scanlines use dynamic thickness calculated by JavaScript
|
||||
// JavaScript calculates optimal thickness to prevent banding at any scale factor
|
||||
// The --scanline-thickness custom property is set by applyScanlineScaling()
|
||||
// The modes (hairline, thin, medium, thick) force the base thickness selection
|
||||
// Some modes may appear the same (e.g. hairline and thin) depending on the display
|
||||
&:before {
|
||||
height: var(--scanline-thickness, $scan-width);
|
||||
}
|
||||
|
||||
&:after {
|
||||
background: repeating-linear-gradient(to bottom,
|
||||
transparent 0,
|
||||
transparent var(--scanline-thickness, $scan-width),
|
||||
$scan-color var(--scanline-thickness, $scan-width),
|
||||
$scan-color calc(var(--scanline-thickness, $scan-width) * 2));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,4 +127,4 @@ $scan-opacity: .75;
|
||||
background-position: 0 50%;
|
||||
// bottom: 0%; // to have a continuous scanline move, use this line (here in 0% step) instead of transform and write, in &:before, { position: absolute; bottom: 100%; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user