diff --git a/index.mjs b/index.mjs index ec48d2e..7d487ed 100644 --- a/index.mjs +++ b/index.mjs @@ -3,7 +3,7 @@ import express from 'express'; import fs from 'fs'; import { readFile } from 'fs/promises'; import { - weatherProxy, radarProxy, outlookProxy, mesonetProxy, + weatherProxy, radarProxy, outlookProxy, mesonetProxy, forecastProxy, } from './proxy/handlers.mjs'; import playlist from './src/playlist.mjs'; import OVERRIDES from './src/overrides.mjs'; @@ -129,6 +129,7 @@ if (!process.env?.STATIC) { app.use('/radar/', radarProxy); app.use('/spc/', outlookProxy); app.use('/mesonet/', mesonetProxy); + app.use('/forecast/', forecastProxy); // Playlist route is available in server mode (not in static mode) app.get('/playlist.json', playlist); diff --git a/proxy/handlers.mjs b/proxy/handlers.mjs index 2a3b4f9..7440064 100644 --- a/proxy/handlers.mjs +++ b/proxy/handlers.mjs @@ -42,3 +42,11 @@ export const mesonetProxy = async (req, res) => { encoding: isBinary ? 'binary' : 'utf8', // Use binary encoding for images }); }; + +// Legacy forecast.weather.gov API proxy +export const forecastProxy = async (req, res) => { + await cache.handleRequest(req, res, 'https://forecast.weather.gov', { + serviceName: 'Forecast.weather.gov', + skipParams: ['u'], + }); +}; diff --git a/server/scripts/modules/autocomplete.mjs b/server/scripts/modules/autocomplete.mjs index dd51176..b529e93 100644 --- a/server/scripts/modules/autocomplete.mjs +++ b/server/scripts/modules/autocomplete.mjs @@ -297,6 +297,9 @@ 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 === '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(); } diff --git a/server/scripts/modules/currentweather.mjs b/server/scripts/modules/currentweather.mjs index 8980979..2612981 100644 --- a/server/scripts/modules/currentweather.mjs +++ b/server/scripts/modules/currentweather.mjs @@ -12,6 +12,7 @@ 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']; @@ -39,6 +40,8 @@ class CurrentWeather extends WeatherDisplay { while (!observations && stationNum < filteredStations.length) { // get the station station = filteredStations[stationNum]; + const stationId = station.properties.stationIdentifier; + stationNum += 1; let candidateObservation; @@ -52,20 +55,12 @@ class CurrentWeather extends WeatherDisplay { stillWaiting: () => this.stillWaiting(), }); } catch (error) { - console.error(`Unexpected error getting Current Conditions for station ${station.properties.stationIdentifier}: ${error.message} (trying next station)`); + 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) { - // Check if the observation data is old - const observationTime = new Date(candidateObservation.features[0].properties.timestamp); - const ageInMinutes = (new Date() - observationTime) / (1000 * 60); - - if (ageInMinutes > 180 && debugFlag('currentweather')) { - console.warn(`Current Observations for station ${station.properties.stationIdentifier} are ${ageInMinutes.toFixed(0)} minutes old (from ${observationTime.toISOString()}), trying next station`); - } - // Attempt making observation data usable with METAR data const originalData = { ...candidateObservation.features[0].properties }; candidateObservation.features[0].properties = augmentObservationWithMetar(candidateObservation.features[0].properties); @@ -83,7 +78,7 @@ class CurrentWeather extends WeatherDisplay { 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 ${station.properties.stationIdentifier} were augmented with METAR data for ${metarReplacements.join(', ')}`); + 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 @@ -99,28 +94,47 @@ class CurrentWeather extends WeatherDisplay { { name: 'ceiling', check: (props) => props.cloudLayers?.[0]?.base?.value === null, required: false }, ]; - const missingRequired = requiredFields.filter((field) => field.required && field.check(augmentedData)).map((field) => field.name); - const missingOptional = requiredFields.filter((field) => !field.required && field.check(augmentedData)).map((field) => field.name); + // 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 ${station.properties.stationIdentifier} is missing optional fields: ${missingOptional.join(', ')} (acceptable)`); + 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 ${station.properties.stationIdentifier} is missing fields: ${allMissing.join(', ')} (${missingRequired.length} required, ${missingOptionalCount} optional) (trying next station)`); + 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 Observations for station ${station.properties.stationIdentifier} failed, trying next station`); + console.log(`Current Conditions for station ${stationId} failed, trying next station`); } else { - console.log(`No features returned for station ${station.properties.stationIdentifier}, trying next station`); + console.log(`No features returned for station ${stationId}, trying next station`); } } } @@ -140,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); @@ -247,17 +283,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; }; diff --git a/server/scripts/modules/latestobservations.mjs b/server/scripts/modules/latestobservations.mjs index dcb69f3..a7e2446 100644 --- a/server/scripts/modules/latestobservations.mjs +++ b/server/scripts/modules/latestobservations.mjs @@ -9,6 +9,7 @@ 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) { @@ -80,14 +81,6 @@ class LatestObservations extends WeatherDisplay { return false; } - // Check if the observation data is old - const observationTime = new Date(data.properties.timestamp); - const ageInMinutes = (new Date() - observationTime) / (1000 * 60); - - if (ageInMinutes > 180 && debugFlag('latestobservations')) { - console.warn(`Latest Observations for station ${station.id} are ${ageInMinutes.toFixed(0)} minutes old (from ${observationTime.toISOString()})`); - } - // Enhance observation data with METAR parsing for missing fields const originalData = { ...data.properties }; data.properties = augmentObservationWithMetar(data.properties); @@ -109,11 +102,22 @@ class LatestObservations extends WeatherDisplay { { name: 'windDirection', check: (props) => props.windDirection?.value === null }, { name: 'textDescription', check: (props) => props.textDescription === null || props.textDescription === '' }, ]; - const missingFields = requiredFields.filter((field) => field.check(augmentedData)).map((field) => field.name); + // 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} are missing required fields: ${missingFields.join(', ')}`); + console.log(`Latest Observations for station ${station.id} is missing fields: ${missingFields.join(', ')}`); } return false; } diff --git a/server/scripts/modules/navigation.mjs b/server/scripts/modules/navigation.mjs index 39016b4..1fe9e9c 100644 --- a/server/scripts/modules/navigation.mjs +++ b/server/scripts/modules/navigation.mjs @@ -250,8 +250,11 @@ const loadDisplay = (direction) => { // 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) { - foundSuitableDisplay = true; - break; + // Prevent infinite recursion by ensuring we don't select the same display + if (idx !== curIdx) { + foundSuitableDisplay = true; + break; + } } } diff --git a/server/scripts/modules/regionalforecast-utils.mjs b/server/scripts/modules/regionalforecast-utils.mjs index 6080b7b..edd0229 100644 --- a/server/scripts/modules/regionalforecast-utils.mjs +++ b/server/scripts/modules/regionalforecast-utils.mjs @@ -4,6 +4,7 @@ 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 @@ -26,25 +27,51 @@ const getRegionalObservation = async (point, city) => { if (!stations || !stations.features || stations.features.length === 0) { if (debugFlag('verbose-failures')) { - console.warn(`Unable to get regional stations for ${city.Name ?? city.city}`); + console.warn(`Unable to get regional stations for ${city.city}`); } return false; } // get the first station const station = stations.features[0].id; + 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 ${city.Name ?? city.city}`); + console.warn(`Unable to get regional observations for station ${stationId}`); } return false; } // Enhance observation data with METAR parsing for missing fields - const augmentedObservation = augmentObservationWithMetar(observation.properties); + 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 (!augmentedObservation.icon) return false; @@ -54,7 +81,7 @@ const getRegionalObservation = async (point, city) => { // return the observation return augmentedObservation; } catch (error) { - console.error(`Unexpected error getting Regional Observation for ${city.Name ?? city.city}: ${error.message}`); + console.error(`Unexpected error getting Regional Observation for ${city.city}: ${error.message}`); return false; } }; diff --git a/server/scripts/modules/regionalforecast.mjs b/server/scripts/modules/regionalforecast.mjs index 4ab24f3..81ac972 100644 --- a/server/scripts/modules/regionalforecast.mjs +++ b/server/scripts/modules/regionalforecast.mjs @@ -7,12 +7,13 @@ 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 = { @@ -78,9 +79,6 @@ 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 using centralized safe Promise handling const regionalDataAll = await safePromiseAll(regionalCities.map(async (city) => { try { @@ -124,25 +122,20 @@ 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.error(`Unexpected error getting Regional Forecast data for '${city.name ?? city.city}': ${error.message}`); diff --git a/server/scripts/modules/utils/mapclick.mjs b/server/scripts/modules/utils/mapclick.mjs new file mode 100644 index 0000000..9396b6d --- /dev/null +++ b/server/scripts/modules/utils/mapclick.mjs @@ -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: 'Polygon', + coordinates: [[[mapClickData.location?.longitude, mapClickData.location?.latitude]]], // Approximate + }, + properties: { + updated: mapClickData.creationDate || new Date().toISOString(), + units: 'us', + forecastGenerator: 'MapClick', + generatedAt: new Date().toISOString(), + updateTime: mapClickData.creationDate || 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) { + 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, +}; diff --git a/server/scripts/modules/utils/url-rewrite.mjs b/server/scripts/modules/utils/url-rewrite.mjs index 08e4ad5..2230472 100644 --- a/server/scripts/modules/utils/url-rewrite.mjs +++ b/server/scripts/modules/utils/url-rewrite.mjs @@ -26,6 +26,10 @@ const rewriteUrl = (_url) => { 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;