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

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,5 +1,5 @@
@use 'shared/_colors'as c;
@use 'shared/_utils'as u;
@use 'shared/_colors' as c;
@use 'shared/_utils' as u;
#almanac-html.weather-display {
background-image: url('../images/backgrounds/3.png');
@@ -11,62 +11,57 @@
@include u.text-shadow();
.sun {
display: table;
margin-left: 50px;
height: 100px;
// Use CSS Grid for cross-browser consistency
// Grid is populated in reading order (left-to-right, top-to-bottom):
display: grid;
grid-template-columns: auto auto auto;
grid-template-rows: auto auto auto;
gap: 0px 90px;
margin: 3px auto 5px auto; // align the bottom of the div with the background
width: fit-content;
line-height: 30px;
&>div {
display: table-row;
.grid-item {
// Reset inherited styles that interfere with grid layout
width: auto;
height: auto;
padding: 0;
margin: 0;
position: relative;
&>div {
display: table-cell;
}
}
.days {
color: c.$column-header-text;
text-align: right;
top: -5px;
.day {
padding-right: 10px;
// Column headers (day names)
&.header {
color: c.$column-header-text;
text-align: center;
}
}
.times {
text-align: right;
.sun-time {
width: 200px;
// Row labels (Sunrise:, Sunset:)
&.row-label {
// color: c.$column-header-text; // screenshots show labels were white
text-align: right;
}
&.times-1 {
top: -10px;
}
&.times-2 {
top: -15px;
// Time values (sunrise/sunset)
&.time {
text-align: center;
}
}
}
.moon {
position: relative;
top: -10px;
padding: 0px 60px;
padding: 7px 50px;
line-height: 36px;
.title {
color: c.$column-header-text;
padding-left: 13px;
}
.day {
display: inline-block;
text-align: center;
width: 130px;
width: 132px;
.icon {
// shadow in image make it look off center
@@ -82,4 +77,4 @@
}
}

View File

@@ -1,5 +1,5 @@
@use 'shared/_colors'as c;
@use 'shared/_utils'as u;
@use 'shared/_colors' as c;
@use 'shared/_utils' as u;
.weather-display .main.current-weather {
&.main {
@@ -58,27 +58,19 @@
font-size: 24pt;
}
.condition {}
.icon {
height: 100px;
img {
max-width: 126px;
margin: 0 auto;
display: block;
}
}
.wind-container {
margin-bottom: 10px;
margin-left: 10px;
display: flex;
&>div {
width: 45%;
display: inline-block;
margin: 0px;
}
.wind-label {
margin-left: 5px;
width: 50%;
}
.wind {
@@ -87,7 +79,8 @@
}
.wind-gusts {
margin-left: 5px;
text-align: right;
font-size: 28px;
}
.location {
@@ -99,4 +92,4 @@
text-wrap: nowrap;
}
}
}
}

View File

@@ -1,17 +1,16 @@
@use 'shared/_colors'as c;
@use 'shared/_utils'as u;
@use 'shared/_colors' as c;
@use 'shared/_utils' as u;
.weather-display .main.hazards {
&.main {
overflow-y: hidden;
height: 480px;
background-color: rgb(112, 35, 35);
.hazard-lines {
min-height: 400px;
padding-top: 10px;
background-color: rgb(112, 35, 35);
.hazard {
font-family: 'Star4000';
font-size: 24pt;
@@ -26,4 +25,4 @@
}
}
}
}
}

View File

@@ -1,5 +1,5 @@
@use 'shared/_utils'as u;
@use 'shared/_colors'as c;
@use 'shared/_utils' as u;
@use 'shared/_colors' as c;
@font-face {
font-family: "Star4000";
@@ -9,6 +9,7 @@
body {
font-family: "Star4000";
margin: 0;
@media (prefers-color-scheme: dark) {
background-color: #000000;
@@ -23,13 +24,17 @@ body {
&.kiosk {
margin: 0px;
padding: 0px;
overflow: hidden;
width: 100vw;
// Always use black background in kiosk mode, regardless of light/dark preference
background-color: #000000 !important;
}
}
#divQuery {
max-width: 640px;
padding: 8px;
.buttons {
display: inline-block;
@@ -89,17 +94,22 @@ body {
font-family: "Star4000";
}
#txtAddress {
#txtLocation {
width: calc(100% - 170px);
max-width: 490px;
font-size: 16pt;
min-width: 200px;
display: inline-block;
// Ensure consistent styling across light and dark modes
background-color: white;
color: black;
border: 2px inset #808080;
@media (prefers-color-scheme: dark) {
background-color: #000000;
color: white;
border: 1px solid darkgray;
border: 2px inset #808080;
}
}
@@ -137,12 +147,26 @@ body {
color: #ffffff;
width: 100%;
max-width: 640px;
margin: 0; // Ensure edge-to-edge display
&.wide {
max-width: 854px;
}
}
.content-wrapper {
padding: 8px;
}
#divTwcMain {
width: 640px;
height: 480px;
.wide & {
width: 854px;
}
}
.kiosk #divTwc {
max-width: unset;
}
@@ -184,7 +208,11 @@ body {
background-color: #000000;
color: #ffffff;
width: 100%;
width: 640px;
.wide & {
width: 854px;
}
@media (prefers-color-scheme: dark) {
background-color: rgb(48, 48, 48);
@@ -196,25 +224,26 @@ body {
padding-left: 6px;
padding-right: 6px;
// scale down the buttons on narrower screens
// Use font-size scaling instead of zoom/transform to avoid layout gaps and preserve icon tap targets.
// While not semantically ideal, it works well for our fixed-layout design.
@media (max-width: 550px) {
zoom: 0.90;
font-size: 0.90em;
}
@media (max-width: 500px) {
zoom: 0.80;
font-size: 0.80em;
}
@media (max-width: 450px) {
zoom: 0.70;
font-size: 0.70em;
}
@media (max-width: 400px) {
zoom: 0.60;
font-size: 0.60em;
}
@media (max-width: 350px) {
zoom: 0.50;
font-size: 0.50em;
}
}
@@ -325,7 +354,6 @@ body {
// background-image: none;
width: unset;
height: unset;
transform-origin: unset;
}
#loading {
@@ -399,7 +427,8 @@ body {
label {
display: block;
max-width: 300px;
max-width: fit-content;
cursor: pointer;
.alert {
display: none;
@@ -414,6 +443,13 @@ body {
#divTwcBottom img {
transform: scale(0.75);
// Make icons larger in widescreen mode on mobile
@media (max-width: 550px) {
.wide & {
transform: scale(1.0); // Larger icons in widescreen
}
}
}
#divTwc:fullscreen,
@@ -446,9 +482,7 @@ body {
.kiosk {
#divTwc #divTwcBottom {
>div {
display: none;
}
display: none;
}
}
@@ -768,15 +802,15 @@ body {
display: none;
}
// Hide instructions in kiosk mode (higher specificity than the show rule)
body.kiosk #loading .instructions {
display: none !important;
}
.kiosk {
#divQuery,
>.info,
>.related-links,
>.heading,
#enabledDisplays,
#settings,
#divInfo {
display: none;
// In kiosk mode, hide everything except the main weather display
>*:not(#divTwc) {
display: none !important;
}
}
}

View File

@@ -1,5 +1,5 @@
@use 'shared/_colors'as c;
@use 'shared/_utils'as u;
@use 'shared/_colors' as c;
@use 'shared/_utils' as u;
.weather-display .progress {
@include u.text-shadow();
@@ -13,6 +13,7 @@
box-sizing: border-box;
height: 310px;
overflow: hidden;
line-height: 28px;
.item {
position: relative;
@@ -117,4 +118,4 @@
transition: width 1s steps(6);
}
}
}
}

View File

@@ -1,5 +1,5 @@
@use 'shared/_colors'as c;
@use 'shared/_utils'as u;
@use 'shared/_colors' as c;
@use 'shared/_utils' as u;
.weather-display .main.travel {
&.main {
@@ -8,14 +8,11 @@
.column-headers {
background-color: c.$column-header;
height: 20px;
position: absolute;
width: 100%;
}
.column-headers {
position: sticky;
top: 0px;
width: 100%;
z-index: 5;
overflow: hidden; // prevent thin gaps between header and content
div {
display: inline-block;
@@ -100,4 +97,4 @@
}
}
}
}
}

View File

@@ -1,5 +1,5 @@
@use 'shared/_colors'as c;
@use 'shared/_utils'as u;
@use 'shared/_colors' as c;
@use 'shared/_utils' as u;
.weather-display {
width: 640px;
@@ -94,11 +94,13 @@
&.has-scroll {
width: 640px;
margin-top: 0;
height: 310px;
overflow: hidden;
&.no-header {
height: 400px;
margin-top: 0; // Reset for no-header case since the gap issue is header-related
}
}
@@ -129,6 +131,12 @@
overflow: hidden;
}
// Remove margins for hazard scrolls to maximize text space
&.hazard .fixed {
margin-left: 0;
margin-right: 0;
}
.scroll-header {
height: 26px;
font-family: "Star4000 Small";
@@ -150,4 +158,4 @@
}
}
}
}

View File

@@ -1,7 +1,8 @@
/* REGULAR SCANLINES SETTINGS */
// width of 1 scanline (min.: 1px)
// width of 1 scanline (responsive units to prevent banding)
$scan-width: 1px;
$scan-width-scaled: 0.15vh; // viewport-relative unit for better scaling
// emulates a damage-your-eyes bad pre-2000 CRT screen ♥ (true, false)
$scan-crt: false;
@@ -75,18 +76,41 @@ $scan-opacity: .75;
@include scan-moving($scan-moving-line);
}
// the scanlines, so!
// the scanlines, so! - with responsive scaling for low-res displays
&:after {
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: $scan-z-index;
background: linear-gradient(to bottom,
transparent 50%,
$scan-color 51%);
background-size: 100% $scan-width*2;
// repeating-linear-gradient is more efficient than linear-gradient+background-size because it doesn't require the browser to calculate tiling
background: repeating-linear-gradient(to bottom,
transparent 0,
transparent $scan-width,
$scan-color $scan-width,
$scan-color calc($scan-width * 2));
@include scan-crt($scan-crt);
// Prevent sub-pixel aliasing on scaled displays
image-rendering: crisp-edges;
image-rendering: pixelated;
}
// Scanlines use dynamic thickness calculated by JavaScript
// JavaScript calculates optimal thickness to prevent banding at any scale factor
// The --scanline-thickness custom property is set by applyScanlineScaling()
// The modes (hairline, thin, medium, thick) force the base thickness selection
// Some modes may appear the same (e.g. hairline and thin) depending on the display
&:before {
height: var(--scanline-thickness, $scan-width);
}
&:after {
background: repeating-linear-gradient(to bottom,
transparent 0,
transparent var(--scanline-thickness, $scan-width),
$scan-color var(--scanline-thickness, $scan-width),
$scan-color calc(var(--scanline-thickness, $scan-width) * 2));
}
}
@@ -103,4 +127,4 @@ $scan-opacity: .75;
background-position: 0 50%;
// bottom: 0%; // to have a continuous scanline move, use this line (here in 0% step) instead of transform and write, in &:before, { position: absolute; bottom: 100%; }
}
}
}