Skip to content

Commit 74ad409

Browse files
committed
feat: enhance weather map ux
1 parent a822ed6 commit 74ad409

12 files changed

Lines changed: 399 additions & 184 deletions
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { Spin } from "antd";
2+
import { LoadingOutlined } from "@ant-design/icons";
3+
4+
interface LoadingOverlayProps {
5+
message?: string;
6+
size?: "small" | "default" | "large";
7+
}
8+
9+
export function LoadingOverlay({ message = "Loading...", size = "large" }: LoadingOverlayProps) {
10+
return (
11+
<div className="absolute inset-0 z-50 flex flex-col items-center justify-center bg-white/80 backdrop-blur-sm">
12+
<Spin
13+
indicator={<LoadingOutlined style={{ fontSize: size === "large" ? 48 : size === "default" ? 32 : 24 }} spin />}
14+
size={size}
15+
/>
16+
{message && <p className="mt-4 text-gray-600 font-medium">{message}</p>}
17+
</div>
18+
);
19+
}
20+
21+
interface MapLoadingOverlayProps {
22+
message?: string;
23+
}
24+
25+
export function MapLoadingOverlay({ message = "Loading map data..." }: MapLoadingOverlayProps) {
26+
return (
27+
<div className="absolute top-20 left-1/2 -translate-x-1/2 z-50 bg-white/90 backdrop-blur-sm px-6 py-3 rounded-lg shadow-lg flex items-center gap-3">
28+
<Spin size="small" />
29+
<span className="text-gray-700 font-medium">{message}</span>
30+
</div>
31+
);
32+
}

src/features/weather-map/components/search-input.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import Icon from "@mdi/react";
22
import { mdiMagnify, mdiLoading } from "@mdi/js";
3-
import { useWeatherMapLayout } from "../context";
3+
import { useWeatherMapStore } from "../store";
44
import { ChangeEvent, KeyboardEvent, useCallback } from "react";
55

66
export function SearchInput() {
@@ -13,7 +13,7 @@ export function SearchInput() {
1313
setSelectedStation,
1414
setMapCenter,
1515
setMapZoom,
16-
} = useWeatherMapLayout();
16+
} = useWeatherMapStore();
1717

