feat: prefer IMGW ALARO forecast data
This commit is contained in:
@@ -1,53 +1,61 @@
|
||||
import type { DailyForecast, HourlyForecast, RawForecastSeries, RawWeatherForecast, WeatherForecast } from "@/types/forecast";
|
||||
import type { DailyForecast, ForecastSource, HourlyForecast, 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];
|
||||
function readNumber(value: unknown) {
|
||||
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);
|
||||
function readString(value: unknown) {
|
||||
return typeof value === "string" && value ? value : null;
|
||||
}
|
||||
|
||||
function isForecastSource(value: unknown): value is ForecastSource {
|
||||
return value === "imgw-alaro" || value === "open-meteo";
|
||||
}
|
||||
|
||||
function normalizeSources(value: unknown): ForecastSource[] {
|
||||
return Array.isArray(value) ? value.filter(isForecastSource) : [];
|
||||
}
|
||||
|
||||
function normalizeHourlyForecast(value: unknown): HourlyForecast[] {
|
||||
return Array.isArray(value) ? value.flatMap((candidate) => {
|
||||
if (!candidate || typeof candidate !== "object") return [];
|
||||
const row = candidate as Partial<HourlyForecast>;
|
||||
const time = readString(row.time);
|
||||
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),
|
||||
temperature: readNumber(row.temperature),
|
||||
feelsLike: readNumber(row.feelsLike),
|
||||
precipitationProbability: readNumber(row.precipitationProbability),
|
||||
precipitation: readNumber(row.precipitation),
|
||||
weatherCode: readNumber(row.weatherCode),
|
||||
windSpeed: readNumber(row.windSpeed),
|
||||
source: isForecastSource(row.source) ? row.source : "open-meteo",
|
||||
}];
|
||||
});
|
||||
}) : [];
|
||||
}
|
||||
|
||||
function normalizeDailyForecast(series: RawForecastSeries = {}): DailyForecast[] {
|
||||
return asArray(series.time).flatMap((_, index) => {
|
||||
const date = readString(series, "time", index);
|
||||
function normalizeDailyForecast(value: unknown): DailyForecast[] {
|
||||
return Array.isArray(value) ? value.flatMap((candidate) => {
|
||||
if (!candidate || typeof candidate !== "object") return [];
|
||||
const row = candidate as Partial<DailyForecast>;
|
||||
const date = readString(row.date);
|
||||
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),
|
||||
temperatureMax: readNumber(row.temperatureMax),
|
||||
temperatureMin: readNumber(row.temperatureMin),
|
||||
precipitationProbability: readNumber(row.precipitationProbability),
|
||||
precipitation: readNumber(row.precipitation),
|
||||
weatherCode: readNumber(row.weatherCode),
|
||||
sunrise: readString(row.sunrise),
|
||||
sunset: readString(row.sunset),
|
||||
sources: normalizeSources(row.sources),
|
||||
}];
|
||||
});
|
||||
}) : [];
|
||||
}
|
||||
|
||||
function normalizeForecast(raw: RawWeatherForecast): WeatherForecast {
|
||||
function normalizeForecast(raw: Partial<WeatherForecast>): 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.");
|
||||
@@ -57,6 +65,7 @@ function normalizeForecast(raw: RawWeatherForecast): WeatherForecast {
|
||||
timezone: typeof raw.timezone === "string" ? raw.timezone : "Europe/Warsaw",
|
||||
hourly: normalizeHourlyForecast(raw.hourly),
|
||||
daily: normalizeDailyForecast(raw.daily),
|
||||
sources: normalizeSources(raw.sources),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -64,5 +73,5 @@ export async function fetchForecast(latitude: number, longitude: number, signal?
|
||||
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);
|
||||
return normalizeForecast(await response.json() as Partial<WeatherForecast>);
|
||||
}
|
||||
|
||||
216
lib/forecast-merge.ts
Normal file
216
lib/forecast-merge.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
import type {
|
||||
DailyForecast,
|
||||
ForecastSource,
|
||||
HourlyForecast,
|
||||
RawForecastSeries,
|
||||
RawImgwForecastResponse,
|
||||
RawImgwForecastRow,
|
||||
RawWeatherForecast,
|
||||
WeatherForecast,
|
||||
} from "@/types/forecast";
|
||||
|
||||
const WARSAW_TIME_ZONE = "Europe/Warsaw";
|
||||
const warsawHourFormatter = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: WARSAW_TIME_ZONE,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
hourCycle: "h23",
|
||||
});
|
||||
|
||||
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 toNumber(value: unknown) {
|
||||
if (typeof value === "number") return Number.isFinite(value) ? value : null;
|
||||
if (typeof value !== "string" || !value.trim()) return null;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function toCelsius(value: unknown) {
|
||||
const temperature = toNumber(value);
|
||||
if (temperature === null) return null;
|
||||
return temperature > 150 ? temperature - 273.15 : temperature;
|
||||
}
|
||||
|
||||
function readImgwWeatherCode(value: unknown) {
|
||||
if (typeof value !== "string") return null;
|
||||
const match = value.match(/z(\d{2})/i);
|
||||
return match ? Number(match[1]) : null;
|
||||
}
|
||||
|
||||
function toWarsawHour(value: unknown) {
|
||||
if (typeof value !== "string") return null;
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return null;
|
||||
const parts = warsawHourFormatter.formatToParts(date);
|
||||
const getPart = (type: Intl.DateTimeFormatPartTypes) => parts.find((part) => part.type === type)?.value ?? "";
|
||||
return `${getPart("year")}-${getPart("month")}-${getPart("day")}T${getPart("hour")}:00`;
|
||||
}
|
||||
|
||||
function normalizeOpenMeteoHourly(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),
|
||||
source: "open-meteo" as const,
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeOpenMeteoDaily(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),
|
||||
sources: ["open-meteo" as const],
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeOpenMeteoForecast(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 : WARSAW_TIME_ZONE,
|
||||
hourly: normalizeOpenMeteoHourly(raw.hourly),
|
||||
daily: normalizeOpenMeteoDaily(raw.daily),
|
||||
sources: ["open-meteo"],
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeImgwHourly(payload?: RawImgwForecastResponse | null) {
|
||||
if (!Array.isArray(payload?.data?.Data)) return new Map<string, Partial<HourlyForecast>>();
|
||||
|
||||
return payload.data.Data.reduce((rows, candidate) => {
|
||||
if (!candidate || typeof candidate !== "object") return rows;
|
||||
const row = candidate as RawImgwForecastRow;
|
||||
const time = toWarsawHour(row.Date);
|
||||
if (!time) return rows;
|
||||
rows.set(time, {
|
||||
time,
|
||||
temperature: toCelsius(row.Temperature),
|
||||
feelsLike: toCelsius(row.Chill),
|
||||
precipitation: toNumber(row.Precipitation),
|
||||
weatherCode: readImgwWeatherCode(row.Icon),
|
||||
windSpeed: toNumber(row.Wind_Speed),
|
||||
source: "imgw-alaro",
|
||||
});
|
||||
return rows;
|
||||
}, new Map<string, Partial<HourlyForecast>>());
|
||||
}
|
||||
|
||||
function getAvailableValues(values: Array<number | null>) {
|
||||
return values.filter((value): value is number => value !== null);
|
||||
}
|
||||
|
||||
function getMinimum(values: Array<number | null>, fallback: number | null) {
|
||||
const availableValues = getAvailableValues(values);
|
||||
return availableValues.length ? Math.min(...availableValues) : fallback;
|
||||
}
|
||||
|
||||
function getMaximum(values: Array<number | null>, fallback: number | null) {
|
||||
const availableValues = getAvailableValues(values);
|
||||
return availableValues.length ? Math.max(...availableValues) : fallback;
|
||||
}
|
||||
|
||||
function getTotal(values: Array<number | null>, fallback: number | null) {
|
||||
const availableValues = getAvailableValues(values);
|
||||
return availableValues.length ? availableValues.reduce((total, value) => total + value, 0) : fallback;
|
||||
}
|
||||
|
||||
function getWeatherCodePriority(code: number | null) {
|
||||
if (code === null) return -1;
|
||||
if (code >= 95) return 8;
|
||||
if (code === 85 || code === 86 || (code >= 71 && code <= 77)) return 7;
|
||||
if ((code >= 61 && code <= 67) || (code >= 80 && code <= 82)) return 6;
|
||||
if (code >= 51 && code <= 57) return 5;
|
||||
if (code === 45 || code === 48) return 4;
|
||||
if (code === 3) return 3;
|
||||
if (code === 1 || code === 2) return 2;
|
||||
if (code === 0) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function getRepresentativeWeatherCode(hours: HourlyForecast[], fallback: number | null) {
|
||||
if (!hours.length) return fallback;
|
||||
return hours.reduce((selected, hour) => (
|
||||
getWeatherCodePriority(hour.weatherCode) > getWeatherCodePriority(selected) ? hour.weatherCode : selected
|
||||
), null as number | null);
|
||||
}
|
||||
|
||||
function getSources(hours: HourlyForecast[], fallback: ForecastSource[]) {
|
||||
const sources = Array.from(new Set(hours.map((hour) => hour.source)));
|
||||
return sources.length ? sources : fallback;
|
||||
}
|
||||
|
||||
function summarizeDay(day: DailyForecast, hours: HourlyForecast[]): DailyForecast {
|
||||
const dayHours = hours.filter((hour) => hour.time.startsWith(`${day.date}T`));
|
||||
return {
|
||||
...day,
|
||||
temperatureMax: getMaximum(dayHours.map((hour) => hour.temperature), day.temperatureMax),
|
||||
temperatureMin: getMinimum(dayHours.map((hour) => hour.temperature), day.temperatureMin),
|
||||
precipitationProbability: getMaximum(dayHours.map((hour) => hour.precipitationProbability), day.precipitationProbability),
|
||||
precipitation: getTotal(dayHours.map((hour) => hour.precipitation), day.precipitation),
|
||||
weatherCode: getRepresentativeWeatherCode(dayHours, day.weatherCode),
|
||||
sources: getSources(dayHours, day.sources),
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeForecastSources(openMeteoPayload: RawWeatherForecast, imgwPayload?: RawImgwForecastResponse | null): WeatherForecast {
|
||||
const openMeteoForecast = normalizeOpenMeteoForecast(openMeteoPayload);
|
||||
const imgwHours = normalizeImgwHourly(imgwPayload);
|
||||
let hasImgwHours = false;
|
||||
const hourly = openMeteoForecast.hourly.map((hour) => {
|
||||
const imgwHour = imgwHours.get(hour.time);
|
||||
if (!imgwHour) return hour;
|
||||
hasImgwHours = true;
|
||||
return {
|
||||
...hour,
|
||||
temperature: imgwHour.temperature ?? hour.temperature,
|
||||
feelsLike: imgwHour.feelsLike ?? hour.feelsLike,
|
||||
precipitation: imgwHour.precipitation ?? hour.precipitation,
|
||||
weatherCode: imgwHour.weatherCode ?? hour.weatherCode,
|
||||
windSpeed: imgwHour.windSpeed ?? hour.windSpeed,
|
||||
source: "imgw-alaro" as const,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
...openMeteoForecast,
|
||||
hourly,
|
||||
daily: openMeteoForecast.daily.map((day) => summarizeDay(day, hourly)),
|
||||
sources: hasImgwHours ? ["imgw-alaro", "open-meteo"] : ["open-meteo"],
|
||||
};
|
||||
}
|
||||
16
lib/i18n.tsx
16
lib/i18n.tsx
@@ -90,7 +90,7 @@ const translations = {
|
||||
"weather.temperatureDetail": "Temperatura powietrza",
|
||||
"forecast.label": "Prognoza modelowa",
|
||||
"forecast.title": "Najbliższe godziny i dni",
|
||||
"forecast.description": "Prognoza dla {location}. Bieżące warunki powyżej pochodzą z IMGW, a poniższe wartości są prognozą modelową.",
|
||||
"forecast.description": "Prognoza dla {location}. Bieżące warunki powyżej pochodzą z IMGW, a poniższe wartości są prognozą modelową preferującą IMGW.",
|
||||
"forecast.hourly": "Najbliższe 24 godziny",
|
||||
"forecast.daily": "Prognoza 7-dniowa",
|
||||
"forecast.today": "Dzisiaj",
|
||||
@@ -115,9 +115,11 @@ const translations = {
|
||||
"forecast.maxProbability": "Maks. szansa opadu",
|
||||
"forecast.pastHour": "Miniona godzina",
|
||||
"forecast.source": "Źródło prognozy:",
|
||||
"forecast.error": "Nie udało się pobrać prognozy Open-Meteo.",
|
||||
"forecast.sourceCombinedDescription": "IMGW ALARO dostarcza dostępne godziny prognozy, a Open-Meteo uzupełnia prawdopodobieństwo opadu i dalszy horyzont do 7 dni.",
|
||||
"forecast.sourceFallbackDescription": "Prognoza jest obecnie wyświetlana zastępczo z Open-Meteo.",
|
||||
"forecast.error": "Nie udało się pobrać prognozy modelowej.",
|
||||
"forecast.emptyTitle": "Brak prognozy",
|
||||
"forecast.emptyDescription": "Open-Meteo nie zwróciło teraz kompletnej prognozy dla tej lokalizacji.",
|
||||
"forecast.emptyDescription": "Źródła prognozy nie zwróciły teraz kompletnej prognozy dla tej lokalizacji.",
|
||||
"forecast.condition.clear": "Bezchmurnie",
|
||||
"forecast.condition.partlyCloudy": "Częściowe zachmurzenie",
|
||||
"forecast.condition.cloudy": "Pochmurno",
|
||||
@@ -267,7 +269,7 @@ const translations = {
|
||||
"weather.temperatureDetail": "Air temperature",
|
||||
"forecast.label": "Model forecast",
|
||||
"forecast.title": "Upcoming hours and days",
|
||||
"forecast.description": "Forecast for {location}. The current conditions above come from IMGW. The values below are a model forecast.",
|
||||
"forecast.description": "Forecast for {location}. The current conditions above come from IMGW. The values below are a model forecast preferring IMGW.",
|
||||
"forecast.hourly": "Next 24 hours",
|
||||
"forecast.daily": "7-day forecast",
|
||||
"forecast.today": "Today",
|
||||
@@ -292,9 +294,11 @@ const translations = {
|
||||
"forecast.maxProbability": "Max. rain chance",
|
||||
"forecast.pastHour": "Past hour",
|
||||
"forecast.source": "Forecast source:",
|
||||
"forecast.error": "Unable to load the Open-Meteo forecast.",
|
||||
"forecast.sourceCombinedDescription": "IMGW ALARO provides the available forecast hours. Open-Meteo supplements precipitation probability and extends the horizon to 7 days.",
|
||||
"forecast.sourceFallbackDescription": "The forecast is currently displayed using Open-Meteo fallback data.",
|
||||
"forecast.error": "Unable to load the model forecast.",
|
||||
"forecast.emptyTitle": "Forecast unavailable",
|
||||
"forecast.emptyDescription": "Open-Meteo did not return a complete forecast for this location.",
|
||||
"forecast.emptyDescription": "The forecast sources did not return a complete forecast for this location.",
|
||||
"forecast.condition.clear": "Clear sky",
|
||||
"forecast.condition.partlyCloudy": "Partly cloudy",
|
||||
"forecast.condition.cloudy": "Cloudy",
|
||||
|
||||
Reference in New Issue
Block a user