453 lines
16 KiB
TypeScript
453 lines
16 KiB
TypeScript
import type {
|
|
HydroStation,
|
|
RawHydroStation,
|
|
RawSynopStation,
|
|
RawWarning,
|
|
SynopStation,
|
|
WeatherMood,
|
|
WeatherWarning,
|
|
WarningKind,
|
|
} from "@/types/imgw";
|
|
import { translate, type Language } from "@/lib/i18n";
|
|
import { getProvinceFromTeryt, normalizeProvinceName } from "@/lib/provinces";
|
|
import type { CurrentWeatherCondition } from "@/types/imgw-current";
|
|
import type { DistanceUnit, PrecipitationUnit, PressureUnit, TemperatureUnit, WindSpeedUnit } from "@/types/units";
|
|
|
|
const locales: Record<Language, string> = { pl: "pl-PL", en: "en-GB" };
|
|
export const DEFAULT_TEMPERATURE_UNIT: TemperatureUnit = "c";
|
|
export const TEMPERATURE_UNITS: TemperatureUnit[] = ["c", "f"];
|
|
export const DEFAULT_WIND_SPEED_UNIT: WindSpeedUnit = "kmh";
|
|
export const WIND_SPEED_UNITS: WindSpeedUnit[] = ["ms", "kmh", "mph", "kt", "bft"];
|
|
export const DEFAULT_PRECIPITATION_UNIT: PrecipitationUnit = "mm";
|
|
export const PRECIPITATION_UNITS: PrecipitationUnit[] = ["mm", "cm", "in"];
|
|
export const DEFAULT_PRESSURE_UNIT: PressureUnit = "hpa";
|
|
export const PRESSURE_UNITS: PressureUnit[] = ["hpa", "kpa", "inhg", "mmhg"];
|
|
export const DEFAULT_DISTANCE_UNIT: DistanceUnit = "km";
|
|
export const DISTANCE_UNITS: DistanceUnit[] = ["km", "mi"];
|
|
|
|
const temperatureUnitLabels: Record<TemperatureUnit, string> = {
|
|
c: "°C",
|
|
f: "°F",
|
|
};
|
|
|
|
const windSpeedUnitLabels: Record<WindSpeedUnit, string> = {
|
|
ms: "m/s",
|
|
kmh: "km/h",
|
|
mph: "mph",
|
|
kt: "kt",
|
|
bft: "Bft",
|
|
};
|
|
|
|
const precipitationUnitLabels: Record<PrecipitationUnit, string> = {
|
|
mm: "mm",
|
|
cm: "cm",
|
|
in: "in",
|
|
};
|
|
|
|
const pressureUnitLabels: Record<PressureUnit, string> = {
|
|
hpa: "hPa",
|
|
kpa: "kPa",
|
|
inhg: "inHg",
|
|
mmhg: "mm Hg",
|
|
};
|
|
|
|
const distanceUnitLabels: Record<DistanceUnit, string> = {
|
|
km: "km",
|
|
mi: "mi",
|
|
};
|
|
|
|
export function toNumber(value: unknown): number | null {
|
|
if (typeof value === "number") return Number.isFinite(value) ? value : null;
|
|
if (typeof value !== "string" || value.trim() === "") return null;
|
|
const parsed = Number(value.replace(",", "."));
|
|
return Number.isFinite(parsed) ? parsed : null;
|
|
}
|
|
|
|
function normalizeDate(value?: string | null): string | null {
|
|
if (!value?.trim()) return null;
|
|
const isoCandidate = value.includes("T") ? value : value.replace(" ", "T");
|
|
const date = new Date(isoCandidate);
|
|
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
|
}
|
|
|
|
function synopMeasuredAt(date?: string | null, hour?: string | null) {
|
|
if (!date?.trim() || !hour?.trim()) return null;
|
|
return normalizeDate(`${date}T${hour.padStart(2, "0")}:00:00Z`);
|
|
}
|
|
|
|
export function normalizeSynopStation(raw: RawSynopStation): SynopStation | null {
|
|
if (!raw.id_stacji?.trim() || !raw.stacja?.trim()) return null;
|
|
return {
|
|
id: raw.id_stacji,
|
|
name: raw.stacja,
|
|
measuredAt: synopMeasuredAt(raw.data_pomiaru, raw.godzina_pomiaru),
|
|
temperature: toNumber(raw.temperatura),
|
|
windSpeed: toNumber(raw.predkosc_wiatru),
|
|
windDirection: toNumber(raw.kierunek_wiatru),
|
|
humidity: toNumber(raw.wilgotnosc_wzgledna),
|
|
rainfall: toNumber(raw.suma_opadu),
|
|
pressure: toNumber(raw.cisnienie),
|
|
};
|
|
}
|
|
|
|
export function normalizeHydroStation(raw: RawHydroStation): HydroStation | null {
|
|
if (!raw.id_stacji?.trim() || !raw.stacja?.trim()) return null;
|
|
return {
|
|
id: raw.id_stacji,
|
|
name: raw.stacja,
|
|
river: raw.rzeka?.trim() || null,
|
|
province: raw.wojewodztwo?.trim() || null,
|
|
longitude: toNumber(raw.lon),
|
|
latitude: toNumber(raw.lat),
|
|
waterLevel: toNumber(raw.stan_wody),
|
|
waterLevelMeasuredAt: normalizeDate(raw.stan_wody_data_pomiaru),
|
|
waterTemperature: toNumber(raw.temperatura_wody),
|
|
waterTemperatureMeasuredAt: normalizeDate(raw.temperatura_wody_data_pomiaru),
|
|
flow: toNumber(raw.przeplyw),
|
|
flowMeasuredAt: normalizeDate(raw.przeplyw_data),
|
|
icePhenomenon: toNumber(raw.zjawisko_lodowe),
|
|
overgrowthPhenomenon: toNumber(raw.zjawisko_zarastania),
|
|
};
|
|
}
|
|
|
|
export function normalizeWarning(raw: RawWarning, kind: WarningKind, index: number): WeatherWarning {
|
|
const provinces = [
|
|
...new Set(
|
|
[
|
|
...(raw.obszary ?? []).map((area) => normalizeProvinceName(area.wojewodztwo)),
|
|
...(raw.teryt ?? []).map(getProvinceFromTeryt),
|
|
].filter((province) => province !== null),
|
|
),
|
|
];
|
|
const describedAreas = (raw.obszary ?? [])
|
|
.map((area) => area.opis?.trim() || area.wojewodztwo?.trim())
|
|
.filter((area): area is string => Boolean(area));
|
|
const areas = describedAreas.length ? describedAreas : provinces;
|
|
const title = raw.zdarzenie?.trim() || raw.nazwa_zdarzenia?.trim() || "";
|
|
return {
|
|
id: `${kind}-${raw.id ?? raw.numer ?? index}-${raw.data_od ?? raw.obowiazuje_od ?? "unknown"}`,
|
|
kind,
|
|
level: toNumber(raw["stopień"] ?? raw.stopien),
|
|
title,
|
|
description: raw.przebieg?.trim() || raw.tresc?.trim() || null,
|
|
comment: raw.komentarz?.trim() || null,
|
|
validFrom: normalizeDate(raw.data_od ?? raw.obowiazuje_od),
|
|
validTo: raw.data_do?.startsWith("9999-") ? null : normalizeDate(raw.data_do ?? raw.obowiazuje_do),
|
|
publishedAt: normalizeDate(raw.opublikowano),
|
|
probability: toNumber(raw.prawdopodobienstwo),
|
|
areas,
|
|
terytCodes: Array.isArray(raw.teryt)
|
|
? raw.teryt.filter((code): code is string => typeof code === "string" && code.trim().length > 0)
|
|
: [],
|
|
provinces,
|
|
office: raw.biuro?.trim() || null,
|
|
};
|
|
}
|
|
|
|
export function isTemperatureUnit(value: unknown): value is TemperatureUnit {
|
|
return typeof value === "string" && TEMPERATURE_UNITS.includes(value as TemperatureUnit);
|
|
}
|
|
|
|
export function getTemperatureUnitLabel(unit: TemperatureUnit) {
|
|
return temperatureUnitLabels[unit];
|
|
}
|
|
|
|
export function convertTemperature(value: number, unit: TemperatureUnit) {
|
|
if (unit === "f") return (value * 9) / 5 + 32;
|
|
return value;
|
|
}
|
|
|
|
export function formatTemperatureValue(
|
|
value: number,
|
|
language: Language = "pl",
|
|
unit: TemperatureUnit = DEFAULT_TEMPERATURE_UNIT,
|
|
) {
|
|
const formattedValue = new Intl.NumberFormat(locales[language], {
|
|
maximumFractionDigits: 0,
|
|
}).format(value);
|
|
return `${formattedValue}${getTemperatureUnitLabel(unit)}`;
|
|
}
|
|
|
|
export function formatTemperature(
|
|
value: number | null,
|
|
language: Language = "pl",
|
|
unit: TemperatureUnit = DEFAULT_TEMPERATURE_UNIT,
|
|
) {
|
|
return value === null
|
|
? translate(language, "common.noData")
|
|
: formatTemperatureValue(convertTemperature(value, unit), language, unit);
|
|
}
|
|
|
|
export function formatHumidity(value: number | null, language: Language = "pl") {
|
|
return value === null ? translate(language, "common.noData") : `${Math.round(value)}%`;
|
|
}
|
|
|
|
export function isWindSpeedUnit(value: unknown): value is WindSpeedUnit {
|
|
return typeof value === "string" && WIND_SPEED_UNITS.includes(value as WindSpeedUnit);
|
|
}
|
|
|
|
export function getWindSpeedUnitLabel(unit: WindSpeedUnit) {
|
|
return windSpeedUnitLabels[unit];
|
|
}
|
|
|
|
export function isPrecipitationUnit(value: unknown): value is PrecipitationUnit {
|
|
return typeof value === "string" && PRECIPITATION_UNITS.includes(value as PrecipitationUnit);
|
|
}
|
|
|
|
export function getPrecipitationUnitLabel(unit: PrecipitationUnit) {
|
|
return precipitationUnitLabels[unit];
|
|
}
|
|
|
|
export function isPressureUnit(value: unknown): value is PressureUnit {
|
|
return typeof value === "string" && PRESSURE_UNITS.includes(value as PressureUnit);
|
|
}
|
|
|
|
export function getPressureUnitLabel(unit: PressureUnit) {
|
|
return pressureUnitLabels[unit];
|
|
}
|
|
|
|
export function isDistanceUnit(value: unknown): value is DistanceUnit {
|
|
return typeof value === "string" && DISTANCE_UNITS.includes(value as DistanceUnit);
|
|
}
|
|
|
|
export function getDistanceUnitLabel(unit: DistanceUnit) {
|
|
return distanceUnitLabels[unit];
|
|
}
|
|
|
|
export function convertWindSpeed(speed: number, unit: WindSpeedUnit) {
|
|
if (unit === "kmh") return speed * 3.6;
|
|
if (unit === "mph") return speed * 2.2369362921;
|
|
if (unit === "kt") return speed * 1.9438444924;
|
|
return speed;
|
|
}
|
|
|
|
export function convertWindSpeedToBeaufort(speed: number) {
|
|
if (speed < 0.3) return 0;
|
|
if (speed < 1.6) return 1;
|
|
if (speed < 3.4) return 2;
|
|
if (speed < 5.5) return 3;
|
|
if (speed < 8) return 4;
|
|
if (speed < 10.8) return 5;
|
|
if (speed < 13.9) return 6;
|
|
if (speed < 17.2) return 7;
|
|
if (speed < 20.8) return 8;
|
|
if (speed < 24.5) return 9;
|
|
if (speed < 28.5) return 10;
|
|
if (speed < 32.7) return 11;
|
|
return 12;
|
|
}
|
|
|
|
export function formatWindSpeed(
|
|
speed: number | null,
|
|
language: Language = "pl",
|
|
unit: WindSpeedUnit = DEFAULT_WIND_SPEED_UNIT,
|
|
) {
|
|
if (speed === null) return translate(language, "common.noData");
|
|
if (unit === "bft") return `${convertWindSpeedToBeaufort(speed)} ${getWindSpeedUnitLabel(unit)}`;
|
|
const convertedSpeed = convertWindSpeed(speed, unit);
|
|
const digits = unit === "ms" ? 1 : 0;
|
|
const formattedSpeed = new Intl.NumberFormat(locales[language], {
|
|
minimumFractionDigits: digits,
|
|
maximumFractionDigits: digits,
|
|
}).format(convertedSpeed);
|
|
return `${formattedSpeed} ${getWindSpeedUnitLabel(unit)}`;
|
|
}
|
|
|
|
export function formatWind(
|
|
speed: number | null,
|
|
direction?: number | null,
|
|
language: Language = "pl",
|
|
unit: WindSpeedUnit = DEFAULT_WIND_SPEED_UNIT,
|
|
) {
|
|
if (speed === null) return translate(language, "common.noData");
|
|
const directionLabel = direction === null || direction === undefined ? "" : ` ${getWindDirection(direction)}`;
|
|
return `${formatWindSpeed(speed, language, unit)}${directionLabel}`;
|
|
}
|
|
|
|
export function formatRainfall(
|
|
value: number | null,
|
|
language: Language = "pl",
|
|
unit: PrecipitationUnit = DEFAULT_PRECIPITATION_UNIT,
|
|
) {
|
|
return formatPrecipitation(value, language, unit);
|
|
}
|
|
|
|
export function convertPrecipitation(value: number, unit: PrecipitationUnit) {
|
|
if (unit === "cm") return value / 10;
|
|
if (unit === "in") return value / 25.4;
|
|
return value;
|
|
}
|
|
|
|
export function formatPrecipitation(
|
|
value: number | null,
|
|
language: Language = "pl",
|
|
unit: PrecipitationUnit = DEFAULT_PRECIPITATION_UNIT,
|
|
) {
|
|
if (value === null) return translate(language, "common.noData");
|
|
const convertedValue = convertPrecipitation(value, unit);
|
|
const digits = unit === "mm" ? (convertedValue < 1 ? 2 : 1) : unit === "cm" ? 2 : 2;
|
|
const formattedValue = new Intl.NumberFormat(locales[language], {
|
|
minimumFractionDigits: unit === "in" ? 2 : 0,
|
|
maximumFractionDigits: digits,
|
|
}).format(convertedValue);
|
|
return `${formattedValue} ${getPrecipitationUnitLabel(unit)}`;
|
|
}
|
|
|
|
export function convertPressure(value: number, unit: PressureUnit) {
|
|
if (unit === "kpa") return value / 10;
|
|
if (unit === "inhg") return value * 0.0295299830714;
|
|
if (unit === "mmhg") return value * 0.750061683;
|
|
return value;
|
|
}
|
|
|
|
export function formatPressure(
|
|
value: number | null,
|
|
language: Language = "pl",
|
|
unit: PressureUnit = DEFAULT_PRESSURE_UNIT,
|
|
) {
|
|
if (value === null) return translate(language, "common.noData");
|
|
const convertedValue = convertPressure(value, unit);
|
|
const digits = unit === "inhg" ? 2 : unit === "kpa" ? 1 : unit === "mmhg" ? 0 : 1;
|
|
const formattedValue = new Intl.NumberFormat(locales[language], {
|
|
minimumFractionDigits: unit === "inhg" ? 2 : 0,
|
|
maximumFractionDigits: digits,
|
|
}).format(convertedValue);
|
|
return `${formattedValue} ${getPressureUnitLabel(unit)}`;
|
|
}
|
|
|
|
export function convertDistance(value: number, unit: DistanceUnit) {
|
|
if (unit === "mi") return value * 0.621371;
|
|
return value;
|
|
}
|
|
|
|
export function formatDistance(
|
|
value: number | null,
|
|
language: Language = "pl",
|
|
unit: DistanceUnit = DEFAULT_DISTANCE_UNIT,
|
|
) {
|
|
if (value === null) return translate(language, "common.noData");
|
|
const convertedValue = convertDistance(value, unit);
|
|
const formattedValue = new Intl.NumberFormat(locales[language], {
|
|
maximumFractionDigits: convertedValue < 10 ? 1 : 0,
|
|
}).format(convertedValue);
|
|
return `${formattedValue} ${getDistanceUnitLabel(unit)}`;
|
|
}
|
|
|
|
export function formatWaterLevel(value: number | null, language: Language = "pl") {
|
|
return value === null ? translate(language, "common.noData") : `${Math.round(value)} cm`;
|
|
}
|
|
|
|
export function formatFlow(value: number | null, language: Language = "pl") {
|
|
return value === null ? translate(language, "common.noData") : `${value.toFixed(2)} m³/s`;
|
|
}
|
|
|
|
export function formatDateTime(
|
|
value: string | null,
|
|
language: Language = "pl",
|
|
fallback = translate(language, "common.noData"),
|
|
) {
|
|
if (!value) return fallback;
|
|
const date = new Date(value);
|
|
if (Number.isNaN(date.getTime())) return fallback;
|
|
return new Intl.DateTimeFormat(locales[language], {
|
|
day: "numeric",
|
|
month: "long",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
}).format(date);
|
|
}
|
|
|
|
export function calculateFeelsLike(temperature: number | null, humidity: number | null, windSpeed: number | null) {
|
|
if (temperature === null) return null;
|
|
if (temperature <= 10 && windSpeed !== null && windSpeed > 1.34) {
|
|
const windKmh = windSpeed * 3.6;
|
|
return 13.12 + 0.6215 * temperature - 11.37 * windKmh ** 0.16 + 0.3965 * temperature * windKmh ** 0.16;
|
|
}
|
|
if (temperature >= 27 && humidity !== null) {
|
|
const c1 = -8.78469475556;
|
|
const c2 = 1.61139411;
|
|
const c3 = 2.33854883889;
|
|
const c4 = -0.14611605;
|
|
const c5 = -0.012308094;
|
|
const c6 = -0.0164248277778;
|
|
const c7 = 0.002211732;
|
|
const c8 = 0.00072546;
|
|
const c9 = -0.000003582;
|
|
return (
|
|
c1 +
|
|
c2 * temperature +
|
|
c3 * humidity +
|
|
c4 * temperature * humidity +
|
|
c5 * temperature ** 2 +
|
|
c6 * humidity ** 2 +
|
|
c7 * temperature ** 2 * humidity +
|
|
c8 * temperature * humidity ** 2 +
|
|
c9 * temperature ** 2 * humidity ** 2
|
|
);
|
|
}
|
|
return temperature;
|
|
}
|
|
|
|
export function getWindDirection(degrees: number) {
|
|
const labels = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];
|
|
return labels[Math.round((((degrees % 360) + 360) % 360) / 45) % 8];
|
|
}
|
|
|
|
export function getWeatherMoodFromData(station: SynopStation, date = new Date()): WeatherMood {
|
|
const hour = date.getHours();
|
|
if (hour < 6 || hour >= 21) return "night";
|
|
if ((station.windSpeed ?? 0) >= 8) return "wind";
|
|
if ((station.temperature ?? 15) <= 3) return "cold";
|
|
if ((station.humidity ?? 0) >= 80) return "cloudy";
|
|
if ((station.temperature ?? 15) >= 20) return "warm";
|
|
return "mild";
|
|
}
|
|
|
|
function getForecastConditionDescription(code: number | null, language: Language) {
|
|
if (code === 0) return translate(language, "forecast.condition.clear");
|
|
if (code === 1 || code === 2) return translate(language, "forecast.condition.partlyCloudy");
|
|
if (code === 3) return translate(language, "forecast.condition.cloudy");
|
|
if (code === 45 || code === 48) return translate(language, "forecast.condition.fog");
|
|
if (code !== null && code >= 51 && code <= 57) return translate(language, "forecast.condition.drizzle");
|
|
if (code !== null && ((code >= 61 && code <= 67) || (code >= 80 && code <= 82)))
|
|
return translate(language, "forecast.condition.rain");
|
|
if (code !== null && ((code >= 71 && code <= 77) || code === 85 || code === 86))
|
|
return translate(language, "forecast.condition.snow");
|
|
if (code !== null && code >= 95) return translate(language, "forecast.condition.thunderstorm");
|
|
return null;
|
|
}
|
|
|
|
function getCloudCoverDescription(cloudCover: number | null | undefined, language: Language) {
|
|
if (cloudCover === null || cloudCover === undefined) return null;
|
|
if (cloudCover >= 75) return translate(language, "forecast.condition.cloudy");
|
|
if (cloudCover >= 25) return translate(language, "forecast.condition.partlyCloudy");
|
|
return null;
|
|
}
|
|
|
|
export function getWeatherDescription(
|
|
station: SynopStation,
|
|
language: Language = "pl",
|
|
condition?: CurrentWeatherCondition,
|
|
currentWeatherCode?: number | null,
|
|
currentCloudCover?: number | null,
|
|
forecastWeatherCode?: number | null,
|
|
) {
|
|
if (condition === "thunderstorm") return translate(language, "weather.thunderstorm");
|
|
if (condition === "snow") return translate(language, "weather.currentSnow");
|
|
if (condition === "rain") return translate(language, "weather.currentRain");
|
|
if ((station.windSpeed ?? 0) >= 8) return translate(language, "weather.strongWind");
|
|
const currentDescription = getForecastConditionDescription(currentWeatherCode ?? null, language);
|
|
const cloudCoverDescription = getCloudCoverDescription(currentCloudCover, language);
|
|
if (
|
|
cloudCoverDescription &&
|
|
(currentWeatherCode === null || currentWeatherCode === undefined || currentWeatherCode === 0)
|
|
)
|
|
return cloudCoverDescription;
|
|
if (currentDescription) return currentDescription;
|
|
if (cloudCoverDescription) return cloudCoverDescription;
|
|
const forecastDescription = getForecastConditionDescription(forecastWeatherCode ?? null, language);
|
|
if (forecastDescription) return forecastDescription;
|
|
if ((station.humidity ?? 0) >= 90) return translate(language, "weather.humid");
|
|
return translate(language, "weather.calm");
|
|
}
|