feat: redesign dashboard around place search

This commit is contained in:
zv
2026-06-01 19:05:31 +02:00
parent 6c2e731c60
commit 0632c67beb
18 changed files with 374 additions and 139 deletions

View File

@@ -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",

View File

@@ -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<HydroSta
return rows.map(normalizeHydroStation).filter((station): station is HydroStation => station !== null);
}
export async function fetchMeteoStationPositions(signal?: AbortSignal): Promise<MeteoStationPosition[]> {
const rows = await getJson<RawMeteoStation[]>("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<WeatherWarning[]> {
const rows = await getJson<RawWarning[]>(kind === "meteo" ? "warningsmeteo" : "warningshydro", signal);
return Array.isArray(rows) ? rows.map((warning, index) => normalizeWarning(warning, kind, index)) : [];

9
lib/location-api.ts Normal file
View File

@@ -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<LocationSearchResult[]> {
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<LocationSearchResult[]>;
}

66
lib/location-utils.ts Normal file
View File

@@ -0,0 +1,66 @@
import type { MeteoStationPosition, SynopStation } from "@/types/imgw";
import type { LocationSearchResult, SelectedLocation } from "@/types/location";
const stationAliases: Record<string, string> = {
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<LocatedSynopStation>((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),
};
}

View File

@@ -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<WeatherStore>()(
@@ -15,13 +18,15 @@ export const useWeatherStore = create<WeatherStore>()(
(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" },
),