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