feat: add Open-Meteo weather forecast
This commit is contained in:
68
lib/forecast-api.ts
Normal file
68
lib/forecast-api.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { DailyForecast, HourlyForecast, RawForecastSeries, RawWeatherForecast, WeatherForecast } from "@/types/forecast";
|
||||
|
||||
function asArray(value: unknown): unknown[] {
|
||||
return Array.isArray(value) ? value : [];
|
||||
}
|
||||
|
||||
function readString(series: RawForecastSeries, key: keyof RawForecastSeries, index: number) {
|
||||
const value = asArray(series[key])[index];
|
||||
return typeof value === "string" && value ? value : null;
|
||||
}
|
||||
|
||||
function readNumber(series: RawForecastSeries, key: keyof RawForecastSeries, index: number) {
|
||||
const value = asArray(series[key])[index];
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function normalizeHourlyForecast(series: RawForecastSeries = {}): HourlyForecast[] {
|
||||
return asArray(series.time).flatMap((_, index) => {
|
||||
const time = readString(series, "time", index);
|
||||
if (!time) return [];
|
||||
return [{
|
||||
time,
|
||||
temperature: readNumber(series, "temperature_2m", index),
|
||||
feelsLike: readNumber(series, "apparent_temperature", index),
|
||||
precipitationProbability: readNumber(series, "precipitation_probability", index),
|
||||
precipitation: readNumber(series, "precipitation", index),
|
||||
weatherCode: readNumber(series, "weather_code", index),
|
||||
windSpeed: readNumber(series, "wind_speed_10m", index),
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeDailyForecast(series: RawForecastSeries = {}): DailyForecast[] {
|
||||
return asArray(series.time).flatMap((_, index) => {
|
||||
const date = readString(series, "time", index);
|
||||
if (!date) return [];
|
||||
return [{
|
||||
date,
|
||||
temperatureMax: readNumber(series, "temperature_2m_max", index),
|
||||
temperatureMin: readNumber(series, "temperature_2m_min", index),
|
||||
precipitationProbability: readNumber(series, "precipitation_probability_max", index),
|
||||
precipitation: readNumber(series, "precipitation_sum", index),
|
||||
weatherCode: readNumber(series, "weather_code", index),
|
||||
sunrise: readString(series, "sunrise", index),
|
||||
sunset: readString(series, "sunset", index),
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeForecast(raw: RawWeatherForecast): WeatherForecast {
|
||||
const latitude = Number(raw.latitude);
|
||||
const longitude = Number(raw.longitude);
|
||||
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) throw new Error("Forecast service returned invalid coordinates.");
|
||||
return {
|
||||
latitude,
|
||||
longitude,
|
||||
timezone: typeof raw.timezone === "string" ? raw.timezone : "Europe/Warsaw",
|
||||
hourly: normalizeHourlyForecast(raw.hourly),
|
||||
daily: normalizeDailyForecast(raw.daily),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchForecast(latitude: number, longitude: number, signal?: AbortSignal) {
|
||||
const params = new URLSearchParams({ latitude: String(latitude), longitude: String(longitude) });
|
||||
const response = await fetch(`/api/forecast?${params}`, { signal });
|
||||
if (!response.ok) throw new Error("Unable to load forecast.");
|
||||
return normalizeForecast(await response.json() as RawWeatherForecast);
|
||||
}
|
||||
28
lib/forecast-utils.ts
Normal file
28
lib/forecast-utils.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { Language, TranslationKey } from "@/lib/i18n";
|
||||
import { translate } from "@/lib/i18n";
|
||||
|
||||
export function getForecastConditionKey(code: number | null): TranslationKey {
|
||||
if (code === 0) return "forecast.condition.clear";
|
||||
if (code === 1 || code === 2) return "forecast.condition.partlyCloudy";
|
||||
if (code === 3) return "forecast.condition.cloudy";
|
||||
if (code === 45 || code === 48) return "forecast.condition.fog";
|
||||
if (code !== null && code >= 51 && code <= 57) return "forecast.condition.drizzle";
|
||||
if (code !== null && ((code >= 61 && code <= 67) || (code >= 80 && code <= 82))) return "forecast.condition.rain";
|
||||
if (code !== null && ((code >= 71 && code <= 77) || code === 85 || code === 86)) return "forecast.condition.snow";
|
||||
if (code !== null && code >= 95) return "forecast.condition.thunderstorm";
|
||||
return "forecast.condition.unknown";
|
||||
}
|
||||
|
||||
export function getForecastCondition(code: number | null, language: Language) {
|
||||
return translate(language, getForecastConditionKey(code));
|
||||
}
|
||||
|
||||
export function formatForecastTemperature(value: number | null, language: Language) {
|
||||
if (value === null) return "—";
|
||||
return `${new Intl.NumberFormat(language === "pl" ? "pl-PL" : "en-GB", { maximumFractionDigits: 0 }).format(value)}°`;
|
||||
}
|
||||
|
||||
export function formatForecastRainfall(value: number | null, language: Language) {
|
||||
if (value === null) return "—";
|
||||
return `${new Intl.NumberFormat(language === "pl" ? "pl-PL" : "en-GB", { maximumFractionDigits: 1 }).format(value)} mm`;
|
||||
}
|
||||
38
lib/i18n.tsx
38
lib/i18n.tsx
@@ -66,6 +66,25 @@ const translations = {
|
||||
"weather.windDirectionDetail": "Kierunek napływu wiatru",
|
||||
"weather.rainfallDetail": "Suma opadu z pomiaru IMGW",
|
||||
"weather.temperatureDetail": "Temperatura powietrza",
|
||||
"forecast.label": "Prognoza modelowa",
|
||||
"forecast.title": "Najbliższe godziny i dni",
|
||||
"forecast.description": "Prognoza dla {location}. Bieżący pomiar powyżej pochodzi ze stacji IMGW, a poniższe wartości są prognozą modelową.",
|
||||
"forecast.hourly": "Najbliższe 24 godziny",
|
||||
"forecast.daily": "Prognoza 7-dniowa",
|
||||
"forecast.today": "Dzisiaj",
|
||||
"forecast.source": "Źródło prognozy:",
|
||||
"forecast.error": "Nie udało się pobrać prognozy Open-Meteo.",
|
||||
"forecast.emptyTitle": "Brak prognozy",
|
||||
"forecast.emptyDescription": "Open-Meteo nie zwróciło teraz kompletnej prognozy dla tej lokalizacji.",
|
||||
"forecast.condition.clear": "Bezchmurnie",
|
||||
"forecast.condition.partlyCloudy": "Częściowe zachmurzenie",
|
||||
"forecast.condition.cloudy": "Pochmurno",
|
||||
"forecast.condition.fog": "Mgła",
|
||||
"forecast.condition.drizzle": "Mżawka",
|
||||
"forecast.condition.rain": "Opady deszczu",
|
||||
"forecast.condition.snow": "Opady śniegu",
|
||||
"forecast.condition.thunderstorm": "Burza",
|
||||
"forecast.condition.unknown": "Brak opisu",
|
||||
"stations.emptyTitle": "Brak pasujących stacji",
|
||||
"station.all": "Wszystkie stacje",
|
||||
"station.label": "Stacja {name}",
|
||||
@@ -176,6 +195,25 @@ const translations = {
|
||||
"weather.windDirectionDetail": "Direction the wind is coming from",
|
||||
"weather.rainfallDetail": "Total rainfall from the IMGW reading",
|
||||
"weather.temperatureDetail": "Air temperature",
|
||||
"forecast.label": "Model forecast",
|
||||
"forecast.title": "Upcoming hours and days",
|
||||
"forecast.description": "Forecast for {location}. The current reading above comes from an IMGW station. The values below are a model forecast.",
|
||||
"forecast.hourly": "Next 24 hours",
|
||||
"forecast.daily": "7-day forecast",
|
||||
"forecast.today": "Today",
|
||||
"forecast.source": "Forecast source:",
|
||||
"forecast.error": "Unable to load the Open-Meteo forecast.",
|
||||
"forecast.emptyTitle": "Forecast unavailable",
|
||||
"forecast.emptyDescription": "Open-Meteo did not return a complete forecast for this location.",
|
||||
"forecast.condition.clear": "Clear sky",
|
||||
"forecast.condition.partlyCloudy": "Partly cloudy",
|
||||
"forecast.condition.cloudy": "Cloudy",
|
||||
"forecast.condition.fog": "Fog",
|
||||
"forecast.condition.drizzle": "Drizzle",
|
||||
"forecast.condition.rain": "Rain",
|
||||
"forecast.condition.snow": "Snow",
|
||||
"forecast.condition.thunderstorm": "Thunderstorm",
|
||||
"forecast.condition.unknown": "Description unavailable",
|
||||
"stations.emptyTitle": "No matching stations",
|
||||
"station.all": "All stations",
|
||||
"station.label": "Station {name}",
|
||||
|
||||
@@ -59,6 +59,8 @@ export function findNearestSynopStation(location: LocationSearchResult, stations
|
||||
return {
|
||||
name: location.name,
|
||||
province: location.province,
|
||||
latitude: location.latitude,
|
||||
longitude: location.longitude,
|
||||
stationId: nearest.station.id,
|
||||
stationName: nearest.station.name,
|
||||
distanceKm: Math.round(nearest.distanceKm),
|
||||
|
||||
Reference in New Issue
Block a user