feat: Implement initial weather dashboard with interactive map, city details, and API integration.

This commit is contained in:
2569718930@qq.com
2026-03-09 10:36:03 +08:00
parent d162b91ed9
commit 283a935f24
35 changed files with 5962 additions and 2098 deletions
+38
View File
@@ -0,0 +1,38 @@
"use client";
import { useEffect, useRef } from "react";
import { Chart, ChartConfiguration, ChartType } from "chart.js/auto";
export function useChart<TType extends ChartType>(
createConfig: () => ChartConfiguration<TType>,
dependencies: React.DependencyList,
) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const chartRef = useRef<Chart<TType> | null>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const config = createConfig();
if (chartRef.current) {
chartRef.current.destroy();
chartRef.current = null;
}
chartRef.current = new Chart(canvas, config);
return () => {
chartRef.current?.destroy();
chartRef.current = null;
};
}, dependencies);
useEffect(() => {
return () => {
chartRef.current?.destroy();
chartRef.current = null;
};
}, []);
return canvasRef;
}
+411
View File
@@ -0,0 +1,411 @@
"use client";
import {
createContext,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
dashboardClient,
getCityRevision,
toCitySummary,
} from "@/lib/dashboard-client";
import {
CityDetail,
CityListItem,
CitySummary,
DashboardState,
HistoryPoint,
HistoryState,
LoadingState,
} from "@/lib/dashboard-types";
interface DashboardStoreValue extends DashboardState {
closeFutureModal: () => void;
closeGuide: () => void;
closeHistory: () => void;
closePanel: () => void;
ensureCityDetail: (cityName: string, force?: boolean) => Promise<CityDetail>;
futureModalDate: string | null;
isGuideOpen: boolean;
loadCities: () => Promise<void>;
openFutureModal: (dateStr: string) => void;
openGuide: () => void;
openHistory: () => Promise<void>;
openTodayModal: () => void;
registerMapStopMotion: (stopMotion: () => void) => void;
refreshAll: () => Promise<void>;
refreshSelectedCity: () => Promise<void>;
selectedDetail: CityDetail | null;
selectCity: (cityName: string) => Promise<void>;
setForecastDate: (dateStr: string | null) => void;
}
const DashboardStoreContext = createContext<DashboardStoreValue | null>(null);
function getInitialLoadingState(): LoadingState {
return {
cities: false,
cityDetail: false,
history: false,
refresh: false,
};
}
function getInitialHistoryState(): HistoryState {
return {
dataByCity: {},
error: null,
isOpen: false,
loading: false,
};
}
export function DashboardStoreProvider({
children,
}: {
children: React.ReactNode;
}) {
const initialCache = dashboardClient.readCityDetailCacheBundle();
const [cities, setCities] = useState<CityListItem[]>([]);
const [cityDetailsByName, setCityDetailsByName] = useState<
Record<string, CityDetail>
>(() => initialCache.details);
const [citySummariesByName, setCitySummariesByName] = useState<
Record<string, CitySummary>
>(() =>
Object.fromEntries(
Object.entries(initialCache.details).map(([cityName, detail]) => [
cityName,
toCitySummary(detail),
]),
),
);
const [cityDetailMetaByName, setCityDetailMetaByName] = useState<
Record<string, { cachedAt: number; revision: string }>
>(() => initialCache.meta);
const [selectedCity, setSelectedCity] = useState<string | null>(null);
const [isPanelOpen, setIsPanelOpen] = useState(false);
const [selectedForecastDate, setSelectedForecastDate] = useState<
string | null
>(null);
const [futureModalDate, setFutureModalDate] = useState<string | null>(null);
const [loadingState, setLoadingState] = useState<LoadingState>(
getInitialLoadingState,
);
const [historyState, setHistoryState] = useState<HistoryState>(
getInitialHistoryState,
);
const [isGuideOpen, setIsGuideOpen] = useState(false);
const mapStopMotionRef = useRef<() => void>(() => {});
const citySummariesRef = useRef<Record<string, CitySummary>>(
Object.fromEntries(
Object.entries(initialCache.details).map(([cityName, detail]) => [
cityName,
toCitySummary(detail),
]),
),
);
const selectedDetail = selectedCity
? cityDetailsByName[selectedCity] || null
: null;
useEffect(() => {
dashboardClient.writeCityDetailCacheBundle(
cityDetailsByName,
cityDetailMetaByName,
);
}, [cityDetailMetaByName, cityDetailsByName]);
useEffect(() => {
citySummariesRef.current = citySummariesByName;
}, [citySummariesByName]);
const ensureCityDetail = async (cityName: string, force = false) => {
const cached = cityDetailsByName[cityName];
const cachedMeta = cityDetailMetaByName[cityName];
if (!force && cached && dashboardClient.isCityDetailFresh(cachedMeta)) {
return cached;
}
if (!force && cached) {
try {
const summary = await dashboardClient.getCitySummary(cityName);
const revision = getCityRevision(summary);
if (revision && revision === cachedMeta?.revision) {
setCityDetailMetaByName((current) => ({
...current,
[cityName]: {
cachedAt: Date.now(),
revision,
},
}));
return cached;
}
} catch {
return cached;
}
}
const detail = await dashboardClient.getCityDetail(cityName, { force });
setCityDetailsByName((current) => ({
...current,
[cityName]: detail,
}));
setCitySummariesByName((current) => ({
...current,
[cityName]: toCitySummary(detail),
}));
setCityDetailMetaByName((current) => ({
...current,
[cityName]: {
cachedAt: Date.now(),
revision: getCityRevision(detail),
},
}));
return detail;
};
const loadCities = async () => {
setLoadingState((current) => ({ ...current, cities: true }));
try {
const nextCities = await dashboardClient.getCities();
setCities(nextCities);
} finally {
setLoadingState((current) => ({ ...current, cities: false }));
}
};
useEffect(() => {
void loadCities();
}, []);
useEffect(() => {
if (!cities.length) return;
const queue = cities
.map((city) => city.name)
.filter((cityName) => !citySummariesRef.current[cityName]);
if (!queue.length) return;
let active = true;
const concurrency = 4;
let cursor = 0;
const worker = async () => {
while (active && cursor < queue.length) {
const cityName = queue[cursor];
cursor += 1;
if (citySummariesRef.current[cityName]) continue;
try {
const summary = await dashboardClient.getCitySummary(cityName);
if (!active) return;
setCitySummariesByName((current) => {
if (current[cityName]) return current;
const next = {
...current,
[cityName]: summary,
};
citySummariesRef.current = next;
return next;
});
} catch {}
}
};
void Promise.all(
Array.from({ length: Math.min(concurrency, queue.length) }, () =>
worker(),
),
);
return () => {
active = false;
};
}, [cities]);
const selectCity = async (cityName: string) => {
setSelectedCity(cityName);
setIsPanelOpen(true);
setSelectedForecastDate(null);
setFutureModalDate(null);
setLoadingState((current) => ({ ...current, cityDetail: true }));
try {
const detail = await ensureCityDetail(cityName);
setSelectedForecastDate(detail.local_date);
} finally {
setLoadingState((current) => ({ ...current, cityDetail: false }));
}
};
const refreshSelectedCity = async () => {
if (!selectedCity) return;
setLoadingState((current) => ({ ...current, refresh: true }));
try {
const detail = await ensureCityDetail(selectedCity, true);
setSelectedForecastDate(detail.local_date);
} finally {
setLoadingState((current) => ({ ...current, refresh: false }));
}
};
const refreshAll = async () => {
dashboardClient.clearCityDetailCache();
setCityDetailsByName({});
setCityDetailMetaByName({});
if (selectedCity) {
setLoadingState((current) => ({ ...current, refresh: true }));
try {
const detail = await dashboardClient.getCityDetail(selectedCity, {
force: true,
});
setCityDetailsByName({ [selectedCity]: detail });
setCitySummariesByName((current) => ({
...current,
[selectedCity]: toCitySummary(detail),
}));
setCityDetailMetaByName({
[selectedCity]: {
cachedAt: Date.now(),
revision: getCityRevision(detail),
},
});
setSelectedForecastDate(detail.local_date);
} finally {
setLoadingState((current) => ({ ...current, refresh: false }));
}
}
};
const openHistory = async () => {
if (!selectedCity) return;
setHistoryState((current) => ({
...current,
error: null,
isOpen: true,
loading: true,
}));
try {
const history = await dashboardClient.getHistory(selectedCity);
setHistoryState((current) => ({
...current,
dataByCity: {
...current.dataByCity,
[selectedCity]: history,
},
loading: false,
}));
} catch (error) {
setHistoryState((current) => ({
...current,
error: String(error),
loading: false,
}));
}
};
const value = useMemo<DashboardStoreValue>(
() => ({
cities,
cityDetailsByName,
citySummariesByName,
closeFutureModal: () => setFutureModalDate(null),
closeGuide: () => setIsGuideOpen(false),
closeHistory: () =>
setHistoryState((current) => ({ ...current, isOpen: false })),
closePanel: () => {
setIsPanelOpen(false);
},
ensureCityDetail,
futureModalDate,
historyState,
isPanelOpen,
isGuideOpen,
loadCities,
loadingState,
openFutureModal: (dateStr: string) => {
mapStopMotionRef.current();
setFutureModalDate(dateStr);
},
openGuide: () => setIsGuideOpen(true),
openHistory,
openTodayModal: () => {
if (selectedDetail?.local_date) {
mapStopMotionRef.current();
setFutureModalDate(selectedDetail.local_date);
}
},
registerMapStopMotion: (stopMotion: () => void) => {
mapStopMotionRef.current = stopMotion;
},
refreshAll,
refreshSelectedCity,
selectedCity,
selectedDetail,
selectedForecastDate,
selectCity,
setForecastDate: (dateStr: string | null) =>
setSelectedForecastDate(dateStr),
}),
[
cities,
cityDetailsByName,
citySummariesByName,
futureModalDate,
historyState,
isPanelOpen,
isGuideOpen,
loadingState,
selectedCity,
selectedDetail,
selectedForecastDate,
],
);
return (
<DashboardStoreContext.Provider value={value}>
{children}
</DashboardStoreContext.Provider>
);
}
export function useDashboardStore() {
const context = useContext(DashboardStoreContext);
if (!context) {
throw new Error(
"useDashboardStore must be used within DashboardStoreProvider",
);
}
return context;
}
export function useCityData(name?: string | null) {
const store = useDashboardStore();
const key = name || store.selectedCity;
return {
data: key ? store.cityDetailsByName[key] || null : null,
isLoading:
store.loadingState.cityDetail &&
Boolean(key) &&
store.selectedCity === key,
};
}
export function useHistoryData(name?: string | null) {
const store = useDashboardStore();
const key = name || store.selectedCity;
return {
data: key
? store.historyState.dataByCity[key] || ([] as HistoryPoint[])
: [],
error: store.historyState.error,
isLoading: store.historyState.loading,
isOpen: store.historyState.isOpen,
};
}
+482
View File
@@ -0,0 +1,482 @@
"use client";
import { useEffect, useRef } from "react";
import L from "leaflet";
import {
CityDetail,
CityListItem,
CitySummary,
NearbyStation,
} from "@/lib/dashboard-types";
import { pickAnkaraNearbyStations } from "@/lib/dashboard-utils";
interface UseLeafletMapArgs {
cities: CityListItem[];
cityDetailsByName: Record<string, CityDetail>;
citySummariesByName: Record<string, CitySummary>;
onClosePanel: () => void;
onEnsureCityDetail: (
cityName: string,
force?: boolean,
) => Promise<CityDetail>;
onRegisterStopMotion: (stopMotion: () => void) => void;
onSelectCity: (cityName: string) => void;
selectedCity: string | null;
selectedDetail: CityDetail | null;
suspendMotion: boolean;
isLoadingDetail: boolean;
}
const AUTO_NEARBY_MIN_ZOOM = 8;
const AUTO_NEARBY_MAX_DISTANCE_M = 120000;
const MAP_MAX_ZOOM = 19;
function createMarkerIcon(
city: CityListItem,
snapshot?: Pick<CityDetail, "current" | "temp_symbol"> | CitySummary,
) {
const riskClass = `risk-${city.risk_level}`;
const label = city.display_name;
const unit = city.temp_unit === "fahrenheit" ? "°F" : "°C";
const shortName = label.length > 10 ? `${label.substring(0, 8)}...` : label;
const tempText =
snapshot?.current?.temp != null ? `${snapshot.current.temp}${unit}` : "--";
return L.divIcon({
className: "",
html: `
<div class="city-marker" data-city="${city.name}">
<div class="marker-bubble ${riskClass}">${tempText}</div>
<div class="marker-name">${shortName}</div>
</div>
`,
iconAnchor: [40, 22],
iconSize: [80, 44],
});
}
function buildNearbyIconHtml(detail: CityDetail, station: NearbyStation) {
const symbol = detail.temp_symbol || "°C";
let windHtml = "";
if (station.wind_dir != null) {
const rotation = (Number(station.wind_dir) + 180) % 360;
const speedRaw = Number(station.wind_speed ?? station.wind_speed_kt);
const speed = Number.isFinite(speedRaw) ? `${speedRaw.toFixed(1)}k` : "";
windHtml = `
<div class="nearby-wind">
<span class="wind-arrow" style="transform: rotate(${rotation}deg)">↑</span>
<span class="wind-val">${speed}</span>
</div>
`;
}
return `
<div class="nearby-marker-premium">
<div class="nearby-pulse">
<div class="pulse-ring"></div>
<div class="pulse-core"></div>
</div>
<div class="nearby-content">
<span class="nearby-label">${station.name || station.icao || "OBS"}</span>
<div class="nearby-stats">
<span class="nearby-temp-val">${station.temp ?? "--"}</span>
<span class="nearby-temp-unit">${symbol}</span>
</div>
</div>
${windHtml}
</div>
`;
}
export function useLeafletMap({
cities,
cityDetailsByName,
citySummariesByName,
onClosePanel,
onEnsureCityDetail,
onRegisterStopMotion,
onSelectCity,
selectedCity,
selectedDetail,
suspendMotion,
isLoadingDetail,
}: UseLeafletMapArgs) {
const containerRef = useRef<HTMLDivElement | null>(null);
const mapRef = useRef<L.Map | null>(null);
const markersRef = useRef<
Record<string, { city: CityListItem; marker: L.Marker }>
>({});
const nearbyLayerRef = useRef<L.LayerGroup | null>(null);
const autoNearbyCityRef = useRef<string | null>(null);
const loadingAutoNearbyRef = useRef(false);
const lastMovedCityRef = useRef<string | null>(null);
const suspendMotionRef = useRef(suspendMotion);
const hasFittedInitialBoundsRef = useRef(false);
const onClosePanelRef = useRef(onClosePanel);
const onRegisterStopMotionRef = useRef(onRegisterStopMotion);
const onSelectCityRef = useRef(onSelectCity);
const onEnsureCityDetailRef = useRef(onEnsureCityDetail);
useEffect(() => {
onClosePanelRef.current = onClosePanel;
}, [onClosePanel]);
useEffect(() => {
onRegisterStopMotionRef.current = onRegisterStopMotion;
}, [onRegisterStopMotion]);
useEffect(() => {
onSelectCityRef.current = onSelectCity;
}, [onSelectCity]);
useEffect(() => {
onEnsureCityDetailRef.current = onEnsureCityDetail;
}, [onEnsureCityDetail]);
useEffect(() => {
suspendMotionRef.current = suspendMotion;
}, [suspendMotion]);
useEffect(() => {
const container = containerRef.current;
if (!container || mapRef.current) return;
const map = L.map(container, {
attributionControl: true,
bounceAtZoomLimits: false,
center: [30, 10],
maxZoom: MAP_MAX_ZOOM,
minZoom: 2,
zoom: 3,
zoomControl: false,
});
L.control.zoom({ position: "bottomright" }).addTo(map);
L.tileLayer(
"https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png",
{
attribution:
'&copy; <a href="https://www.openstreetmap.org/">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>',
maxZoom: 19,
subdomains: "abcd",
},
).addTo(map);
const nearbyLayer = L.layerGroup().addTo(map);
mapRef.current = map;
nearbyLayerRef.current = nearbyLayer;
// Track which city we've already moved to for the current selection
onRegisterStopMotionRef.current(() => {
map.stop();
});
const handleMapClick = () => {
onClosePanelRef.current();
};
map.on("click", handleMapClick);
return () => {
onRegisterStopMotionRef.current(() => {});
map.off("click", handleMapClick);
map.remove();
mapRef.current = null;
nearbyLayerRef.current = null;
markersRef.current = {};
};
}, []);
// Handle initial view if cities are loaded
useEffect(() => {
const map = mapRef.current;
if (!map || !cities.length || hasFittedInitialBoundsRef.current) return;
// Only run fitBounds once for the initial list of cities
const bounds = cities.map((city) => [city.lat, city.lon]) as [
number,
number,
][];
if (bounds.length) {
map.fitBounds(bounds, {
animate: false,
maxZoom: 4,
padding: [60, 60],
});
hasFittedInitialBoundsRef.current = true;
}
}, [cities]);
const lastCityDataRef = useRef<
Record<string, { temp?: number | null; risk?: string }>
>({});
// Handle marker synchronization
useEffect(() => {
const map = mapRef.current;
if (!map || !cities.length) return;
const currentMarkers = markersRef.current;
const nextMarkers: typeof currentMarkers = {};
const nextLastData: typeof lastCityDataRef.current = {};
cities.forEach((city) => {
const detail = cityDetailsByName[city.name];
const summary = citySummariesByName[city.name];
const snapshot = detail || summary;
const existing = currentMarkers[city.name];
const currentTemp = snapshot?.current?.temp;
const currentRisk = city.risk_level;
const lastData = lastCityDataRef.current[city.name];
const dataChanged =
!lastData ||
lastData.temp !== currentTemp ||
lastData.risk !== currentRisk;
if (existing) {
if (dataChanged) {
existing.marker.setIcon(createMarkerIcon(city, snapshot));
}
nextMarkers[city.name] = { city, marker: existing.marker };
nextLastData[city.name] = { temp: currentTemp, risk: currentRisk };
return;
}
// Create new marker
const marker = L.marker([city.lat, city.lon], {
icon: createMarkerIcon(city, snapshot),
}).addTo(map);
marker.on("click", () => {
map.stop();
// Reset lastMovedCity so we can re-fly if needed
lastMovedCityRef.current = null;
onSelectCityRef.current(city.name);
});
nextMarkers[city.name] = { city, marker };
nextLastData[city.name] = { temp: currentTemp, risk: currentRisk };
});
// Cleanup removed markers
Object.entries(currentMarkers).forEach(([name, entry]) => {
if (!nextMarkers[name]) {
map.removeLayer(entry.marker);
}
});
markersRef.current = nextMarkers;
lastCityDataRef.current = nextLastData;
}, [cities, cityDetailsByName, citySummariesByName]);
useEffect(() => {
Object.entries(markersRef.current).forEach(([name, entry]) => {
const element = entry.marker.getElement();
if (!element) return;
const markerRoot = element.querySelector(".city-marker");
markerRoot?.classList.toggle("selected", name === selectedCity);
});
}, [selectedCity]);
useEffect(() => {
if (!mapRef.current || !nearbyLayerRef.current) return;
const map = mapRef.current;
const layer = nearbyLayerRef.current;
function renderNearbyStations(detail: CityDetail, preserveView = false) {
layer.clearLayers();
const allNearby = Array.isArray(detail.mgm_nearby)
? detail.mgm_nearby
: [];
const nearbyStations =
String(detail.name || "").toLowerCase() === "ankara"
? pickAnkaraNearbyStations(allNearby)
: allNearby;
if (!nearbyStations.length) {
if (!preserveView && detail.lat != null && detail.lon != null) {
map.flyTo([detail.lat, detail.lon], 10, {
animate: true,
duration: 1.5,
easeLinearity: 0.25,
});
}
return;
}
const latLngs: Array<[number, number]> = [];
if (detail.lat != null && detail.lon != null) {
latLngs.push([detail.lat, detail.lon]);
}
nearbyStations.forEach((station) => {
const sLat = Number(station.lat);
const sLon = Number(station.lon);
// Ignore invalid (0,0) or null coordinates which cause global zoom-out
if (!Number.isFinite(sLat) || !Number.isFinite(sLon)) return;
if (Math.abs(sLat) < 0.1 && Math.abs(sLon) < 0.1) return;
const icon = L.divIcon({
className: "",
html: buildNearbyIconHtml(detail, station),
iconAnchor: [16, 19],
iconSize: [240, 38],
});
L.marker([sLat, sLon], { icon }).addTo(layer);
latLngs.push([sLat, sLon]);
});
if (preserveView) return;
// Note: Movement for selected cities is now handled by the centralized effect.
// This section is primarily for auto-discovery movement if needed.
}
async function maybeAutoShowNearbyStations() {
if (suspendMotion) {
map.stop();
return;
}
if (selectedDetail) {
// Just render stations, no camera move from here
renderNearbyStations(selectedDetail, true);
return;
}
// If no city selected, reset the move tracker
lastMovedCityRef.current = null;
if (map.getZoom() < AUTO_NEARBY_MIN_ZOOM) {
autoNearbyCityRef.current = null;
layer.clearLayers();
return;
}
const center = map.getCenter();
let best: { cityName: string; distance: number } | null = null;
for (const [cityName, entry] of Object.entries(markersRef.current)) {
const distance = map.distance(
center,
L.latLng(entry.city.lat, entry.city.lon),
);
if (distance > AUTO_NEARBY_MAX_DISTANCE_M) continue;
if (!best || distance < best.distance) {
best = { cityName, distance };
}
}
const targetCity = best?.cityName || null;
if (!targetCity) {
autoNearbyCityRef.current = null;
layer.clearLayers();
return;
}
if (
autoNearbyCityRef.current === targetCity &&
layer.getLayers().length > 0
) {
return;
}
autoNearbyCityRef.current = targetCity;
const cachedDetail = cityDetailsByName[targetCity];
if (cachedDetail) {
renderNearbyStations(cachedDetail, true);
return;
}
if (loadingAutoNearbyRef.current) return;
loadingAutoNearbyRef.current = true;
try {
const detail = await onEnsureCityDetailRef.current(targetCity, false);
renderNearbyStations(detail, true);
} catch {
} finally {
loadingAutoNearbyRef.current = false;
}
}
const syncVisibility = () => {
if (suspendMotion) {
map.stop();
return;
}
if (map.getZoom() < 7) {
if (map.hasLayer(layer)) {
map.removeLayer(layer);
}
} else if (!map.hasLayer(layer)) {
map.addLayer(layer);
}
void maybeAutoShowNearbyStations();
};
syncVisibility();
map.on("zoomend", syncVisibility);
map.on("moveend", maybeAutoShowNearbyStations);
return () => {
map.off("zoomend", syncVisibility);
map.off("moveend", maybeAutoShowNearbyStations);
};
}, [cityDetailsByName, selectedCity, selectedDetail, suspendMotion]);
// Centralized City Selection Zoom Effect
// Higher level than selection: we only flyTo once the data is loaded (selectedDetail)
// This satisfies "loading之后再出现动画吧"
useEffect(() => {
if (!selectedCity) {
lastMovedCityRef.current = null;
return;
}
const map = mapRef.current;
if (!map || suspendMotion || !selectedDetail || isLoadingDetail) return;
// Check if the detail matches the selection (case-insensitive)
if (selectedDetail.name?.toLowerCase() !== selectedCity.toLowerCase()) {
return;
}
if (lastMovedCityRef.current === selectedCity) return;
const entry = markersRef.current[selectedCity];
if (!entry) return;
// Lock the move
lastMovedCityRef.current = selectedCity;
// We use a micro-delay (50ms) to allow the browser to settle
// after the loading overlay disappears and the detail panel renders.
const timer = setTimeout(() => {
const currentMap = mapRef.current;
if (
!currentMap ||
lastMovedCityRef.current !== selectedCity ||
suspendMotion
)
return;
currentMap.stop();
currentMap.flyTo([entry.city.lat, entry.city.lon], 11, {
animate: true,
duration: 1.1,
easeLinearity: 0.22,
});
}, 50);
return () => clearTimeout(timer);
}, [selectedCity, selectedDetail, suspendMotion, isLoadingDetail]);
useEffect(() => {
if (!suspendMotion) return;
mapRef.current?.stop();
}, [suspendMotion]);
return { containerRef };
}