add international forecast support with Open-Meteo

This commit is contained in:
mrkmntal 2026-04-07 14:57:23 -04:00
commit 7098414f67
19 changed files with 566 additions and 899 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 <br> tags to force height beyond the minimum, then subtract the padding
const originalHTML = template.innerHTML;
const paddingBRs = '<br/>'.repeat(7);
template.innerHTML = originalHTML + paddingBRs;
// Measure the padded height
const paddedHeight = template.offsetHeight;
const paddedLines = Math.round(paddedHeight / lineHeight);
// Calculate actual content lines by subtracting the 7 BR lines we added
const actualLines = Math.max(1, paddedLines - 7);
// Restore original content
template.innerHTML = originalHTML;
forecastLineCounts.push(actualLines);
if (debugFlag('localforecast')) {
console.log(`LocalForecast: Forecast ${index} measured ${actualLines} lines (${paddedHeight}px with padding - ${7 * lineHeight}px = ${actualLines * lineHeight}px actual, ${lineHeight}px line-height)`);
}
}
});
// Apply height padding for proper scrolling display (keep existing system working)
templates.forEach((forecast) => {
const newHeight = Math.ceil(forecast.offsetHeight / this.pageHeight) * this.pageHeight;
forecast.style.height = `${newHeight}px`;
});
// Calculate total screens based on padded height (for navigation system)
const forecastsElem = templates[0].parentNode;
const totalHeight = forecastsElem.scrollHeight;
this.timing.totalScreens = Math.round(totalHeight / this.pageHeight);
// Now calculate timing based on actual measured line counts, ignoring padding
const maxLinesPerScreen = 7; // 280px / 40px line height
const screenTimings = []; forecastLineCounts.forEach((lines, forecastIndex) => {
if (lines <= maxLinesPerScreen) {
// Single screen for this forecast
screenTimings.push({ forecastIndex, lines, type: 'single' });
} else {
// Multiple screens for this forecast
let remainingLines = lines;
let isFirst = true;
while (remainingLines > 0) {
const linesThisScreen = Math.min(remainingLines, maxLinesPerScreen);
const type = isFirst ? 'first-of-multi' : 'remainder';
screenTimings.push({ forecastIndex, lines: linesThisScreen, type });
remainingLines -= linesThisScreen;
isFirst = false;
}
}
});
// Create timing array based on measured line counts
const screenDelays = screenTimings.map((screenInfo, screenIndex) => {
const screenLines = screenInfo.lines;
// Apply timing rules based on actual screen content lines
let timingMultiplier;
if (screenLines === 1) {
timingMultiplier = 0.6; // 1 line = shortest (3.0s at normal speed)
} else if (screenLines === 2) {
timingMultiplier = 0.8; // 2 lines = shorter (4.0s at normal speed)
} else if (screenLines >= 6) {
timingMultiplier = 1.4; // 6+ lines = longer (7.0s at normal speed)
} else {
timingMultiplier = 1.0; // 3-5 lines = normal (5.0s at normal speed)
}
// Convert to base counts
const desiredDurationMs = timingMultiplier * originalBaseDuration;
const baseCounts = Math.round(desiredDurationMs / this.timing.baseDelay);
if (debugFlag('localforecast')) {
console.log(`LocalForecast: Screen ${screenIndex}: ${screenLines} lines, ${timingMultiplier.toFixed(2)}x multiplier, ${desiredDurationMs}ms desired, ${baseCounts} counts (forecast ${screenInfo.forecastIndex}, ${screenInfo.type})`);
}
return baseCounts;
});
// Adjust timing array to match actual screen count if needed
while (screenDelays.length < this.timing.totalScreens) {
// Add fallback timing for extra screens
const fallbackCounts = Math.round(originalBaseDuration / this.timing.baseDelay);
screenDelays.push(fallbackCounts);
console.warn(`LocalForecast: using fallback timing for Screen ${screenDelays.length - 1}: 5 lines, 1.00x multiplier, ${fallbackCounts} counts`);
}
// Truncate if we have too many calculated screens
if (screenDelays.length > this.timing.totalScreens) {
const removed = screenDelays.splice(this.timing.totalScreens);
console.warn(`LocalForecast: Truncated ${removed.length} excess screen timings`);
}
// Set the timing array based on screen content
this.timing.delay = screenDelays;
if (debugFlag('localforecast')) {
console.log(`LocalForecast: Final screen count - calculated: ${screenTimings.length}, actual: ${this.timing.totalScreens}, timing array: ${screenDelays.length}`);
const multipliers = screenDelays.map((counts) => counts * this.timing.baseDelay / originalBaseDuration);
console.log('LocalForecast: Screen multipliers:', multipliers);
console.log('LocalForecast: Expected durations (ms):', screenDelays.map((counts) => counts * this.timing.baseDelay));
}
}
}
// format the forecast
// 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'));

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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