wayfarer-weather/lib/weather/providers/openmeteo.ts

134 lines
5.1 KiB
TypeScript
Raw Normal View History

2026-10-31 21:20:16 -05:00
import { CONDITION_LABELS } from "../describe";
import { fetchJson, fromUnix, maybe, MAX_DAYS, MAX_HOURS, nextHours, round1 } from "../http";
import type { Condition, WeatherProvider } from "../types";
/**
* Open-Meteo — free, no key, and the deepest data: hourly and daily out to 16 days.
* Docs: https://open-meteo.com/en/docs
*
* We ask for `timeformat=unixtime` so every time comes back as a plain number.
*/
const HOURLY_VARS = [
"temperature_2m", "apparent_temperature", "precipitation_probability", "precipitation",
"weather_code", "wind_speed_10m", "wind_gusts_10m", "wind_direction_10m",
"relative_humidity_2m", "uv_index", "is_day",
] as const;
const DAILY_VARS = [
"weather_code", "temperature_2m_max", "temperature_2m_min", "precipitation_probability_max",
"precipitation_sum", "wind_speed_10m_max", "wind_gusts_10m_max", "wind_direction_10m_dominant",
"uv_index_max", "sunrise", "sunset",
] as const;
const CURRENT_VARS = [
"temperature_2m", "apparent_temperature", "relative_humidity_2m", "is_day", "precipitation",
"weather_code", "cloud_cover", "pressure_msl", "wind_speed_10m", "wind_direction_10m",
"wind_gusts_10m", "dew_point_2m", "visibility", "uv_index",
] as const;
// Each section is "variable name → value" (current) or "variable name → array of values".
type Row<Names extends readonly string[]> = Record<Names[number], number>;
type Columns<Names extends readonly string[]> = Record<Names[number], (number | null)[]> & { time: number[] };
interface OpenMeteoResponse {
timezone: string;
current: Row<typeof CURRENT_VARS> & { time: number };
hourly: Columns<typeof HOURLY_VARS>;
daily: Columns<typeof DAILY_VARS>;
}
/** WMO weather codes, listed at the bottom of the Open-Meteo docs page. */
function toCondition(code: number | null): Condition {
if (code === null || code <= 1) return "clear";
if (code <= 3) return "clouds";
if (code === 45 || code === 48) return "fog";
if ((code >= 71 && code <= 77) || code === 85 || code === 86) return "snow";
if (code >= 95) return "storm";
return "rain"; // drizzle (51–57), rain (61–67), showers (80–82)
}
const TWELVE_HOURS = 12 * 60 * 60;
export const openMeteo: WeatherProvider = {
id: "openmeteo",
label: "Open-Meteo",
envKey: null,
limits: () => ({ forecastDays: 14, hourlyStepHours: 1 }),
async getForecast(lat, lon) {
const params = new URLSearchParams({
latitude: String(lat),
longitude: String(lon),
current: CURRENT_VARS.join(","),
hourly: HOURLY_VARS.join(","),
daily: DAILY_VARS.join(","),
timezone: "auto",
timeformat: "unixtime",
forecast_days: String(MAX_DAYS),
});
const { current: c, hourly: h, daily: d, timezone } = await fetchJson<OpenMeteoResponse>(
`https://api.open-meteo.com/v1/forecast?${params}`,
"Open-Meteo",
);
const condition = toCondition(c.weather_code);
const hourly = h.time.map((t, i) => ({
time: fromUnix(t),
tempC: round1(h.temperature_2m[i] ?? 0),
feelsLikeC: maybe(h.apparent_temperature[i]),
condition: toCondition(h.weather_code[i]),
isDay: h.is_day[i] === 1,
precipChance: maybe(h.precipitation_probability[i]),
precipMm: maybe(h.precipitation[i]),
windKph: round1(h.wind_speed_10m[i] ?? 0),
gustKph: maybe(h.wind_gusts_10m[i]),
windDeg: maybe(h.wind_direction_10m[i]),
humidity: maybe(h.relative_humidity_2m[i]),
uvIndex: maybe(h.uv_index[i]),
}));
return {
timeZone: timezone,
hourlyStepHours: 1,
alerts: [], // Open-Meteo has no alerts; the API route adds NWS alerts for US locations
current: {
time: fromUnix(c.time),
tempC: round1(c.temperature_2m),
feelsLikeC: round1(c.apparent_temperature),
condition,
description: CONDITION_LABELS[condition],
isDay: c.is_day === 1,
humidity: c.relative_humidity_2m,
windKph: round1(c.wind_speed_10m),
gustKph: maybe(c.wind_gusts_10m),
windDeg: maybe(c.wind_direction_10m),
pressureHpa: maybe(c.pressure_msl),
dewPointC: maybe(c.dew_point_2m),
visibilityKm: c.visibility == null ? null : round1(c.visibility / 1000),
uvIndex: maybe(c.uv_index),
cloudCover: maybe(c.cloud_cover),
precipMm: maybe(c.precipitation),
},
hourly: nextHours(hourly, MAX_HOURS),
daily: d.time.map((t, i) => ({
time: fromUnix(t + TWELVE_HOURS), // daily times are local midnight; use midday
highC: round1(d.temperature_2m_max[i] ?? 0),
lowC: round1(d.temperature_2m_min[i] ?? 0),
condition: toCondition(d.weather_code[i]),
summary: null,
precipChance: maybe(d.precipitation_probability_max[i]),
precipMm: maybe(d.precipitation_sum[i]),
windKph: maybe(d.wind_speed_10m_max[i]),
gustKph: maybe(d.wind_gusts_10m_max[i]),
windDeg: maybe(d.wind_direction_10m_dominant[i]),
uvIndexMax: maybe(d.uv_index_max[i]),
humidity: null,
sunrise: d.sunrise[i] ? fromUnix(d.sunrise[i]!) : null,
sunset: d.sunset[i] ? fromUnix(d.sunset[i]!) : null,
})),
};
},
};