feat: redesign dashboard around place search
This commit is contained in:
60
lib/i18n.tsx
60
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",
|
||||
|
||||
@@ -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
9
lib/location-api.ts
Normal 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
66
lib/location-utils.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
@@ -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" },
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user