feat: add Open-Meteo weather forecast
This commit is contained in:
17
README.md
17
README.md
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
**Pogoda z danych IMGW. Prosto. Pięknie. Aktualnie.**
|
**Pogoda z danych IMGW. Prosto. Pięknie. Aktualnie.**
|
||||||
|
|
||||||
`wtr.` to nowoczesna pogodowa PWA dla Polski oparta o publiczne dane IMGW. Aplikacja prezentuje bieżące odczyty synoptyczne, stacje hydrologiczne oraz ostrzeżenia w spokojnym, mobilnym interfejsie z gradientami, kartami glassmorphism i subtelnymi animacjami.
|
`wtr.` to nowoczesna pogodowa PWA dla Polski oparta o publiczne dane IMGW i jawnie oznaczoną prognozę modelową Open-Meteo. Aplikacja prezentuje bieżące odczyty synoptyczne, prognozę godzinową i 7-dniową, stacje hydrologiczne oraz ostrzeżenia w spokojnym, mobilnym interfejsie z gradientami, kartami glassmorphism i subtelnymi animacjami.
|
||||||
|
|
||||||
Interfejs jest dostępny po polsku i angielsku. Wybrany język jest zapisywany lokalnie w przeglądarce. Oryginalne treści ostrzeżeń oraz nazwy stacji pochodzą bezpośrednio z API IMGW i nie są automatycznie tłumaczone.
|
Interfejs jest dostępny po polsku i angielsku. Wybrany język jest zapisywany lokalnie w przeglądarce. Oryginalne treści ostrzeżeń oraz nazwy stacji pochodzą bezpośrednio z API IMGW i nie są automatycznie tłumaczone.
|
||||||
|
|
||||||
@@ -38,9 +38,9 @@ npm run build
|
|||||||
npm run start
|
npm run start
|
||||||
```
|
```
|
||||||
|
|
||||||
## Dane IMGW
|
## Źródła danych
|
||||||
|
|
||||||
Aplikacja korzysta wyłącznie z rzeczywistych publicznych danych IMGW:
|
Bieżące pomiary i komunikaty pochodzą z rzeczywistych publicznych danych IMGW:
|
||||||
|
|
||||||
- dane synoptyczne: `https://danepubliczne.imgw.pl/api/data/synop`
|
- dane synoptyczne: `https://danepubliczne.imgw.pl/api/data/synop`
|
||||||
- pojedyncza stacja synoptyczna: `https://danepubliczne.imgw.pl/api/data/synop/id/{id}`
|
- pojedyncza stacja synoptyczna: `https://danepubliczne.imgw.pl/api/data/synop/id/{id}`
|
||||||
@@ -50,20 +50,23 @@ Aplikacja korzysta wyłącznie z rzeczywistych publicznych danych IMGW:
|
|||||||
- dane meteorologiczne: `https://danepubliczne.imgw.pl/api/data/meteo/`
|
- dane meteorologiczne: `https://danepubliczne.imgw.pl/api/data/meteo/`
|
||||||
- lista produktów: `https://danepubliczne.imgw.pl/api/data/product`
|
- lista produktów: `https://danepubliczne.imgw.pl/api/data/product`
|
||||||
|
|
||||||
Do wyszukiwania nazw miejscowości, bez pobierania danych pogodowych, używany jest endpoint `https://geocoding-api.open-meteo.com/v1/search`. Przed wdrożeniem komercyjnym należy sprawdzić aktualne warunki korzystania z Open-Meteo lub zastąpić geokoder własnym dostawcą.
|
Prognoza godzinowa i 7-dniowa pochodzi z Open-Meteo Forecast API: `https://api.open-meteo.com/v1/forecast`. Jest prezentowana oddzielnie od bieżących pomiarów IMGW i podpisana w interfejsie jako prognoza modelowa.
|
||||||
|
|
||||||
Przeglądarka pobiera dane przez whitelistowane proxy w `app/api/imgw/[...path]/route.ts`. Pozwala to ujednolicić cache, błędy API i bezpiecznie obsłużyć hydro bez mixed content.
|
Do wyszukiwania nazw miejscowości używany jest endpoint `https://geocoding-api.open-meteo.com/v1/search`. Przed wdrożeniem komercyjnym należy sprawdzić aktualne warunki korzystania z Open-Meteo lub zastąpić usługę własnym dostawcą.
|
||||||
|
|
||||||
|
Przeglądarka pobiera dane przez route handlery Next.js. Proxy IMGW w `app/api/imgw/[...path]/route.ts` pozwala ujednolicić cache, błędy API i bezpiecznie obsłużyć hydro bez mixed content. Prognozę obsługuje `app/api/forecast/route.ts`.
|
||||||
|
|
||||||
## Ograniczenia API
|
## Ograniczenia API
|
||||||
|
|
||||||
Publiczny endpoint synoptyczny IMGW udostępnia najnowszy pomiar, a nie pełną prognozę godzinową lub wielodniową i nie historię odczytów. `wtr.` nie generuje fikcyjnych prognoz. Widok stacji prezentuje aktualne parametry i jawnie opisany snapshot pomiarowy. Brakujące wartości są oznaczane jako `Brak danych`.
|
Publiczny endpoint synoptyczny IMGW udostępnia najnowszy pomiar, a nie pełną prognozę godzinową lub wielodniową i nie historię odczytów. Dlatego prognoza pochodzi z Open-Meteo i jest wyraźnie oddzielona od pomiarów IMGW. `wtr.` nie generuje fikcyjnych prognoz. Widok stacji prezentuje aktualne parametry i jawnie opisany snapshot pomiarowy. Brakujące wartości są oznaczane jako `Brak danych`.
|
||||||
|
|
||||||
Czas aktualizacji parametrów hydrologicznych może się różnić. Interfejs pokazuje czas pomiaru, aby starsze odczyty nie wyglądały na bieżące.
|
Czas aktualizacji parametrów hydrologicznych może się różnić. Interfejs pokazuje czas pomiaru, aby starsze odczyty nie wyglądały na bieżące.
|
||||||
|
|
||||||
## Struktura projektu
|
## Struktura projektu
|
||||||
|
|
||||||
```text
|
```text
|
||||||
app/ routing, layout, proxy IMGW, offline fallback
|
app/ routing, layout, proxy danych, offline fallback
|
||||||
|
components/forecast/ prognoza godzinowa i dzienna Open-Meteo
|
||||||
components/dashboard dashboard aplikacji
|
components/dashboard dashboard aplikacji
|
||||||
components/weather/ hero, stacje, metryki i szczegóły
|
components/weather/ hero, stacje, metryki i szczegóły
|
||||||
components/warnings/ alerty meteo i hydro
|
components/warnings/ alerty meteo i hydro
|
||||||
|
|||||||
39
app/api/forecast/route.ts
Normal file
39
app/api/forecast/route.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
const FORECAST_URL = "https://api.open-meteo.com/v1/forecast";
|
||||||
|
|
||||||
|
function parseCoordinate(value: string | null, min: number, max: number) {
|
||||||
|
if (!value?.trim()) return null;
|
||||||
|
const coordinate = Number(value);
|
||||||
|
return Number.isFinite(coordinate) && coordinate >= min && coordinate <= max ? coordinate : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const latitude = parseCoordinate(searchParams.get("latitude"), -90, 90);
|
||||||
|
const longitude = parseCoordinate(searchParams.get("longitude"), -180, 180);
|
||||||
|
if (latitude === null || longitude === null) {
|
||||||
|
return NextResponse.json({ error: "Invalid coordinates." }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
latitude: String(latitude),
|
||||||
|
longitude: String(longitude),
|
||||||
|
hourly: "temperature_2m,apparent_temperature,precipitation_probability,precipitation,weather_code,wind_speed_10m",
|
||||||
|
daily: "weather_code,temperature_2m_max,temperature_2m_min,precipitation_probability_max,precipitation_sum,sunrise,sunset",
|
||||||
|
timezone: "Europe/Warsaw",
|
||||||
|
forecast_hours: "24",
|
||||||
|
forecast_days: "7",
|
||||||
|
wind_speed_unit: "ms",
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${FORECAST_URL}?${params}`, { next: { revalidate: 900 } });
|
||||||
|
if (!response.ok) return NextResponse.json({ error: "Forecast service is unavailable." }, { status: 502 });
|
||||||
|
return NextResponse.json(await response.json(), {
|
||||||
|
headers: { "Cache-Control": "public, s-maxage=900, stale-while-revalidate=1800" },
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json({ error: "Forecast service is unavailable." }, { status: 502 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,8 @@ import { PageLoadingSkeleton } from "@/components/states/loading-skeleton";
|
|||||||
import { ErrorState } from "@/components/states/error-state";
|
import { ErrorState } from "@/components/states/error-state";
|
||||||
import { useI18n } from "@/lib/i18n";
|
import { useI18n } from "@/lib/i18n";
|
||||||
import { useMeteoStationPositions } from "@/hooks/use-meteo-stations";
|
import { useMeteoStationPositions } from "@/hooks/use-meteo-stations";
|
||||||
|
import { ForecastPanel } from "@/components/forecast/forecast-panel";
|
||||||
|
import { locateSynopStations } from "@/lib/location-utils";
|
||||||
|
|
||||||
export function DashboardPage() {
|
export function DashboardPage() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -24,11 +26,17 @@ export function DashboardPage() {
|
|||||||
?? stations.find((station) => station.name === DEFAULT_STATION_NAME)
|
?? stations.find((station) => station.name === DEFAULT_STATION_NAME)
|
||||||
?? stations[0];
|
?? stations[0];
|
||||||
const activeLocation = selectedLocation?.stationId === selectedStation.id ? selectedLocation : null;
|
const activeLocation = selectedLocation?.stationId === selectedStation.id ? selectedLocation : null;
|
||||||
|
const stationPosition = locateSynopStations(stations, positions).find((station) => station.id === selectedStation.id);
|
||||||
|
const hasActiveLocationCoordinates = Number.isFinite(activeLocation?.latitude) && Number.isFinite(activeLocation?.longitude);
|
||||||
|
const forecastLatitude = hasActiveLocationCoordinates ? activeLocation?.latitude : stationPosition?.latitude;
|
||||||
|
const forecastLongitude = hasActiveLocationCoordinates ? activeLocation?.longitude : stationPosition?.longitude;
|
||||||
|
const forecastLocationName = hasActiveLocationCoordinates ? activeLocation?.name ?? selectedStation.name : selectedStation.name;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-10">
|
<div className="space-y-10">
|
||||||
<LocationSearch stations={stations} positions={positions} />
|
<LocationSearch stations={stations} positions={positions} />
|
||||||
<WeatherHero station={selectedStation} locationName={activeLocation?.name} distanceKm={activeLocation?.distanceKm} />
|
<WeatherHero station={selectedStation} locationName={activeLocation?.name} distanceKm={activeLocation?.distanceKm} />
|
||||||
|
<ForecastPanel latitude={forecastLatitude} longitude={forecastLongitude} locationName={forecastLocationName} />
|
||||||
<FavoritesSection stations={stations} />
|
<FavoritesSection stations={stations} />
|
||||||
<FeaturedStationsSection stations={stations} />
|
<FeaturedStationsSection stations={stations} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
13
components/forecast/forecast-icon.tsx
Normal file
13
components/forecast/forecast-icon.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Cloud, CloudDrizzle, CloudFog, CloudLightning, CloudRain, CloudSnow, CloudSun, Sun } from "lucide-react";
|
||||||
|
|
||||||
|
export function ForecastIcon({ code, className = "" }: { code: number | null; className?: string }) {
|
||||||
|
const Icon = code === 0 ? Sun
|
||||||
|
: code === 1 || code === 2 ? CloudSun
|
||||||
|
: code === 45 || code === 48 ? CloudFog
|
||||||
|
: code !== null && code >= 51 && code <= 57 ? CloudDrizzle
|
||||||
|
: code !== null && ((code >= 61 && code <= 67) || (code >= 80 && code <= 82)) ? CloudRain
|
||||||
|
: code !== null && ((code >= 71 && code <= 77) || code === 85 || code === 86) ? CloudSnow
|
||||||
|
: code !== null && code >= 95 ? CloudLightning
|
||||||
|
: Cloud;
|
||||||
|
return <Icon className={className} strokeWidth={1.45} />;
|
||||||
|
}
|
||||||
104
components/forecast/forecast-panel.tsx
Normal file
104
components/forecast/forecast-panel.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { CalendarDays, Clock3, CloudSun, Droplets, ExternalLink, RefreshCw } from "lucide-react";
|
||||||
|
import { ForecastIcon } from "@/components/forecast/forecast-icon";
|
||||||
|
import { LoadingSkeleton } from "@/components/states/loading-skeleton";
|
||||||
|
import { EmptyState } from "@/components/states/empty-state";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card } from "@/components/ui/card";
|
||||||
|
import { useForecast } from "@/hooks/use-forecast";
|
||||||
|
import { useI18n } from "@/lib/i18n";
|
||||||
|
import { formatForecastRainfall, formatForecastTemperature, getForecastCondition } from "@/lib/forecast-utils";
|
||||||
|
import type { DailyForecast } from "@/types/forecast";
|
||||||
|
|
||||||
|
function formatHour(value: string) {
|
||||||
|
return value.slice(11, 16);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDay(value: string, locale: string, todayLabel: string, index: number) {
|
||||||
|
if (index === 0) return todayLabel;
|
||||||
|
return new Intl.DateTimeFormat(locale, { weekday: "short", timeZone: "UTC" }).format(new Date(`${value}T12:00:00Z`));
|
||||||
|
}
|
||||||
|
|
||||||
|
function DailyForecastRow({ day, index }: { day: DailyForecast; index: number }) {
|
||||||
|
const { language, locale, t } = useI18n();
|
||||||
|
return (
|
||||||
|
<motion.li
|
||||||
|
initial={{ opacity: 0, y: 8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: Math.min(index * 0.04, 0.24), duration: 0.28 }}
|
||||||
|
className="grid grid-cols-[4.5rem_1fr_auto] items-center gap-2 border-t border-white/30 py-3 first:border-t-0 dark:border-white/10 sm:grid-cols-[5rem_1fr_5rem_auto]"
|
||||||
|
>
|
||||||
|
<p className="text-sm font-semibold capitalize">{formatDay(day.date, locale, t("forecast.today"), index)}</p>
|
||||||
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
<ForecastIcon code={day.weatherCode} className="size-6 shrink-0 text-sky-600 dark:text-sky-300" />
|
||||||
|
<span className="truncate text-xs text-slate-600 dark:text-slate-300">{getForecastCondition(day.weatherCode, language)} · {formatForecastRainfall(day.precipitation, language)}</span>
|
||||||
|
</div>
|
||||||
|
<span className="hidden items-center gap-1 text-xs text-sky-700 dark:text-sky-300 sm:flex"><Droplets className="size-3" />{day.precipitationProbability === null ? "—" : `${day.precipitationProbability}%`}</span>
|
||||||
|
<p className="whitespace-nowrap text-sm"><strong>{formatForecastTemperature(day.temperatureMax, language)}</strong><span className="ml-2 text-slate-500 dark:text-slate-400">{formatForecastTemperature(day.temperatureMin, language)}</span></p>
|
||||||
|
</motion.li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ForecastPanel({ latitude, longitude, locationName }: { latitude?: number; longitude?: number; locationName: string }) {
|
||||||
|
const { language, t } = useI18n();
|
||||||
|
const { data: forecast, isPending, isError, refetch } = useForecast(latitude, longitude);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<p className="flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.16em] text-sky-700 dark:text-sky-300"><CloudSun className="size-4" />{t("forecast.label")}</p>
|
||||||
|
<h2 className="mt-2 text-xl font-semibold tracking-tight">{t("forecast.title")}</h2>
|
||||||
|
<p className="mt-1 max-w-3xl text-sm leading-6 text-slate-600 dark:text-slate-300">{t("forecast.description", { location: locationName })}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!Number.isFinite(latitude) || !Number.isFinite(longitude) || isPending ? (
|
||||||
|
<div className="grid gap-3 lg:grid-cols-[1.35fr_1fr]" aria-busy="true">
|
||||||
|
<LoadingSkeleton className="h-60" />
|
||||||
|
<LoadingSkeleton className="h-60" />
|
||||||
|
</div>
|
||||||
|
) : isError || !forecast ? (
|
||||||
|
<Card className="flex min-h-40 flex-col items-center justify-center p-6 text-center">
|
||||||
|
<p className="text-sm text-slate-600 dark:text-slate-300">{t("forecast.error")}</p>
|
||||||
|
<Button variant="glass" className="mt-4" onClick={() => refetch()}><RefreshCw className="size-4" />{t("common.retry")}</Button>
|
||||||
|
</Card>
|
||||||
|
) : !forecast.hourly.length || !forecast.daily.length ? (
|
||||||
|
<EmptyState title={t("forecast.emptyTitle")} description={t("forecast.emptyDescription")} />
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-3 lg:grid-cols-[1.35fr_1fr]">
|
||||||
|
<Card className="overflow-hidden p-4 sm:p-5">
|
||||||
|
<h3 className="flex items-center gap-2 text-sm font-semibold"><Clock3 className="size-4 text-sky-600 dark:text-sky-300" />{t("forecast.hourly")}</h3>
|
||||||
|
<div className="-mx-4 mt-4 overflow-x-auto px-4 pb-1 sm:-mx-5 sm:px-5">
|
||||||
|
<ul className="flex min-w-max gap-2">
|
||||||
|
{forecast.hourly.map((hour, index) => (
|
||||||
|
<motion.li
|
||||||
|
key={hour.time}
|
||||||
|
initial={{ opacity: 0, y: 8 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: Math.min(index * 0.018, 0.3), duration: 0.25 }}
|
||||||
|
className="w-[4.6rem] rounded-2xl border border-white/35 bg-white/25 px-2 py-3 text-center dark:border-white/10 dark:bg-white/5"
|
||||||
|
title={getForecastCondition(hour.weatherCode, language)}
|
||||||
|
>
|
||||||
|
<p className="text-xs font-medium text-slate-600 dark:text-slate-300">{formatHour(hour.time)}</p>
|
||||||
|
<ForecastIcon code={hour.weatherCode} className="mx-auto my-3 size-6 text-sky-600 dark:text-sky-300" />
|
||||||
|
<p className="text-lg font-semibold tracking-tight">{formatForecastTemperature(hour.temperature, language)}</p>
|
||||||
|
<p className="mt-2 flex items-center justify-center gap-1 text-[0.66rem] text-sky-700 dark:text-sky-300"><Droplets className="size-3" />{hour.precipitationProbability === null ? "—" : `${hour.precipitationProbability}%`}</p>
|
||||||
|
</motion.li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4 sm:p-5">
|
||||||
|
<h3 className="flex items-center gap-2 text-sm font-semibold"><CalendarDays className="size-4 text-sky-600 dark:text-sky-300" />{t("forecast.daily")}</h3>
|
||||||
|
<ul className="mt-2">{forecast.daily.map((day, index) => <DailyForecastRow day={day} index={index} key={day.date} />)}</ul>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-[0.68rem] text-slate-500 dark:text-slate-400">
|
||||||
|
{t("forecast.source")} <a href="https://open-meteo.com/" target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 underline decoration-slate-400/60 underline-offset-2 transition hover:text-sky-700 dark:hover:text-sky-300">Open-Meteo <ExternalLink className="size-3" /></a>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
16
hooks/use-forecast.ts
Normal file
16
hooks/use-forecast.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { fetchForecast } from "@/lib/forecast-api";
|
||||||
|
import { QUERY_GC_TIME } from "@/lib/constants";
|
||||||
|
|
||||||
|
export function useForecast(latitude?: number, longitude?: number) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["forecast", latitude, longitude],
|
||||||
|
queryFn: ({ signal }) => fetchForecast(latitude as number, longitude as number, signal),
|
||||||
|
enabled: Number.isFinite(latitude) && Number.isFinite(longitude),
|
||||||
|
staleTime: 15 * 60 * 1000,
|
||||||
|
gcTime: QUERY_GC_TIME,
|
||||||
|
retry: 2,
|
||||||
|
});
|
||||||
|
}
|
||||||
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.windDirectionDetail": "Kierunek napływu wiatru",
|
||||||
"weather.rainfallDetail": "Suma opadu z pomiaru IMGW",
|
"weather.rainfallDetail": "Suma opadu z pomiaru IMGW",
|
||||||
"weather.temperatureDetail": "Temperatura powietrza",
|
"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",
|
"stations.emptyTitle": "Brak pasujących stacji",
|
||||||
"station.all": "Wszystkie stacje",
|
"station.all": "Wszystkie stacje",
|
||||||
"station.label": "Stacja {name}",
|
"station.label": "Stacja {name}",
|
||||||
@@ -176,6 +195,25 @@ const translations = {
|
|||||||
"weather.windDirectionDetail": "Direction the wind is coming from",
|
"weather.windDirectionDetail": "Direction the wind is coming from",
|
||||||
"weather.rainfallDetail": "Total rainfall from the IMGW reading",
|
"weather.rainfallDetail": "Total rainfall from the IMGW reading",
|
||||||
"weather.temperatureDetail": "Air temperature",
|
"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",
|
"stations.emptyTitle": "No matching stations",
|
||||||
"station.all": "All stations",
|
"station.all": "All stations",
|
||||||
"station.label": "Station {name}",
|
"station.label": "Station {name}",
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ export function findNearestSynopStation(location: LocationSearchResult, stations
|
|||||||
return {
|
return {
|
||||||
name: location.name,
|
name: location.name,
|
||||||
province: location.province,
|
province: location.province,
|
||||||
|
latitude: location.latitude,
|
||||||
|
longitude: location.longitude,
|
||||||
stationId: nearest.station.id,
|
stationId: nearest.station.id,
|
||||||
stationName: nearest.station.name,
|
stationName: nearest.station.name,
|
||||||
distanceKm: Math.round(nearest.distanceKm),
|
distanceKm: Math.round(nearest.distanceKm),
|
||||||
|
|||||||
52
types/forecast.ts
Normal file
52
types/forecast.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
export interface RawForecastSeries {
|
||||||
|
time?: unknown;
|
||||||
|
temperature_2m?: unknown;
|
||||||
|
apparent_temperature?: unknown;
|
||||||
|
precipitation_probability?: unknown;
|
||||||
|
precipitation?: unknown;
|
||||||
|
weather_code?: unknown;
|
||||||
|
wind_speed_10m?: unknown;
|
||||||
|
temperature_2m_max?: unknown;
|
||||||
|
temperature_2m_min?: unknown;
|
||||||
|
precipitation_probability_max?: unknown;
|
||||||
|
precipitation_sum?: unknown;
|
||||||
|
sunrise?: unknown;
|
||||||
|
sunset?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RawWeatherForecast {
|
||||||
|
latitude?: unknown;
|
||||||
|
longitude?: unknown;
|
||||||
|
timezone?: unknown;
|
||||||
|
hourly?: RawForecastSeries;
|
||||||
|
daily?: RawForecastSeries;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HourlyForecast {
|
||||||
|
time: string;
|
||||||
|
temperature: number | null;
|
||||||
|
feelsLike: number | null;
|
||||||
|
precipitationProbability: number | null;
|
||||||
|
precipitation: number | null;
|
||||||
|
weatherCode: number | null;
|
||||||
|
windSpeed: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DailyForecast {
|
||||||
|
date: string;
|
||||||
|
temperatureMax: number | null;
|
||||||
|
temperatureMin: number | null;
|
||||||
|
precipitationProbability: number | null;
|
||||||
|
precipitation: number | null;
|
||||||
|
weatherCode: number | null;
|
||||||
|
sunrise: string | null;
|
||||||
|
sunset: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WeatherForecast {
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
timezone: string;
|
||||||
|
hourly: HourlyForecast[];
|
||||||
|
daily: DailyForecast[];
|
||||||
|
}
|
||||||
@@ -10,6 +10,8 @@ export interface LocationSearchResult {
|
|||||||
export interface SelectedLocation {
|
export interface SelectedLocation {
|
||||||
name: string;
|
name: string;
|
||||||
province: string | null;
|
province: string | null;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
stationId: string;
|
stationId: string;
|
||||||
stationName: string;
|
stationName: string;
|
||||||
distanceKm: number;
|
distanceKm: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user