init
This commit is contained in:
commit
6250d23297
53 changed files with 6381 additions and 0 deletions
138
lib/weather/providers/tomorrow.ts
Normal file
138
lib/weather/providers/tomorrow.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
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",
|
||||
signupUrl: "https://app.tomorrow.io/signup",
|
||||
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,
|
||||
})),
|
||||
};
|
||||
},
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue