263 lines
8.5 KiB
JavaScript
263 lines
8.5 KiB
JavaScript
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 OPEN_METEO_RADAR_OBSERVATION_PARAMETERS = [
|
|
'hourly=temperature_2m,weather_code,is_day,wind_speed_10m,wind_gusts_10m,wind_direction_10m',
|
|
'forecast_days=1',
|
|
'timezone=auto',
|
|
'models=best_match',
|
|
].join('&');
|
|
|
|
const OPEN_METEO_OBSERVATION_CACHE_TTL_MS = 10 * 60 * 1000;
|
|
const OPEN_METEO_TRAVEL_FORECAST_CACHE_TTL_MS = 30 * 60 * 1000;
|
|
const openMeteoObservationCache = new Map();
|
|
const openMeteoTravelForecastCache = new Map();
|
|
|
|
const getPoint = async (lat, lon) => {
|
|
const point = await safeJson(`https://api.weather.gov/points/${lat.toFixed(4)},${lon.toFixed(4)}`);
|
|
if (!point) {
|
|
if (debugFlag('verbose-failures')) {
|
|
console.warn(`Unable to get points for ${lat},${lon}`);
|
|
}
|
|
return false;
|
|
}
|
|
return point;
|
|
};
|
|
|
|
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 getAggregatedOpenMeteoForecast = async (lat, lon) => {
|
|
const forecast = await getOpenMeteoForecast(lat, lon);
|
|
if (!forecast) return false;
|
|
|
|
const aggregatedForecast = aggregateWeatherForecastData(forecast);
|
|
if (!aggregatedForecast) {
|
|
if (debugFlag('verbose-failures')) {
|
|
console.warn(`Unable to aggregate Open-Meteo forecast for ${lat},${lon}`);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
return {
|
|
forecast,
|
|
aggregatedForecast,
|
|
};
|
|
};
|
|
|
|
const getCachedAggregatedOpenMeteoForecast = async (lat, lon) => {
|
|
const cacheKey = `${lat.toFixed(4)},${lon.toFixed(4)}`;
|
|
const cachedEntry = openMeteoTravelForecastCache.get(cacheKey);
|
|
const now = Date.now();
|
|
if (cachedEntry && (now - cachedEntry.fetchedAt) < OPEN_METEO_TRAVEL_FORECAST_CACHE_TTL_MS) {
|
|
return cachedEntry.data;
|
|
}
|
|
|
|
const forecast = await getAggregatedOpenMeteoForecast(lat, lon);
|
|
if (forecast) {
|
|
openMeteoTravelForecastCache.set(cacheKey, {
|
|
data: forecast,
|
|
fetchedAt: now,
|
|
});
|
|
return forecast;
|
|
}
|
|
|
|
if (cachedEntry) {
|
|
return cachedEntry.data;
|
|
}
|
|
|
|
return false;
|
|
};
|
|
|
|
const getOpenMeteoObservationSnapshot = async (lat, lon) => {
|
|
const cacheKey = `${lat.toFixed(4)},${lon.toFixed(4)}`;
|
|
const cachedEntry = openMeteoObservationCache.get(cacheKey);
|
|
const now = Date.now();
|
|
if (cachedEntry && (now - cachedEntry.fetchedAt) < OPEN_METEO_OBSERVATION_CACHE_TTL_MS) {
|
|
return cachedEntry.data;
|
|
}
|
|
|
|
const forecast = await safeJson(`https://api.open-meteo.com/v1/forecast?latitude=${lat}&longitude=${lon}&${OPEN_METEO_RADAR_OBSERVATION_PARAMETERS}`);
|
|
if (!forecast?.hourly?.time?.length) {
|
|
if (debugFlag('verbose-failures')) {
|
|
console.warn(`Unable to get Open-Meteo radar observation snapshot for ${lat},${lon}`);
|
|
}
|
|
if (cachedEntry) {
|
|
return cachedEntry.data;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
let nearestIndex = 0;
|
|
let nearestDelta = Number.POSITIVE_INFINITY;
|
|
|
|
forecast.hourly.time.forEach((time, index) => {
|
|
const delta = Math.abs(new Date(time).getTime() - now);
|
|
if (delta < nearestDelta) {
|
|
nearestDelta = delta;
|
|
nearestIndex = index;
|
|
}
|
|
});
|
|
|
|
const snapshot = {
|
|
time: forecast.hourly.time[nearestIndex],
|
|
temperature: forecast.hourly.temperature_2m?.[nearestIndex] ?? null,
|
|
weatherCode: forecast.hourly.weather_code?.[nearestIndex] ?? 0,
|
|
isDay: Boolean(forecast.hourly.is_day?.[nearestIndex] ?? 1),
|
|
windSpeed: forecast.hourly.wind_speed_10m?.[nearestIndex] ?? 0,
|
|
windGusts: forecast.hourly.wind_gusts_10m?.[nearestIndex] ?? 0,
|
|
windDirection: forecast.hourly.wind_direction_10m?.[nearestIndex] ?? 0,
|
|
timezone: forecast.timezone,
|
|
};
|
|
|
|
openMeteoObservationCache.set(cacheKey, {
|
|
data: snapshot,
|
|
fetchedAt: now,
|
|
});
|
|
|
|
return snapshot;
|
|
};
|
|
|
|
const weatherConditions = [
|
|
{ codes: [0], text: ['Clear'] },
|
|
{ codes: [1, 2, 3], text: ['Mostly Clear', 'Some Clouds', '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'] },
|
|
];
|
|
|
|
// Wind descriptor thresholds (km/h)
|
|
const getWindDescriptor = (windSpeedKmh, windGustsKmh) => {
|
|
// Use max of sustained wind or weighted gusts
|
|
const maxWind = Math.max(windSpeedKmh, (windGustsKmh || 0) * 0.8);
|
|
|
|
if (maxWind >= 56) return 'Very Windy';
|
|
if (maxWind >= 36) return 'Windy';
|
|
if (maxWind >= 21) return 'Breezy';
|
|
return null;
|
|
};
|
|
|
|
// Get condition text with wind descriptor
|
|
const getConditionTextWithWind = (weatherCode, windSpeedKmh, windGustsKmh) => {
|
|
const baseCondition = getConditionText(weatherCode);
|
|
const windDesc = getWindDescriptor(windSpeedKmh, windGustsKmh);
|
|
|
|
if (windDesc) {
|
|
// For clear sky conditions, just use the wind descriptor
|
|
if (weatherCode === 0) {
|
|
return windDesc;
|
|
}
|
|
return `${baseCondition} ${windDesc}`;
|
|
}
|
|
return baseCondition;
|
|
};
|
|
|
|
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,
|
|
getAggregatedOpenMeteoForecast,
|
|
getCachedAggregatedOpenMeteoForecast,
|
|
getOpenMeteoObservationSnapshot,
|
|
aggregateWeatherForecastData,
|
|
getConditionText,
|
|
getWindDescriptor,
|
|
getConditionTextWithWind,
|
|
};
|