Merge remote-tracking branch 'eddyg/station-name-improvements' into code-refactor

This commit is contained in:
Matt Walsh
2025-08-03 22:10:17 -05:00
74 changed files with 27978 additions and 34895 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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',
},
},
];

View File

@@ -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' });

View File

@@ -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');

View File

@@ -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();
}
}

View File

@@ -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;
};

View File

@@ -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 = '&nbsp;'.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) => {

View File

@@ -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;
};

View File

@@ -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)

View File

@@ -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;
}

View File

@@ -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);

View File

@@ -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');
}
}
};

View 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;

View File

@@ -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');
}
};

View File

@@ -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'));

View File

@@ -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'));

View File

@@ -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();

View File

@@ -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,
};

View File

@@ -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 {

View File

@@ -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() {

View File

@@ -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;
}
};

View File

@@ -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

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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

View 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;

View File

@@ -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)];

View File

@@ -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,
};

View 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,
};

View 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,
};

View File

@@ -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,
};

View 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;

View File

@@ -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);

View 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,
};

View 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;

View File

@@ -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;

View 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;

View File

@@ -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;
}

View 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,
};

View File

@@ -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 {

View File

@@ -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
View 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 };

File diff suppressed because it is too large Load Diff