feat: Implement initial weather dashboard with interactive map, city details, and API integration.
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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:
|
||||
'© <a href="https://www.openstreetmap.org/">OSM</a> © <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 };
|
||||
}
|
||||
Reference in New Issue
Block a user