fix: select current IMGW Hybrid records
This commit is contained in:
@@ -38,7 +38,7 @@ Repozytorium nie ma obecnie skryptu testów, osobnego skryptu type-check ani for
|
|||||||
- Dane zewnętrzne pobieraj przez route handlery Next.js. Nie omijaj allowlisty w `app/api/imgw/[...path]/route.ts`.
|
- Dane zewnętrzne pobieraj przez route handlery Next.js. Nie omijaj allowlisty w `app/api/imgw/[...path]/route.ts`.
|
||||||
- Traktuj IMGW jako źródło bieżących pomiarów, hydro i ostrzeżeń. Prognozę Open-Meteo pokazuj oddzielnie jako prognozę modelową. Nie generuj fikcyjnych danych ani nie przedstawiaj prognozy jako pomiaru IMGW.
|
- Traktuj IMGW jako źródło bieżących pomiarów, hydro i ostrzeżeń. Prognozę Open-Meteo pokazuj oddzielnie jako prognozę modelową. Nie generuj fikcyjnych danych ani nie przedstawiaj prognozy jako pomiaru IMGW.
|
||||||
- Dashboard hero korzysta z publicznego endpointu Hybrid oficjalnego portalu IMGW przez `app/api/imgw-current/route.ts`, z fallbackiem do godzinowego `synop`. Hybrid ma krótki cache i dostarcza m.in. opad 10-minutowy; nie przedstawiaj go jako akumulowanej sumy opadu stacji.
|
- Dashboard hero korzysta z publicznego endpointu Hybrid oficjalnego portalu IMGW przez `app/api/imgw-current/route.ts`, z fallbackiem do godzinowego `synop`. Hybrid ma krótki cache i dostarcza m.in. opad 10-minutowy; nie przedstawiaj go jako akumulowanej sumy opadu stacji.
|
||||||
- Hybrid może zwrócić wyłącznie lokalny opad MERGE bez parametrów AROME. Zachowuj taki opad jako częściową analizę lokalną, a pozostałe parametry uzupełniaj jawnym fallbackiem `synop`.
|
- Hybrid wybieraj z lokalnego rekordu aktualnej godziny UTC, zgodnie z portalem `meteo.imgw.pl`; rekord może być 10-minutowy albo godzinowy. Jeśli IMGW zwraca wyłącznie lokalny opad MERGE bez pełnych parametrów, zachowuj go jako częściową analizę lokalną, a pozostałe parametry uzupełniaj jawnym fallbackiem `synop`.
|
||||||
- W UI rozdzielaj lokalną analizę Hybrid dla współrzędnych miejscowości od kontekstowej informacji o najbliższej stacji pomiarowej. Fallback `synop` oznaczaj jawnie; dla stacji oddalonej o co najmniej 30 km zachowuj ostrzeżenie o możliwej różnicy warunków lokalnych.
|
- W UI rozdzielaj lokalną analizę Hybrid dla współrzędnych miejscowości od kontekstowej informacji o najbliższej stacji pomiarowej. Fallback `synop` oznaczaj jawnie; dla stacji oddalonej o co najmniej 30 km zachowuj ostrzeżenie o możliwej różnicy warunków lokalnych.
|
||||||
- Route handler prognozy pobiera godzinowe dane Open-Meteo dla pełnych 7 dni. Dashboard pokazuje najbliższe 24 przyszłe godziny oraz wykresy pełnego bieżącego dnia, a widok szczegółowy dnia korzysta z pełnego zestawu godzin dla wybranej daty.
|
- Route handler prognozy pobiera godzinowe dane Open-Meteo dla pełnych 7 dni. Dashboard pokazuje najbliższe 24 przyszłe godziny oraz wykresy pełnego bieżącego dnia, a widok szczegółowy dnia korzysta z pełnego zestawu godzin dla wybranej daty.
|
||||||
- `synop.suma_opadu` jest akumulowaną sumą opadu. Nie używaj jej jako sygnału, że pada w tej chwili, ani do sterowania animacją deszczu.
|
- `synop.suma_opadu` jest akumulowaną sumą opadu. Nie używaj jej jako sygnału, że pada w tej chwili, ani do sterowania animacją deszczu.
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ Przeglądarka pobiera dane przez route handlery Next.js. Proxy IMGW w `app/api/i
|
|||||||
|
|
||||||
## Ograniczenia API
|
## Ograniczenia API
|
||||||
|
|
||||||
Dashboard korzysta z publicznego endpointu IMGW Hybrid używanego przez oficjalny portal `meteo.imgw.pl`. Dzięki temu bieżące warunki są analizowane dla współrzędnych wybranej miejscowości, aktualizowane częściej niż godzinowe dane synoptyczne i mogą pokazać rzeczywisty opad z ostatnich 10 minut. Interfejs oddzielnie opisuje lokalną analizę Hybrid oraz najbliższą stację pomiarową IMGW. Pokrycie Hybrid może być częściowe: jeśli IMGW publikuje lokalny opad MERGE bez parametrów AROME, hero zachowuje lokalny opad, a temperaturę, wiatr, wilgotność i ciśnienie jawnie uzupełnia fallbackiem ze stacji. Jeśli usługa Hybrid nie odpowiada, hero zachowuje cały pomiar `synop` jako oznaczony fallback. Gdy fallback pochodzi ze stacji oddalonej od miejscowości o co najmniej 30 km, interfejs ostrzega o możliwej różnicy warunków lokalnych. Endpoint Hybrid jest częścią publicznego frontendu IMGW, ale nie jest opisany w stabilnej dokumentacji `danepubliczne.imgw.pl`, więc integrację należy monitorować przy zmianach portalu.
|
Dashboard korzysta z publicznego endpointu IMGW Hybrid używanego przez oficjalny portal `meteo.imgw.pl`. Bieżące warunki są wybierane z lokalnego rekordu aktualnej godziny dla współrzędnych miejscowości, zgodnie z zachowaniem portalu IMGW, i mogą dodatkowo pokazać rzeczywisty opad z ostatnich 10 minut. Interfejs oddzielnie opisuje lokalną analizę Hybrid oraz najbliższą stację pomiarową IMGW. Pokrycie Hybrid może być częściowe: jeśli IMGW publikuje lokalny opad MERGE bez pełnego rekordu parametrów, hero zachowuje lokalny opad, a temperaturę, wiatr, wilgotność i ciśnienie jawnie uzupełnia fallbackiem ze stacji. Jeśli usługa Hybrid nie odpowiada, hero zachowuje cały pomiar `synop` jako oznaczony fallback. Gdy fallback pochodzi ze stacji oddalonej od miejscowości o co najmniej 30 km, interfejs ostrzega o możliwej różnicy warunków lokalnych. Endpoint Hybrid jest częścią publicznego frontendu IMGW, ale nie jest opisany w stabilnej dokumentacji `danepubliczne.imgw.pl`, więc integrację należy monitorować przy zmianach portalu.
|
||||||
|
|
||||||
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 oraz bieżącej analizy Hybrid. `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 oraz bieżącej analizy Hybrid. `wtr.` nie generuje fikcyjnych prognoz. Widok stacji prezentuje aktualne parametry i jawnie opisany snapshot pomiarowy. Brakujące wartości są oznaczane jako `Brak danych`.
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { useI18n } from "@/lib/i18n";
|
|||||||
import { formatForecastRainfall, formatForecastTemperature } from "@/lib/forecast-utils";
|
import { formatForecastRainfall, formatForecastTemperature } from "@/lib/forecast-utils";
|
||||||
import type { HourlyForecast } from "@/types/forecast";
|
import type { HourlyForecast } from "@/types/forecast";
|
||||||
|
|
||||||
|
const INITIAL_CHART_DIMENSION = { width: 1, height: 1 };
|
||||||
|
|
||||||
function formatHour(value: string) {
|
function formatHour(value: string) {
|
||||||
return value.slice(11, 16);
|
return value.slice(11, 16);
|
||||||
}
|
}
|
||||||
@@ -25,8 +27,8 @@ export function DayForecastCharts({ hours }: { hours: HourlyForecast[] }) {
|
|||||||
<Card className="p-4 sm:p-5">
|
<Card className="p-4 sm:p-5">
|
||||||
<h3 className="text-sm font-semibold">{t("forecast.temperatureChart")}</h3>
|
<h3 className="text-sm font-semibold">{t("forecast.temperatureChart")}</h3>
|
||||||
<p className="mt-1 text-xs leading-5 text-slate-600 dark:text-slate-300">{t("forecast.temperatureChartDescription")}</p>
|
<p className="mt-1 text-xs leading-5 text-slate-600 dark:text-slate-300">{t("forecast.temperatureChartDescription")}</p>
|
||||||
<div className="mt-4 h-56 w-full">
|
<div className="mt-4 h-56 min-w-0">
|
||||||
<ResponsiveContainer width="100%" height="100%" minWidth={0}>
|
<ResponsiveContainer width="100%" height="100%" minWidth={0} minHeight={0} initialDimension={INITIAL_CHART_DIMENSION}>
|
||||||
<ComposedChart data={rows} margin={{ left: -20, right: 8, top: 8 }}>
|
<ComposedChart data={rows} margin={{ left: -20, right: 8, top: 8 }}>
|
||||||
<CartesianGrid stroke="rgba(148,163,184,0.18)" strokeDasharray="4 4" vertical={false} />
|
<CartesianGrid stroke="rgba(148,163,184,0.18)" strokeDasharray="4 4" vertical={false} />
|
||||||
<XAxis dataKey="time" axisLine={false} tickLine={false} interval={3} tick={{ fill: "currentColor", fontSize: 11 }} />
|
<XAxis dataKey="time" axisLine={false} tickLine={false} interval={3} tick={{ fill: "currentColor", fontSize: 11 }} />
|
||||||
@@ -46,8 +48,8 @@ export function DayForecastCharts({ hours }: { hours: HourlyForecast[] }) {
|
|||||||
<Card className="p-4 sm:p-5">
|
<Card className="p-4 sm:p-5">
|
||||||
<h3 className="text-sm font-semibold">{t("forecast.rainfallChart")}</h3>
|
<h3 className="text-sm font-semibold">{t("forecast.rainfallChart")}</h3>
|
||||||
<p className="mt-1 text-xs leading-5 text-slate-600 dark:text-slate-300">{t("forecast.rainfallChartDescription")}</p>
|
<p className="mt-1 text-xs leading-5 text-slate-600 dark:text-slate-300">{t("forecast.rainfallChartDescription")}</p>
|
||||||
<div className="mt-4 h-56 w-full">
|
<div className="mt-4 h-56 min-w-0">
|
||||||
<ResponsiveContainer width="100%" height="100%" minWidth={0}>
|
<ResponsiveContainer width="100%" height="100%" minWidth={0} minHeight={0} initialDimension={INITIAL_CHART_DIMENSION}>
|
||||||
<ComposedChart data={rows} margin={{ left: -20, right: -10, top: 8 }}>
|
<ComposedChart data={rows} margin={{ left: -20, right: -10, top: 8 }}>
|
||||||
<CartesianGrid stroke="rgba(148,163,184,0.18)" strokeDasharray="4 4" vertical={false} />
|
<CartesianGrid stroke="rgba(148,163,184,0.18)" strokeDasharray="4 4" vertical={false} />
|
||||||
<XAxis dataKey="time" axisLine={false} tickLine={false} interval={3} tick={{ fill: "currentColor", fontSize: 11 }} />
|
<XAxis dataKey="time" axisLine={false} tickLine={false} interval={3} tick={{ fill: "currentColor", fontSize: 11 }} />
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import type { SynopStation } from "@/types/imgw";
|
|||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import { useI18n } from "@/lib/i18n";
|
import { useI18n } from "@/lib/i18n";
|
||||||
|
|
||||||
|
const INITIAL_CHART_DIMENSION = { width: 1, height: 1 };
|
||||||
|
|
||||||
export function SnapshotChart({ station }: { station: SynopStation }) {
|
export function SnapshotChart({ station }: { station: SynopStation }) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const rows = [
|
const rows = [
|
||||||
@@ -18,8 +20,8 @@ export function SnapshotChart({ station }: { station: SynopStation }) {
|
|||||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-sky-700 dark:text-sky-300">{t("snapshot.label")}</p>
|
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-sky-700 dark:text-sky-300">{t("snapshot.label")}</p>
|
||||||
<h2 className="mt-2 text-xl font-semibold tracking-tight">{t("snapshot.title")}</h2>
|
<h2 className="mt-2 text-xl font-semibold tracking-tight">{t("snapshot.title")}</h2>
|
||||||
<p className="mt-1 text-sm leading-6 text-slate-600 dark:text-slate-300">{t("snapshot.description")}</p>
|
<p className="mt-1 text-sm leading-6 text-slate-600 dark:text-slate-300">{t("snapshot.description")}</p>
|
||||||
<div className="mt-5 h-52 w-full">
|
<div className="mt-5 h-52 min-w-0">
|
||||||
<ResponsiveContainer width="100%" height="100%" minWidth={0}>
|
<ResponsiveContainer width="100%" height="100%" minWidth={0} minHeight={0} initialDimension={INITIAL_CHART_DIMENSION}>
|
||||||
<BarChart data={rows} layout="vertical" margin={{ left: 0, right: 16 }}>
|
<BarChart data={rows} layout="vertical" margin={{ left: 0, right: 16 }}>
|
||||||
<XAxis type="number" hide domain={[0, 100]} />
|
<XAxis type="number" hide domain={[0, 100]} />
|
||||||
<YAxis type="category" dataKey="name" width={86} axisLine={false} tickLine={false} tick={{ fill: "currentColor", fontSize: 12 }} />
|
<YAxis type="category" dataKey="name" width={86} axisLine={false} tickLine={false} tick={{ fill: "currentColor", fontSize: 12 }} />
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import { useI18n } from "@/lib/i18n";
|
|||||||
export function WeatherHero({ station, currentWeather, currentWeatherLoading = false, locationName, distanceKm }: { station: SynopStation; currentWeather?: ImgwCurrentWeather | null; currentWeatherLoading?: boolean; locationName?: string; distanceKm?: number }) {
|
export function WeatherHero({ station, currentWeather, currentWeatherLoading = false, locationName, distanceKm }: { station: SynopStation; currentWeather?: ImgwCurrentWeather | null; currentWeatherLoading?: boolean; locationName?: string; distanceKm?: number }) {
|
||||||
const { language, t } = useI18n();
|
const { language, t } = useI18n();
|
||||||
const displayedLocationName = locationName ?? station.name;
|
const displayedLocationName = locationName ?? station.name;
|
||||||
const hasFullHybridAnalysis = currentWeather?.coverage === "full";
|
const hasFullHybridAnalysis = currentWeather?.coverage === "full" || currentWeather?.coverage === "hourly";
|
||||||
const hasPartialHybridAnalysis = currentWeather?.coverage === "precipitation-only";
|
const hasPartialHybridAnalysis = currentWeather?.coverage === "precipitation-only";
|
||||||
const hasDistantFallback = !hasFullHybridAnalysis && !currentWeatherLoading && distanceKm !== undefined && distanceKm >= 30;
|
const hasDistantFallback = !hasFullHybridAnalysis && !currentWeatherLoading && distanceKm !== undefined && distanceKm >= 30;
|
||||||
const displayedStation = currentWeather ? {
|
const displayedStation = currentWeather ? {
|
||||||
|
|||||||
@@ -32,17 +32,22 @@ function getCondition(weatherCode: number | null, rainfall10m: number | null, sn
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getCurrentUtcHour() {
|
||||||
|
return new Date().toISOString().slice(0, 13);
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizeImgwCurrentWeather(payload: RawImgwHybridWeatherResponse): ImgwCurrentWeather | null {
|
export function normalizeImgwCurrentWeather(payload: RawImgwHybridWeatherResponse): ImgwCurrentWeather | null {
|
||||||
if (!payload.data?.Valid || !Array.isArray(payload.data.Data)) return null;
|
if (!payload.data?.Valid || !Array.isArray(payload.data.Data)) return null;
|
||||||
|
|
||||||
const rows = payload.data.Data
|
const rows = payload.data.Data
|
||||||
.filter((candidate): candidate is RawImgwHybridWeatherRow => {
|
.filter((candidate): candidate is RawImgwHybridWeatherRow => {
|
||||||
if (!candidate || typeof candidate !== "object") return false;
|
if (!candidate || typeof candidate !== "object") return false;
|
||||||
return candidate.Type === "Type_Ten_Minutes" && normalizeDate(candidate.Date) !== null;
|
return (candidate.Type === "Type_Ten_Minutes" || candidate.Type === "Type_Hour") && normalizeDate(candidate.Date) !== null;
|
||||||
})
|
})
|
||||||
.sort((left, right) => String(left.Date).localeCompare(String(right.Date)));
|
.sort((left, right) => String(left.Date).localeCompare(String(right.Date)));
|
||||||
const fullRow = rows.find((candidate) => typeof candidate.MODEL === "string" && candidate.MODEL.includes("AROME"));
|
const currentUtcHour = getCurrentUtcHour();
|
||||||
const precipitationRow = rows.find((candidate) => candidate.Precipitation10m !== undefined);
|
const fullRow = rows.find((candidate) => String(candidate.Date).startsWith(currentUtcHour) && candidate.Temperature !== undefined);
|
||||||
|
const precipitationRow = rows.find((candidate) => String(candidate.Date).startsWith(currentUtcHour) && candidate.Precipitation10m !== undefined);
|
||||||
const row = fullRow ?? precipitationRow;
|
const row = fullRow ?? precipitationRow;
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
|
|
||||||
@@ -53,7 +58,7 @@ export function normalizeImgwCurrentWeather(payload: RawImgwHybridWeatherRespons
|
|||||||
const weatherCode = getWeatherCode(row.Icon10);
|
const weatherCode = getWeatherCode(row.Icon10);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
coverage: fullRow ? "full" : "precipitation-only",
|
coverage: fullRow?.Type === "Type_Ten_Minutes" ? "full" : fullRow ? "hourly" : "precipitation-only",
|
||||||
measuredAt,
|
measuredAt,
|
||||||
temperature: toCelsius(row.Temperature),
|
temperature: toCelsius(row.Temperature),
|
||||||
feelsLike: toCelsius(row.Chill),
|
feelsLike: toCelsius(row.Chill),
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export interface RawImgwHybridWeatherResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type CurrentWeatherCondition = "rain" | "snow" | "thunderstorm" | null;
|
export type CurrentWeatherCondition = "rain" | "snow" | "thunderstorm" | null;
|
||||||
export type ImgwCurrentWeatherCoverage = "full" | "precipitation-only";
|
export type ImgwCurrentWeatherCoverage = "full" | "hourly" | "precipitation-only";
|
||||||
|
|
||||||
export interface ImgwCurrentWeather {
|
export interface ImgwCurrentWeather {
|
||||||
coverage: ImgwCurrentWeatherCoverage;
|
coverage: ImgwCurrentWeatherCoverage;
|
||||||
|
|||||||
Reference in New Issue
Block a user