add international forecast support with Open-Meteo
This commit is contained in:
parent
91cc2bd663
commit
7098414f67
19 changed files with 566 additions and 899 deletions
13
README.md
13
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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue