feat: implement state-managed interactive map entry and optimize image loading performance
This commit is contained in:
@@ -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';
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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} />;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user