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

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