137 lines
5 KiB
TypeScript
137 lines
5 KiB
TypeScript
import { CONDITION_LABELS } from "../describe";
|
|
import { fetchJson, maybe, maybeMsToKph, MAX_HOURS, msToKph, nextHours, ProviderError, readKey, round1 } from "../http";
|
|
import type { Condition, WeatherProvider } from "../types";
|
|
|
|
/**
|
|
* Tomorrow.io — one forecast request with hourly (≈120 h) and daily (≈6 days) timelines.
|
|
* "Current" is the first hourly entry, which Tomorrow.io sets to the present hour.
|
|
* Free plan: 25 requests/hour, so the 10-minute cache matters here.
|
|
* Docs: https://docs.tomorrow.io/reference/weather-forecast
|
|
*/
|
|
|
|
interface TmrwHourlyValues {
|
|
temperature: number;
|
|
temperatureApparent: number;
|
|
humidity: number;
|
|
windSpeed: number; // m/s with units=metric
|
|
windGust?: number;
|
|
windDirection?: number;
|
|
weatherCode: number;
|
|
precipitationProbability?: number;
|
|
rainIntensity?: number; // mm/h
|
|
snowIntensity?: number;
|
|
uvIndex?: number;
|
|
pressureSurfaceLevel?: number;
|
|
dewPoint?: number;
|
|
visibility?: number; // km
|
|
cloudCover?: number;
|
|
}
|
|
|
|
interface TmrwDailyValues {
|
|
temperatureMax: number;
|
|
temperatureMin: number;
|
|
weatherCodeMax: number;
|
|
precipitationProbabilityMax?: number;
|
|
rainAccumulationSum?: number;
|
|
snowAccumulationSum?: number;
|
|
windSpeedMax?: number;
|
|
windGustMax?: number;
|
|
windDirectionAvg?: number;
|
|
uvIndexMax?: number;
|
|
humidityAvg?: number;
|
|
sunriseTime?: string;
|
|
sunsetTime?: string;
|
|
}
|
|
|
|
interface TmrwResponse {
|
|
timelines: {
|
|
hourly: { time: string; values: TmrwHourlyValues }[];
|
|
daily: { time: string; values: TmrwDailyValues }[];
|
|
};
|
|
}
|
|
|
|
/** Weather codes: https://docs.tomorrow.io/reference/data-layers-weather-codes */
|
|
function toCondition(code: number): Condition {
|
|
if (code === 1000 || code === 1100) return "clear";
|
|
if (code === 8000) return "storm";
|
|
if (code >= 2000 && code < 3000) return "fog";
|
|
if (code >= 4000 && code < 5000) return "rain";
|
|
if (code >= 5000 && code < 6000) return "snow";
|
|
if (code >= 6000 && code < 7000) return "rain"; // freezing rain / drizzle
|
|
if (code >= 7000 && code < 8000) return "snow"; // ice pellets
|
|
return "clouds";
|
|
}
|
|
|
|
/** Daytime if the instant falls between sunrise and sunset of a day that has them. */
|
|
function isDaytime(instant: string, days: TmrwResponse["timelines"]["daily"]): boolean {
|
|
const t = Date.parse(instant);
|
|
return days.some(({ values: v }) =>
|
|
v.sunriseTime && v.sunsetTime ? t >= Date.parse(v.sunriseTime) && t < Date.parse(v.sunsetTime) : false,
|
|
);
|
|
}
|
|
|
|
export const tomorrowIo: WeatherProvider = {
|
|
id: "tomorrow",
|
|
label: "Tomorrow.io",
|
|
envKey: "TOMORROW_API_KEY",
|
|
limits: () => ({ forecastDays: 6, hourlyStepHours: 1 }),
|
|
|
|
async getForecast(lat, lon) {
|
|
const key = readKey("TOMORROW_API_KEY");
|
|
const url = `https://api.tomorrow.io/v4/weather/forecast?location=${lat},${lon}×teps=1h,1d&units=metric&apikey=${key}`;
|
|
const { timelines } = await fetchJson<TmrwResponse>(url, "Tomorrow.io");
|
|
|
|
const hourly = timelines.hourly.map(({ time, values: v }) => ({
|
|
time: new Date(time).toISOString(),
|
|
tempC: round1(v.temperature),
|
|
feelsLikeC: round1(v.temperatureApparent),
|
|
condition: toCondition(v.weatherCode),
|
|
isDay: isDaytime(time, timelines.daily),
|
|
precipChance: maybe(v.precipitationProbability),
|
|
precipMm: maybe((v.rainIntensity ?? 0) + (v.snowIntensity ?? 0)),
|
|
windKph: msToKph(v.windSpeed),
|
|
gustKph: maybeMsToKph(v.windGust),
|
|
windDeg: maybe(v.windDirection),
|
|
humidity: maybe(v.humidity),
|
|
uvIndex: maybe(v.uvIndex),
|
|
}));
|
|
|
|
const now = timelines.hourly[0];
|
|
if (!now) throw new ProviderError("Tomorrow.io returned an empty forecast", 502);
|
|
const condition = toCondition(now.values.weatherCode);
|
|
|
|
return {
|
|
timeZone: null, // Tomorrow.io doesn't say; the API route fills it in
|
|
hourlyStepHours: 1,
|
|
alerts: [],
|
|
current: {
|
|
...hourly[0],
|
|
feelsLikeC: hourly[0].feelsLikeC ?? hourly[0].tempC,
|
|
humidity: Math.round(now.values.humidity),
|
|
description: CONDITION_LABELS[condition],
|
|
pressureHpa: maybe(now.values.pressureSurfaceLevel),
|
|
dewPointC: maybe(now.values.dewPoint),
|
|
visibilityKm: maybe(now.values.visibility),
|
|
cloudCover: maybe(now.values.cloudCover),
|
|
precipMm: hourly[0].precipMm,
|
|
},
|
|
hourly: nextHours(hourly, MAX_HOURS),
|
|
daily: timelines.daily.map(({ time, values: v }) => ({
|
|
time: new Date(time).toISOString(), // Tomorrow.io starts each day at 6 am local
|
|
highC: round1(v.temperatureMax),
|
|
lowC: round1(v.temperatureMin),
|
|
condition: toCondition(v.weatherCodeMax),
|
|
summary: null,
|
|
precipChance: maybe(v.precipitationProbabilityMax),
|
|
precipMm: maybe((v.rainAccumulationSum ?? 0) + (v.snowAccumulationSum ?? 0)),
|
|
windKph: maybeMsToKph(v.windSpeedMax),
|
|
gustKph: maybeMsToKph(v.windGustMax),
|
|
windDeg: maybe(v.windDirectionAvg),
|
|
uvIndexMax: maybe(v.uvIndexMax),
|
|
humidity: maybe(v.humidityAvg),
|
|
sunrise: v.sunriseTime ?? null,
|
|
sunset: v.sunsetTime ?? null,
|
|
})),
|
|
};
|
|
},
|
|
};
|