129 lines
5.4 KiB
TypeScript
129 lines
5.4 KiB
TypeScript
import { toNumber } from "@/lib/weather-utils";
|
|
import type { ImgwCurrentWeather, RawImgwHybridWeatherResponse, RawImgwHybridWeatherRow } from "@/types/imgw-current";
|
|
|
|
const STALE_TEN_MINUTE_THRESHOLD_MS = 2 * 60 * 60 * 1000;
|
|
|
|
interface NormalizedHybridRow {
|
|
row: RawImgwHybridWeatherRow;
|
|
measuredAt: string;
|
|
timestamp: number;
|
|
}
|
|
|
|
function toCelsius(value: unknown) {
|
|
const temperature = toNumber(value);
|
|
if (temperature === null) return null;
|
|
return temperature > 150 ? temperature - 273.15 : temperature;
|
|
}
|
|
|
|
function toHectopascals(value: unknown) {
|
|
const pressure = toNumber(value);
|
|
if (pressure === null) return null;
|
|
return pressure > 2_000 ? pressure / 100 : pressure;
|
|
}
|
|
|
|
function normalizeDate(value: unknown) {
|
|
if (typeof value !== "string") return null;
|
|
const date = new Date(value);
|
|
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
|
}
|
|
|
|
function normalizeHybridRow(candidate: RawImgwHybridWeatherRow): NormalizedHybridRow | null {
|
|
const measuredAt = normalizeDate(candidate.Date);
|
|
if (!measuredAt) return null;
|
|
const timestamp = new Date(measuredAt).getTime();
|
|
return Number.isNaN(timestamp) ? null : { row: candidate, measuredAt, timestamp };
|
|
}
|
|
|
|
function getWeatherCode(iconCode: unknown) {
|
|
if (typeof iconCode !== "string") return null;
|
|
const match = iconCode.match(/z(\d{2})/i);
|
|
return match ? Number(match[1]) : null;
|
|
}
|
|
|
|
function getCondition(weatherCode: number | null, rainfall10m: number | null, snowfall10m: number | null) {
|
|
if (weatherCode !== null && weatherCode >= 95) return "thunderstorm" as const;
|
|
if ((snowfall10m ?? 0) > 0) return "snow" as const;
|
|
if ((rainfall10m ?? 0) > 0) return "rain" as const;
|
|
return null;
|
|
}
|
|
|
|
function hasNumericValue(value: unknown) {
|
|
return toNumber(value) !== null;
|
|
}
|
|
|
|
function isFullWeatherRow(candidate: RawImgwHybridWeatherRow) {
|
|
return hasNumericValue(candidate.Temperature)
|
|
&& hasNumericValue(candidate.Chill)
|
|
&& hasNumericValue(candidate.Humidity)
|
|
&& hasNumericValue(candidate.Wind_Speed)
|
|
&& hasNumericValue(candidate.PressureMSL);
|
|
}
|
|
|
|
function hasPrecipitationValue(candidate: RawImgwHybridWeatherRow) {
|
|
return hasNumericValue(candidate.Precipitation10m) || hasNumericValue(candidate.Rain10m) || hasNumericValue(candidate.Snow10m);
|
|
}
|
|
|
|
function pickFullWeatherRow(rows: NormalizedHybridRow[]) {
|
|
const tenMinuteRow = rows.find((candidate) => candidate.row.Type === "Type_Ten_Minutes" && isFullWeatherRow(candidate.row));
|
|
const hourlyRow = rows.find((candidate) => candidate.row.Type === "Type_Hour" && isFullWeatherRow(candidate.row));
|
|
if (!tenMinuteRow) return hourlyRow ?? null;
|
|
if (!hourlyRow) return tenMinuteRow;
|
|
return hourlyRow.timestamp - tenMinuteRow.timestamp > STALE_TEN_MINUTE_THRESHOLD_MS ? hourlyRow : tenMinuteRow;
|
|
}
|
|
|
|
function pickPrecipitationRow(rows: NormalizedHybridRow[], fullRow: NormalizedHybridRow | null) {
|
|
if (fullRow && hasPrecipitationValue(fullRow.row)) return fullRow;
|
|
const precipitationRows = rows.filter((candidate) => candidate.row.Type === "Type_Ten_Minutes" && hasPrecipitationValue(candidate.row));
|
|
if (!precipitationRows.length) return null;
|
|
if (!fullRow) return precipitationRows[0];
|
|
return precipitationRows.reduce((best, candidate) => (
|
|
Math.abs(candidate.timestamp - fullRow.timestamp) < Math.abs(best.timestamp - fullRow.timestamp) ? candidate : best
|
|
));
|
|
}
|
|
|
|
export function normalizeImgwCurrentWeather(payload: RawImgwHybridWeatherResponse): ImgwCurrentWeather | null {
|
|
if (!payload.data?.Valid || !Array.isArray(payload.data.Data)) return null;
|
|
|
|
const rows = payload.data.Data
|
|
.flatMap((candidate): NormalizedHybridRow[] => {
|
|
if (!candidate || typeof candidate !== "object") return [];
|
|
const row = candidate as RawImgwHybridWeatherRow;
|
|
if (row.Type !== "Type_Ten_Minutes" && row.Type !== "Type_Hour") return [];
|
|
const normalizedRow = normalizeHybridRow(row);
|
|
return normalizedRow ? [normalizedRow] : [];
|
|
});
|
|
const fullRow = pickFullWeatherRow(rows);
|
|
const precipitationRow = pickPrecipitationRow(rows, fullRow);
|
|
const row = fullRow ?? precipitationRow;
|
|
if (!row) return null;
|
|
|
|
const precipitationSource = precipitationRow?.row ?? row.row;
|
|
const rainfall10m = toNumber(precipitationSource.Rain10m);
|
|
const snowfall10m = toNumber(precipitationSource.Snow10m);
|
|
const weatherCode = getWeatherCode(row.row.Icon10);
|
|
|
|
return {
|
|
coverage: fullRow?.row.Type === "Type_Ten_Minutes" ? "full" : fullRow ? "hourly" : "precipitation-only",
|
|
measuredAt: row.measuredAt,
|
|
temperature: toCelsius(row.row.Temperature),
|
|
feelsLike: toCelsius(row.row.Chill),
|
|
windSpeed: toNumber(row.row.Wind_Speed),
|
|
windDirection: toNumber(row.row.Wind_Dir),
|
|
humidity: toNumber(row.row.Humidity),
|
|
pressure: toHectopascals(row.row.PressureMSL),
|
|
precipitation10m: toNumber(precipitationSource.Precipitation10m),
|
|
rainfall10m,
|
|
snowfall10m,
|
|
cloudCover: toNumber(row.row.Cloud),
|
|
weatherCode,
|
|
condition: getCondition(weatherCode, rainfall10m, snowfall10m),
|
|
};
|
|
}
|
|
|
|
export async function fetchImgwCurrentWeather(latitude: number, longitude: number, signal?: AbortSignal) {
|
|
const params = new URLSearchParams({ latitude: String(latitude), longitude: String(longitude) });
|
|
const response = await fetch(`/api/imgw-current?${params}`, { signal });
|
|
if (!response.ok) throw new Error("Nie udało się pobrać bieżącej analizy IMGW Hybrid.");
|
|
return normalizeImgwCurrentWeather(await response.json() as RawImgwHybridWeatherResponse);
|
|
}
|