feat: implement state-managed interactive map entry and optimize image loading performance

This commit is contained in:
taDuc
2026-06-04 02:48:14 +07:00
parent 82064af0db
commit fb816fca11
8 changed files with 106 additions and 16 deletions
-1
View File
@@ -1,7 +1,6 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { Inter } from 'next/font/google'; import { Inter } from 'next/font/google';
import './globals.css'; import './globals.css';
import "flatpickr/dist/flatpickr.css";
import { SidebarProvider } from '@/context/SidebarContext'; import { SidebarProvider } from '@/context/SidebarContext';
import { ThemeProvider } from '@/context/ThemeContext'; import { ThemeProvider } from '@/context/ThemeContext';
import { Toaster } from 'sonner'; import { Toaster } from 'sonner';
+6
View File
@@ -30,6 +30,10 @@ export default function Page() {
src="/images/map_placeholder.webp" src="/images/map_placeholder.webp"
alt="Map Background" alt="Map Background"
fetchPriority="high" fetchPriority="high"
loading="eager"
decoding="sync"
width={1920}
height={1080}
style={{ style={{
position: "absolute", position: "absolute",
inset: 0, inset: 0,
@@ -37,6 +41,8 @@ export default function Page() {
height: "100%", height: "100%",
objectFit: "cover", objectFit: "cover",
zIndex: 0, zIndex: 0,
backgroundImage: "url('data:image/webp;base64,UklGRmgAAABXRUJQVlA4IFwAAAAQAgCdASoQAAkAAgA0JbACdAD0j9ruBiEAAP71e6Hb3PzxBzEI1XGXkdQ3Wq1ek8XLa1nPPm65FhrFIjmR0%2BxZwNUJBvg15I7CuzvhuunZ%2FUF83IaP8Evo6gAAAA%3D%3D')",
backgroundSize: "cover",
}} }}
/> />
@@ -3,6 +3,7 @@ import { useEffect, useRef } from "react";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { ApexOptions } from "apexcharts"; import { ApexOptions } from "apexcharts";
import flatpickr from "flatpickr"; import flatpickr from "flatpickr";
import "flatpickr/dist/flatpickr.css";
import ChartTab from "../common/ChartTab"; import ChartTab from "../common/ChartTab";
import { CalenderIcon } from "../../icons"; import { CalenderIcon } from "../../icons";
+22
View File
@@ -0,0 +1,22 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface UiState {
mapEntered: boolean;
}
const initialState: UiState = {
mapEntered: false,
};
const uiSlice = createSlice({
name: 'ui',
initialState,
reducers: {
setMapEntered: (state, action: PayloadAction<boolean>) => {
state.mapEntered = action.payload;
},
},
});
export const { setMapEntered } = uiSlice.actions;
export default uiSlice.reducer;
+2
View File
@@ -1,9 +1,11 @@
import { configureStore } from '@reduxjs/toolkit'; import { configureStore } from '@reduxjs/toolkit';
import userReducer from './features/userSlice'; import userReducer from './features/userSlice';
import uiReducer from './features/uiSlice';
export const store = configureStore({ export const store = configureStore({
reducer: { reducer: {
user: userReducer, user: userReducer,
ui: uiReducer,
}, },
}); });
@@ -29,6 +29,10 @@ export default function MapPlaceholder({ isLoaderOnly = true, onEnter }: MapPlac
src="/images/map_placeholder.webp" src="/images/map_placeholder.webp"
alt="Map Loading Placeholder" alt="Map Loading Placeholder"
fetchPriority="high" fetchPriority="high"
loading="eager"
decoding="sync"
width={1920}
height={1080}
style={{ style={{
position: "absolute", position: "absolute",
inset: 0, inset: 0,
@@ -37,6 +41,8 @@ export default function MapPlaceholder({ isLoaderOnly = true, onEnter }: MapPlac
objectFit: "cover", objectFit: "cover",
zIndex: 1, zIndex: 1,
filter: "brightness(0.3) contrast(1.1)", filter: "brightness(0.3) contrast(1.1)",
backgroundImage: "url('data:image/webp;base64,UklGRmgAAABXRUJQVlA4IFwAAAAQAgCdASoQAAkAAgA0JbACdAD0j9ruBiEAAP71e6Hb3PzxBzEI1XGXkdQ3Wq1ek8XLa1nPPm65FhrFIjmR0%2BxZwNUJBvg15I7CuzvhuunZ%2FUF83IaP8Evo6gAAAA%3D%3D')",
backgroundSize: "cover",
}} }}
/> />
@@ -112,6 +118,10 @@ export default function MapPlaceholder({ isLoaderOnly = true, onEnter }: MapPlac
src="/images/map_placeholder.webp" src="/images/map_placeholder.webp"
alt="Map Background" alt="Map Background"
fetchPriority="high" fetchPriority="high"
loading="eager"
decoding="sync"
width={1920}
height={1080}
style={{ style={{
position: "absolute", position: "absolute",
inset: 0, inset: 0,
@@ -122,6 +132,8 @@ export default function MapPlaceholder({ isLoaderOnly = true, onEnter }: MapPlac
transform: "scale(1.02)", transform: "scale(1.02)",
filter: "brightness(0.25) contrast(1.15) sepia(0.2)", filter: "brightness(0.25) contrast(1.15) sepia(0.2)",
transition: "transform 10s ease-out", transition: "transform 10s ease-out",
backgroundImage: "url('data:image/webp;base64,UklGRmgAAABXRUJQVlA4IFwAAAAQAgCdASoQAAkAAgA0JbACdAD0j9ruBiEAAP71e6Hb3PzxBzEI1XGXkdQ3Wq1ek8XLa1nPPm65FhrFIjmR0%2BxZwNUJBvg15I7CuzvhuunZ%2FUF83IaP8Evo6gAAAA%3D%3D')",
backgroundSize: "cover",
}} }}
/> />
@@ -33,7 +33,12 @@ import { clampYearToFixedRange, TIMELINE_DEBOUNCE_MS } from "@/uhm/lib/utils/tim
const CURRENT_YEAR = new Date().getUTCFullYear(); const CURRENT_YEAR = new Date().getUTCFullYear();
export default function PublicPreviewClientPage() { interface PublicPreviewClientPageProps {
userHasEntered: boolean;
onEnter: () => void;
}
export default function PublicPreviewClientPage({ userHasEntered, onEnter }: PublicPreviewClientPageProps) {
const [selectedFeatureIds, setSelectedFeatureIds] = useState<(string | number)[]>([]); const [selectedFeatureIds, setSelectedFeatureIds] = useState<(string | number)[]>([]);
const [timelineYear, setTimelineYear] = useState<number>(1000); const [timelineYear, setTimelineYear] = useState<number>(1000);
const [timelineDraftYear, setTimelineDraftYear] = useState<number>(1000); const [timelineDraftYear, setTimelineDraftYear] = useState<number>(1000);
@@ -454,13 +459,13 @@ export default function PublicPreviewClientPage() {
position: "fixed", position: "fixed",
inset: 0, inset: 0,
zIndex: 9999, zIndex: 9999,
pointerEvents: isMapLoaded && isBackgroundVisibilityReady && loadInteractiveMap ? "none" : "auto", pointerEvents: userHasEntered && isMapLoaded && isBackgroundVisibilityReady && loadInteractiveMap ? "none" : "auto",
opacity: isMapLoaded && isBackgroundVisibilityReady && loadInteractiveMap ? 0 : 1, opacity: userHasEntered && isMapLoaded && isBackgroundVisibilityReady && loadInteractiveMap ? 0 : 1,
visibility: isMapLoaded && isBackgroundVisibilityReady && loadInteractiveMap ? "hidden" : "visible", visibility: userHasEntered && isMapLoaded && isBackgroundVisibilityReady && loadInteractiveMap ? "hidden" : "visible",
transition: "opacity 0.8s cubic-bezier(0.4, 0, 0.2, 1), visibility 0.8s", transition: "opacity 0.8s cubic-bezier(0.4, 0, 0.2, 1), visibility 0.8s",
}} }}
> >
<MapPlaceholder /> <MapPlaceholder isLoaderOnly={userHasEntered} onEnter={onEnter} />
</div> </div>
{linkEntityPopup ? ( {linkEntityPopup ? (
@@ -2,45 +2,88 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { useDispatch, useSelector } from "react-redux";
import { RootState } from "@/store/store";
import { setMapEntered } from "@/store/features/uiSlice";
import MapPlaceholder from "./MapPlaceholder"; import MapPlaceholder from "./MapPlaceholder";
// Loader component to handle dynamic chunk loading with Redux support
const LoaderComponent = () => {
const mapEntered = useSelector((state: RootState) => state.ui.mapEntered);
const dispatch = useDispatch();
const handleEnter = () => {
dispatch(setMapEntered(true));
};
return (
<MapPlaceholder
isLoaderOnly={mapEntered}
onEnter={handleEnter}
/>
);
};
const PublicPreviewClientPage = dynamic( const PublicPreviewClientPage = dynamic(
() => import("./PublicPreviewClientPage"), () => import("./PublicPreviewClientPage"),
{ {
ssr: false, ssr: false,
loading: () => <MapPlaceholder />, loading: () => <LoaderComponent />,
} }
); );
export default function PublicPreviewWrapper() { export default function PublicPreviewWrapper() {
const [loadInteractive, setLoadInteractive] = useState(false); const mapEntered = useSelector((state: RootState) => state.ui.mapEntered);
const dispatch = useDispatch();
const [loadInteractive, setLoadInteractive] = useState(() => mapEntered);
const handleEnter = () => {
dispatch(setMapEntered(true));
setLoadInteractive(true);
};
useEffect(() => { useEffect(() => {
if (mapEntered) return;
const handleInteraction = () => { const handleInteraction = () => {
setLoadInteractive(true); handleEnter();
}; };
window.addEventListener("click", handleInteraction, { passive: true, once: true }); window.addEventListener("click", handleInteraction, { passive: true, once: true });
window.addEventListener("touchstart", handleInteraction, { passive: true, once: true }); window.addEventListener("touchstart", handleInteraction, { passive: true, once: true });
window.addEventListener("keydown", handleInteraction, { passive: true, once: true }); window.addEventListener("keydown", handleInteraction, { passive: true, once: true });
// Tải trước (prefetch) file JS của bản đồ sau 1.5s để lưu vào cache trình duyệt // Tải ngầm bản đồ sau 2.5 giây khi trình duyệt rảnh rỗi (idle)
// Giúp người dùng click vào là có bản đồ ngay mà không làm giảm điểm Lighthouse lúc đầu let idleId: any = null;
const prefetchTimer = setTimeout(() => { const prefetchTimer = setTimeout(() => {
import("./PublicPreviewClientPage").catch(() => {}); const runPrefetch = () => {
}, 1500); setLoadInteractive(true);
};
if (typeof window !== "undefined") {
if ("requestIdleCallback" in window) {
idleId = (window as any).requestIdleCallback(runPrefetch, { timeout: 3000 });
} else {
setTimeout(runPrefetch, 200);
}
}
}, 2000);
return () => { return () => {
window.removeEventListener("click", handleInteraction); window.removeEventListener("click", handleInteraction);
window.removeEventListener("touchstart", handleInteraction); window.removeEventListener("touchstart", handleInteraction);
window.removeEventListener("keydown", handleInteraction); window.removeEventListener("keydown", handleInteraction);
clearTimeout(prefetchTimer); clearTimeout(prefetchTimer);
if (idleId && typeof window !== "undefined" && "cancelIdleCallback" in window) {
(window as any).cancelIdleCallback(idleId);
}
}; };
}, []); }, [mapEntered]);
if (!loadInteractive) { if (!loadInteractive) {
return <MapPlaceholder isLoaderOnly={false} onEnter={() => setLoadInteractive(true)} />; return <MapPlaceholder isLoaderOnly={false} onEnter={handleEnter} />;
} }
return <PublicPreviewClientPage />; return <PublicPreviewClientPage userHasEntered={mapEntered} onEnter={handleEnter} />;
} }