133 lines
5.1 KiB
TypeScript
133 lines
5.1 KiB
TypeScript
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,
|
||
})),
|
||
};
|
||
},
|
||
};
|