1818
const handleSearch = useCallback(async () => {
1919
if (!searchQuery.trim())

src/features/weather-map/components/station-info-panel.tsx

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,21 @@
11
import Icon from "@mdi/react";
22
import { mdiClose } from "@mdi/js";
3+
import { Spin } from "antd";
34
import { ForecastChart } from "./customized/forecast-chart";
45
import { LevelData, StationInfo } from "../types";
56

67
interface StationInfoPanelProps {
78
station: StationInfo | null;
89
levelData: LevelData[];
910
onClose: () => void;
11+
isLoading?: boolean;
1012
}
1113

1214
export function StationInfoPanel({
1315
station,
1416
levelData,
1517
onClose,
18+
isLoading = false,
1619
}: StationInfoPanelProps) {
1720
if (!station) {
1821
return null;
@@ -36,14 +39,29 @@ export function StationInfoPanel({
3639
</div>
3740

3841
<div className="flex-1 flex flex-col gap-4 overflow-y-auto">
39-
{levelData.map((data, index) => (
40-
<ForecastChart
41-
key={index}
42-
title={data.title}
43-
data={data.data}
44-
color={data.color}
45-
/>
46-
))}
42+
{isLoading
43+
? (
44+
<div className="flex flex-col items-center justify-center py-8 gap-4">
45+
<Spin size="large" />
46+
<p className="text-gray-600">Đang tải dữ liệu dự báo...</p>
47+
</div>
48+
)
49+
: levelData.length > 0
50+
? (
51+
levelData.map((data, index) => (
52+
<ForecastChart
53+
key={index}
54+
title={data.title}
55+
data={data.data}
56+
color={data.color}
57+
/>
58+
))
59+
)
60+
: (
61+
<div className="text-center py-8 text-gray-500">
62+
Không có dữ liệu dự báo
63+
</div>
64+
)}
4765
</div>
4866
</div>
4967
);

src/features/weather-map/components/storm-selector.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@ import { mdiWeatherHurricaneOutline } from "@mdi/js";
33
import Icon from "@mdi/react";
44
import dayjs from "dayjs";
55
import { useEffect } from "react";
6-
import { useWeatherMapLayout } from "../context";
6+
import { useWeatherMapStore } from "../store";
77

88
export function StormSelector() {
9-
const { isStormSelectorOpen, setSelectedStormId, setStorms, storms, selectedStormId } = useWeatherMapLayout();
9+
const { isStormSelectorOpen, setSelectedStormId, setStorms, storms, selectedStormId } = useWeatherMapStore();
1010
const handleStormClick = (stormId: number | null) => {
1111
setSelectedStormId(stormId);
1212
};

src/features/weather-map/components/timeline-control.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import Icon from "@mdi/react";
33
import { ConfigProvider, DatePicker, Slider } from "antd";
44
import dayjs from "dayjs";
55
import { FC, ReactNode } from "react";
6-
import { useWeatherMapLayout } from "../context";
6+
import { useWeatherMapStore } from "../store";
77

88
const CircleButton: FC<{ children: ReactNode; className?: string; onClick?: () => void }> = ({
99
children,
@@ -19,7 +19,7 @@ const CircleButton: FC<{ children: ReactNode; className?: string; onClick?: () =
1919
);
2020

2121
export function TimelineControl() {
22-
const { selectedHour, setSelectedHour, selectedDate, setSelectedDate, sliderMarks, sliderDisabled } = useWeatherMapLayout();
22+
const { selectedHour, setSelectedHour, selectedDate, setSelectedDate, sliderMarks, sliderDisabled } = useWeatherMapStore();
2323

2424
const handlePreviousDay = () => {
2525
if (selectedDate) {

src/features/weather-map/components/weather-sidebar.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Link } from "@tanstack/react-router";
2-
import { useWeatherMapLayout } from "../context";
2+
import { useWeatherMapStore } from "../store";
33

44
import {
55
mdiAccountKeyOutline,
@@ -46,7 +46,7 @@ const menuItems = [
4646
];
4747

4848
export function WeatherSidebar() {
49-
const { sidebarCollapsed, toggleSidebar } = useWeatherMapLayout();
49+
const { sidebarCollapsed, toggleSidebar } = useWeatherMapStore();
5050

5151
return (
5252
<aside className="flex flex-col w-[219px] p-4 gap-2 rounded-2xl bg-white/70 backdrop-blur-sm shadow-lg transition-all duration-300">
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { useQuery } from "@tanstack/react-query";
2+
import { precipitationApi } from "@/services/apis/precipitation.api";
3+
import { RainfallRecordRead, StationRead } from "@/types/precipitation";
4+
5+
export function useStations() {
6+
return useQuery({
7+
queryKey: ["precipitation", "stations"],
8+
queryFn: async () => {
9+
const response = await precipitationApi.stations.list({ limit: 1000 });
10+
return response.data;
11+
},
12+
staleTime: 5 * 60 * 1000, // 5 minutes
13+
});
14+
}
15+
16+
export function useStationRainfallRecords(
17+
stationId: number | null,
18+
startDate?: Date | null,
19+
endDate?: Date | null,
20+
) {
21+
return useQuery({
22+
queryKey: ["precipitation", "rainfall-records", "station", stationId, startDate, endDate],
23+
queryFn: async () => {
24+
if (!stationId || !startDate || !endDate)
25+
return [];
26+
27+
const response = await precipitationApi.rainfallRecords.list({
28+
station_id: stationId,
29+
start_date: startDate.toISOString().split("T")[0],
30+
end_date: endDate.toISOString().split("T")[0],
31+
});
32+
33+
return response.data;
34+
},
35+
enabled: !!stationId && !!startDate && !!endDate,
36+
staleTime: 2 * 60 * 1000, // 2 minutes
37+
});
38+
}
39+
40+
export function useAllStationsRainfallRecords(
41+
selectedDate: Date | null,
42+
stations: StationRead[],
43+
) {
44+
return useQuery({
45+
queryKey: ["precipitation", "rainfall-records", "all-stations", selectedDate],
46+
queryFn: async () => {
47+
if (!selectedDate || stations.length === 0) {
48+
return new Map<number, RainfallRecordRead[]>();
49+
}
50+
51+
const targetDate = new Date(selectedDate);
52+
const startTime = new Date(targetDate);
53+
startTime.setDate(startTime.getDate() - 15);
54+
const endTime = new Date(targetDate);
55+
endTime.setDate(endTime.getDate() + 15);
56+
57+
const response = await precipitationApi.rainfallRecords.list({
58+
start_date: startTime.toISOString().split("T")[0],
59+
end_date: endTime.toISOString().split("T")[0],
60+
limit: 10000,
61+
});
62+
63+
// Create a Map to store rainfall records for each station
64+
const recordsMap = new Map<number, RainfallRecordRead[]>();
65+
for (const record of response.data) {
66+
if (!recordsMap.has(record.station_id)) {
67+
recordsMap.set(record.station_id, []);
68+
}
69+
recordsMap.get(record.station_id)!.push(record);
70+
}
71+
72+
return recordsMap;
73+
},
74+
enabled: !!selectedDate && stations.length > 0,
75+
staleTime: 2 * 60 * 1000, // 2 minutes
76+
});
77+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { useQuery } from "@tanstack/react-query";
2+
import { reservoirsApi } from "@/services/apis/reservoirs.api";
3+
import { ReservoirOperationRead, ReservoirRead } from "@/types/reservoirs";
4+
5+
export function useReservoirs() {
6+
return useQuery({
7+
queryKey: ["reservoirs"],
8+
queryFn: async () => {
9+
const response = await reservoirsApi.reservoirs.list({ limit: 1000 });
10+
return response.data;
11+
},
12+
staleTime: 5 * 60 * 1000, // 5 minutes
13+
});
14+
}
15+
16+
export function useReservoirOperations(
17+
reservoirId: number | null,
18+
startDate?: Date | null,
19+
endDate?: Date | null,
20+
) {
21+
return useQuery({
22+
queryKey: ["reservoir-operations", reservoirId, startDate, endDate],
23+
queryFn: async () => {
24+
if (!reservoirId || !startDate || !endDate)
25+
return [];
26+
27+
const response = await reservoirsApi.operations.list({
28+
reservoir_id: reservoirId,
29+
start_date: startDate.toISOString().split("T")[0],
30+
end_date: endDate.toISOString().split("T")[0],
31+
});
32+
33+
return response.data;
34+
},
35+
enabled: !!reservoirId && !!startDate && !!endDate,
36+
staleTime: 2 * 60 * 1000, // 2 minutes
37+
});
38+
}
39+
40+
export function useAllReservoirOperations(
41+
selectedDate: Date | null,
42+
reservoirs: ReservoirRead[],
43+
) {
44+
return useQuery({
45+
queryKey: ["reservoir-operations", "all", selectedDate],
46+
queryFn: async () => {
47+
if (!selectedDate || reservoirs.length === 0) {
48+
return {
49+
operationsMap: new Map<number, ReservoirOperationRead[]>(),
50+
sliderMarks: {},
51+
};
52+
}
53+
54+
const targetDate = new Date(selectedDate);
55+
const startTime = new Date(targetDate);
56+
const endTime = new Date(targetDate);
57+
endTime.setDate(endTime.getDate() + 1);
58+
59+
const operationsMap = new Map<number, ReservoirOperationRead[]>();
60+
const marks = {} as Record<number, string>;
61+
62+
// Fetch operations data for all reservoirs
63+
const response = await reservoirsApi.operations.list({
64+
start_date: startTime.toISOString().split("T")[0],
65+
end_date: endTime.toISOString().split("T")[0],
66+
limit: 10000,
67+
});
68+
69+
// Group operations by reservoir
70+
for (const operation of response.data) {
71+
if (!operationsMap.has(operation.reservoir_id)) {
72+
operationsMap.set(operation.reservoir_id, []);
73+
}
74+
operationsMap.get(operation.reservoir_id)!.push(operation);
75+
76+
// Build slider marks from timestamps
77+
const hour = new Date(operation.timestamp).getHours();
78+
marks[hour] = `${hour}:00`;
79+
}
80+
81+
return { operationsMap, sliderMarks: marks };
82+
},
83+
enabled: !!selectedDate && reservoirs.length > 0,
84+
staleTime: 2 * 60 * 1000, // 2 minutes
85+
});
86+
}

src/features/weather-map/layout.tsx

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,14 @@ import { SearchInput } from "./components/search-input";
55
import { StormSelector } from "./components/storm-selector";
66
import { TimelineControl } from "./components/timeline-control";
77
import { WeatherSidebar } from "./components/weather-sidebar";
8-
import { WeatherMapLayoutProvider, useWeatherMapLayout } from "./context";
8+
import { useWeatherMapStore } from "./store";
99

1010
function WeatherMapLayoutContent() {
1111
const {
1212
selectedStation,
1313
mapCenter,
1414
mapZoom,
15-
} = useWeatherMapLayout();
15+
} = useWeatherMapStore();
1616

1717
return (
1818
<div className="h-screen w-screen relative">
@@ -51,9 +51,5 @@ function WeatherMapLayoutContent() {
5151
}
5252

5353
export function WeatherMapLayout() {
54-
return (
55-
<WeatherMapLayoutProvider>
56-
<WeatherMapLayoutContent />
57-
</WeatherMapLayoutProvider>
58-
);
54+
return <WeatherMapLayoutContent />;
5955
}

0 commit comments

Comments
 (0)