mirror of
https://github.com/netbymatt/ws4kp.git
synced 2026-04-14 07:39:29 -07:00
Add MapClick adapter and "fallback" logic when observations are stale
- Create utils/mapclick.mjs with centralized MapClick API functionality - Refactor modules to use the new utility: - Current Weather - Latest Observations - Regional Forecast - Add staleness checking utility for use by modules
This commit is contained in:
@@ -3,7 +3,7 @@ import express from 'express';
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import { readFile } from 'fs/promises';
|
import { readFile } from 'fs/promises';
|
||||||
import {
|
import {
|
||||||
weatherProxy, radarProxy, outlookProxy, mesonetProxy,
|
weatherProxy, radarProxy, outlookProxy, mesonetProxy, forecastProxy,
|
||||||
} from './proxy/handlers.mjs';
|
} from './proxy/handlers.mjs';
|
||||||
import playlist from './src/playlist.mjs';
|
import playlist from './src/playlist.mjs';
|
||||||
import OVERRIDES from './src/overrides.mjs';
|
import OVERRIDES from './src/overrides.mjs';
|
||||||
@@ -129,6 +129,7 @@ if (!process.env?.STATIC) {
|
|||||||
app.use('/radar/', radarProxy);
|
app.use('/radar/', radarProxy);
|
||||||
app.use('/spc/', outlookProxy);
|
app.use('/spc/', outlookProxy);
|
||||||
app.use('/mesonet/', mesonetProxy);
|
app.use('/mesonet/', mesonetProxy);
|
||||||
|
app.use('/forecast/', forecastProxy);
|
||||||
|
|
||||||
// Playlist route is available in server mode (not in static mode)
|
// Playlist route is available in server mode (not in static mode)
|
||||||
app.get('/playlist.json', playlist);
|
app.get('/playlist.json', playlist);
|
||||||
|
|||||||
@@ -42,3 +42,11 @@ export const mesonetProxy = async (req, res) => {
|
|||||||
encoding: isBinary ? 'binary' : 'utf8', // Use binary encoding for images
|
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'],
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -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
|
// if a click is detected on the page, generally we hide the suggestions, unless the click was within the autocomplete elements
|
||||||
checkOutsideClick(e) {
|
checkOutsideClick(e) {
|
||||||
if (e.target.id === 'txtLocation') 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;
|
if (e.target?.parentNode?.classList?.contains(this.options.containerClass)) return;
|
||||||
this.hideSuggestions();
|
this.hideSuggestions();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
temperature, windSpeed, pressure, distanceMeters, distanceKilometers,
|
temperature, windSpeed, pressure, distanceMeters, distanceKilometers,
|
||||||
} from './utils/units.mjs';
|
} from './utils/units.mjs';
|
||||||
import { debugFlag } from './utils/debug.mjs';
|
import { debugFlag } from './utils/debug.mjs';
|
||||||
|
import { isDataStale, enhanceObservationWithMapClick } from './utils/mapclick.mjs';
|
||||||
|
|
||||||
// some stations prefixed do not provide all the necessary data
|
// 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 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) {
|
while (!observations && stationNum < filteredStations.length) {
|
||||||
// get the station
|
// get the station
|
||||||
station = filteredStations[stationNum];
|
station = filteredStations[stationNum];
|
||||||
|
const stationId = station.properties.stationIdentifier;
|
||||||
|
|
||||||
stationNum += 1;
|
stationNum += 1;
|
||||||
|
|
||||||
let candidateObservation;
|
let candidateObservation;
|
||||||
@@ -52,20 +55,12 @@ class CurrentWeather extends WeatherDisplay {
|
|||||||
stillWaiting: () => this.stillWaiting(),
|
stillWaiting: () => this.stillWaiting(),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} 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;
|
candidateObservation = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if request was successful and has data
|
// Check if request was successful and has data
|
||||||
if (candidateObservation && candidateObservation.features?.length > 0) {
|
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
|
// Attempt making observation data usable with METAR data
|
||||||
const originalData = { ...candidateObservation.features[0].properties };
|
const originalData = { ...candidateObservation.features[0].properties };
|
||||||
candidateObservation.features[0].properties = augmentObservationWithMetar(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 augmentedData = candidateObservation.features[0].properties;
|
||||||
const metarReplacements = metarFields.filter((field) => field.check(originalData, augmentedData)).map((field) => field.name);
|
const metarReplacements = metarFields.filter((field) => field.check(originalData, augmentedData)).map((field) => field.name);
|
||||||
if (debugFlag('currentweather') && metarReplacements.length > 0) {
|
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
|
// 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 },
|
{ 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);
|
// Use enhanced observation with MapClick fallback
|
||||||
const missingOptional = requiredFields.filter((field) => !field.required && field.check(augmentedData)).map((field) => field.name);
|
// 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;
|
const missingOptionalCount = missingOptional.length;
|
||||||
|
|
||||||
|
// Check final data quality
|
||||||
// Allow one optional field to be missing
|
// Allow one optional field to be missing
|
||||||
if (missingRequired.length === 0 && missingOptionalCount <= 1) {
|
if (missingRequired.length === 0 && missingOptionalCount <= 1) {
|
||||||
// Station data is good, use it
|
// Station data is good, use it
|
||||||
observations = candidateObservation;
|
observations = candidateObservation;
|
||||||
if (debugFlag('currentweather') && missingOptional.length > 0) {
|
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 {
|
} else {
|
||||||
const allMissing = [...missingRequired, ...missingOptional];
|
const allMissing = [...missingRequired, ...missingOptional];
|
||||||
if (debugFlag('currentweather')) {
|
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')) {
|
} else if (debugFlag('verbose-failures')) {
|
||||||
if (!candidateObservation) {
|
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 {
|
} 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
|
// stop here if we're disabled
|
||||||
if (!superResult) return;
|
if (!superResult) return;
|
||||||
|
|
||||||
// preload the icon
|
// Data is available, ensure we're enabled for display
|
||||||
preloadImg(getLargeIcon(observations.features[0].properties.icon));
|
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);
|
this.setStatus(STATUS.loaded);
|
||||||
}
|
}
|
||||||
|
|
||||||
async drawCanvas() {
|
async drawCanvas() {
|
||||||
super.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;
|
let condition = this.data.observations.textDescription;
|
||||||
if (condition.length > 15) {
|
if (condition.length > 15) {
|
||||||
condition = shortConditions(condition);
|
condition = shortConditions(condition);
|
||||||
@@ -247,17 +283,23 @@ const parseData = (data) => {
|
|||||||
data.WindGust = windConverter(observations.windGust.value);
|
data.WindGust = windConverter(observations.windGust.value);
|
||||||
data.WindUnit = windConverter.units;
|
data.WindUnit = windConverter.units;
|
||||||
data.Humidity = Math.round(observations.relativeHumidity.value);
|
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.PressureDirection = '';
|
||||||
data.TextConditions = observations.textDescription;
|
data.TextConditions = observations.textDescription;
|
||||||
|
|
||||||
// set wind speed of 0 as calm
|
// set wind speed of 0 as calm
|
||||||
if (data.WindSpeed === 0) data.WindSpeed = 'Calm';
|
if (data.WindSpeed === 0) data.WindSpeed = 'Calm';
|
||||||
|
|
||||||
// difference since last measurement (pascals, looking for difference of more than 150)
|
// if two measurements are available, use the difference (in pascals) to determine pressure trend
|
||||||
const pressureDiff = (observations.barometricPressure.value - data.features[1].properties.barometricPressure.value);
|
if (data.features.length > 1 && data.features[1].properties.barometricPressure?.value) {
|
||||||
if (pressureDiff > 150) data.PressureDirection = 'R';
|
const pressureDiff = (observations.barometricPressure.value - data.features[1].properties.barometricPressure.value);
|
||||||
if (pressureDiff < -150) data.PressureDirection = 'F';
|
if (pressureDiff > 150) data.PressureDirection = 'R';
|
||||||
|
if (pressureDiff < -150) data.PressureDirection = 'F';
|
||||||
|
}
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { registerDisplay } from './navigation.mjs';
|
|||||||
import augmentObservationWithMetar from './utils/metar.mjs';
|
import augmentObservationWithMetar from './utils/metar.mjs';
|
||||||
import settings from './settings.mjs';
|
import settings from './settings.mjs';
|
||||||
import { debugFlag } from './utils/debug.mjs';
|
import { debugFlag } from './utils/debug.mjs';
|
||||||
|
import { enhanceObservationWithMapClick } from './utils/mapclick.mjs';
|
||||||
|
|
||||||
class LatestObservations extends WeatherDisplay {
|
class LatestObservations extends WeatherDisplay {
|
||||||
constructor(navId, elemId) {
|
constructor(navId, elemId) {
|
||||||
@@ -80,14 +81,6 @@ class LatestObservations extends WeatherDisplay {
|
|||||||
return false;
|
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
|
// Enhance observation data with METAR parsing for missing fields
|
||||||
const originalData = { ...data.properties };
|
const originalData = { ...data.properties };
|
||||||
data.properties = augmentObservationWithMetar(data.properties);
|
data.properties = augmentObservationWithMetar(data.properties);
|
||||||
@@ -109,11 +102,22 @@ class LatestObservations extends WeatherDisplay {
|
|||||||
{ name: 'windDirection', check: (props) => props.windDirection?.value === null },
|
{ name: 'windDirection', check: (props) => props.windDirection?.value === null },
|
||||||
{ name: 'textDescription', check: (props) => props.textDescription === null || props.textDescription === '' },
|
{ 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 (missingFields.length > 0) {
|
||||||
if (debugFlag('latestobservations')) {
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -250,8 +250,11 @@ const loadDisplay = (direction) => {
|
|||||||
// convert form simple 0-10 to start at current display index +/-1 and wrap
|
// convert form simple 0-10 to start at current display index +/-1 and wrap
|
||||||
idx = wrap(curIdx + (i + 1) * direction, totalDisplays);
|
idx = wrap(curIdx + (i + 1) * direction, totalDisplays);
|
||||||
if (displays[idx].status === STATUS.loaded && displays[idx].timing.totalScreens > 0) {
|
if (displays[idx].status === STATUS.loaded && displays[idx].timing.totalScreens > 0) {
|
||||||
foundSuitableDisplay = true;
|
// Prevent infinite recursion by ensuring we don't select the same display
|
||||||
break;
|
if (idx !== curIdx) {
|
||||||
|
foundSuitableDisplay = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { safeJson } from './utils/fetch.mjs';
|
|||||||
import { temperature as temperatureUnit } from './utils/units.mjs';
|
import { temperature as temperatureUnit } from './utils/units.mjs';
|
||||||
import augmentObservationWithMetar from './utils/metar.mjs';
|
import augmentObservationWithMetar from './utils/metar.mjs';
|
||||||
import { debugFlag } from './utils/debug.mjs';
|
import { debugFlag } from './utils/debug.mjs';
|
||||||
|
import { enhanceObservationWithMapClick } from './utils/mapclick.mjs';
|
||||||
|
|
||||||
const buildForecast = (forecast, city, cityXY) => {
|
const buildForecast = (forecast, city, cityXY) => {
|
||||||
// get a unit converter
|
// get a unit converter
|
||||||
@@ -26,25 +27,51 @@ const getRegionalObservation = async (point, city) => {
|
|||||||
|
|
||||||
if (!stations || !stations.features || stations.features.length === 0) {
|
if (!stations || !stations.features || stations.features.length === 0) {
|
||||||
if (debugFlag('verbose-failures')) {
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// get the first station
|
// get the first station
|
||||||
const station = stations.features[0].id;
|
const station = stations.features[0].id;
|
||||||
|
const stationId = stations.features[0].properties.stationIdentifier;
|
||||||
// get the observation data using centralized safe handling
|
// get the observation data using centralized safe handling
|
||||||
const observation = await safeJson(`${station}/observations/latest`);
|
const observation = await safeJson(`${station}/observations/latest`);
|
||||||
|
|
||||||
if (!observation) {
|
if (!observation) {
|
||||||
if (debugFlag('verbose-failures')) {
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enhance observation data with METAR parsing for missing fields
|
// 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
|
// preload the image
|
||||||
if (!augmentedObservation.icon) return false;
|
if (!augmentedObservation.icon) return false;
|
||||||
@@ -54,7 +81,7 @@ const getRegionalObservation = async (point, city) => {
|
|||||||
// return the observation
|
// return the observation
|
||||||
return augmentedObservation;
|
return augmentedObservation;
|
||||||
} catch (error) {
|
} 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;
|
return false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,12 +7,13 @@ import { safeJson, safePromiseAll } from './utils/fetch.mjs';
|
|||||||
import { temperature as temperatureUnit } from './utils/units.mjs';
|
import { temperature as temperatureUnit } from './utils/units.mjs';
|
||||||
import { getSmallIcon } from './icons.mjs';
|
import { getSmallIcon } from './icons.mjs';
|
||||||
import { preloadImg } from './utils/image.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 WeatherDisplay from './weatherdisplay.mjs';
|
||||||
import { registerDisplay } from './navigation.mjs';
|
import { registerDisplay } from './navigation.mjs';
|
||||||
import * as utils from './regionalforecast-utils.mjs';
|
import * as utils from './regionalforecast-utils.mjs';
|
||||||
import { getPoint } from './utils/weather.mjs';
|
import { getPoint } from './utils/weather.mjs';
|
||||||
import { debugFlag } from './utils/debug.mjs';
|
import { debugFlag } from './utils/debug.mjs';
|
||||||
|
import filterExpiredPeriods from './utils/forecast-utils.mjs';
|
||||||
|
|
||||||
// map offset
|
// map offset
|
||||||
const mapOffsetXY = {
|
const mapOffsetXY = {
|
||||||
@@ -78,9 +79,6 @@ class RegionalForecast extends WeatherDisplay {
|
|||||||
// get a unit converter
|
// get a unit converter
|
||||||
const temperatureConverter = temperatureUnit();
|
const temperatureConverter = temperatureUnit();
|
||||||
|
|
||||||
// get now as DateTime for calculations below
|
|
||||||
const now = DateTime.now();
|
|
||||||
|
|
||||||
// get regional forecasts and observations using centralized safe Promise handling
|
// get regional forecasts and observations using centralized safe Promise handling
|
||||||
const regionalDataAll = await safePromiseAll(regionalCities.map(async (city) => {
|
const regionalDataAll = await safePromiseAll(regionalCities.map(async (city) => {
|
||||||
try {
|
try {
|
||||||
@@ -124,25 +122,20 @@ class RegionalForecast extends WeatherDisplay {
|
|||||||
// preload the icon
|
// preload the icon
|
||||||
preloadImg(getSmallIcon(regionalObservation.icon, !regionalObservation.daytime));
|
preloadImg(getSmallIcon(regionalObservation.icon, !regionalObservation.daytime));
|
||||||
|
|
||||||
// return a pared-down forecast
|
// filter out expired periods first, then use the next two periods for forecast
|
||||||
// 0th object should contain the current conditions, but when WFOs go offline or otherwise don't post
|
const activePeriods = filterExpiredPeriods(forecast.properties.periods);
|
||||||
// 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
|
// ensure we have enough periods for forecast
|
||||||
const { periods } = forecast.properties;
|
if (activePeriods.length < 3) {
|
||||||
const currentPeriod = periods.reduce((prev, period, index) => {
|
console.warn(`Insufficient active periods for ${city.Name ?? city.city}: only ${activePeriods.length} periods available`);
|
||||||
const start = DateTime.fromISO(period.startTime);
|
return false;
|
||||||
const end = DateTime.fromISO(period.endTime);
|
}
|
||||||
const interval = Interval.fromDateTimes(start, end);
|
|
||||||
if (interval.contains(now)) {
|
|
||||||
return index;
|
|
||||||
}
|
|
||||||
return prev;
|
|
||||||
}, 0);
|
|
||||||
// group together the current observation and next two periods
|
// group together the current observation and next two periods
|
||||||
return [
|
return [
|
||||||
regionalObservation,
|
regionalObservation,
|
||||||
utils.buildForecast(forecast.properties.periods[currentPeriod + 1], city, cityXY),
|
utils.buildForecast(activePeriods[1], city, cityXY),
|
||||||
utils.buildForecast(forecast.properties.periods[currentPeriod + 2], city, cityXY),
|
utils.buildForecast(activePeriods[2], city, cityXY),
|
||||||
];
|
];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Unexpected error getting Regional Forecast data for '${city.name ?? city.city}': ${error.message}`);
|
console.error(`Unexpected error getting Regional Forecast data for '${city.name ?? city.city}': ${error.message}`);
|
||||||
|
|||||||
669
server/scripts/modules/utils/mapclick.mjs
Normal file
669
server/scripts/modules/utils/mapclick.mjs
Normal file
@@ -0,0 +1,669 @@
|
|||||||
|
/**
|
||||||
|
* MapClick API Fallback Utility
|
||||||
|
*
|
||||||
|
* Provides fallback functionality to fetch weather data from forecast.weather.gov's MapClick API
|
||||||
|
* when the primary api.weather.gov data is stale or incomplete.
|
||||||
|
*
|
||||||
|
* MapClick uses the SBN feed which typically has faster METAR (airport) station updates
|
||||||
|
* but is limited to airport stations only. The primary API uses MADIS which is more
|
||||||
|
* comprehensive but can have delayed ingestion.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { safeJson } from './fetch.mjs';
|
||||||
|
import { debugFlag } from './debug.mjs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse MapClick date format to JavaScript Date
|
||||||
|
* @param {string} dateString - Format: "18 Jun 23:53 pm EDT"
|
||||||
|
* @returns {Date|null} - Parsed date or null if invalid
|
||||||
|
*/
|
||||||
|
export const parseMapClickDate = (dateString) => {
|
||||||
|
try {
|
||||||
|
// Extract components using regex
|
||||||
|
const match = dateString.match(/(\d{1,2})\s+(\w{3})\s+(\d{1,2}):(\d{2})\s+(am|pm)\s+(\w{3})/i);
|
||||||
|
if (!match) return null;
|
||||||
|
|
||||||
|
const [, day, month, hour, minute, ampm, timezone] = match;
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
|
||||||
|
// Convert to 12-hour format since we have AM/PM
|
||||||
|
let hour12 = parseInt(hour, 10);
|
||||||
|
// If it's in 24-hour format but we have AM/PM, convert it
|
||||||
|
if (hour12 > 12) {
|
||||||
|
hour12 -= 12;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconstruct in a format that Date.parse understands (12-hour format with AM/PM)
|
||||||
|
const standardFormat = `${month} ${day}, ${currentYear} ${hour12}:${minute}:00 ${ampm.toUpperCase()} ${timezone}`;
|
||||||
|
|
||||||
|
const parsedDate = new Date(standardFormat);
|
||||||
|
|
||||||
|
// Check if the date is valid
|
||||||
|
if (Number.isNaN(parsedDate.getTime())) {
|
||||||
|
console.warn(`MapClick: Invalid date parsed from: ${dateString} -> ${standardFormat}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsedDate;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`MapClick: Failed to parse date: ${dateString}`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize icon name to determine if it's night and get base name for mapping
|
||||||
|
* @param {string} iconName - Icon name without extension
|
||||||
|
* @returns {Object} - { isNightTime: boolean, baseIconName: string }
|
||||||
|
*/
|
||||||
|
const normalizeIconName = (iconName) => {
|
||||||
|
// Handle special cases where 'n' is not a prefix (hi_nshwrs, hi_ntsra)
|
||||||
|
const hiNightMatch = iconName.match(/^hi_n(.+)/);
|
||||||
|
if (hiNightMatch) {
|
||||||
|
return {
|
||||||
|
isNightTime: true,
|
||||||
|
baseIconName: `hi_${hiNightMatch[1]}`, // Reconstruct as hi_[condition]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle the general 'n' prefix rule (including nra, nwind_skc, etc.)
|
||||||
|
if (iconName.startsWith('n')) {
|
||||||
|
return {
|
||||||
|
isNightTime: true,
|
||||||
|
baseIconName: iconName.substring(1), // Strip the 'n' prefix
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not a night icon
|
||||||
|
return {
|
||||||
|
isNightTime: false,
|
||||||
|
baseIconName: iconName,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert MapClick weather image filename to weather.gov API icon format
|
||||||
|
* @param {string} weatherImage - MapClick weather image filename (e.g., 'bkn.png')
|
||||||
|
* @returns {string|null} - Weather.gov API icon URL or null if invalid/missing
|
||||||
|
*/
|
||||||
|
const convertMapClickIcon = (weatherImage) => {
|
||||||
|
// Return null for missing, invalid, or NULL values - let caller handle defaults
|
||||||
|
if (!weatherImage || weatherImage === 'NULL' || weatherImage === 'NA') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove .png extension if present
|
||||||
|
const iconName = weatherImage.replace('.png', '');
|
||||||
|
|
||||||
|
// Determine if this is a night icon and get the base name for mapping
|
||||||
|
const { isNightTime, baseIconName } = normalizeIconName(iconName);
|
||||||
|
const timeOfDay = isNightTime ? 'night' : 'day';
|
||||||
|
|
||||||
|
// MapClick icon filename to weather.gov API condition mapping
|
||||||
|
// This maps MapClick specific icon names to standard API icon names
|
||||||
|
// Night variants are handled by stripping 'n' prefix before lookup
|
||||||
|
// based on https://www.weather.gov/forecast-icons/
|
||||||
|
const iconMapping = {
|
||||||
|
// Clear/Fair conditions
|
||||||
|
skc: 'skc', // Clear sky condition
|
||||||
|
|
||||||
|
// Cloud coverage
|
||||||
|
few: 'few', // A few clouds
|
||||||
|
sct: 'sct', // Scattered clouds / Partly cloudy
|
||||||
|
bkn: 'bkn', // Broken clouds / Mostly cloudy
|
||||||
|
ovc: 'ovc', // Overcast
|
||||||
|
|
||||||
|
// Light Rain + Drizzle
|
||||||
|
minus_ra: 'rain', // Light rain -> rain
|
||||||
|
ra: 'rain', // Rain
|
||||||
|
// Note: nra.png is used for both light rain and rain at night
|
||||||
|
// but normalizeIconName strips the 'n' to get 'ra' which maps to 'rain'
|
||||||
|
|
||||||
|
// Snow variants
|
||||||
|
sn: 'snow', // Snow
|
||||||
|
|
||||||
|
// Rain + Snow combinations
|
||||||
|
ra_sn: 'rain_snow', // Rain snow
|
||||||
|
rasn: 'rain_snow', // Standard rain snow
|
||||||
|
|
||||||
|
// Ice Pellets/Sleet
|
||||||
|
raip: 'rain_sleet', // Rain ice pellets -> rain_sleet
|
||||||
|
ip: 'sleet', // Ice pellets
|
||||||
|
|
||||||
|
// Freezing Rain
|
||||||
|
ra_fzra: 'rain_fzra', // Rain freezing rain -> rain_fzra
|
||||||
|
fzra: 'fzra', // Freezing rain
|
||||||
|
|
||||||
|
// Freezing Rain + Snow
|
||||||
|
fzra_sn: 'snow_fzra', // Freezing rain snow -> snow_fzra
|
||||||
|
|
||||||
|
// Snow + Ice Pellets
|
||||||
|
snip: 'snow_sleet', // Snow ice pellets -> snow_sleet
|
||||||
|
|
||||||
|
// Showers
|
||||||
|
hi_shwrs: 'rain_showers_hi', // Isolated showers -> rain_showers_hi
|
||||||
|
shra: 'rain_showers', // Showers -> rain_showers
|
||||||
|
|
||||||
|
// Thunderstorms
|
||||||
|
tsra: 'tsra', // Thunderstorm
|
||||||
|
scttsra: 'tsra_sct', // Scattered thunderstorm -> tsra_sct
|
||||||
|
hi_tsra: 'tsra_hi', // Isolated thunderstorm -> tsra_hi
|
||||||
|
|
||||||
|
// Fog
|
||||||
|
fg: 'fog', // Fog
|
||||||
|
|
||||||
|
// Wind conditions
|
||||||
|
wind_skc: 'wind_skc', // Clear and windy
|
||||||
|
wind_few: 'wind_few', // Few clouds and windy
|
||||||
|
wind_sct: 'wind_sct', // Scattered clouds and windy
|
||||||
|
wind_bkn: 'wind_bkn', // Broken clouds and windy
|
||||||
|
wind_ovc: 'wind_ovc', // Overcast and windy
|
||||||
|
|
||||||
|
// Extreme weather
|
||||||
|
blizzard: 'blizzard', // Blizzard
|
||||||
|
cold: 'cold', // Cold
|
||||||
|
hot: 'hot', // Hot
|
||||||
|
du: 'dust', // Dust
|
||||||
|
fu: 'smoke', // Smoke
|
||||||
|
hz: 'haze', // Haze
|
||||||
|
|
||||||
|
// Tornadoes
|
||||||
|
fc: 'tornado', // Funnel cloud
|
||||||
|
tor: 'tornado', // Tornado
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get the mapped condition, return null if not found in the mapping
|
||||||
|
const condition = iconMapping[baseIconName];
|
||||||
|
if (!condition) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `/icons/land/${timeOfDay}/${condition}?size=medium`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert MapClick observation data to match the standard API format
|
||||||
|
*
|
||||||
|
* This is NOT intended to be a full replacment process, but rather a minimal
|
||||||
|
* fallback for the data used in WS4KP.
|
||||||
|
*
|
||||||
|
* @param {Object} mapClickObs - MapClick observation data
|
||||||
|
* @returns {Object} - Data formatted to match api.weather.gov structure
|
||||||
|
*/
|
||||||
|
export const convertMapClickObservationsToApiFormat = (mapClickObs) => {
|
||||||
|
// Convert temperature from Fahrenheit to Celsius (only if valid)
|
||||||
|
const tempF = parseFloat(mapClickObs.Temp);
|
||||||
|
const tempC = !Number.isNaN(tempF) ? (tempF - 32) * 5 / 9 : null;
|
||||||
|
|
||||||
|
const dewpF = parseFloat(mapClickObs.Dewp);
|
||||||
|
const dewpC = !Number.isNaN(dewpF) ? (dewpF - 32) * 5 / 9 : null;
|
||||||
|
|
||||||
|
// Convert wind speed from mph to km/h (only if valid)
|
||||||
|
const windMph = parseFloat(mapClickObs.Winds);
|
||||||
|
const windKmh = !Number.isNaN(windMph) ? windMph * 1.60934 : null;
|
||||||
|
|
||||||
|
// Convert wind gust from mph to km/h (only if valid and not "NA")
|
||||||
|
const gustMph = mapClickObs.Gust !== 'NA' ? parseFloat(mapClickObs.Gust) : NaN;
|
||||||
|
const windGust = !Number.isNaN(gustMph) ? gustMph * 1.60934 : null;
|
||||||
|
|
||||||
|
// Convert wind direction (only if valid)
|
||||||
|
const windDir = parseFloat(mapClickObs.Windd);
|
||||||
|
const windDirection = !Number.isNaN(windDir) ? windDir : null;
|
||||||
|
|
||||||
|
// Convert pressure from inHg to Pa (only if valid)
|
||||||
|
const pressureInHg = parseFloat(mapClickObs.SLP);
|
||||||
|
const pressurePa = !Number.isNaN(pressureInHg) ? pressureInHg * 3386.39 : null;
|
||||||
|
|
||||||
|
// Convert visibility from miles to meters (only if valid)
|
||||||
|
const visibilityMiles = parseFloat(mapClickObs.Visibility);
|
||||||
|
const visibilityMeters = !Number.isNaN(visibilityMiles) ? visibilityMiles * 1609.34 : null;
|
||||||
|
|
||||||
|
// Convert relative humidity (only if valid)
|
||||||
|
const relh = parseFloat(mapClickObs.Relh);
|
||||||
|
const relativeHumidity = !Number.isNaN(relh) ? relh : null;
|
||||||
|
|
||||||
|
// Convert wind chill from Fahrenheit to Celsius (only if valid and not "NA")
|
||||||
|
const windChillF = mapClickObs.WindChill !== 'NA' ? parseFloat(mapClickObs.WindChill) : NaN;
|
||||||
|
const windChill = !Number.isNaN(windChillF) ? (windChillF - 32) * 5 / 9 : null;
|
||||||
|
|
||||||
|
// Convert MapClick weather image to weather.gov API icon format
|
||||||
|
const iconUrl = convertMapClickIcon(mapClickObs.Weatherimage);
|
||||||
|
|
||||||
|
return {
|
||||||
|
features: [
|
||||||
|
{
|
||||||
|
properties: {
|
||||||
|
timestamp: parseMapClickDate(mapClickObs.Date)?.toISOString() || new Date().toISOString(),
|
||||||
|
temperature: { value: tempC, unitCode: 'wmoUnit:degC' },
|
||||||
|
dewpoint: { value: dewpC, unitCode: 'wmoUnit:degC' },
|
||||||
|
windDirection: { value: windDirection, unitCode: 'wmoUnit:degree_(angle)' },
|
||||||
|
windSpeed: { value: windKmh, unitCode: 'wmoUnit:km_h-1' },
|
||||||
|
windGust: { value: windGust, unitCode: 'wmoUnit:km_h-1' },
|
||||||
|
barometricPressure: { value: pressurePa, unitCode: 'wmoUnit:Pa' },
|
||||||
|
visibility: { value: visibilityMeters, unitCode: 'wmoUnit:m' },
|
||||||
|
relativeHumidity: { value: relativeHumidity, unitCode: 'wmoUnit:percent' },
|
||||||
|
textDescription: mapClickObs.Weather || null,
|
||||||
|
icon: iconUrl, // Can be null if no valid icon available
|
||||||
|
heatIndex: { value: null },
|
||||||
|
windChill: { value: windChill },
|
||||||
|
cloudLayers: [], // no cloud layer data available from MapClick
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert MapClick forecast data to weather.gov API forecast format
|
||||||
|
* @param {Object} mapClickData - Raw MapClick response data
|
||||||
|
* @returns {Object|null} - Forecast data in API format or null if invalid
|
||||||
|
*/
|
||||||
|
export const convertMapClickForecastToApiFormat = (mapClickData) => {
|
||||||
|
if (!mapClickData?.data || !mapClickData?.time) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, time } = mapClickData;
|
||||||
|
const {
|
||||||
|
temperature, weather, iconLink, text, pop,
|
||||||
|
} = data;
|
||||||
|
|
||||||
|
if (!temperature || !weather || !iconLink || !text || !time.startValidTime || !time.startPeriodName) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert each forecast period
|
||||||
|
const periods = temperature.map((temp, index) => {
|
||||||
|
if (index >= weather.length || index >= iconLink.length || index >= text.length || index >= time.startValidTime.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine if this is a daytime period based on the period name
|
||||||
|
const periodName = time.startPeriodName[index] || '';
|
||||||
|
const isDaytime = !periodName.toLowerCase().includes('night');
|
||||||
|
|
||||||
|
// Convert icon from MapClick format to API format
|
||||||
|
let icon = iconLink[index];
|
||||||
|
if (icon) {
|
||||||
|
let filename = null;
|
||||||
|
|
||||||
|
// Handle DualImage.php URLs: extract from 'i' parameter
|
||||||
|
if (icon.includes('DualImage.php')) {
|
||||||
|
const iMatch = icon.match(/[?&]i=([^&]+)/);
|
||||||
|
if (iMatch) {
|
||||||
|
[, filename] = iMatch;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Handle regular image URLs: extract filename from path, removing percentage numbers
|
||||||
|
const pathMatch = icon.match(/\/([^/]+?)(?:\d+)?(?:\.png)?$/);
|
||||||
|
if (pathMatch) {
|
||||||
|
[, filename] = pathMatch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filename) {
|
||||||
|
icon = convertMapClickIcon(filename);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
number: index + 1,
|
||||||
|
name: periodName,
|
||||||
|
startTime: time.startValidTime[index],
|
||||||
|
endTime: index + 1 < time.startValidTime.length ? time.startValidTime[index + 1] : null,
|
||||||
|
isDaytime,
|
||||||
|
temperature: parseInt(temp, 10),
|
||||||
|
temperatureUnit: 'F',
|
||||||
|
temperatureTrend: null,
|
||||||
|
probabilityOfPrecipitation: {
|
||||||
|
unitCode: 'wmoUnit:percent',
|
||||||
|
value: pop[index] ? parseInt(pop[index], 10) : null,
|
||||||
|
},
|
||||||
|
dewpoint: {
|
||||||
|
unitCode: 'wmoUnit:degC',
|
||||||
|
value: null, // MapClick doesn't provide dewpoint in forecast
|
||||||
|
},
|
||||||
|
relativeHumidity: {
|
||||||
|
unitCode: 'wmoUnit:percent',
|
||||||
|
value: null, // MapClick doesn't provide humidity in forecast
|
||||||
|
},
|
||||||
|
windSpeed: null, // MapClick doesn't provide wind speed in forecast
|
||||||
|
windDirection: null, // MapClick doesn't provide wind direction in forecast
|
||||||
|
icon,
|
||||||
|
shortForecast: weather[index],
|
||||||
|
detailedForecast: text[index],
|
||||||
|
};
|
||||||
|
}).filter((period) => period !== null);
|
||||||
|
|
||||||
|
// Return in API forecast format
|
||||||
|
return {
|
||||||
|
type: 'Feature',
|
||||||
|
geometry: {
|
||||||
|
type: '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,
|
||||||
|
};
|
||||||
@@ -26,6 +26,10 @@ const rewriteUrl = (_url) => {
|
|||||||
url.protocol = window.location.protocol;
|
url.protocol = window.location.protocol;
|
||||||
url.host = window.location.host;
|
url.host = window.location.host;
|
||||||
url.pathname = `/api${url.pathname}`;
|
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') {
|
} else if (url.origin === 'https://www.spc.noaa.gov') {
|
||||||
url.protocol = window.location.protocol;
|
url.protocol = window.location.protocol;
|
||||||
url.host = window.location.host;
|
url.host = window.location.host;
|
||||||
|
|||||||
Reference in New Issue
Block a user