diff --git a/README.md b/README.md
index fdf2a90..579d905 100644
--- a/README.md
+++ b/README.md
@@ -48,9 +48,18 @@ Open your browser and navigate to https://localhost:8080
## Does WeatherStar 4000+ work outside of the USA?
-This project is tightly coupled to [NOAA's Weather API](https://www.weather.gov/documentation/services-web-api), which is exclusive to the United States. Using NOAA's Weather API is a crucial requirement to provide an authentic WeatherStar 4000+ experience.
+Yes for the core forecast screens. The main weather flow now uses Open-Meteo so search, current conditions, hourly, local forecast, extended forecast, and almanac work internationally.
-If you would like to display weather information for international locations (outside of the USA), please checkout a fork of this project created by [@mwood77](https://github.com/mwood77):
+Some legacy displays still rely on [NOAA's Weather API](https://www.weather.gov/documentation/services-web-api) and remain available only for United States locations for now:
+
+* Hazards
+* Latest Observations
+* Regional Forecast
+* Travel Forecast
+* SPC Outlook
+* Local Radar
+
+Earlier international work on this idea was explored in a fork created by [@mwood77](https://github.com/mwood77):
- [`ws4kp-international`](https://github.com/mwood77/ws4kp-international)
## Deployment Modes
diff --git a/index.mjs b/index.mjs
index 0f5e1e8..2e06e51 100644
--- a/index.mjs
+++ b/index.mjs
@@ -5,7 +5,7 @@ import { readFile } from 'fs/promises';
import { exec } from 'child_process';
import { promisify } from 'util';
import {
- weatherProxy, radarProxy, outlookProxy, mesonetProxy, forecastProxy,
+ weatherProxy, radarProxy, outlookProxy, mesonetProxy, forecastProxy, openMeteoProxy,
} from './proxy/handlers.mjs';
import playlist from './src/playlist.mjs';
import OVERRIDES from './src/overrides.mjs';
@@ -253,6 +253,7 @@ if (!process.env?.STATIC) {
app.use('/spc/', outlookProxy);
app.use('/mesonet/', mesonetProxy);
app.use('/forecast/', forecastProxy);
+ app.use('/open-meteo/', openMeteoProxy);
// 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 7440064..f368462 100644
--- a/proxy/handlers.mjs
+++ b/proxy/handlers.mjs
@@ -50,3 +50,10 @@ export const forecastProxy = async (req, res) => {
skipParams: ['u'],
});
};
+
+export const openMeteoProxy = async (req, res) => {
+ await cache.handleRequest(req, res, 'https://api.open-meteo.com', {
+ serviceName: 'Open-Meteo',
+ skipParams: ['u'],
+ });
+};
diff --git a/server/scripts/index.mjs b/server/scripts/index.mjs
index a64b1ac..47a19f2 100644
--- a/server/scripts/index.mjs
+++ b/server/scripts/index.mjs
@@ -30,6 +30,7 @@ const category = categories.join(',');
const TXT_ADDRESS_SELECTOR = '#txtLocation';
const TOGGLE_FULL_SCREEN_SELECTOR = '#ToggleFullScreen';
const BNT_GET_GPS_SELECTOR = '#btnGetGps';
+const LOCATION_STORAGE_KEY = 'latLonLocation';
const init = async () => {
// Load core data first - app cannot function without it
@@ -86,7 +87,6 @@ const init = async () => {
paramName: 'text',
params: {
f: 'json',
- countryCode: 'USA',
category,
maxSuggestions: 10,
},
@@ -117,9 +117,9 @@ const init = async () => {
if (parsedParameters.latLonQuery && !parsedParameters.latLon) {
const txtAddress = document.querySelector(TXT_ADDRESS_SELECTOR);
txtAddress.value = parsedParameters.latLonQuery;
- const geometry = await geocodeLatLonQuery(parsedParameters.latLonQuery);
- if (geometry) {
- doRedirectToGeometry(geometry);
+ const locationResult = await geocodeLatLonQuery(parsedParameters.latLonQuery);
+ if (locationResult?.geometry) {
+ doRedirectToGeometry(locationResult.geometry, undefined, locationResult.location);
}
} else if (latLon && !fromGPS) {
// update in-page search box if using cached data, or parsed parameter
@@ -171,6 +171,7 @@ const init = async () => {
localStorage.removeItem('latLonQuery');
localStorage.removeItem('latLon');
+ localStorage.removeItem(LOCATION_STORAGE_KEY);
localStorage.removeItem('latLonFromGPS');
document.querySelector(BNT_GET_GPS_SELECTOR).classList.remove('active');
});
@@ -182,6 +183,24 @@ const init = async () => {
// register hidden settings for search and location query
registerHiddenSetting('latLonQuery', () => localStorage.getItem('latLonQuery'));
registerHiddenSetting('latLon', () => localStorage.getItem('latLon'));
+ registerHiddenSetting('latLonLocation', () => localStorage.getItem(LOCATION_STORAGE_KEY));
+};
+
+const normalizeArcGisLocation = (rawLocation = {}, fallbackLabel = '') => {
+ const attributes = rawLocation.attributes ?? rawLocation.address ?? {};
+ const countryCode = attributes.CountryCode ?? attributes.countryCode ?? attributes.country_code ?? null;
+ const country = attributes.Country ?? attributes.countryName ?? attributes.country ?? null;
+ const state = attributes.RegionAbbr ?? attributes.Region ?? attributes.Subregion ?? attributes.region ?? '';
+ const city = attributes.City ?? attributes.CityName ?? attributes.MetroArea ?? rawLocation.name ?? '';
+ const label = fallbackLabel || rawLocation.name || [city, state || country].filter(Boolean).join(', ');
+
+ return {
+ city,
+ state,
+ country,
+ countryCode,
+ label,
+ };
};
const geocodeLatLonQuery = async (query) => {
@@ -195,7 +214,10 @@ const geocodeLatLonQuery = async (query) => {
const loc = data.locations?.[0];
if (loc) {
- return loc.feature.geometry;
+ return {
+ geometry: loc.feature.geometry,
+ location: normalizeArcGisLocation(loc, query),
+ };
}
return null;
} catch (error) {
@@ -204,6 +226,28 @@ const geocodeLatLonQuery = async (query) => {
}
};
+const reverseGeocodeLatLon = async (latitude, longitude) => {
+ try {
+ const data = await json('https://geocode.arcgis.com/arcgis/rest/services/World/GeocodeServer/reverseGeocode', {
+ data: {
+ location: `${longitude},${latitude}`,
+ f: 'json',
+ },
+ });
+
+ if (!data) return null;
+
+ const location = normalizeArcGisLocation(data, `${round2(latitude, 4)}, ${round2(longitude, 4)}`);
+ return {
+ location,
+ label: location.label,
+ };
+ } catch (error) {
+ console.error('Reverse geocoding failed:', error);
+ return null;
+ }
+};
+
const autocompleteOnSelect = async (suggestion) => {
// Note: it's fine that this uses json instead of safeJson since it's infrequent and user-initiated
const data = await json('https://geocode.arcgis.com/arcgis/rest/services/World/GeocodeServer/find', {
@@ -218,17 +262,21 @@ const autocompleteOnSelect = async (suggestion) => {
if (loc) {
localStorage.removeItem('latLonFromGPS');
document.querySelector(BNT_GET_GPS_SELECTOR).classList.remove('active');
- doRedirectToGeometry(loc.feature.geometry);
+ doRedirectToGeometry(loc.feature.geometry, undefined, normalizeArcGisLocation(loc, suggestion.value));
} else {
console.error('An unexpected error occurred. Please try a different search string.');
}
};
-const doRedirectToGeometry = (geom, haveDataCallback) => {
+const doRedirectToGeometry = (geom, haveDataCallback, locationMetadata) => {
const latLon = { lat: round2(geom.y, 4), lon: round2(geom.x, 4) };
// Save the query
- localStorage.setItem('latLonQuery', document.querySelector(TXT_ADDRESS_SELECTOR).value);
+ const query = locationMetadata?.label ?? document.querySelector(TXT_ADDRESS_SELECTOR).value;
+ localStorage.setItem('latLonQuery', query);
localStorage.setItem('latLon', JSON.stringify(latLon));
+ if (locationMetadata) {
+ localStorage.setItem(LOCATION_STORAGE_KEY, JSON.stringify(locationMetadata));
+ }
// get the data
loadData(latLon, haveDataCallback);
@@ -477,6 +525,7 @@ const btnGetGpsClick = async () => {
if (btn.classList.contains('active')) {
btn.classList.remove('active');
localStorage.removeItem('latLonFromGPS');
+ localStorage.removeItem(LOCATION_STORAGE_KEY);
return;
}
@@ -487,22 +536,26 @@ const btnGetGpsClick = async () => {
const position = await getPosition();
const { latitude, longitude } = position.coords;
- getForecastFromLatLon(latitude, longitude, true);
+ await getForecastFromLatLon(latitude, longitude, true);
};
-const getForecastFromLatLon = (latitude, longitude, fromGps = false) => {
+
+const getForecastFromLatLon = async (latitude, longitude, fromGps = false) => {
const txtAddress = document.querySelector(TXT_ADDRESS_SELECTOR);
txtAddress.value = `${round2(latitude, 4)}, ${round2(longitude, 4)}`;
+ const reverseLocation = await reverseGeocodeLatLon(latitude, longitude);
+ const locationMetadata = reverseLocation?.location;
+ const query = reverseLocation?.label ?? `${round2(latitude, 4)}, ${round2(longitude, 4)}`;
- doRedirectToGeometry({ y: latitude, x: longitude }, (point) => {
- const location = point.properties.relativeLocation.properties;
- // Save the query
- const query = `${location.city}, ${location.state}`;
- localStorage.setItem('latLon', JSON.stringify({ lat: latitude, lon: longitude }));
- localStorage.setItem('latLonQuery', query);
- localStorage.setItem('latLonFromGPS', fromGps);
- txtAddress.value = `${location.city}, ${location.state}`;
- });
+ localStorage.setItem('latLon', JSON.stringify({ lat: latitude, lon: longitude }));
+ localStorage.setItem('latLonQuery', query);
+ localStorage.setItem('latLonFromGPS', fromGps);
+ if (locationMetadata) {
+ localStorage.setItem(LOCATION_STORAGE_KEY, JSON.stringify(locationMetadata));
+ }
+ txtAddress.value = query;
+
+ doRedirectToGeometry({ y: latitude, x: longitude });
};
const getCustomCode = async () => {
diff --git a/server/scripts/modules/currentweather.mjs b/server/scripts/modules/currentweather.mjs
index 53fbd5a..f81df33 100644
--- a/server/scripts/modules/currentweather.mjs
+++ b/server/scripts/modules/currentweather.mjs
@@ -1,22 +1,14 @@
// current weather conditions display
import STATUS from './status.mjs';
import { preloadImg } from './utils/image.mjs';
-import { safeJson } from './utils/fetch.mjs';
import { directionToNSEW } from './utils/calc.mjs';
-import { locationCleanup } from './utils/string.mjs';
-import { getLargeIcon } from './icons.mjs';
import WeatherDisplay from './weatherdisplay.mjs';
import { registerDisplay } from './navigation.mjs';
-import augmentObservationWithMetar from './utils/metar.mjs';
import {
- temperature, windSpeed, pressure, distanceMeters, distanceKilometers,
+ temperature, windSpeed, pressure, distanceKilometers, distanceMeters,
} from './utils/units.mjs';
-import { debugFlag } from './utils/debug.mjs';
-import { isDataStale, enhanceObservationWithMapClick } from './utils/mapclick.mjs';
-import { DateTime } from '../vendor/auto/luxon.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'];
+import { getConditionText } from './utils/weather.mjs';
+import { getLargeIconFromWmoCode } from './icons.mjs';
class CurrentWeather extends WeatherDisplay {
constructor(navId, elemId) {
@@ -24,217 +16,64 @@ class CurrentWeather extends WeatherDisplay {
}
async getData(weatherParameters, refresh) {
- // always load the data for use in the lower scroll
const superResult = super.getData(weatherParameters, refresh);
- // note: current weather does not use old data on a silent refresh
- // this is deliberate because it can pull data from more than one station in sequence
+ this.data = parseData(this.weatherParameters);
- // filter for 4-letter observation stations, only those contain sky conditions and thus an icon
- const filteredStations = this.weatherParameters.stations.filter((station) => station?.properties?.stationIdentifier?.length === 4 && !skipStations.includes(station.properties.stationIdentifier.slice(0, 1)));
-
- // Load the observations
- let observations;
- let station;
-
- // station number counter
- let stationNum = 0;
- while (!observations && stationNum < filteredStations.length) {
- // get the station
- station = filteredStations[stationNum];
- const stationId = station.properties.stationIdentifier;
-
- stationNum += 1;
-
- let candidateObservation;
- try {
- // eslint-disable-next-line no-await-in-loop
- candidateObservation = await safeJson(`${station.id}/observations`, {
- data: {
- limit: 5, // we need the two most recent observations to calculate pressure direction, and to back fill any missing data
- },
- retryCount: 3,
- stillWaiting: () => this.stillWaiting(),
- });
- } catch (error) {
- console.error(`Unexpected error getting Current Conditions for station ${stationId}: ${error.message} (trying next station)`);
- candidateObservation = undefined;
- }
-
- // Check if request was successful and has data
- if (candidateObservation && candidateObservation.features?.length > 0) {
- // Attempt making observation data usable with METAR data
- const originalData = { ...candidateObservation.features[0].properties };
- candidateObservation.features[0].properties = augmentObservationWithMetar(candidateObservation.features[0].properties);
- const metarFields = [
- { name: 'temperature', check: (orig, metar) => orig.temperature?.value === null && metar.temperature?.value !== null },
- { name: 'windSpeed', check: (orig, metar) => orig.windSpeed?.value === null && metar.windSpeed?.value !== null },
- { name: 'windDirection', check: (orig, metar) => orig.windDirection?.value === null && metar.windDirection?.value !== null },
- { name: 'windGust', check: (orig, metar) => orig.windGust?.value === null && metar.windGust?.value !== null },
- { name: 'dewpoint', check: (orig, metar) => orig.dewpoint?.value === null && metar.dewpoint?.value !== null },
- { name: 'barometricPressure', check: (orig, metar) => orig.barometricPressure?.value === null && metar.barometricPressure?.value !== null },
- { name: 'relativeHumidity', check: (orig, metar) => orig.relativeHumidity?.value === null && metar.relativeHumidity?.value !== null },
- { name: 'visibility', check: (orig, metar) => orig.visibility?.value === null && metar.visibility?.value !== null },
- { name: 'ceiling', check: (orig, metar) => orig.cloudLayers?.[0]?.base?.value === null && metar.cloudLayers?.[0]?.base?.value !== null },
- ];
- const augmentedData = candidateObservation.features[0].properties;
- const metarReplacements = metarFields.filter((field) => field.check(originalData, augmentedData)).map((field) => field.name);
- if (debugFlag('currentweather') && metarReplacements.length > 0) {
- console.log(`Current Conditions for station ${stationId} were augmented with METAR data for ${metarReplacements.join(', ')}`);
- }
-
- // test data quality - check required fields and allow one optional field to be missing
- const requiredFields = [
- { name: 'temperature', check: (props) => props.temperature?.value === null, required: true },
- { name: 'textDescription', check: (props) => props.textDescription === null || props.textDescription === '', required: true },
- { name: 'windSpeed', check: (props) => props.windSpeed?.value === null, required: false },
- { name: 'dewpoint', check: (props) => props.dewpoint?.value === null, required: false },
- { name: 'barometricPressure', check: (props) => props.barometricPressure?.value === null, required: false },
- { name: 'visibility', check: (props) => props.visibility?.value === null, required: false },
- { name: 'relativeHumidity', check: (props) => props.relativeHumidity?.value === null, required: false },
- { name: 'ceiling', check: (props) => props.cloudLayers?.[0]?.base?.value === null, required: false },
- ];
-
- // Use enhanced observation with MapClick fallback
- // eslint-disable-next-line no-await-in-loop
- const enhancedResult = await enhanceObservationWithMapClick(augmentedData, {
- requiredFields,
- maxOptionalMissing: 1, // Allow one optional field to be missing
- stationId,
- stillWaiting: () => this.stillWaiting(),
- debugContext: 'currentweather',
- });
-
- candidateObservation.features[0].properties = enhancedResult.data;
- const { missingFields } = enhancedResult;
- const missingRequired = missingFields.filter((fieldName) => {
- const field = requiredFields.find((f) => f.name === fieldName && f.required);
- return !!field;
- });
- const missingOptional = missingFields.filter((fieldName) => {
- const field = requiredFields.find((f) => f.name === fieldName && !f.required);
- return !!field;
- });
- const missingOptionalCount = missingOptional.length;
-
- // Check final data quality
- // Allow one optional field to be missing
- if (missingRequired.length === 0 && missingOptionalCount <= 1) {
- // Station data is good, use it
- observations = candidateObservation;
- if (debugFlag('currentweather') && missingOptional.length > 0) {
- console.log(`Data for station ${stationId} is missing optional fields: ${missingOptional.join(', ')} (acceptable)`);
- }
- } else {
- const allMissing = [...missingRequired, ...missingOptional];
- if (debugFlag('currentweather')) {
- console.log(`Data for station ${stationId} is missing fields: ${allMissing.join(', ')} (${missingRequired.length} required, ${missingOptionalCount} optional) (trying next station)`);
- }
- }
- } else if (debugFlag('verbose-failures')) {
- if (!candidateObservation) {
- console.log(`Current Conditions for station ${stationId} failed, trying next station`);
- } else {
- console.log(`No features returned for station ${stationId}, trying next station`);
- }
- }
- }
- // test for data received
- if (!observations) {
- console.error('Current Conditions failure: all nearby weather stations exhausted!');
+ if (!this.data) {
if (this.isEnabled) this.setStatus(STATUS.failed);
- // send failed to subscribers
this.getDataCallback(undefined);
return;
}
- // we only get here if there was no error above
- this.data = parseData({ ...observations, station });
this.getDataCallback();
- // stop here if we're disabled
if (!superResult) return;
- // 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);
- }
- }
+ preloadImg(this.data.Icon);
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;
+ let condition = getConditionText(this.data.TextConditions);
if (condition.length > 15) {
condition = shortConditions(condition);
}
- const wind = (typeof this.data.WindSpeed === 'number') ? this.data.WindDirection.padEnd(3, '') + this.data.WindSpeed.toString().padStart(3, ' ') : this.data.WindSpeed;
-
- // get location (city name) from StationInfo if available (allows for overrides)
- const location = (StationInfo[this.data.station.properties.stationIdentifier]?.city ?? locationCleanup(this.data.station.properties.name)).substr(0, 20);
+ const wind = this.data.WindSpeed > 0
+ ? this.data.WindDirection.padEnd(3, '') + this.data.WindSpeed.toString().padStart(3, ' ')
+ : 'Calm';
const fill = {
temp: this.data.Temperature + String.fromCharCode(176),
condition,
wind,
- location,
+ location: this.data.city.substr(0, 20),
humidity: `${this.data.Humidity}%`,
dewpoint: this.data.DewPoint + String.fromCharCode(176),
- ceiling: (this.data.Ceiling === 0 ? 'Unlimited' : this.data.Ceiling + this.data.CeilingUnit),
- visibility: this.data.Visibility + this.data.VisibilityUnit,
+ ceiling: this.data.Ceiling === 0 ? 'Unlimited' : `${this.data.Ceiling}${this.data.CeilingUnit}`,
+ visibility: `${this.data.Visibility}${this.data.VisibilityUnit}`,
pressure: `${this.data.Pressure} ${this.data.PressureDirection}`,
icon: { type: 'img', src: this.data.Icon },
};
- if (this.data.WindGust !== '-') fill['wind-gusts'] = `Gusts to ${this.data.WindGust}`;
-
- if (this.data.observations.heatIndex.value && this.data.HeatIndex !== this.data.Temperature) {
- fill['heat-index-label'] = 'Heat Index:';
- fill['heat-index'] = this.data.HeatIndex + String.fromCharCode(176);
- } else if (this.data.observations.windChill.value && this.data.WindChill !== '' && this.data.WindChill < this.data.Temperature) {
- fill['heat-index-label'] = 'Wind Chill:';
- fill['heat-index'] = this.data.WindChill + String.fromCharCode(176);
- }
+ if (this.data.WindGust > 0) fill['wind-gusts'] = `Gusts to ${this.data.WindGust}`;
const area = this.elem.querySelector('.main');
-
area.innerHTML = '';
area.append(this.fillTemplate('weather', fill));
this.finishDraw();
}
- // make data available outside this class
- // promise allows for data to be requested before it is available
async getCurrentWeather(stillWaiting) {
- // an external caller has requested data, set up auto reload
this.setAutoReload();
if (stillWaiting) this.stillWaitingCallbacks.push(stillWaiting);
return new Promise((resolve) => {
if (this.data) resolve({ data: this.data, parameters: this.weatherParameters });
- // data not available, put it into the data callback queue
- this.getDataCallbacks.push(() => resolve(this.data));
+ this.getDataCallbacks.push(() => resolve({ data: this.data, parameters: this.weatherParameters }));
});
}
}
@@ -245,108 +84,77 @@ const shortConditions = (_condition) => {
condition = condition.replace(/Heavy/g, 'H');
condition = condition.replace(/Partly/g, 'P');
condition = condition.replace(/Mostly/g, 'M');
- condition = condition.replace(/Few/g, 'F');
condition = condition.replace(/Thunderstorm/g, 'T\'storm');
- condition = condition.replace(/ in /g, '');
- condition = condition.replace(/Vicinity/g, '');
condition = condition.replace(/ and /g, ' ');
condition = condition.replace(/Freezing Rain/g, 'Frz Rn');
condition = condition.replace(/Freezing/g, 'Frz');
- condition = condition.replace(/Unknown Precip/g, '');
- condition = condition.replace(/L Snow Fog/g, 'L Snw/Fog');
condition = condition.replace(/ with /g, '/');
return condition;
};
-// format the received data
-const parseData = (data) => {
- // get the unit converter
- const windConverter = windSpeed();
+const getCurrentWeatherByHourFromTime = (data) => {
+ const currentTime = new Date();
+ const currentDateKey = currentTime.toLocaleDateString('en-CA', { timeZone: data.timeZone });
+ const availableTimes = data.forecast[currentDateKey]?.hours ?? Object.values(data.forecast)[0]?.hours ?? [];
+ if (availableTimes.length === 0) return null;
+
+ const closestTime = availableTimes.reduce((prev, curr) => {
+ const prevDiff = Math.abs(new Date(prev.time) - currentTime);
+ const currDiff = Math.abs(new Date(curr.time) - currentTime);
+ return currDiff < prevDiff ? curr : prev;
+ });
+
+ const threeHoursAgo = new Date(currentTime.getTime() - 3 * 60 * 60 * 1000);
+ const previousHour = availableTimes
+ .filter((entry) => new Date(entry.time) <= currentTime && new Date(entry.time) >= threeHoursAgo)
+ .reduce((prev, curr) => {
+ const prevDiff = Math.abs(new Date(prev.time) - threeHoursAgo);
+ const currDiff = Math.abs(new Date(curr.time) - threeHoursAgo);
+ return currDiff < prevDiff ? curr : prev;
+ }, availableTimes[0]);
+
+ const diff = (closestTime.pressure_msl ?? 0) - (previousHour.pressure_msl ?? 0);
+ let pressureTrend = 'Steady';
+ if (diff > 0.5) pressureTrend = 'Rising';
+ if (diff < -0.5) pressureTrend = 'Falling';
+ closestTime.pressureTrend = pressureTrend;
+ closestTime.uv_index_max = data.forecast[currentDateKey]?.uv_index_max ?? closestTime.uv_index ?? 0;
+ return closestTime;
+};
+
+const parseData = (weatherParameters) => {
+ const currentForecast = getCurrentWeatherByHourFromTime(weatherParameters);
+ if (!currentForecast) return null;
+
const temperatureConverter = temperature();
- const metersConverter = distanceMeters();
- const kilometersConverter = distanceKilometers();
+ const windConverter = windSpeed();
const pressureConverter = pressure();
+ const ceilingConverter = distanceMeters();
+ const visibilityConverter = distanceKilometers();
+ const ceilingMeters = Math.max(0, ((currentForecast.temperature_2m ?? 0) - (currentForecast.dew_point_2m ?? 0)) * 68);
- const observations = backfill(data.features);
- // values from api are provided in metric
- data.observations = observations;
- data.Temperature = temperatureConverter(observations.temperature.value);
- data.TemperatureUnit = temperatureConverter.units;
- data.DewPoint = temperatureConverter(observations.dewpoint.value);
- data.Ceiling = metersConverter(observations.cloudLayers[0]?.base?.value ?? 0);
- data.CeilingUnit = metersConverter.units;
- data.Visibility = kilometersConverter(observations.visibility.value);
- data.VisibilityUnit = kilometersConverter.units;
- data.Pressure = pressureConverter(observations.barometricPressure.value);
- data.PressureUnit = pressureConverter.units;
- data.HeatIndex = temperatureConverter(observations.heatIndex.value);
- data.WindChill = temperatureConverter(observations.windChill.value);
- data.WindSpeed = windConverter(observations.windSpeed.value);
- data.WindDirection = directionToNSEW(observations.windDirection.value);
- data.WindGust = windConverter(observations.windGust.value);
- data.WindUnit = windConverter.units;
- data.Humidity = Math.round(observations.relativeHumidity.value);
-
- // 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';
-
- // 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;
+ return {
+ city: weatherParameters.city,
+ timeZone: weatherParameters.timeZone,
+ Temperature: temperatureConverter(currentForecast.temperature_2m),
+ TemperatureUnit: temperatureConverter.units,
+ DewPoint: temperatureConverter(currentForecast.dew_point_2m),
+ Ceiling: ceilingConverter(ceilingMeters),
+ CeilingUnit: ceilingConverter.units,
+ Visibility: visibilityConverter(currentForecast.visibility),
+ VisibilityUnit: visibilityConverter.units,
+ WindSpeed: windConverter(currentForecast.wind_speed_10m),
+ WindDirection: directionToNSEW(currentForecast.wind_direction_10m ?? 0),
+ Pressure: pressureConverter((currentForecast.pressure_msl ?? 0) * 100),
+ PressureDirection: currentForecast.pressureTrend,
+ Humidity: Math.round(currentForecast.relative_humidity_2m ?? 0),
+ WindGust: windConverter(currentForecast.wind_gusts_10m),
+ WindUnit: windConverter.units,
+ TextConditions: Number(currentForecast.weather_code ?? 0),
+ Icon: getLargeIconFromWmoCode(currentForecast.weather_code, Boolean(currentForecast.is_day)),
+ };
};
-// default to the latest data in the provided observations, but use older data if something is missing
-const backfill = (data) => {
- // make easy to use timestamps
- const sortedData = data.map((observation) => {
- observation.timestamp = DateTime.fromISO(observation.properties.timestamp);
- return observation;
- });
-
- // sort by timestamp with [0] being the earliest
- sortedData.sort((a, b) => b.timestamp - a.timestamp);
-
- // create the result data
- const result = {};
-
- // backfill each property
- Object.keys(sortedData[0].properties).forEach((key) => {
- // qualify the key (must have value)
- if (Object.hasOwn(sortedData[0].properties?.[key] ?? {}, 'value')) {
- // backfill this property
- result[key] = backfillProperty(sortedData, key);
- } else {
- // use the property as is
- result[key] = sortedData[0].properties[key];
- }
- });
-
- return result;
-};
-
-// return the property with a value closest to the [0] index
-// reduce returns the first non-null value in the array
-const backfillProperty = (data, key) => data.reduce(
- (prev, cur) => {
- const curValue = cur.properties?.[key]?.value;
- if (prev.value === null && curValue !== null && curValue !== undefined) return cur.properties[key];
- return prev;
- },
- { value: null }, // null is the default provided by the api
-);
-
const display = new CurrentWeather(1, 'current-weather');
registerDisplay(display);
diff --git a/server/scripts/modules/currentweatherscroll.mjs b/server/scripts/modules/currentweatherscroll.mjs
index 7a1badf..ed61faa 100644
--- a/server/scripts/modules/currentweatherscroll.mjs
+++ b/server/scripts/modules/currentweatherscroll.mjs
@@ -1,4 +1,3 @@
-import { locationCleanup } from './utils/string.mjs';
import getCurrentWeather from './currentweather.mjs';
import { currentDisplay } from './navigation.mjs';
import getHazards from './hazards.mjs';
@@ -150,21 +149,10 @@ const baseScreens = [
// hazards
hazards,
// station name
- (data) => {
- const location = (StationInfo[data.station.properties.stationIdentifier]?.city ?? locationCleanup(data.station.properties.name)).substr(0, 20);
- return `Conditions at ${location}`;
- },
+ (data) => `Conditions at ${data.city.substr(0, 20)}`,
// temperature
- (data) => {
- let text = `Temp: ${data.Temperature}${degree}${data.TemperatureUnit}`;
- if (data.observations.heatIndex.value) {
- text += ` Heat Index: ${data.HeatIndex}${degree}${data.TemperatureUnit}`;
- } else if (data.observations.windChill.value) {
- text += ` Wind Chill: ${data.WindChill}${degree}${data.TemperatureUnit}`;
- }
- return text;
- },
+ (data) => `Temp: ${data.Temperature}${degree}${data.TemperatureUnit}`,
// humidity
(data) => `Humidity: ${data.Humidity}% Dewpoint: ${data.DewPoint}${degree}${data.TemperatureUnit}`,
diff --git a/server/scripts/modules/extendedforecast.mjs b/server/scripts/modules/extendedforecast.mjs
index d5989fd..c80776e 100644
--- a/server/scripts/modules/extendedforecast.mjs
+++ b/server/scripts/modules/extendedforecast.mjs
@@ -1,80 +1,37 @@
// display extended forecast graphically
-// (technically this uses the same data as the local forecast, but we'll let the cache deal with that)
import STATUS from './status.mjs';
-import { safeJson } from './utils/fetch.mjs';
-import { DateTime } from '../vendor/auto/luxon.mjs';
-import { getLargeIcon } from './icons.mjs';
-import { preloadImg } from './utils/image.mjs';
import WeatherDisplay from './weatherdisplay.mjs';
import { registerDisplay } from './navigation.mjs';
-import settings from './settings.mjs';
-import filterExpiredPeriods from './utils/forecast-utils.mjs';
-import { debugFlag } from './utils/debug.mjs';
+import { temperature } from './utils/units.mjs';
+import { getConditionText } from './utils/weather.mjs';
+import { getLargeIconFromWmoCode } from './icons.mjs';
+import { preloadImg } from './utils/image.mjs';
class ExtendedForecast extends WeatherDisplay {
constructor(navId, elemId) {
super(navId, elemId, 'Extended Forecast', true);
-
- // set timings
this.timing.totalScreens = 2;
}
- async getData(weatherParameters, refresh) {
- if (!super.getData(weatherParameters, refresh)) return;
-
- try {
- // request us or si units using centralized safe handling
- this.data = await safeJson(this.weatherParameters.forecast, {
- data: {
- units: settings.units.value,
- },
- retryCount: 3,
- stillWaiting: () => this.stillWaiting(),
- });
-
- // if there's no new data and no previous data, fail
- if (!this.data) {
- // console.warn(`Unable to get extended forecast for ${this.weatherParameters.latitude},${this.weatherParameters.longitude} in ${this.weatherParameters.state}`);
- if (this.isEnabled) this.setStatus(STATUS.failed);
- return;
- }
-
- // we only get here if there was data (new or existing)
- this.screenIndex = 0;
- this.setStatus(STATUS.loaded);
- } catch (error) {
- console.error(`Unexpected error getting Extended Forecast: ${error.message}`);
- if (this.isEnabled) this.setStatus(STATUS.failed);
- }
+ async getData(weatherParameters) {
+ if (!super.getData(weatherParameters)) return;
+ this.data = parseForecast(this.weatherParameters);
+ this.screenIndex = 0;
+ this.setStatus(STATUS.loaded);
}
async drawCanvas() {
super.drawCanvas();
+ const forecast = this.data.slice(0 + 3 * this.screenIndex, 3 + this.screenIndex * 3);
+ const days = forecast.map((day) => this.fillTemplate('day', {
+ icon: { type: 'img', src: day.icon },
+ condition: day.text,
+ date: day.dayName,
+ 'value-hi': Math.round(day.high),
+ ...(day.low !== undefined ? { 'value-lo': Math.round(day.low) } : {}),
+ }));
- // determine bounds
- // grab the first three or second set of three array elements
- const forecast = parse(this.data.properties.periods, this.weatherParameters.forecast).slice(0 + 3 * this.screenIndex, 3 + this.screenIndex * 3);
-
- // create each day template
- const days = forecast.map((Day) => {
- const fill = {
- icon: { type: 'img', src: Day.icon },
- condition: Day.text,
- date: Day.dayName,
- };
-
- const { low, high } = Day;
- if (low !== undefined) {
- fill['value-lo'] = Math.round(low);
- }
- fill['value-hi'] = Math.round(high);
-
- // return the filled template
- return this.fillTemplate('day', fill);
- });
-
- // empty and update the container
const dayContainer = this.elem.querySelector('.day-container');
dayContainer.innerHTML = '';
dayContainer.append(...days);
@@ -82,117 +39,28 @@ class ExtendedForecast extends WeatherDisplay {
}
}
-// the api provides the forecast in 12 hour increments, flatten to day increments with high and low temperatures
-const parse = (fullForecast, forecastUrl) => {
- // filter out expired periods first
- const activePeriods = filterExpiredPeriods(fullForecast, forecastUrl);
-
- if (debugFlag('extendedforecast')) {
- console.log('ExtendedForecast: First few active periods:');
- activePeriods.slice(0, 4).forEach((period, index) => {
- console.log(` [${index}] ${period.name}: ${period.startTime} to ${period.endTime} (isDaytime: ${period.isDaytime})`);
- });
- }
-
- // Skip the first period if it's nighttime (like "Tonight") since extended forecast
- // should focus on upcoming full days, not the end of the current day
- let startIndex = 0;
-
- if (activePeriods.length > 0 && !activePeriods[0].isDaytime) {
- startIndex = 1;
- if (debugFlag('extendedforecast')) {
- console.log(`ExtendedForecast: Skipping first period "${activePeriods[0].name}" because it's nighttime`);
- }
- } else if (activePeriods.length > 0) {
- if (debugFlag('extendedforecast')) {
- console.log(`ExtendedForecast: Starting with first period "${activePeriods[0].name}" because it's daytime`);
- }
- }
-
- // track the destination forecast index
- let destIndex = 0;
- const forecast = [];
-
- // if the first period is nighttime it is skipped above via startIndex
- for (let i = startIndex; i < activePeriods.length; i += 1) {
- const period = activePeriods[i];
-
- if (!forecast[destIndex]) {
- forecast.push({
- dayName: '', low: undefined, high: undefined, text: undefined, icon: undefined,
- });
- }
- // get the object to modify/populate
- const fDay = forecast[destIndex];
-
- if (period.isDaytime) {
- // day time is the high temperature
- fDay.high = period.temperature;
- fDay.icon = getLargeIcon(period.icon);
- fDay.text = shortenExtendedForecastText(period.shortForecast);
- fDay.dayName = DateTime.fromISO(period.startTime).startOf('day').toLocaleString({ weekday: 'short' });
- // preload the icon
- preloadImg(fDay.icon);
- // Wait for the corresponding night period to increment
- } else {
- // low temperature
- fDay.low = period.temperature;
- // Increment after processing night period
- destIndex += 1;
- }
- }
-
- if (debugFlag('extendedforecast')) {
- console.log('ExtendedForecast: Final forecast array:');
- forecast.forEach((day, index) => {
- console.log(` [${index}] ${day.dayName}: High=${day.high}°, Low=${day.low}°, Text="${day.text}"`);
- });
- }
-
- return forecast;
+const parseForecast = (weatherParameters) => {
+ const temperatureConverter = temperature();
+ return Object.entries(weatherParameters.forecast).slice(0, 6).map(([date, period]) => {
+ const text = shortenExtendedForecastText(getConditionText(period.weather_code ?? 0));
+ const icon = getLargeIconFromWmoCode(period.weather_code, true);
+ preloadImg(icon);
+ return {
+ text,
+ icon,
+ dayName: new Date(`${date}T12:00:00`).toLocaleDateString('en-US', { weekday: 'short', timeZone: weatherParameters.timeZone }),
+ high: temperatureConverter(period.temperature_2m_max),
+ low: temperatureConverter(period.temperature_2m_min),
+ };
+ });
};
-const regexList = [
- [/ and /gi, ' '],
- [/slight /gi, ''],
- [/chance /gi, ''],
- [/very /gi, ''],
- [/patchy /gi, ''],
- [/Areas Of /gi, ''],
- [/areas /gi, ''],
- [/dense /gi, ''],
- [/Thunderstorm/g, 'T\'Storm'],
-];
-const shortenExtendedForecastText = (long) => {
- // run all regexes
- const short = regexList.reduce((working, [regex, replace]) => working.replace(regex, replace), long);
+const shortenExtendedForecastText = (text) => text
+ .replace(/Slight /gi, '')
+ .replace(/Moderate /gi, '')
+ .replace(/Thunderstorm/gi, 'T\'Storm')
+ .split(' ')
+ .slice(0, 2)
+ .join(' ');
- let conditions = short.split(' ');
- if (short.indexOf('then') !== -1) {
- conditions = short.split(' then ');
- conditions = conditions[1].split(' ');
- }
-
- let short1 = conditions[0].substr(0, 10);
- let short2 = '';
- if (conditions[1]) {
- if (short1.endsWith('.')) {
- short1 = short1.replace(/\./, '');
- } else {
- short2 = conditions[1].substr(0, 10);
- }
-
- if (short2 === 'Blowing') {
- short2 = '';
- }
- }
- let result = short1;
- if (short2 !== '') {
- result += ` ${short2}`;
- }
-
- return result;
-};
-
-// register display
registerDisplay(new ExtendedForecast(8, 'extended-forecast'));
diff --git a/server/scripts/modules/hazards.mjs b/server/scripts/modules/hazards.mjs
index a2835f2..fb1b9c6 100644
--- a/server/scripts/modules/hazards.mjs
+++ b/server/scripts/modules/hazards.mjs
@@ -52,6 +52,13 @@ class Hazards extends WeatherDisplay {
async getData(weatherParameters, refresh) {
// super checks for enabled
const superResult = super.getData(weatherParameters, refresh);
+ if (!this.weatherParameters?.supportsNoaaDisplays) {
+ this.data = [];
+ this.timing.totalScreens = 0;
+ this.getDataCallback();
+ this.setStatus(STATUS.loaded);
+ return;
+ }
// hazards performs a silent refresh, but does not fall back to a previous fetch if no data is available
// this is intentional to ensure the latest alerts only are displayed.
diff --git a/server/scripts/modules/hourly.mjs b/server/scripts/modules/hourly.mjs
index bd4b1c7..1f7723f 100644
--- a/server/scripts/modules/hourly.mjs
+++ b/server/scripts/modules/hourly.mjs
@@ -1,27 +1,17 @@
// hourly forecast list
import STATUS from './status.mjs';
-import { DateTime, Interval, Duration } from '../vendor/auto/luxon.mjs';
-import { safeJson } from './utils/fetch.mjs';
+import { DateTime } from '../vendor/auto/luxon.mjs';
import { temperature as temperatureUnit, windSpeed as windUnit } from './utils/units.mjs';
-import { getHourlyIcon } from './icons.mjs';
import { directionToNSEW } from './utils/calc.mjs';
import WeatherDisplay from './weatherdisplay.mjs';
import { registerDisplay, timeZone } from './navigation.mjs';
-import getSun from './almanac.mjs';
import calculateScrollTiming from './utils/scroll-timing.mjs';
-import { debugFlag } from './utils/debug.mjs';
+import { getSmallIconFromWmoCode } from './icons.mjs';
class Hourly extends WeatherDisplay {
constructor(navId, elemId, defaultActive) {
- // special height and width for scrolling
super(navId, elemId, 'Hourly Forecast', defaultActive);
-
- // cache for scroll calculations
- // This cache is essential because baseCountChange() is called 25 times per second (every 40ms)
- // during scrolling. Without caching, we'd perform hundreds of expensive DOM layout queries during
- // the full scroll cycle. The cache reduces this to one calculation when content changes, then
- // reuses cached values to try and get smoother scrolling.
this.scrollCache = {
displayHeight: 0,
contentHeight: 0,
@@ -31,92 +21,50 @@ class Hourly extends WeatherDisplay {
}
async getData(weatherParameters, refresh) {
- // super checks for enabled
const superResponse = super.getData(weatherParameters, refresh);
+ this.data = parseForecast(this.weatherParameters);
- try {
- const forecast = await safeJson(this.weatherParameters.forecastGridData, { retryCount: 3, stillWaiting: () => this.stillWaiting() });
-
- if (forecast) {
- try {
- // parse the forecast
- this.data = await parseForecast(forecast.properties);
- } catch (error) {
- console.error(`Hourly forecast parsing failed: ${error.message}`);
- }
- } else if (debugFlag('verbose-failures')) {
- console.warn(`Using previous hourly forecast for ${this.weatherParameters.forecastGridData}`);
- }
-
- // use old data if available, fail if no data at all
- if (!this.data) {
- if (this.isEnabled) this.setStatus(STATUS.failed);
- // return undefined to other subscribers
- this.getDataCallback(undefined);
- return;
- }
-
- this.getDataCallback();
- if (!superResponse) return;
-
- this.setStatus(STATUS.loaded);
- this.drawLongCanvas();
- } catch (error) {
- console.error(`Unexpected error getting hourly forecast: ${error.message}`);
+ if (!this.data?.length) {
if (this.isEnabled) this.setStatus(STATUS.failed);
this.getDataCallback(undefined);
+ return;
}
+
+ this.getDataCallback();
+ if (!superResponse) return;
+
+ this.setStatus(STATUS.loaded);
+ this.drawLongCanvas();
}
async drawLongCanvas() {
- // get the list element and populate
const list = this.elem.querySelector('.hourly-lines');
list.innerHTML = '';
const startingHour = DateTime.local().setZone(timeZone());
-
- // shorten to 24 hours
const shortData = this.data.slice(0, 24);
-
const lines = shortData.map((data, index) => {
- const fillValues = {};
- // hour
const hour = startingHour.plus({ hours: index });
- fillValues.hour = hour.toLocaleString({ weekday: 'short', hour: 'numeric' });
-
- // temperatures, convert to strings with no decimal
- const temperature = data.temperature.toString().padStart(3);
- const feelsLike = data.apparentTemperature.toString().padStart(3);
- fillValues.temp = temperature;
-
- // apparent temperature is color coded if different from actual temperature (after fill is applied)
- fillValues.like = feelsLike;
-
- // wind
- fillValues.wind = 'Calm';
- if (data.windSpeed > 0) {
- const windSpeed = Math.round(data.windSpeed).toString();
- fillValues.wind = data.windDirection + (Array(6 - data.windDirection.length - windSpeed.length).join(' ')) + windSpeed;
- }
-
- // image
- fillValues.icon = { type: 'img', src: data.icon };
+ const fillValues = {
+ hour: hour.toLocaleString({ weekday: 'short', hour: 'numeric' }),
+ temp: data.temperature.toString().padStart(3),
+ like: data.apparentTemperature.toString().padStart(3),
+ wind: data.windSpeed > 0
+ ? data.windDirection + (Array(6 - data.windDirection.length - Math.round(data.windSpeed).toString().length).join(' ')) + Math.round(data.windSpeed).toString()
+ : 'Calm',
+ icon: { type: 'img', src: data.icon },
+ };
const filledRow = this.fillTemplate('hourly-row', fillValues);
-
- // alter the color of the feels like column to reflect wind chill or heat index
if (data.apparentTemperature < data.temperature) {
filledRow.querySelector('.like').classList.add('wind-chill');
- } else if (feelsLike > temperature) {
+ } else if (data.apparentTemperature > data.temperature) {
filledRow.querySelector('.like').classList.add('heat-index');
}
-
return filledRow;
});
list.append(...lines);
-
- // update timing based on actual content
this.setTiming(list);
}
@@ -126,53 +74,37 @@ class Hourly extends WeatherDisplay {
}
showCanvas() {
- // special to hourly to draw the remainder of the canvas
this.drawCanvas();
super.showCanvas();
}
- // screen index change callback just runs the base count callback
screenIndexChange() {
this.baseCountChange(this.navBaseCount);
}
- // base count change callback
baseCountChange(count) {
- // get the hourly lines element and cache measurements if needed
const hourlyLines = this.elem.querySelector('.hourly-lines');
if (!hourlyLines) return;
- // update cache if needed (when content changes or first run)
if (this.scrollCache.hourlyLines !== hourlyLines || this.scrollCache.displayHeight === 0) {
this.scrollCache.displayHeight = this.elem.querySelector('.main').offsetHeight;
this.scrollCache.contentHeight = hourlyLines.offsetHeight;
this.scrollCache.maxOffset = Math.max(0, this.scrollCache.contentHeight - this.scrollCache.displayHeight);
this.scrollCache.hourlyLines = hourlyLines;
-
- // Set up hardware acceleration on the hourly lines element
hourlyLines.style.willChange = 'transform';
hourlyLines.style.backfaceVisibility = 'hidden';
}
- // calculate scroll offset and don't go past end
let offsetY = Math.min(this.scrollCache.maxOffset, (count - this.scrollTiming.initialCounts) * this.scrollTiming.pixelsPerCount);
-
- // don't let offset go negative
if (offsetY < 0) offsetY = 0;
-
- // use transform instead of scrollTo for hardware acceleration
hourlyLines.style.transform = `translateY(-${Math.round(offsetY)}px)`;
}
- // make data available outside this class
- // promise allows for data to be requested before it is available
async getHourlyData(stillWaiting) {
if (stillWaiting) this.stillWaitingCallbacks.push(stillWaiting);
- // an external caller has requested data, set up auto reload
this.setAutoReload();
return new Promise((resolve) => {
if (this.data) resolve(this.data);
- // data not available, put it into the data callback queue
this.getDataCallbacks.push(() => resolve(this.data));
});
}
@@ -180,87 +112,50 @@ class Hourly extends WeatherDisplay {
setTiming(list) {
const container = this.elem.querySelector('.main');
const timingConfig = calculateScrollTiming(list, container);
-
- // Apply the calculated timing
this.timing.baseDelay = timingConfig.baseDelay;
this.timing.delay = timingConfig.delay;
this.scrollTiming = timingConfig.scrollTiming;
-
this.calcNavTiming();
}
}
-// extract specific values from forecast and format as an array
-const parseForecast = async (data) => {
- // get unit converters
+const parseForecast = (weatherParameters) => {
const temperatureConverter = temperatureUnit();
const windConverter = windUnit();
+ const currentTime = new Date();
+ const todayKey = currentTime.toLocaleDateString('en-CA', { timeZone: weatherParameters.timeZone });
+ const tomorrowKey = DateTime.fromISO(todayKey).plus({ days: 1 }).toISODate();
+ const availableTimes = [
+ ...(weatherParameters.forecast[todayKey]?.hours ?? []),
+ ...(weatherParameters.forecast[tomorrowKey]?.hours ?? []),
+ ];
+ if (!availableTimes.length) return [];
- // parse data
- const temperature = expand(data.temperature.values);
- const apparentTemperature = expand(data.apparentTemperature.values);
- const windSpeed = expand(data.windSpeed.values);
- const windDirection = expand(data.windDirection.values);
- const skyCover = expand(data.skyCover.values); // cloud icon
- const weather = expand(data.weather.values); // fog icon
- const iceAccumulation = expand(data.iceAccumulation.values); // ice icon
- const probabilityOfPrecipitation = expand(data.probabilityOfPrecipitation.values); // rain icon
- const snowfallAmount = expand(data.snowfallAmount.values); // snow icon
- const dewpoint = expand(data.dewpoint.values);
+ let closestIndex = 0;
+ let minDiff = Math.abs(new Date(availableTimes[0].time) - currentTime);
+ availableTimes.forEach((entry, index) => {
+ const diff = Math.abs(new Date(entry.time) - currentTime);
+ if (diff < minDiff) {
+ minDiff = diff;
+ closestIndex = index;
+ }
+ });
- const icons = await determineIcon(skyCover, weather, iceAccumulation, probabilityOfPrecipitation, snowfallAmount, windSpeed);
-
- return temperature.map((val, idx) => ({
- temperature: temperatureConverter(temperature[idx]),
+ return availableTimes.slice(closestIndex).map((hour) => ({
+ temperature: temperatureConverter(hour.temperature_2m),
temperatureUnit: temperatureConverter.units,
- apparentTemperature: temperatureConverter(apparentTemperature[idx]),
- windSpeed: windConverter(windSpeed[idx]),
+ apparentTemperature: temperatureConverter(hour.apparent_temperature),
+ windSpeed: windConverter(hour.wind_speed_10m),
windUnit: windConverter.units,
- windDirection: directionToNSEW(windDirection[idx]),
- probabilityOfPrecipitation: probabilityOfPrecipitation[idx],
- skyCover: skyCover[idx],
- icon: icons[idx],
- dewpoint: temperatureConverter(dewpoint[idx]),
+ windDirection: directionToNSEW(hour.wind_direction_10m ?? 0),
+ probabilityOfPrecipitation: Math.round(hour.precipitation_probability ?? 0),
+ skyCover: Math.round(hour.cloud_cover ?? 0),
+ icon: getSmallIconFromWmoCode(hour.weather_code, Boolean(hour.is_day)),
+ dewpoint: temperatureConverter(hour.dew_point_2m),
}));
};
-// given forecast paramaters determine a suitable icon
-const determineIcon = async (skyCover, weather, iceAccumulation, probabilityOfPrecipitation, snowfallAmount, windSpeed) => {
- const startOfHour = DateTime.local().startOf('hour');
- const sunTimes = (await getSun()).sun;
- const overnight = Interval.fromDateTimes(DateTime.fromJSDate(sunTimes[0].sunset), DateTime.fromJSDate(sunTimes[1].sunrise));
- const tomorrowOvernight = DateTime.fromJSDate(sunTimes[1].sunset);
- return skyCover.map((val, idx) => {
- const hour = startOfHour.plus({ hours: idx });
- const isNight = overnight.contains(hour) || (hour > tomorrowOvernight);
- return getHourlyIcon(skyCover[idx], weather[idx], iceAccumulation[idx], probabilityOfPrecipitation[idx], snowfallAmount[idx], windSpeed[idx], isNight);
- });
-};
-
-// expand a set of values with durations to an hour-by-hour array
-const expand = (data, maxHours = 36) => {
- const startOfHour = DateTime.utc().startOf('hour').toMillis();
- const result = []; // resulting expanded values
- data.forEach((item) => {
- let startTime = Date.parse(item.validTime.substr(0, item.validTime.indexOf('/')));
- const duration = Duration.fromISO(item.validTime.substr(item.validTime.indexOf('/') + 1)).shiftTo('milliseconds').values.milliseconds;
- const endTime = startTime + duration;
- // loop through duration at one hour intervals
- do {
- // test for timestamp greater than now
- if (startTime >= startOfHour && result.length < maxHours) {
- result.push(item.value); // push data array
- } // timestamp is after now
- // increment start time by 1 hour
- startTime += 3_600_000;
- } while (startTime < endTime && result.length < maxHours);
- }); // for each value
-
- return result;
-};
-
-// register display
-const display = new Hourly(3, 'hourly', false);
+const display = new Hourly(3, 'hourly', true);
registerDisplay(display);
export default display.getHourlyData.bind(display);
diff --git a/server/scripts/modules/icons.mjs b/server/scripts/modules/icons.mjs
index 5c439bd..db30d83 100644
--- a/server/scripts/modules/icons.mjs
+++ b/server/scripts/modules/icons.mjs
@@ -2,8 +2,57 @@ import largeIcon from './icons/icons-large.mjs';
import smallIcon from './icons/icons-small.mjs';
import hourlyIcon from './icons/icons-hourly.mjs';
+const getWeatherGovTokenFromWmoCode = (code) => {
+ switch (Number(code)) {
+ case 0: return 'skc';
+ case 1: return 'few';
+ case 2: return 'sct';
+ case 3: return 'ovc';
+ case 45:
+ case 48:
+ return 'fog';
+ case 51:
+ case 53:
+ case 55:
+ case 80:
+ return 'rain_showers';
+ case 56:
+ case 57:
+ case 66:
+ case 67:
+ return 'fzra';
+ case 61:
+ case 63:
+ case 65:
+ case 81:
+ case 82:
+ return 'rain';
+ case 71:
+ case 73:
+ case 75:
+ case 85:
+ case 86:
+ return 'snow';
+ case 77:
+ return 'sleet';
+ case 95:
+ case 96:
+ case 99:
+ return 'tsra';
+ default:
+ return 'ovc';
+ }
+};
+
+const buildSyntheticIconUrl = (code, isDaytime = true) => `/icons/land/${isDaytime ? 'day' : 'night'}/${getWeatherGovTokenFromWmoCode(code)}`;
+
+const getLargeIconFromWmoCode = (code, isDaytime = true) => largeIcon(buildSyntheticIconUrl(code, isDaytime), !isDaytime);
+const getSmallIconFromWmoCode = (code, isDaytime = true) => smallIcon(buildSyntheticIconUrl(code, isDaytime), !isDaytime);
+
export {
largeIcon as getLargeIcon,
smallIcon as getSmallIcon,
hourlyIcon as getHourlyIcon,
+ getLargeIconFromWmoCode,
+ getSmallIconFromWmoCode,
};
diff --git a/server/scripts/modules/latestobservations.mjs b/server/scripts/modules/latestobservations.mjs
index a7e2446..9e05a28 100644
--- a/server/scripts/modules/latestobservations.mjs
+++ b/server/scripts/modules/latestobservations.mjs
@@ -21,6 +21,13 @@ class LatestObservations extends WeatherDisplay {
async getData(weatherParameters, refresh) {
if (!super.getData(weatherParameters, refresh)) return;
+ if (!this.weatherParameters?.supportsNoaaDisplays) {
+ this.data = [];
+ this.timing.totalScreens = 0;
+ this.setStatus(STATUS.loaded);
+ return;
+ }
+ this.timing.totalScreens = 1;
// latest observations does a silent refresh but will not fall back to previously fetched data
// this is intentional because up to 30 stations are available to pull data from
diff --git a/server/scripts/modules/localforecast.mjs b/server/scripts/modules/localforecast.mjs
index e305714..e8a0cbd 100644
--- a/server/scripts/modules/localforecast.mjs
+++ b/server/scripts/modules/localforecast.mjs
@@ -1,265 +1,78 @@
// display text based local forecast
import STATUS from './status.mjs';
-import { safeJson } from './utils/fetch.mjs';
import WeatherDisplay from './weatherdisplay.mjs';
import { registerDisplay } from './navigation.mjs';
-import settings from './settings.mjs';
-import filterExpiredPeriods from './utils/forecast-utils.mjs';
-import { debugFlag } from './utils/debug.mjs';
+import { temperature, windSpeed } from './utils/units.mjs';
+import { directionToNSEW } from './utils/calc.mjs';
+import { getConditionText } from './utils/weather.mjs';
class LocalForecast extends WeatherDisplay {
- static BASE_FORECAST_DURATION_MS = 5000; // Base duration (in ms) for a standard 3-5 line forecast page
-
constructor(navId, elemId) {
super(navId, elemId, 'Local Forecast', true);
-
- // set timings
- this.timing.baseDelay = LocalForecast.BASE_FORECAST_DURATION_MS;
+ this.timing.baseDelay = 5000;
}
- async getData(weatherParameters, refresh) {
- if (!super.getData(weatherParameters, refresh)) return;
+ async getData(weatherParameters) {
+ if (!super.getData(weatherParameters)) return;
- // get raw data
- const rawData = await this.getRawData(this.weatherParameters);
- // check for data, or if there's old data available
- if (!rawData && !this.data) {
- // fail for no old or new data
+ const conditions = buildLocalForecastPages(this.weatherParameters);
+ if (!conditions.length) {
if (this.isEnabled) this.setStatus(STATUS.failed);
return;
}
- // store the data
- this.data = rawData || this.data;
- // parse raw data and filter out expired periods
- const conditions = parse(this.data, this.weatherParameters.forecast);
- // read each text
- this.screenTexts = conditions.map((condition) => {
- // process the text
- let text = `${condition.DayName.toUpperCase()}...`;
- const conditionText = condition.Text;
- text += conditionText.toUpperCase().replace('...', ' ');
-
- return text;
- });
-
- // fill the forecast texts
+ this.screenTexts = conditions.map((condition) => `${condition.DayName.toUpperCase()}...${condition.Text.toUpperCase()}`);
const templates = this.screenTexts.map((text) => this.fillTemplate('forecast', { text }));
const forecastsElem = this.elem.querySelector('.forecasts');
forecastsElem.innerHTML = '';
forecastsElem.append(...templates);
- // Get page height for screen calculations
- this.pageHeight = forecastsElem.parentNode.offsetHeight;
-
- this.calculateContentAwareTiming(templates);
-
- this.calcNavTiming();
-
- this.setStatus(STATUS.loaded);
- }
-
- // get the unformatted data (also used by extended forecast)
- async getRawData(weatherParameters) {
- // request us or si units using centralized safe handling
- const data = await safeJson(weatherParameters.forecast, {
- data: {
- units: settings.units.value,
- },
- retryCount: 3,
- stillWaiting: () => this.stillWaiting(),
+ this.pageHeight = forecastsElem.parentNode.scrollHeight;
+ templates.forEach((forecast) => {
+ const newHeight = Math.ceil(forecast.scrollHeight / this.pageHeight) * this.pageHeight;
+ forecast.style.height = `${newHeight}px`;
});
- if (!data) {
- return false;
- }
-
- return data;
+ this.timing.totalScreens = forecastsElem.scrollHeight / this.pageHeight;
+ this.calcNavTiming();
+ this.setStatus(STATUS.loaded);
}
async drawCanvas() {
super.drawCanvas();
-
const top = -this.screenIndex * this.pageHeight;
this.elem.querySelector('.forecasts').style.top = `${top}px`;
-
this.finishDraw();
}
-
- // calculate dynamic timing based on height measurement template approach
- calculateContentAwareTiming(templates) {
- if (!templates || templates.length === 0) {
- this.timing.delay = 1; // fallback to single delay if no templates
- return;
- }
-
- // Use the original base duration constant for timing calculations
- const originalBaseDuration = LocalForecast.BASE_FORECAST_DURATION_MS;
- this.timing.baseDelay = 250; // use 250ms per count for precise timing control
-
- // Get line height from CSS for accurate calculations
- const sampleForecast = templates[0];
- const computedStyle = window.getComputedStyle(sampleForecast);
- const lineHeight = parseInt(computedStyle.lineHeight, 10);
-
- // Calculate the actual width that forecast text uses
- // Use the forecast container that's already been set up
- const forecastContainer = this.elem.querySelector('.local-forecast .container');
- let effectiveWidth;
-
- if (!forecastContainer) {
- console.error('LocalForecast: Could not find forecast container for width calculation, using fallback width');
- effectiveWidth = 492; // "magic number" from manual calculations as fallback
- } else {
- const containerStyle = window.getComputedStyle(forecastContainer);
- const containerWidth = forecastContainer.offsetWidth;
- const paddingLeft = parseInt(containerStyle.paddingLeft, 10) || 0;
- const paddingRight = parseInt(containerStyle.paddingRight, 10) || 0;
- effectiveWidth = containerWidth - paddingLeft - paddingRight;
-
- if (debugFlag('localforecast')) {
- console.log(`LocalForecast: Using measurement width of ${effectiveWidth}px (container=${containerWidth}px, padding=${paddingLeft}+${paddingRight}px)`);
- }
- }
-
- // Measure each forecast period to get actual line counts
- const forecastLineCounts = [];
- templates.forEach((template, index) => {
- const currentHeight = template.offsetHeight;
- const currentLines = Math.round(currentHeight / lineHeight);
-
- if (currentLines > 7) {
- // Multi-page forecasts measure correctly, so use the measurement directly
- forecastLineCounts.push(currentLines);
-
- if (debugFlag('localforecast')) {
- console.log(`LocalForecast: Forecast ${index} measured ${currentLines} lines (${currentHeight}px direct measurement, ${lineHeight}px line-height)`);
- }
- } else {
- // If may be 7 lines or less, we need to pad the content to ensure proper height measurement
- // Short forecasts are capped by CSS min-height: 280px (7 lines)
- // Add 7
tags to force height beyond the minimum, then subtract the padding
- const originalHTML = template.innerHTML;
- const paddingBRs = '
'.repeat(7);
- template.innerHTML = originalHTML + paddingBRs;
-
- // Measure the padded height
- const paddedHeight = template.offsetHeight;
- const paddedLines = Math.round(paddedHeight / lineHeight);
-
- // Calculate actual content lines by subtracting the 7 BR lines we added
- const actualLines = Math.max(1, paddedLines - 7);
-
- // Restore original content
- template.innerHTML = originalHTML;
-
- forecastLineCounts.push(actualLines);
-
- if (debugFlag('localforecast')) {
- console.log(`LocalForecast: Forecast ${index} measured ${actualLines} lines (${paddedHeight}px with padding - ${7 * lineHeight}px = ${actualLines * lineHeight}px actual, ${lineHeight}px line-height)`);
- }
- }
- });
-
- // Apply height padding for proper scrolling display (keep existing system working)
- templates.forEach((forecast) => {
- const newHeight = Math.ceil(forecast.offsetHeight / this.pageHeight) * this.pageHeight;
- forecast.style.height = `${newHeight}px`;
- });
-
- // Calculate total screens based on padded height (for navigation system)
- const forecastsElem = templates[0].parentNode;
- const totalHeight = forecastsElem.scrollHeight;
- this.timing.totalScreens = Math.round(totalHeight / this.pageHeight);
-
- // Now calculate timing based on actual measured line counts, ignoring padding
- const maxLinesPerScreen = 7; // 280px / 40px line height
- const screenTimings = []; forecastLineCounts.forEach((lines, forecastIndex) => {
- if (lines <= maxLinesPerScreen) {
- // Single screen for this forecast
- screenTimings.push({ forecastIndex, lines, type: 'single' });
- } else {
- // Multiple screens for this forecast
- let remainingLines = lines;
- let isFirst = true;
-
- while (remainingLines > 0) {
- const linesThisScreen = Math.min(remainingLines, maxLinesPerScreen);
- const type = isFirst ? 'first-of-multi' : 'remainder';
-
- screenTimings.push({ forecastIndex, lines: linesThisScreen, type });
-
- remainingLines -= linesThisScreen;
- isFirst = false;
- }
- }
- });
-
- // Create timing array based on measured line counts
- const screenDelays = screenTimings.map((screenInfo, screenIndex) => {
- const screenLines = screenInfo.lines;
-
- // Apply timing rules based on actual screen content lines
- let timingMultiplier;
- if (screenLines === 1) {
- timingMultiplier = 0.6; // 1 line = shortest (3.0s at normal speed)
- } else if (screenLines === 2) {
- timingMultiplier = 0.8; // 2 lines = shorter (4.0s at normal speed)
- } else if (screenLines >= 6) {
- timingMultiplier = 1.4; // 6+ lines = longer (7.0s at normal speed)
- } else {
- timingMultiplier = 1.0; // 3-5 lines = normal (5.0s at normal speed)
- }
-
- // Convert to base counts
- const desiredDurationMs = timingMultiplier * originalBaseDuration;
- const baseCounts = Math.round(desiredDurationMs / this.timing.baseDelay);
-
- if (debugFlag('localforecast')) {
- console.log(`LocalForecast: Screen ${screenIndex}: ${screenLines} lines, ${timingMultiplier.toFixed(2)}x multiplier, ${desiredDurationMs}ms desired, ${baseCounts} counts (forecast ${screenInfo.forecastIndex}, ${screenInfo.type})`);
- }
-
- return baseCounts;
- });
-
- // Adjust timing array to match actual screen count if needed
- while (screenDelays.length < this.timing.totalScreens) {
- // Add fallback timing for extra screens
- const fallbackCounts = Math.round(originalBaseDuration / this.timing.baseDelay);
- screenDelays.push(fallbackCounts);
- console.warn(`LocalForecast: using fallback timing for Screen ${screenDelays.length - 1}: 5 lines, 1.00x multiplier, ${fallbackCounts} counts`);
- }
-
- // Truncate if we have too many calculated screens
- if (screenDelays.length > this.timing.totalScreens) {
- const removed = screenDelays.splice(this.timing.totalScreens);
- console.warn(`LocalForecast: Truncated ${removed.length} excess screen timings`);
- }
-
- // Set the timing array based on screen content
- this.timing.delay = screenDelays;
-
- if (debugFlag('localforecast')) {
- console.log(`LocalForecast: Final screen count - calculated: ${screenTimings.length}, actual: ${this.timing.totalScreens}, timing array: ${screenDelays.length}`);
- const multipliers = screenDelays.map((counts) => counts * this.timing.baseDelay / originalBaseDuration);
- console.log('LocalForecast: Screen multipliers:', multipliers);
- console.log('LocalForecast: Expected durations (ms):', screenDelays.map((counts) => counts * this.timing.baseDelay));
- }
- }
}
-// format the forecast
-// filter out expired periods, then use the first 6 forecasts
-const parse = (forecast, forecastUrl) => {
- const allPeriods = forecast.properties.periods;
- const activePeriods = filterExpiredPeriods(allPeriods, forecastUrl);
+const buildLocalForecastPages = (weatherParameters) => {
+ const days = Object.entries(weatherParameters.forecast).slice(0, 3);
+ const temperatureConverter = temperature();
+ const windConverter = windSpeed();
- return activePeriods.slice(0, 6).map((text) => ({
- // format day and text
- DayName: text.name.toUpperCase(),
- Text: text.detailedForecast,
- }));
+ return days.map(([date, day]) => {
+ const dayName = new Date(`${date}T12:00:00`).toLocaleDateString('en-US', { weekday: 'long', timeZone: weatherParameters.timeZone });
+ const high = temperatureConverter(day.temperature_2m_max);
+ const low = temperatureConverter(day.temperature_2m_min);
+ const precip = Math.round(day.precipitation_probability ?? 0);
+ const windDirection = directionToNSEW(day.wind_direction_10m ?? 0);
+ const wind = windConverter(day.wind_speed_10m ?? 0);
+ const condition = getConditionText(day.weather_code ?? 0);
+ let text = `${condition}. High ${high}. Low ${low}.`;
+ if (precip > 20) {
+ text += ` Chance of precipitation ${precip} percent.`;
+ }
+ if (wind > 0) {
+ text += ` Wind ${windDirection} ${wind} ${windConverter.units}.`;
+ }
+
+ return {
+ DayName: dayName,
+ Text: text,
+ };
+ });
};
-// register display
+
registerDisplay(new LocalForecast(7, 'local-forecast'));
diff --git a/server/scripts/modules/navigation.mjs b/server/scripts/modules/navigation.mjs
index 92cd384..c220738 100644
--- a/server/scripts/modules/navigation.mjs
+++ b/server/scripts/modules/navigation.mjs
@@ -3,7 +3,7 @@ import noSleep from './utils/nosleep.mjs';
import STATUS from './status.mjs';
import { wrap } from './utils/calc.mjs';
import { safeJson } from './utils/fetch.mjs';
-import { getPoint } from './utils/weather.mjs';
+import { getPoint, getOpenMeteoForecast, aggregateWeatherForecastData } from './utils/weather.mjs';
import { debugFlag } from './utils/debug.mjs';
import settings from './settings.mjs';
@@ -15,6 +15,30 @@ const displays = [];
let playing = false;
let progress;
const weatherParameters = {};
+const LOCATION_STORAGE_KEY = 'latLonLocation';
+
+const getStoredLocationMetadata = () => {
+ try {
+ const raw = localStorage.getItem(LOCATION_STORAGE_KEY);
+ return raw ? JSON.parse(raw) : null;
+ } catch {
+ return null;
+ }
+};
+
+const isUsLocation = (location) => ['US', 'USA'].includes((location?.countryCode ?? '').toUpperCase());
+
+const getFallbackLocation = (latLon) => {
+ const query = localStorage.getItem('latLonQuery') ?? `${latLon.lat.toFixed(4)}, ${latLon.lon.toFixed(4)}`;
+ const [city = query, state = ''] = query.split(',').map((part) => part.trim());
+ return {
+ city,
+ state,
+ country: '',
+ countryCode: '',
+ label: query,
+ };
+};
const init = async () => {
// set up the resize handler with debounce logic to prevent rapid-fire calls
@@ -60,38 +84,33 @@ const message = (data) => {
};
const getWeather = async (latLon, haveDataCallback) => {
- // get initial weather data
- const point = await getPoint(latLon.lat, latLon.lon);
+ const location = getStoredLocationMetadata() ?? getFallbackLocation(latLon);
+ const openMeteoForecast = await getOpenMeteoForecast(latLon.lat, latLon.lon);
+ if (!openMeteoForecast) return;
- // check if point data was successfully retrieved
- if (!point) {
- return;
- }
+ const aggregatedForecast = aggregateWeatherForecastData(openMeteoForecast);
+ if (!aggregatedForecast) return;
- if (typeof haveDataCallback === 'function') haveDataCallback(point);
+ if (typeof haveDataCallback === 'function') haveDataCallback(location);
try {
- // get stations using centralized safe handling
- const stations = await safeJson(point.properties.observationStations);
+ const supportsNoaaDisplays = isUsLocation(location);
+ let point = null;
+ let stations = null;
+ let stationId = '';
- if (!stations) {
- console.warn('Failed to get Observation Stations');
- return;
+ if (supportsNoaaDisplays) {
+ point = await getPoint(latLon.lat, latLon.lon);
+ if (point?.properties?.observationStations) {
+ stations = await safeJson(point.properties.observationStations);
+ stationId = stations?.features?.[0]?.properties?.stationIdentifier ?? '';
+ }
}
- // check if stations data is valid
- if (!stations || !stations.features || stations.features.length === 0) {
- console.warn('No Observation Stations found for this location');
- return;
- }
-
- const StationId = stations.features[0].properties.stationIdentifier;
-
- let { city } = point.properties.relativeLocation.properties;
- const { state } = point.properties.relativeLocation.properties;
-
- if (StationId in StationInfo) {
- city = StationInfo[StationId].city;
+ const state = location.state || point?.properties?.relativeLocation?.properties?.state || '';
+ let city = location.city || point?.properties?.relativeLocation?.properties?.city || localStorage.getItem('latLonQuery') || '';
+ if (stationId && stationId in StationInfo) {
+ city = StationInfo[stationId].city;
[city] = city.split('/');
city = city.replace(/\s+$/, '');
}
@@ -99,20 +118,23 @@ const getWeather = async (latLon, haveDataCallback) => {
// populate the weather parameters
weatherParameters.latitude = latLon.lat;
weatherParameters.longitude = latLon.lon;
- weatherParameters.zoneId = point.properties.forecastZone.substr(-6);
- weatherParameters.radarId = point.properties.radarStation.substr(-3);
- weatherParameters.stationId = StationId;
- weatherParameters.weatherOffice = point.properties.cwa;
weatherParameters.city = city;
weatherParameters.state = state;
- weatherParameters.timeZone = point.properties.timeZone;
- weatherParameters.forecast = point.properties.forecast;
- weatherParameters.forecastGridData = point.properties.forecastGridData;
- weatherParameters.stations = stations.features;
- weatherParameters.relativeLocation = point.properties.relativeLocation.properties;
+ weatherParameters.country = location.country ?? '';
+ weatherParameters.countryCode = location.countryCode ?? '';
+ weatherParameters.timeZone = openMeteoForecast.timezone;
+ weatherParameters.forecast = aggregatedForecast;
+ weatherParameters.supportsNoaaDisplays = !!(supportsNoaaDisplays && point && stations?.features?.length);
+ weatherParameters.zoneId = point?.properties?.forecastZone?.substr(-6) ?? '';
+ weatherParameters.radarId = point?.properties?.radarStation?.substr(-3) ?? '';
+ weatherParameters.stationId = stationId;
+ weatherParameters.weatherOffice = point?.properties?.cwa ?? '';
+ weatherParameters.forecastGridData = point?.properties?.forecastGridData ?? '';
+ weatherParameters.stations = stations?.features ?? [];
+ weatherParameters.relativeLocation = point?.properties?.relativeLocation?.properties ?? null;
// update the main process for display purposes
- populateWeatherParameters(weatherParameters, point.properties);
+ populateWeatherParameters(weatherParameters, point?.properties);
// reset the scroll
postMessage({ type: 'current-weather-scroll', method: 'reload' });
@@ -779,12 +801,12 @@ const registerProgress = (_progress) => {
const populateWeatherParameters = (params, point) => {
document.querySelector('#spanCity').innerHTML = `${params.city}, `;
- document.querySelector('#spanState').innerHTML = params.state;
- document.querySelector('#spanStationId').innerHTML = params.stationId;
- document.querySelector('#spanRadarId').innerHTML = params.radarId;
- document.querySelector('#spanZoneId').innerHTML = params.zoneId;
- document.querySelector('#spanOfficeId').innerHTML = point.cwa;
- document.querySelector('#spanGridPoint').innerHTML = `${point.gridX},${point.gridY}`;
+ document.querySelector('#spanState').innerHTML = params.state || params.country || '';
+ document.querySelector('#spanStationId').innerHTML = params.stationId || '';
+ document.querySelector('#spanRadarId').innerHTML = params.radarId || '';
+ document.querySelector('#spanZoneId').innerHTML = params.zoneId || '';
+ document.querySelector('#spanOfficeId').innerHTML = point?.cwa ?? '';
+ document.querySelector('#spanGridPoint').innerHTML = point ? `${point.gridX},${point.gridY}` : '';
};
const latLonReceived = (data, haveDataCallback) => {
diff --git a/server/scripts/modules/radar.mjs b/server/scripts/modules/radar.mjs
index 2ffc03e..8f08c7b 100644
--- a/server/scripts/modules/radar.mjs
+++ b/server/scripts/modules/radar.mjs
@@ -49,6 +49,12 @@ class Radar extends WeatherDisplay {
async getData(weatherParameters, refresh) {
if (!super.getData(weatherParameters, refresh)) return;
+ if (!this.weatherParameters?.supportsNoaaDisplays) {
+ this.timing.totalScreens = 0;
+ this.setStatus(STATUS.loaded);
+ return;
+ }
+ this.timing.totalScreens = 1;
// ALASKA AND HAWAII AREN'T SUPPORTED!
if (this.weatherParameters.state === 'AK' || this.weatherParameters.state === 'HI') {
diff --git a/server/scripts/modules/regionalforecast.mjs b/server/scripts/modules/regionalforecast.mjs
index 05d54a8..b5a081b 100644
--- a/server/scripts/modules/regionalforecast.mjs
+++ b/server/scripts/modules/regionalforecast.mjs
@@ -31,6 +31,13 @@ class RegionalForecast extends WeatherDisplay {
async getData(weatherParameters, refresh) {
if (!super.getData(weatherParameters, refresh)) return;
+ if (!this.weatherParameters?.supportsNoaaDisplays) {
+ this.data = [];
+ this.timing.totalScreens = 0;
+ this.setStatus(STATUS.loaded);
+ return;
+ }
+ this.timing.totalScreens = 3;
// regional forecast implements a silent reload
// but it will not fall back to previously loaded data if data can not be loaded
// there are enough other cities available to populate the map sufficiently even if some do not load
diff --git a/server/scripts/modules/spc-outlook.mjs b/server/scripts/modules/spc-outlook.mjs
index b0aad2a..615daed 100644
--- a/server/scripts/modules/spc-outlook.mjs
+++ b/server/scripts/modules/spc-outlook.mjs
@@ -59,6 +59,12 @@ class SpcOutlook extends WeatherDisplay {
async getData(weatherParameters, refresh) {
if (weatherParameters) this.weatherParameters = weatherParameters;
if (!super.getData(weatherParameters, refresh)) return;
+ if (!this.weatherParameters?.supportsNoaaDisplays) {
+ this.data = [];
+ this.timing.totalScreens = 0;
+ this.setStatus(STATUS.loaded);
+ return;
+ }
// SPC outlook data does not need to be reloaded on a location change, only during silent refresh
if (!this.rawOutlookData || refresh) {
diff --git a/server/scripts/modules/travelforecast.mjs b/server/scripts/modules/travelforecast.mjs
index d5b4f02..a9e85c6 100644
--- a/server/scripts/modules/travelforecast.mjs
+++ b/server/scripts/modules/travelforecast.mjs
@@ -34,6 +34,12 @@ class TravelForecast extends WeatherDisplay {
async getData(weatherParameters, refresh) {
// super checks for enabled
if (!super.getData(weatherParameters, refresh)) return;
+ if (!this.weatherParameters?.supportsNoaaDisplays) {
+ this.data = [];
+ this.timing.totalScreens = 0;
+ this.setStatus(STATUS.loaded);
+ return;
+ }
// clear stored data if not refresh
if (!refresh) {
diff --git a/server/scripts/modules/utils/url-rewrite.mjs b/server/scripts/modules/utils/url-rewrite.mjs
index 2230472..652a6f3 100644
--- a/server/scripts/modules/utils/url-rewrite.mjs
+++ b/server/scripts/modules/utils/url-rewrite.mjs
@@ -42,6 +42,10 @@ const rewriteUrl = (_url) => {
url.protocol = window.location.protocol;
url.host = window.location.host;
url.pathname = `/mesonet${url.pathname}`;
+ } else if (url.origin === 'https://api.open-meteo.com') {
+ url.protocol = window.location.protocol;
+ url.host = window.location.host;
+ url.pathname = `/open-meteo${url.pathname}`;
} else if (typeof OVERRIDES !== 'undefined' && OVERRIDES?.RADAR_HOST && url.origin === `https://${OVERRIDES.RADAR_HOST}`) {
// Handle override radar host
url.protocol = window.location.protocol;
diff --git a/server/scripts/modules/utils/weather.mjs b/server/scripts/modules/utils/weather.mjs
index b80b1ec..45bb155 100644
--- a/server/scripts/modules/utils/weather.mjs
+++ b/server/scripts/modules/utils/weather.mjs
@@ -1,6 +1,13 @@
import { safeJson } from './fetch.mjs';
import { debugFlag } from './debug.mjs';
+const OPEN_METEO_FORECAST_PARAMETERS = [
+ 'daily=temperature_2m_max,temperature_2m_min,uv_index_max',
+ 'hourly=temperature_2m,relative_humidity_2m,dew_point_2m,apparent_temperature,precipitation_probability,precipitation,rain,showers,snowfall,snow_depth,weather_code,pressure_msl,surface_pressure,cloud_cover,visibility,uv_index,is_day,sunshine_duration,wind_speed_10m,wind_direction_10m,wind_gusts_10m',
+ 'timezone=auto',
+ 'models=best_match',
+].join('&');
+
const getPoint = async (lat, lon) => {
const point = await safeJson(`https://api.weather.gov/points/${lat.toFixed(4)},${lon.toFixed(4)}`);
if (!point) {
@@ -12,7 +19,111 @@ const getPoint = async (lat, lon) => {
return point;
};
-export {
- // eslint-disable-next-line import/prefer-default-export
- getPoint,
+const getOpenMeteoForecast = async (lat, lon) => {
+ const forecast = await safeJson(`https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}&${OPEN_METEO_FORECAST_PARAMETERS}`);
+ if (!forecast) {
+ if (debugFlag('verbose-failures')) {
+ console.warn(`Unable to get Open-Meteo forecast for ${lat},${lon}`);
+ }
+ return false;
+ }
+ return forecast;
+};
+
+const weatherConditions = [
+ { codes: [0], text: ['Clear sky'] },
+ { codes: [1, 2, 3], text: ['Mainly clear', 'Partly cloudy', 'Overcast'] },
+ { codes: [45, 48], text: ['Fog', 'Depositing rime fog'] },
+ { codes: [51, 53, 55], text: ['Light Drizzle', 'Moderate Drizzle', 'Dense Drizzle'] },
+ { codes: [56, 57], text: ['Light Freezing Drizzle', 'Dense Freezing Drizzle'] },
+ { codes: [61, 63, 65], text: ['Slight Rain', 'Moderate Rain', 'Heavy Rain'] },
+ { codes: [66, 67], text: ['Light Freezing Rain', 'Heavy Freezing Rain'] },
+ { codes: [71, 73, 75], text: ['Slight Snow Fall', 'Moderate Snow Fall', 'Heavy Snow Fall'] },
+ { codes: [77], text: ['Snow Grains'] },
+ { codes: [80, 81, 82], text: ['Slight Rain Showers', 'Moderate Rain Showers', 'Violent Rain Showers'] },
+ { codes: [85, 86], text: ['Slight Snow Showers', 'Heavy Snow Showers'] },
+ { codes: [95], text: ['Thunderstorm'] },
+ { codes: [96, 99], text: ['Thunderstorm with Slight Hail', 'Thunderstorm with Heavy Hail'] },
+];
+
+const getConditionText = (code) => {
+ const condition = weatherConditions.find((item) => item.codes.includes(Number(code)));
+ if (!condition) {
+ console.warn(`Unable to determine weather condition from code: ${code}`);
+ return 'Unknown Conditions';
+ }
+
+ const index = condition.codes.findIndex((item) => item === Number(code));
+ return condition.text[index];
+};
+
+const aggregateWeatherForecastData = (forecastResponse) => {
+ if (!forecastResponse?.hourly || !forecastResponse?.daily) {
+ console.warn('aggregateWeatherForecastData: missing hourly or daily forecast data.');
+ return null;
+ }
+
+ const { hourly, daily } = forecastResponse;
+ const keys = Object.keys(hourly).filter((key) => key !== 'time');
+ const dailyData = {};
+
+ hourly.time.forEach((timestamp, index) => {
+ const date = timestamp.split('T')[0];
+
+ if (!dailyData[date]) {
+ dailyData[date] = { hours: [], weather_code_counts: {} };
+ keys.forEach((key) => {
+ dailyData[date][key] = { sum: 0, count: 0 };
+ });
+ }
+
+ const hourData = { time: timestamp };
+ keys.forEach((key) => {
+ const value = hourly[key][index];
+ hourData[key] = value;
+ if (value !== null) {
+ dailyData[date][key].sum += value;
+ dailyData[date][key].count += 1;
+ }
+ });
+
+ if (hourly.weather_code?.[index] !== undefined && hourly.weather_code[index] !== null) {
+ const weatherCode = hourly.weather_code[index];
+ dailyData[date].weather_code_counts[weatherCode] = (dailyData[date].weather_code_counts[weatherCode] || 0) + 1;
+ }
+
+ dailyData[date].hours.push(hourData);
+ });
+
+ const dailyAverages = {};
+ Object.entries(dailyData).forEach(([date, data]) => {
+ dailyAverages[date] = { hours: data.hours };
+ keys.forEach((key) => {
+ const { sum, count } = data[key];
+ dailyAverages[date][key] = count > 0 ? sum / count : null;
+ });
+
+ const weatherCodes = Object.entries(data.weather_code_counts);
+ if (weatherCodes.length > 0) {
+ [dailyAverages[date].weather_code] = weatherCodes.reduce((a, b) => (b[1] > a[1] ? b : a));
+ }
+ });
+
+ daily.time.forEach((date, index) => {
+ if (!dailyAverages[date]) {
+ dailyAverages[date] = { hours: [] };
+ }
+ dailyAverages[date].temperature_2m_max = daily.temperature_2m_max[index];
+ dailyAverages[date].temperature_2m_min = daily.temperature_2m_min[index];
+ dailyAverages[date].uv_index_max = daily.uv_index_max[index];
+ });
+
+ return dailyAverages;
+};
+
+export {
+ getPoint,
+ getOpenMeteoForecast,
+ aggregateWeatherForecastData,
+ getConditionText,
};