134 lines
5.1 KiB
TypeScript
134 lines
5.1 KiB
TypeScript
import { fetchJson, fromUnix, maybe, maybeMsToKph, msToKph, round1 } from "../../http";
|
|
import type { DailyForecast, HourlyForecast, ProviderForecast } from "../../types";
|
|
import { OWM_LABEL, popToPercent, precipitation, toCondition, type OwmWeatherTag } from "./shared";
|
|
|
|
/**
|
|
* OpenWeather FREE plan:
|
|
* /data/2.5/weather → current conditions
|
|
* /data/2.5/forecast → 5 days in 3-hour steps
|
|
* There is no daily endpoint, so we build days by grouping the 3-hour steps.
|
|
*/
|
|
|
|
const BASE_URL = "https://api.openweathermap.org/data/2.5";
|
|
|
|
interface OwmCurrentResponse {
|
|
dt: number;
|
|
timezone: number; // seconds from UTC
|
|
weather: OwmWeatherTag[];
|
|
main: { temp: number; feels_like: number; humidity: number; pressure: number };
|
|
visibility?: number; // metres
|
|
wind: { speed: number; deg?: number; gust?: number }; // m/s
|
|
clouds?: { all: number };
|
|
rain?: { "1h"?: number };
|
|
snow?: { "1h"?: number };
|
|
sys: { sunrise: number; sunset: number };
|
|
}
|
|
|
|
interface OwmForecastEntry {
|
|
dt: number;
|
|
main: { temp: number; feels_like: number; temp_min: number; temp_max: number; humidity: number };
|
|
weather: OwmWeatherTag[];
|
|
wind: { speed: number; deg?: number; gust?: number };
|
|
pop?: number;
|
|
rain?: { "3h"?: number };
|
|
snow?: { "3h"?: number };
|
|
sys: { pod: "d" | "n" };
|
|
}
|
|
|
|
interface OwmForecastResponse {
|
|
list: OwmForecastEntry[];
|
|
city: { timezone: number };
|
|
}
|
|
|
|
function toHourly(e: OwmForecastEntry): HourlyForecast {
|
|
return {
|
|
time: fromUnix(e.dt),
|
|
tempC: round1(e.main.temp),
|
|
feelsLikeC: round1(e.main.feels_like),
|
|
condition: toCondition(e.weather[0].id),
|
|
isDay: e.sys.pod === "d",
|
|
precipChance: popToPercent(e.pop),
|
|
precipMm: precipitation(e.rain?.["3h"], e.snow?.["3h"]),
|
|
windKph: msToKph(e.wind.speed),
|
|
gustKph: maybeMsToKph(e.wind.gust),
|
|
windDeg: maybe(e.wind.deg),
|
|
humidity: e.main.humidity,
|
|
uvIndex: null, // not on the free plan
|
|
};
|
|
}
|
|
|
|
/** Groups the 3-hour steps by local calendar day and summarises each day. */
|
|
function buildDaily(response: OwmForecastResponse, today: OwmCurrentResponse): DailyForecast[] {
|
|
const offset = response.city.timezone;
|
|
const localDay = (e: OwmForecastEntry) => new Date((e.dt + offset) * 1000).toISOString().slice(0, 10);
|
|
const localHour = (e: OwmForecastEntry) => new Date((e.dt + offset) * 1000).getUTCHours();
|
|
|
|
const entriesByDay = new Map<string, OwmForecastEntry[]>();
|
|
for (const entry of response.list) {
|
|
const day = localDay(entry);
|
|
entriesByDay.set(day, [...(entriesByDay.get(day) ?? []), entry]);
|
|
}
|
|
|
|
return [...entriesByDay.values()].map((entries, index) => {
|
|
// The step nearest midday best represents "what the day looks like".
|
|
const midday = entries.reduce((best, e) => (Math.abs(localHour(e) - 12) < Math.abs(localHour(best) - 12) ? e : best));
|
|
const pops = entries.map((e) => popToPercent(e.pop) ?? 0);
|
|
const gusts = entries.map((e) => e.wind.gust ?? 0);
|
|
|
|
return {
|
|
time: fromUnix(midday.dt),
|
|
highC: round1(Math.max(...entries.map((e) => e.main.temp_max))),
|
|
lowC: round1(Math.min(...entries.map((e) => e.main.temp_min))),
|
|
condition: toCondition(midday.weather[0].id),
|
|
summary: midday.weather[0].description,
|
|
precipChance: Math.max(...pops),
|
|
precipMm: round1(entries.reduce((sum, e) => sum + (precipitation(e.rain?.["3h"], e.snow?.["3h"]) ?? 0), 0)),
|
|
windKph: msToKph(Math.max(...entries.map((e) => e.wind.speed))),
|
|
gustKph: Math.max(...gusts) > 0 ? msToKph(Math.max(...gusts)) : null,
|
|
windDeg: maybe(midday.wind.deg),
|
|
uvIndexMax: null,
|
|
humidity: Math.round(entries.reduce((sum, e) => sum + e.main.humidity, 0) / entries.length),
|
|
// Only today's sun times are available on this plan.
|
|
sunrise: index === 0 ? fromUnix(today.sys.sunrise) : null,
|
|
sunset: index === 0 ? fromUnix(today.sys.sunset) : null,
|
|
};
|
|
});
|
|
}
|
|
|
|
export async function getFreeForecast(lat: number, lon: number, key: string): Promise<ProviderForecast> {
|
|
const query = `lat=${lat}&lon=${lon}&units=metric&appid=${key}`;
|
|
|
|
// Both requests are independent, so run them at the same time.
|
|
const [now, forecast] = await Promise.all([
|
|
fetchJson<OwmCurrentResponse>(`${BASE_URL}/weather?${query}`, OWM_LABEL),
|
|
fetchJson<OwmForecastResponse>(`${BASE_URL}/forecast?${query}`, OWM_LABEL),
|
|
]);
|
|
|
|
const tag = now.weather[0];
|
|
|
|
return {
|
|
timeZone: null, // only a UTC offset is given; the API route fills in the name
|
|
hourlyStepHours: 3,
|
|
alerts: [],
|
|
current: {
|
|
time: fromUnix(now.dt),
|
|
tempC: round1(now.main.temp),
|
|
feelsLikeC: round1(now.main.feels_like),
|
|
condition: toCondition(tag.id),
|
|
description: tag.description,
|
|
isDay: tag.icon.endsWith("d"),
|
|
humidity: now.main.humidity,
|
|
windKph: msToKph(now.wind.speed),
|
|
gustKph: maybeMsToKph(now.wind.gust),
|
|
windDeg: maybe(now.wind.deg),
|
|
pressureHpa: now.main.pressure,
|
|
dewPointC: null,
|
|
visibilityKm: now.visibility === undefined ? null : round1(now.visibility / 1000),
|
|
uvIndex: null,
|
|
cloudCover: maybe(now.clouds?.all),
|
|
precipMm: precipitation(now.rain?.["1h"], now.snow?.["1h"]),
|
|
},
|
|
hourly: forecast.list.map(toHourly),
|
|
daily: buildDaily(forecast, now),
|
|
};
|
|
}
|