From f60be2f5439b0f7d08f7a2608d5d500137930ca0 Mon Sep 17 00:00:00 2001 From: Truong Vo <41848815+vmtruong301296@users.noreply.github.com> Date: Tue, 2 Dec 2025 08:49:54 +0700 Subject: [PATCH] =?UTF-8?q?fix=20ch=E1=BB=A9c=20n=C4=83ng=20history?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- FRONTEND/src/components/ModalHistory.tsx | 233 ++++++++++++++++------- 1 file changed, 159 insertions(+), 74 deletions(-) diff --git a/FRONTEND/src/components/ModalHistory.tsx b/FRONTEND/src/components/ModalHistory.tsx index f49a23e..4ae8625 100644 --- a/FRONTEND/src/components/ModalHistory.tsx +++ b/FRONTEND/src/components/ModalHistory.tsx @@ -53,7 +53,8 @@ function ModalHistory({ const [activeStation, setActiveStation] = useState(""); const [activeTimePeriod, setActiveTimePeriod] = useState("current"); const scrollViewportRef = useRef(null); - const isAutoSwitchingRef = useRef(false); + const stationRefs = useRef>(new Map()); + const scrollTimeoutRef = useRef(null); useEffect(() => { if (!socket || !opened) return; @@ -89,15 +90,32 @@ function ModalHistory({ setHistoryData([]); setActiveStation(""); setActiveTimePeriod("current"); + stationRefs.current.clear(); } + + return () => { + if (scrollTimeoutRef.current) { + clearTimeout(scrollTimeoutRef.current); + } + }; }, [opened]); - // Change station or filter will reset scroll + // Scroll to station when activeStation changes (only when clicked, not when auto-detected) + const isManualScrollRef = useRef(false); + useEffect(() => { - if (scrollViewportRef?.current) { - scrollViewportRef.current.scrollTop = 0; + if (activeStation && isManualScrollRef.current) { + setTimeout(() => { + scrollToStation(Number(activeStation)); + // Reset flag after scroll completes + setTimeout(() => { + isManualScrollRef.current = false; + }, 500); + }, 100); } - }, [activeStation, activeTimePeriod]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeStation]); + // Utility function to format timestamp (can be used later if needed) // const formatTimestamp = (timestamp: number) => { @@ -163,17 +181,35 @@ function ModalHistory({ return grouped; }; - const currentStationData = historyData.find( - (station) => station.stationId.toString() === activeStation - ); + // Apply time filter to ALL stations data (không filter bỏ stations không có data) + const allFilteredStations = historyData.map((station) => ({ + ...station, + filteredHistory: filterHistoryByTime(station.history), + })); - // Apply time filter to current station data - const filteredHistory = currentStationData - ? filterHistoryByTime(currentStationData.history) - : []; + // Group each station's history by line number + const allGroupedStations = allFilteredStations.map((station) => ({ + ...station, + groupedHistory: station.filteredHistory.length > 0 + ? groupHistoryByLine(station.filteredHistory) + : new Map(), + })); - // Group filtered history by line number - const groupedHistory = groupHistoryByLine(filteredHistory); + // Function to scroll to a specific station + const scrollToStation = (stationId: number) => { + const stationElement = stationRefs.current.get(stationId); + if (stationElement && scrollViewportRef.current) { + const scrollContainer = scrollViewportRef.current; + const containerTop = scrollContainer.getBoundingClientRect().top; + const elementTop = stationElement.getBoundingClientRect().top; + const scrollTop = scrollContainer.scrollTop; + + scrollContainer.scrollTo({ + top: scrollTop + elementTop - containerTop - 10, // 10px offset from top + behavior: "smooth", + }); + } + }; if (!opened) return null; @@ -262,9 +298,11 @@ function ModalHistory({ ? "filled" : "outline" } - onClick={() => - setActiveStation(station.stationId.toString()) - } + onClick={() => { + isManualScrollRef.current = true; + setActiveStation(station.stationId.toString()); + scrollToStation(station.stationId); + }} > {station.stationName} @@ -304,71 +342,101 @@ function ModalHistory({ { - if ( - !scrollViewportRef.current || - isAutoSwitchingRef.current - ) - return; + onScrollPositionChange={() => { + if (isManualScrollRef.current || !scrollViewportRef.current) return; - const scrollTop = position.y; - const target = scrollViewportRef.current; - const scrollHeight = target.scrollHeight; - const clientHeight = target.clientHeight; - - // Check if scrolled to bottom - const isAtBottom = - Math.abs(scrollTop + clientHeight - scrollHeight) < 5; - - if (isAtBottom && historyData.length > 0) { - isAutoSwitchingRef.current = true; - - const currentStationIndex = historyData.findIndex( - (station) => - station.stationId.toString() === activeStation - ); - - if ( - currentStationIndex !== -1 && - currentStationIndex < historyData.length - 1 - ) { - // Chuyển sang station tiếp theo - setActiveStation( - historyData[ - currentStationIndex + 1 - ].stationId.toString() - ); - - // Reset scroll to top - setTimeout(() => { - if (scrollViewportRef.current) { - scrollViewportRef.current.scrollTop = 0; - } - setTimeout(() => { - isAutoSwitchingRef.current = false; - }, 100); - }, 150); - } else { - isAutoSwitchingRef.current = false; - } + // Debounce để scroll mượt hơn + if (scrollTimeoutRef.current) { + clearTimeout(scrollTimeoutRef.current); } + + scrollTimeoutRef.current = setTimeout(() => { + if (!scrollViewportRef.current) return; + + const scrollContainer = scrollViewportRef.current; + const containerRect = scrollContainer.getBoundingClientRect(); + const topThreshold = containerRect.top + 10; // Sát top với threshold 10px + + // Find which station header is closest to top + // Ưu tiên: header đã vượt qua top threshold (ở trên) > header chưa đến (ở dưới) + type StationInfo = { id: number; distance: number; isAbove: boolean }; + let bestStation: StationInfo | null = null; + + Array.from(stationRefs.current.entries()).forEach(([stationId, element]) => { + const elementRect = element.getBoundingClientRect(); + const elementTop = elementRect.top; + const elementBottom = elementRect.bottom; + + // Check if station header is visible (có phần nào đó trong viewport) + const isVisible = elementBottom >= containerRect.top && elementTop <= containerRect.bottom; + + if (isVisible) { + const isAbove = elementTop <= topThreshold; // Header đã vượt qua top + const distance = Math.abs(elementTop - topThreshold); + const stationIdNum = Number(stationId); + + // Ưu tiên header đã vượt qua top (isAbove = true) + if (!bestStation || + (isAbove && !bestStation.isAbove) || + (isAbove === bestStation.isAbove && distance < bestStation.distance)) { + bestStation = { id: stationIdNum, distance, isAbove }; + } + } + }); + + // Update active station if found + if (bestStation) { + const stationIdStr = String((bestStation as StationInfo).id); + if (stationIdStr !== activeStation) { + setActiveStation(stationIdStr); + } + } + }, 150); // Debounce 150ms để mượt hơn }} > - {groupedHistory.size > 0 ? ( + {allGroupedStations.length > 0 ? ( <> - {Array.from(groupedHistory.entries()) - .sort(([lineA], [lineB]) => lineA - lineB) - .map(([lineNumber, items]) => ( + {allGroupedStations.map((station) => ( + + {/* Station Title */} { + if (el) { + stationRefs.current.set(station.stationId, el); + } + }} style={{ + padding: "12px 16px", + backgroundColor: "#495057", + color: "white", + fontWeight: 700, marginBottom: "8px", - border: "1px solid #dee2e6", borderRadius: "4px", - overflow: "hidden", + position: "sticky", + top: 0, + zIndex: 10, }} > + + 📍 {station.stationName} + + + + {/* Line groups for this station */} + {station.groupedHistory.size > 0 ? ( + Array.from(station.groupedHistory.entries()) + .sort(([lineA], [lineB]) => lineA - lineB) + .map(([lineNumber, items]) => ( + {/* Header của nhóm - hiển thị line number */} - ))} - - ))} + ))} + + )) + ) : ( + + + No history data available for {TIME_PERIODS.find( + (p) => p.value === activeTimePeriod + )?.label} + + + )} + + ))} {/* Spacer để đảm bảo có thể scroll ngay cả khi content ít */} @@ -463,7 +548,7 @@ function ModalHistory({ style={{ height: "calc(75vh - 80px)" }} > - {currentStationData + {historyData.length > 0 ? `No history data available for ${ TIME_PERIODS.find( (p) => p.value === activeTimePeriod