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

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