diff --git a/hooks/use-location-search.ts b/hooks/use-location-search.ts
new file mode 100644
index 0000000..f55dee5
--- /dev/null
+++ b/hooks/use-location-search.ts
@@ -0,0 +1,21 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { useQuery } from "@tanstack/react-query";
+import { fetchLocations } from "@/lib/location-api";
+import type { Language } from "@/lib/i18n";
+
+export function useLocationSearch(query: string, language: Language) {
+ const [debouncedQuery, setDebouncedQuery] = useState(query);
+ useEffect(() => {
+ const timeout = window.setTimeout(() => setDebouncedQuery(query.trim()), 300);
+ return () => window.clearTimeout(timeout);
+ }, [query]);
+ return useQuery({
+ queryKey: ["location-search", debouncedQuery, language],
+ queryFn: ({ signal }) => fetchLocations(debouncedQuery, language, signal),
+ enabled: debouncedQuery.length >= 2,
+ staleTime: 24 * 60 * 60 * 1000,
+ retry: 1,
+ });
+}
diff --git a/hooks/use-meteo-stations.ts b/hooks/use-meteo-stations.ts
new file mode 100644
index 0000000..32dc4ba
--- /dev/null
+++ b/hooks/use-meteo-stations.ts
@@ -0,0 +1,15 @@
+"use client";
+
+import { useQuery } from "@tanstack/react-query";
+import { fetchMeteoStationPositions } from "@/lib/imgw-api";
+import { QUERY_GC_TIME, QUERY_STALE_TIME } from "@/lib/constants";
+
+export function useMeteoStationPositions() {
+ return useQuery({
+ queryKey: ["meteo-station-positions"],
+ queryFn: ({ signal }) => fetchMeteoStationPositions(signal),
+ staleTime: QUERY_STALE_TIME,
+ gcTime: QUERY_GC_TIME,
+ retry: 2,
+ });
+}
diff --git a/lib/i18n.tsx b/lib/i18n.tsx
index bdb627c..0d47edf 100644
--- a/lib/i18n.tsx
+++ b/lib/i18n.tsx
@@ -25,6 +25,19 @@ const translations = {
"error.title": "Nie udało się pobrać danych",
"error.description": "Sprawdź połączenie i spróbuj ponownie.",
"dashboard.error": "Nie udało się pobrać listy stacji synoptycznych IMGW.",
+ "location.label": "Twoja lokalizacja",
+ "location.searchLabel": "Szukaj miejscowości w Polsce",
+ "location.placeholder": "Wpisz miejscowość, np. Piaseczno…",
+ "location.clear": "Wyczyść wyszukiwanie",
+ "location.error": "Nie udało się wyszukać miejscowości. Spróbuj ponownie.",
+ "location.preparing": "Przygotowuję listę najbliższych stacji IMGW…",
+ "location.empty": "Nie znaleziono pasującej miejscowości w Polsce.",
+ "location.nearest": "Najbliższa stacja IMGW",
+ "location.currentSource": "{location}: odczyt ze stacji IMGW {station}, około {distance} km.",
+ "location.heroSource": "stacja IMGW: {station} · około {distance} km",
+ "location.attribution": "Wyszukiwanie miejscowości:",
+ "featured.label": "Szybki wybór",
+ "featured.title": "Wybrane stacje IMGW",
"favorites.title": "Ulubione lokalizacje",
"favorites.empty": "Dodaj stacje do ulubionych, aby mieć ich odczyty pod ręką.",
"favorites.addStation": "Dodaj {name} do ulubionych",
@@ -53,24 +66,7 @@ const translations = {
"weather.windDirectionDetail": "Kierunek napływu wiatru",
"weather.rainfallDetail": "Suma opadu z pomiaru IMGW",
"weather.temperatureDetail": "Temperatura powietrza",
- "stations.section": "Stacje synoptyczne",
- "stations.title": "Pogoda w Polsce",
- "stations.searchLabel": "Szukaj stacji synoptycznej",
- "stations.searchPlaceholder": "Szukaj stacji IMGW…",
- "stations.sortLabel": "Sortowanie stacji",
- "stations.filterLabel": "Filtr stacji",
- "stations.sortAlphabetical": "Alfabetycznie",
- "stations.sortTemperatureDesc": "Temperatura: najwyższa",
- "stations.sortTemperatureAsc": "Temperatura: najniższa",
- "stations.sortHumidityDesc": "Wilgotność: najwyższa",
- "stations.sortPressureDesc": "Ciśnienie: najwyższe",
- "stations.filterAll": "Wszystkie stacje",
- "stations.filterWarmest": "Najcieplejsze",
- "stations.filterColdest": "Najzimniejsze",
- "stations.filterWindy": "Największy wiatr",
- "stations.filterRainy": "Największy opad",
"stations.emptyTitle": "Brak pasujących stacji",
- "stations.emptyDescription": "Zmień wyszukiwanie lub wybierz inny filtr.",
"station.all": "Wszystkie stacje",
"station.label": "Stacja {name}",
"station.parameters": "Aktualne parametry",
@@ -139,6 +135,19 @@ const translations = {
"error.title": "Unable to load data",
"error.description": "Check your connection and try again.",
"dashboard.error": "Unable to load the IMGW synoptic station list.",
+ "location.label": "Your location",
+ "location.searchLabel": "Search places in Poland",
+ "location.placeholder": "Enter a place, e.g. Piaseczno…",
+ "location.clear": "Clear search",
+ "location.error": "Unable to search for places. Try again.",
+ "location.preparing": "Preparing the nearest IMGW stations…",
+ "location.empty": "No matching place was found in Poland.",
+ "location.nearest": "Nearest IMGW station",
+ "location.currentSource": "{location}: reading from IMGW station {station}, approximately {distance} km away.",
+ "location.heroSource": "IMGW station: {station} · approximately {distance} km",
+ "location.attribution": "Place search:",
+ "featured.label": "Quick select",
+ "featured.title": "Selected IMGW stations",
"favorites.title": "Favourite locations",
"favorites.empty": "Add stations to favourites to keep their readings close at hand.",
"favorites.addStation": "Add {name} to favourites",
@@ -167,24 +176,7 @@ const translations = {
"weather.windDirectionDetail": "Direction the wind is coming from",
"weather.rainfallDetail": "Total rainfall from the IMGW reading",
"weather.temperatureDetail": "Air temperature",
- "stations.section": "Synoptic stations",
- "stations.title": "Weather in Poland",
- "stations.searchLabel": "Search synoptic stations",
- "stations.searchPlaceholder": "Search IMGW stations…",
- "stations.sortLabel": "Sort stations",
- "stations.filterLabel": "Filter stations",
- "stations.sortAlphabetical": "Alphabetically",
- "stations.sortTemperatureDesc": "Temperature: highest",
- "stations.sortTemperatureAsc": "Temperature: lowest",
- "stations.sortHumidityDesc": "Humidity: highest",
- "stations.sortPressureDesc": "Pressure: highest",
- "stations.filterAll": "All stations",
- "stations.filterWarmest": "Warmest",
- "stations.filterColdest": "Coldest",
- "stations.filterWindy": "Strongest wind",
- "stations.filterRainy": "Highest rainfall",
"stations.emptyTitle": "No matching stations",
- "stations.emptyDescription": "Adjust your search or select a different filter.",
"station.all": "All stations",
"station.label": "Station {name}",
"station.parameters": "Current parameters",
diff --git a/lib/imgw-api.ts b/lib/imgw-api.ts
index 69418c2..5cc6233 100644
--- a/lib/imgw-api.ts
+++ b/lib/imgw-api.ts
@@ -5,7 +5,9 @@ import {
} from "@/lib/weather-utils";
import type {
HydroStation,
+ MeteoStationPosition,
RawHydroStation,
+ RawMeteoStation,
RawSynopStation,
RawWarning,
SynopStation,
@@ -39,6 +41,16 @@ export async function fetchHydroStations(signal?: AbortSignal): Promise station !== null);
}
+export async function fetchMeteoStationPositions(signal?: AbortSignal): Promise {
+ const rows = await getJson("meteo", signal);
+ return rows.flatMap((row) => {
+ const latitude = Number(row.lat);
+ const longitude = Number(row.lon);
+ if (!row.nazwa_stacji?.trim() || !Number.isFinite(latitude) || !Number.isFinite(longitude)) return [];
+ return [{ name: row.nazwa_stacji, latitude, longitude }];
+ });
+}
+
async function fetchWarningsByKind(kind: WarningKind, signal?: AbortSignal): Promise {
const rows = await getJson(kind === "meteo" ? "warningsmeteo" : "warningshydro", signal);
return Array.isArray(rows) ? rows.map((warning, index) => normalizeWarning(warning, kind, index)) : [];
diff --git a/lib/location-api.ts b/lib/location-api.ts
new file mode 100644
index 0000000..fd0b941
--- /dev/null
+++ b/lib/location-api.ts
@@ -0,0 +1,9 @@
+import type { Language } from "@/lib/i18n";
+import type { LocationSearchResult } from "@/types/location";
+
+export async function fetchLocations(query: string, language: Language, signal?: AbortSignal): Promise {
+ const params = new URLSearchParams({ query, language });
+ const response = await fetch(`/api/locations/search?${params}`, { signal });
+ if (!response.ok) throw new Error("Location search is temporarily unavailable.");
+ return response.json() as Promise;
+}
diff --git a/lib/location-utils.ts b/lib/location-utils.ts
new file mode 100644
index 0000000..7898ec5
--- /dev/null
+++ b/lib/location-utils.ts
@@ -0,0 +1,66 @@
+import type { MeteoStationPosition, SynopStation } from "@/types/imgw";
+import type { LocationSearchResult, SelectedLocation } from "@/types/location";
+
+const stationAliases: Record = {
+ gdansk: "gdanskrebiechowo",
+ gorzow: "gorzowwielkopolski",
+ katowice: "katowicemuchowiec",
+ kielce: "kielcesukow",
+ kolo: "koloradoszewice",
+ kolobrzeg: "kolobrzegdzwirzyno",
+ krakow: "krakowbalice",
+ lublin: "lublinradawiec",
+ lodz: "lodzlublinek",
+ poznan: "poznanlawica",
+ resko: "reskosmolsko",
+ rzeszow: "rzeszowjasionka",
+ warszawa: "warszawaokecie",
+};
+
+function normalizeName(value: string) {
+ return value
+ .replace(/[Łł]/g, "l")
+ .normalize("NFD")
+ .replace(/[\u0300-\u036f]/g, "")
+ .replace(/[^a-zA-Z0-9]/g, "")
+ .toLowerCase();
+}
+
+function distanceKm(latitudeA: number, longitudeA: number, latitudeB: number, longitudeB: number) {
+ const earthRadiusKm = 6371;
+ const toRadians = (value: number) => value * Math.PI / 180;
+ const latitudeDistance = toRadians(latitudeB - latitudeA);
+ const longitudeDistance = toRadians(longitudeB - longitudeA);
+ const a = Math.sin(latitudeDistance / 2) ** 2 +
+ Math.cos(toRadians(latitudeA)) * Math.cos(toRadians(latitudeB)) * Math.sin(longitudeDistance / 2) ** 2;
+ return earthRadiusKm * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
+}
+
+export interface LocatedSynopStation extends SynopStation {
+ latitude: number;
+ longitude: number;
+}
+
+export function locateSynopStations(stations: SynopStation[], positions: MeteoStationPosition[]) {
+ const positionsByName = new Map(positions.map((position) => [normalizeName(position.name), position]));
+ return stations.flatMap((station) => {
+ const normalizedStation = normalizeName(station.name);
+ const position = positionsByName.get(stationAliases[normalizedStation] ?? normalizedStation);
+ return position ? [{ ...station, latitude: position.latitude, longitude: position.longitude }] : [];
+ });
+}
+
+export function findNearestSynopStation(location: LocationSearchResult, stations: LocatedSynopStation[]): SelectedLocation | null {
+ const nearest = stations.reduce<{ station: LocatedSynopStation; distanceKm: number } | null>((best, station) => {
+ const distance = distanceKm(location.latitude, location.longitude, station.latitude, station.longitude);
+ return !best || distance < best.distanceKm ? { station, distanceKm: distance } : best;
+ }, null);
+ if (!nearest) return null;
+ return {
+ name: location.name,
+ province: location.province,
+ stationId: nearest.station.id,
+ stationName: nearest.station.name,
+ distanceKm: Math.round(nearest.distanceKm),
+ };
+}
diff --git a/lib/store.ts b/lib/store.ts
index 7a0fad5..106997d 100644
--- a/lib/store.ts
+++ b/lib/store.ts
@@ -2,12 +2,15 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
+import type { SelectedLocation } from "@/types/location";
interface WeatherStore {
favorites: string[];
selectedStationId: string | null;
+ selectedLocation: SelectedLocation | null;
toggleFavorite: (id: string) => void;
selectStation: (id: string) => void;
+ selectLocation: (location: SelectedLocation) => void;
}
export const useWeatherStore = create()(
@@ -15,13 +18,15 @@ export const useWeatherStore = create()(
(set) => ({
favorites: [],
selectedStationId: null,
+ selectedLocation: null,
toggleFavorite: (id) =>
set((state) => ({
favorites: state.favorites.includes(id)
? state.favorites.filter((favoriteId) => favoriteId !== id)
: [...state.favorites, id],
})),
- selectStation: (id) => set({ selectedStationId: id }),
+ selectStation: (id) => set({ selectedStationId: id, selectedLocation: null }),
+ selectLocation: (location) => set({ selectedStationId: location.stationId, selectedLocation: location }),
}),
{ name: "wtr:preferences" },
),
diff --git a/types/imgw.ts b/types/imgw.ts
index a0fe2b9..7784deb 100644
--- a/types/imgw.ts
+++ b/types/imgw.ts
@@ -23,6 +23,19 @@ export interface SynopStation {
pressure: number | null;
}
+export interface RawMeteoStation {
+ kod_stacji?: string | null;
+ nazwa_stacji?: string | null;
+ lon?: string | null;
+ lat?: string | null;
+}
+
+export interface MeteoStationPosition {
+ name: string;
+ latitude: number;
+ longitude: number;
+}
+
export interface RawHydroStation {
id_stacji?: string | null;
stacja?: string | null;
diff --git a/types/location.ts b/types/location.ts
new file mode 100644
index 0000000..768391d
--- /dev/null
+++ b/types/location.ts
@@ -0,0 +1,16 @@
+export interface LocationSearchResult {
+ id: number;
+ name: string;
+ latitude: number;
+ longitude: number;
+ province: string | null;
+ district: string | null;
+}
+
+export interface SelectedLocation {
+ name: string;
+ province: string | null;
+ stationId: string;
+ stationName: string;
+ distanceKm: number;
+}