feat: persist map viewport in localStorage, optimize public preview loading with deferred interactive map, and add geometry background management actions.
Build and Release / release (push) Failing after 33s

This commit is contained in:
taDuc
2026-06-04 00:28:26 +07:00
parent 288cde5dcf
commit 5b13ec8d8d
28 changed files with 2690 additions and 601 deletions
+1 -3
View File
@@ -1,13 +1,11 @@
@import 'tailwindcss';
@import './fonts.css';
@custom-variant dark (&:is(.dark *));
@theme {
--font-*: initial;
--font-outfit: Outfit, sans-serif;
--font-inter: "SF Pro Display", ui-sans-serif, system-ui, sans-serif;
--font-inter: var(--font-inter), ui-sans-serif, system-ui, sans-serif;
--breakpoint-*: initial;
--breakpoint-2xsm: 375px;
+16 -27
View File
@@ -1,43 +1,32 @@
import localFont from 'next/font/local';
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
import "flatpickr/dist/flatpickr.css";
import { SidebarProvider } from '@/context/SidebarContext';
import { ThemeProvider } from '@/context/ThemeContext';
import { Toaster } from 'sonner';
import StoreProvider from '@/store/StoreProvider';
export const metadata: Metadata = {
title: 'Ultimate History Map',
description: 'Bản đồ tương tác lịch sử thế giới qua các thời kỳ',
};
const sfPro = localFont({
src: [
{
path: '../../public/font/SF-Pro-Display/SF-Pro-Display-Regular.otf',
weight: '400',
style: 'normal',
},
{
path: '../../public/font/SF-Pro-Display/SF-Pro-Display-Medium.otf',
weight: '500',
style: 'normal',
},
{
path: '../../public/font/SF-Pro-Display/SF-Pro-Display-Semibold.otf',
weight: '600',
style: 'normal',
},
{
path: '../../public/font/SF-Pro-Display/SF-Pro-Display-Bold.otf',
weight: '700',
style: 'normal',
},
],
})
const inter = Inter({
subsets: ['latin', 'vietnamese'],
weight: ['400', '500', '600', '700'],
variable: '--font-inter',
display: 'swap',
});
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className={`${sfPro.className} dark:bg-gray-900`}>
<html lang="en" className={inter.variable}>
<body className={`${inter.className} dark:bg-gray-900`}>
<StoreProvider>
<ThemeProvider>
<SidebarProvider>{children} <Toaster closeButton richColors position="top-right" /> </SidebarProvider>
+69 -468
View File
@@ -1,477 +1,78 @@
"use client";
import type { Metadata } from "next";
import PublicPreviewWrapper from "@/uhm/components/preview/PublicPreviewWrapper";
import { useEffect, useState } from "react";
export const metadata: Metadata = {
title: "Ultimate History Map | Bản Đồ Lịch Sử Thế Giới Tương Tác",
description: "Khám phá lịch sử thế giới qua bản đồ tương tác theo dòng thời gian. Xem lại các trận đánh diễn biến lịch sử sinh động qua hệ thống Replay.",
};
import PreviewMapShell from "@/uhm/components/preview/PreviewMapShell";
import ReplayPreviewOverlay from "@/uhm/components/editor/ReplayPreviewOverlay";
import { usePublicPreviewData } from "@/uhm/components/preview/hooks/usePublicPreviewData";
import { useReplayPreview } from "@/uhm/lib/replay/useReplayPreview";
import type { MapHandle } from "@/uhm/components/Map";
import { useRef, useMemo, useCallback } from "react";
import { usePublicPreviewInteraction } from "@/uhm/components/preview/hooks/usePublicPreviewInteraction";
import PresentPlaceSearch, {
type HistoricalGeometryFocusPayload,
type PresentPlaceSelection,
} from "@/uhm/components/editor/PresentPlaceSearch";
import { fitMapToFeatureCollection } from "@/uhm/components/map/mapUtils";
import type { FeatureCollection } from "@/uhm/types/geo";
import {
type BackgroundLayerId,
type BackgroundLayerVisibility,
HIDDEN_BACKGROUND_LAYER_VISIBILITY,
} from "@/uhm/lib/map/styles/backgroundLayers";
import {
loadBackgroundLayerVisibilityFromStorage,
persistBackgroundLayerVisibility,
} from "@/uhm/lib/editor/background/backgroundVisibilityStorage";
import { GEO_TYPE_KEYS } from "@/uhm/lib/map/geo/geoTypeMap";
import { clampYearToFixedRange, TIMELINE_DEBOUNCE_MS } from "@/uhm/lib/utils/timeline";
const CURRENT_YEAR = new Date().getUTCFullYear();
const srOnlyStyle: React.CSSProperties = {
position: "absolute",
width: "1px",
height: "1px",
padding: "0",
margin: "-1px",
overflow: "hidden",
clip: "rect(0, 0, 0, 0)",
whiteSpace: "nowrap",
border: "0",
};
export default function Page() {
const [selectedFeatureIds, setSelectedFeatureIds] = useState<(string | number)[]>([]);
const [timelineYear, setTimelineYear] = useState<number>(1000);
const [timelineDraftYear, setTimelineDraftYear] = useState<number>(1000);
const [timeRange, setTimeRange] = useState<number>(0);
const [backgroundVisibility, setBackgroundVisibility] = useState<BackgroundLayerVisibility>(
() => ({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY })
);
const [isBackgroundVisibilityReady, setIsBackgroundVisibilityReady] = useState(false);
const [geometryVisibility, setGeometryVisibility] = useState<Record<string, boolean>>(() => {
const init: Record<string, boolean> = {};
for (const key of GEO_TYPE_KEYS) init[key] = true;
return init;
});
const [sidebarWidth, setSidebarWidth] = useState<number>(() => {
if (typeof window !== "undefined") {
const saved = localStorage.getItem("public-wiki-sidebar-width");
if (saved) {
const parsed = parseInt(saved, 10);
if (!isNaN(parsed) && parsed >= 320 && parsed <= 800) return parsed;
}
}
return 420;
});
const [isLargeScreen, setIsLargeScreen] = useState(false);
const mapHandleRef = useRef<MapHandle>(null);
const isFirstMount = useRef(true);
const [replayMode, setReplayMode] = useState<"idle" | "playing">("idle");
const [selectedReplayStageId, setSelectedReplayStageId] = useState<number | null>(null);
const [selectedReplayStepIndex, setSelectedReplayStepIndex] = useState<number | null>(null);
const [focusedPresentPlace, setFocusedPresentPlace] = useState<PresentPlaceSelection | null>(null);
const [searchTimelineYear, setSearchTimelineYear] = useState(timelineYear);
useEffect(() => {
if (replayMode !== "playing") {
setSearchTimelineYear(timelineYear);
}
}, [timelineYear, replayMode]);
const {
data,
renderDraft,
labelContextDraft,
relations,
setRelations,
isTimelineLoading,
timelineStatus,
isRelationsLoading,
relationsStatus,
replays,
} = usePublicPreviewData({ timelineYear: searchTimelineYear, timeRange });
const activeReplay = useMemo(() => {
if (!selectedFeatureIds.length || !replays?.length) return null;
for (const featureId of selectedFeatureIds) {
const id = String(featureId);
// 1. Direct geometry_id match (priority)
for (const replay of replays) {
if (String(replay.geometry_id || "").trim() === id) {
const firstStage = replay.detail?.find((s) => Array.isArray(s?.steps) && s.steps.length > 0);
if (firstStage) {
return { replay, stageId: firstStage.id, stepIndex: 0 };
}
}
}
// 2. Fallback: Check inside steps parameters
for (const replay of replays) {
for (const stage of replay.detail || []) {
for (let stepIndex = 0; stepIndex < (stage.steps || []).length; stepIndex++) {
const step = stage.steps[stepIndex];
if (step?.use_geo_function?.some((g) => g.params && Array.isArray(g.params) && g.params.some((p) => String(p) === id))) {
return { replay, stageId: stage.id, stepIndex };
}
}
}
}
}
return null;
}, [replays, selectedFeatureIds]);
const getMapInstance = useCallback(() => mapHandleRef.current?.getMap() || null, []);
const handleSelectReplayStep = useCallback((stageId: number | null, stepIndex: number | null) => {
setSelectedReplayStageId(stageId);
setSelectedReplayStepIndex(stepIndex);
}, []);
const replayPreview = useReplayPreview({
replay: activeReplay?.replay || null,
draft: renderDraft,
getMapInstance,
initialTimelineYear: timelineDraftYear,
initialTimelineFilterEnabled: false,
initialMapViewState: null,
selectedStageId: selectedReplayStageId,
selectedStepIndex: selectedReplayStepIndex,
onSelectStep: handleSelectReplayStep,
});
const {
activeEntity,
activeWiki,
isActiveWikiLoading,
activeWikiError,
linkEntityPopup,
linkEntityPopupRef,
getHoverPopupContent,
selectEntity,
handleWikiLinkRequest,
closeWikiSidebar,
setLinkEntityPopup,
isManualSidebarOpen,
} = usePublicPreviewInteraction({
data,
relations,
setRelations,
selectedFeatureIds,
setSelectedFeatureIds,
replayActiveWikiId: replayPreview.activeWikiId,
replayMode,
});
useEffect(() => {
if (typeof window === "undefined") return;
const handleResize = () => {
setIsLargeScreen(window.innerWidth >= 1024);
};
handleResize();
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
useEffect(() => {
const timeoutId = window.setTimeout(() => {
if (timelineDraftYear !== timelineYear) setTimelineYear(timelineDraftYear);
}, TIMELINE_DEBOUNCE_MS);
return () => window.clearTimeout(timeoutId);
}, [timelineDraftYear, timelineYear]);
useEffect(() => {
if (typeof window !== "undefined") {
const saved = localStorage.getItem("timeline-year");
if (saved) {
const parsed = parseInt(saved, 10);
if (!isNaN(parsed)) {
const clamped = clampYearToFixedRange(parsed);
setTimelineYear(clamped);
setTimelineDraftYear(clamped);
}
}
}
}, []);
useEffect(() => {
if (isFirstMount.current) {
isFirstMount.current = false;
return;
}
if (typeof window !== "undefined") {
localStorage.setItem("timeline-year", String(timelineYear));
}
}, [timelineYear]);
useEffect(() => {
const timeoutId = window.setTimeout(() => {
setBackgroundVisibility(loadBackgroundLayerVisibilityFromStorage());
setIsBackgroundVisibilityReady(true);
}, 0);
return () => window.clearTimeout(timeoutId);
}, []);
const maxDragWidth = typeof window !== "undefined"
? Math.min(800, window.innerWidth - 340)
: 800;
const updateBackgroundVisibility = (updater: (prev: BackgroundLayerVisibility) => BackgroundLayerVisibility) => {
setBackgroundVisibility((prev) => {
const next = updater(prev);
persistBackgroundLayerVisibility(next);
return next;
});
};
const handleToggleBackgroundLayer = (id: BackgroundLayerId) => {
updateBackgroundVisibility((prev) => ({ ...prev, [id]: !prev[id] }));
};
const handleTimelineYearChange = (nextYear: number) => {
setTimelineDraftYear(clampYearToFixedRange(Math.trunc(nextYear)));
};
const handleTimeRangeChange = (nextRange: number) => {
const safe = Number.isFinite(nextRange) ? Math.trunc(nextRange) : 0;
setTimeRange(Math.max(0, Math.min(30, safe)));
};
useEffect(() => {
if (replayMode === "playing" && !replayPreview.isPlaying) {
replayPreview.playFromSelection();
}
}, [replayMode, replayPreview.isPlaying, replayPreview.playFromSelection]);
const handlePlayPreviewReplay = useCallback(() => {
if (!activeReplay) return;
setReplayMode("playing");
setSelectedReplayStageId(activeReplay.stageId);
setSelectedReplayStepIndex(activeReplay.stepIndex);
}, [activeReplay]);
const handleExitReplay = useCallback(() => {
setReplayMode("idle");
replayPreview.resetPreview();
setFocusedPresentPlace(null);
}, [replayPreview]);
const handleFocusPresentPlace = useCallback((place: PresentPlaceSelection) => {
setFocusedPresentPlace(place);
const map = mapHandleRef.current?.getMap();
if (map) {
const currentZoom = map.getZoom();
map.flyTo({
center: [place.lng, place.lat],
zoom: Math.max(currentZoom, 13.5),
});
}
}, []);
const clearPresentPlaceFocus = useCallback(() => {
setFocusedPresentPlace(null);
}, []);
const handleFocusHistoricalGeometry = useCallback((payload: HistoricalGeometryFocusPayload) => {
setFocusedPresentPlace(null);
const map = mapHandleRef.current?.getMap();
if (map && payload.geometry?.draw_geometry) {
const fc: FeatureCollection = {
type: "FeatureCollection",
features: [
{
type: "Feature",
properties: {
id: payload.geometry.id,
},
geometry: payload.geometry.draw_geometry,
},
],
};
fitMapToFeatureCollection(map, fc, 84, { duration: 1000 });
}
if (payload.geometry.time_start != null) {
handleTimelineYearChange(payload.geometry.time_start);
}
setSelectedFeatureIds([payload.geometry.id]);
const linkedEntityIds = relations.geometryEntityIds[String(payload.geometry.id)] || [];
if (linkedEntityIds.length === 1) {
selectEntity(linkedEntityIds[0], {
sourceFeatureId: payload.geometry.id,
selectGeometry: false,
});
}
}, [relations.geometryEntityIds, selectEntity, setSelectedFeatureIds]);
const filteredRenderDraft = useMemo(() => {
if (replayMode !== "playing" || !replayPreview.hiddenGeometryIds?.length) {
return renderDraft;
}
const hiddenIds = new Set(replayPreview.hiddenGeometryIds);
return {
type: "FeatureCollection" as const,
features: renderDraft.features.filter(
(feature) => !hiddenIds.has(String(feature.properties.id))
),
};
}, [replayMode, renderDraft, replayPreview.hiddenGeometryIds]);
const filteredLabelContextDraft = useMemo(() => {
if (replayMode !== "playing" || !replayPreview.hiddenGeometryIds?.length) {
return labelContextDraft;
}
const hiddenIds = new Set(replayPreview.hiddenGeometryIds);
return {
type: "FeatureCollection" as const,
features: labelContextDraft.features.filter(
(feature) => !hiddenIds.has(String(feature.properties.id))
),
};
}, [replayMode, labelContextDraft, replayPreview.hiddenGeometryIds]);
const currentTimelineYear = replayMode === "playing" ? replayPreview.timelineYear : timelineDraftYear;
const activeStepLabel = useMemo(() => {
if (
replayPreview.activeCursor.stageId == null ||
replayPreview.activeCursor.stepIndex == null
) {
return null;
}
return `Stage #${replayPreview.activeCursor.stageId} · Step ${replayPreview.activeCursor.stepIndex + 1}`;
}, [replayPreview.activeCursor.stageId, replayPreview.activeCursor.stepIndex]);
const isSidebarOpen = replayMode === "playing"
? (replayPreview.sidebarOpen || isManualSidebarOpen)
: Boolean(activeEntity);
const displayedActiveEntity = isSidebarOpen ? activeEntity : null;
const displayedActiveWiki = isSidebarOpen ? activeWiki : null;
const computedTimelineStyle = useMemo(() => {
const rightMargin = (displayedActiveEntity && isLargeScreen) ? sidebarWidth + 32 : 18;
return {
left: "88px",
right: `${rightMargin}px`,
transition: "right 0.3s cubic-bezier(0.4, 0, 0.2, 1), left 0.3s cubic-bezier(0.4, 0, 0.2, 1)",
};
}, [displayedActiveEntity, isLargeScreen, sidebarWidth]);
return (
<>
{isBackgroundVisibilityReady ? (
<PreviewMapShell
mapHandleRef={mapHandleRef}
renderDraft={filteredRenderDraft}
labelContextDraft={filteredLabelContextDraft}
labelTimelineYear={currentTimelineYear}
selectedFeatureIds={selectedFeatureIds}
onSelectFeatureIds={setSelectedFeatureIds}
backgroundVisibility={backgroundVisibility}
geometryVisibility={geometryVisibility}
onToggleBackground={handleToggleBackgroundLayer}
onToggleGeometry={(typeKey) => {
setGeometryVisibility((prev) => ({
...prev,
[typeKey]: prev[typeKey] === false,
}));
}}
timelineYear={currentTimelineYear}
onTimelineYearChange={handleTimelineYearChange}
timelineTimeRange={timeRange}
onTimelineTimeRangeChange={handleTimeRangeChange}
isTimelineLoading={isTimelineLoading || isRelationsLoading}
timelineStatusText={relationsStatus || timelineStatus}
timelineStyle={computedTimelineStyle}
hoverPopupEnabled
getHoverPopupContent={getHoverPopupContent}
activeEntity={displayedActiveEntity}
activeWiki={displayedActiveWiki}
isWikiLoading={isActiveWikiLoading}
wikiError={activeWikiError}
onCloseWikiSidebar={closeWikiSidebar}
onWikiLinkRequest={handleWikiLinkRequest}
sidebarWidth={sidebarWidth}
onSidebarWidthChange={setSidebarWidth}
maxSidebarDragWidth={maxDragWidth}
onPlayPreviewReplay={activeReplay && replayMode === "idle" ? handlePlayPreviewReplay : undefined}
timelineDisabled={replayMode === "playing"}
overlay={
replayMode === "playing" ? (
<ReplayPreviewOverlay
isPreviewMode={true}
isPlaying={replayPreview.isPlaying}
dialog={replayPreview.dialog}
toasts={replayPreview.toasts}
sidebarOpen={isSidebarOpen}
sidebarWidth={sidebarWidth}
playbackSpeed={replayPreview.playbackSpeed}
activeStepLabel={activeStepLabel}
activeStepNumber={replayPreview.activeStepNumber}
totalSteps={replayPreview.totalSteps}
onPlayPreview={replayPreview.playFromStart}
onStopPreview={replayPreview.stopPreview}
onResetPreview={replayPreview.resetPreview}
onExitPreview={handleExitReplay}
/>
) : null
}
>
<div
style={{
position: "absolute",
top: 10,
left: 80,
zIndex: 18,
display: "flex",
gap: "10px",
alignItems: "flex-start",
pointerEvents: "auto",
}}
>
<PresentPlaceSearch
focusedPlace={focusedPresentPlace}
onFocusPlace={handleFocusPresentPlace}
onFocusHistoricalGeometry={handleFocusHistoricalGeometry}
onClearFocus={clearPresentPlaceFocus}
style={{
position: "relative",
top: 0,
left: 0,
width: "min(392px, calc(100vw - 120px))",
}}
/>
</div>
</PreviewMapShell>
) : (
<div className="h-screen w-full bg-[#0b1220]" />
)}
<div style={{ position: "relative", width: "100%", height: "100vh", overflow: "hidden", backgroundColor: "#0b1220" }}>
{/* Preload LCP image */}
<link rel="preload" as="image" href="/images/map_placeholder.webp" fetchPriority="high" />
{linkEntityPopup ? (
<div
ref={linkEntityPopupRef}
className="fixed z-[60] w-[240px] overflow-hidden rounded-xl border border-gray-200 bg-white shadow-xl dark:border-gray-800 dark:bg-gray-950"
style={{ top: linkEntityPopup.top, left: linkEntityPopup.left }}
>
<div className="border-b border-gray-200 px-3 py-2 dark:border-gray-800">
<div className="text-sm font-semibold text-gray-900 dark:text-gray-100">
Related Entities
</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
/wiki/{linkEntityPopup.slug}
</div>
</div>
<div className="max-h-[220px] overflow-y-auto p-2">
<div className="grid gap-1">
{linkEntityPopup.entities.map((entity) => (
<button
key={entity.id}
type="button"
onClick={() => {
selectEntity(entity.id, { preferredWikiSlug: linkEntityPopup.slug });
setLinkEntityPopup(null);
}}
className="rounded-lg px-3 py-2 text-left text-sm text-gray-700 transition hover:bg-gray-50 hover:text-gray-900 dark:text-gray-200 dark:hover:bg-white/[0.04] dark:hover:text-white"
>
{entity.name}
</button>
))}
</div>
</div>
{/* Permanent, static LCP image that is NEVER hidden or unmounted */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src="/images/map_placeholder.webp"
alt="Map Background"
fetchPriority="high"
style={{
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
objectFit: "cover",
zIndex: 0,
}}
/>
{/* Header (SSR & SEO) */}
<header style={srOnlyStyle}>
<nav>
<a href="/">Trang chủ</a>
<a href="/faq">Hướng dẫn / FAQ</a>
<a href="/about-us">Về chúng tôi</a>
<a href="/user">Quản trị viên</a>
</nav>
</header>
{/* Main Content & Semantic Heading (SSR & SEO) */}
<main style={{ position: "relative", zIndex: 1, width: "100%", height: "100%" }}>
<div style={srOnlyStyle}>
<h1>Ultimate History Map - Bản Đ Tương Tác Lịch Sử</h1>
<p>
Dự án Ultimate History Map cung cấp cái nhìn trực quan sinh đng về sự thay đi biên giới, các quốc gia, sự kiện lịch sử thế giới theo từng năm.
</p>
<p>
Tính năng chính bao gồm:
- Xem bản đ lịch sử theo dòng thời gian (Timeline).
- Trình phát diễn biến lịch sử chiến trận (Replay).
- Tra cứu thông tin sự kiện lịch sử (Wiki & Entities).
</p>
</div>
) : null}
</>
{/* Stateful Interactive Client Component */}
<PublicPreviewWrapper />
</main>
{/* Footer (SSR & SEO) */}
<footer style={srOnlyStyle}>
<p>&copy; {new Date().getFullYear()} Ultimate History Map. All rights reserved.</p>
</footer>
</div>
);
}
+2 -2
View File
@@ -2,8 +2,8 @@ import { API_ENDPOINTS } from "@/uhm/api/config";
import { requestJson } from "@/uhm/api/http";
import type { BattleReplay } from "@/uhm/types/projects";
const BATCH_SIZE = 20;
const BATCH_CONCURRENCY = 6;
const BATCH_SIZE = 100;
const BATCH_CONCURRENCY = 4;
export async function fetchBattleReplaysByGeometryIds(geometryIds: string[]): Promise<Record<string, BattleReplay[]>> {
const uniqueIds = Array.from(new Set(
+1 -1
View File
@@ -4,7 +4,7 @@ import type { Entity } from "@/uhm/api/entities";
import type { Wiki } from "@/uhm/api/wikis";
const RELATION_BATCH_SIZE = 20;
const RELATION_BATCH_CONCURRENCY = 10;
const RELATION_BATCH_CONCURRENCY = 4;
export type WikiContentPreview = {
id: string;
+39 -4
View File
@@ -1,8 +1,6 @@
"use client";
import { type CSSProperties, useEffect, useRef, forwardRef, useImperativeHandle, memo } from "react";
import "maplibre-gl/dist/maplibre-gl.css";
import { Feature, FeatureCollection, Geometry } from "@/uhm/lib/editor/state/useEditorState";
import { BackgroundLayerVisibility } from "@/uhm/lib/map/styles/backgroundLayers";
import type { EditorMode } from "@/uhm/lib/editor/session/sessionTypes";
@@ -74,6 +72,7 @@ type MapProps = {
onPlayPreviewReplay?: () => void;
viewMode?: "local" | "global";
onViewModeChange?: (mode: "local" | "global") => void;
onLoad?: () => void;
};
const Map = memo(forwardRef<MapHandle, MapProps>(function Map({
@@ -114,6 +113,7 @@ const Map = memo(forwardRef<MapHandle, MapProps>(function Map({
onPlayPreviewReplay,
viewMode = "local",
onViewModeChange,
onLoad,
}, ref) {
// Ref giữ mode mới nhất cho MapLibre handlers được register một lần.
const modeRef = useRef<MapProps["mode"]>(mode);
@@ -161,6 +161,11 @@ const Map = memo(forwardRef<MapHandle, MapProps>(function Map({
onBindGeometriesRef.current = onBindGeometries;
localFeatureIdsRef.current = localFeatureIds;
useEffect(() => {
// Dynamically import MapLibre CSS to prevent it from blocking initial layout bundle CSS load.
import("maplibre-gl/dist/maplibre-gl.css");
}, []);
// Hook sở hữu lifecycle MapLibre instance và các control camera/projection.
const {
mapRef,
@@ -270,6 +275,12 @@ const Map = memo(forwardRef<MapHandle, MapProps>(function Map({
}
}, [mode, isMapLoaded, mapRef]);
useEffect(() => {
if (isMapLoaded && onLoad) {
onLoad();
}
}, [isMapLoaded, onLoad]);
const hasImageOverlay = Boolean(imageOverlay);
useEffect(() => {
const map = mapRef.current;
@@ -282,8 +293,32 @@ const Map = memo(forwardRef<MapHandle, MapProps>(function Map({
}, [hasImageOverlay, isMapLoaded, mapRef]);
return (
<div style={{ width: "100%", height, position: "relative" }}>
<div ref={containerRef} style={{ width: "100%", height: "100%" }} />
<div style={{ width: "100%", height, position: "relative", backgroundColor: "#0b1220" }}>
{/* Opaque map placeholder image for LCP optimization */}
<img
src="/images/map_placeholder.webp"
alt="Map Loading Placeholder"
fetchPriority="high"
style={{
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
objectFit: "cover",
zIndex: 0,
}}
/>
<div
ref={containerRef}
style={{
width: "100%",
height: "100%",
position: "relative",
zIndex: 1,
backgroundColor: "transparent"
}}
/>
{fatalInitError ? (
<div
@@ -776,26 +776,6 @@ function MapFunctionShortcutPanel({
)
}
/>
<ShortcutButton
label="Enable Filter"
tone="green"
onClick={() =>
onAppendActions(
[{ function_name: "set_timeline_filter", params: [true] }],
"Map: enable timeline filter"
)
}
/>
<ShortcutButton
label="Disable Filter"
tone="slate"
onClick={() =>
onAppendActions(
[{ function_name: "set_timeline_filter", params: [false] }],
"Map: disable timeline filter"
)
}
/>
</div>
</div>
</Panel>
@@ -877,6 +857,28 @@ function GeoFunctionShortcutPanel({
)
}
/>
<ShortcutButton
label="Đặt làm BG"
tone="teal"
disabled={!hasSelection}
onClick={() =>
onAppendActions(
[{ function_name: "set_as_background_geometries", params: [selectedIds] }],
`Geo: đặt ${selectedCount} geo làm background`
)
}
/>
<ShortcutButton
label="Loại khỏi BG"
tone="teal"
disabled={!hasSelection}
onClick={() =>
onAppendActions(
[{ function_name: "remove_from_background_geometries", params: [selectedIds] }],
`Geo: loại ${selectedCount} geo khỏi background`
)
}
/>
</div>
</div>
</Panel>
@@ -1,6 +1,6 @@
"use client";
import { useMemo, useState } from "react";
import { useMemo, useState, useRef } from "react";
import type {
BattleReplay,
GeoFunctionName,
@@ -91,6 +91,7 @@ export default function ReplayTimelineSidebar({
onStopPreview,
onResetPreview,
}: Props) {
const fileInputRef = useRef<HTMLInputElement>(null);
const stages = useMemo(() => replay?.detail || [], [replay?.detail]);
const selectedStage =
stages.find((stage) => stage.id === selectedStageId) ||
@@ -157,6 +158,77 @@ export default function ReplayTimelineSidebar({
window.URL.revokeObjectURL(url);
};
const handleImportReplayJsonClick = () => {
fileInputRef.current?.click();
};
const handleImportReplayJson = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
try {
const jsonText = e.target?.result as string;
const parsed = JSON.parse(jsonText);
let importedReplay: BattleReplay | null = null;
if (parsed && typeof parsed === "object") {
if (parsed.current_replay && typeof parsed.current_replay === "object") {
importedReplay = parsed.current_replay as BattleReplay;
} else if (parsed.id && parsed.target_geometry_ids && Array.isArray(parsed.detail)) {
importedReplay = parsed as BattleReplay;
}
}
if (!importedReplay || !Array.isArray(importedReplay.detail)) {
alert("Định dạng file JSON không hợp lệ cho Replay!");
return;
}
if (replay && importedReplay.geometry_id !== replay.geometry_id) {
const confirmImport = window.confirm(
`Geometry ID của replay nhập vào (${importedReplay.geometry_id}) khác với geometry ID hiện tại (${replay.geometry_id}). Bạn có muốn tiếp tục?`
);
if (!confirmImport) return;
}
onMutateReplay("Replay: import JSON", (draftReplay) => {
draftReplay.detail = importedReplay!.detail;
draftReplay.target_geometry_ids = importedReplay!.target_geometry_ids || draftReplay.target_geometry_ids;
draftReplay.id = replay?.id || draftReplay.id;
draftReplay.geometry_id = replay?.geometry_id || draftReplay.geometry_id;
});
} catch (err) {
alert("Lỗi đọc file JSON: " + (err as Error).message);
}
};
reader.readAsText(file);
event.target.value = "";
};
function getBackgroundGeometryIdsFromReplay(replay: any): Set<string> {
const bgIds = new Set<string>();
if (!replay || !Array.isArray(replay.detail)) return bgIds;
for (const stage of replay.detail) {
if (!Array.isArray(stage.steps)) continue;
for (const step of stage.steps) {
if (Array.isArray(step.use_geo_function)) {
for (const action of step.use_geo_function) {
if (action.function_name === "set_as_background_geometries") {
const ids = Array.isArray(action.params[0]) ? action.params[0] : [];
for (const id of ids) bgIds.add(String(id));
} else if (action.function_name === "remove_from_background_geometries") {
const ids = Array.isArray(action.params[0]) ? action.params[0] : [];
for (const id of ids) bgIds.delete(String(id));
}
}
}
}
}
return bgIds;
}
const handleCreateStage = () => {
if (!replay) return;
if (!validateReplayTimeFormat(createStageForm.detail_time_start) ||
@@ -167,6 +239,19 @@ export default function ReplayTimelineSidebar({
stages.length > 0
? Math.max(...stages.map((stage) => stage.id)) + 1
: 0;
const bgIds = getBackgroundGeometryIdsFromReplay(replay);
const geometriesToHide = (replay.target_geometry_ids || []).filter(
(id: string) => !bgIds.has(String(id))
);
const initialGeoFunctions = [];
if (geometriesToHide.length > 0) {
initialGeoFunctions.push({
function_name: "set_geometry_visibility" as const,
params: [geometriesToHide, false],
});
}
const nextStage: ReplayStage = {
id: nextId,
title: createStageForm.title.trim() || undefined,
@@ -177,7 +262,7 @@ export default function ReplayTimelineSidebar({
duration: 5000,
use_UI_function: [],
use_map_function: [],
use_geo_function: [],
use_geo_function: initialGeoFunctions,
use_narrow_function: [],
},
],
@@ -413,7 +498,7 @@ export default function ReplayTimelineSidebar({
? `${pendingSaveCount} thay đổi chưa commit. Thoát replay để commit từ editor chính.`
: "Replay đang đồng bộ với snapshot hiện tại."}
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 8 }}>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
<button
type="button"
onClick={onUndoReplay}
@@ -427,6 +512,31 @@ export default function ReplayTimelineSidebar({
>
Undo replay
</button>
<button
type="button"
onClick={onExitReplay}
style={{
...buttonStyle,
background: "#0f766e",
border: "none",
}}
>
Thoát replay
</button>
<button
type="button"
onClick={handleImportReplayJsonClick}
disabled={!replay}
style={{
...buttonStyle,
background: replay ? "#b45309" : "#1e293b",
border: "none",
cursor: replay ? "pointer" : "not-allowed",
opacity: replay ? 1 : 0.7,
}}
>
Import JSON
</button>
<button
type="button"
onClick={handleExportReplayJson}
@@ -441,18 +551,14 @@ export default function ReplayTimelineSidebar({
>
Export JSON
</button>
<button
type="button"
onClick={onExitReplay}
style={{
...buttonStyle,
background: "#0f766e",
border: "none",
}}
>
Thoát replay
</button>
</div>
<input
type="file"
ref={fileInputRef}
onChange={handleImportReplayJson}
accept=".json"
style={{ display: "none" }}
/>
<div
style={{
display: "grid",
@@ -1315,7 +1421,6 @@ const narrativeFunctionLabels: Record<NarrativeFunctionName, string> = {
const mapFunctionLabels: Record<MapFunctionName, string> = {
set_camera_view: "Camera view",
set_timeline_filter: "Lọc timeline",
set_labels_visible: "Hiện nhãn map",
};
@@ -1328,6 +1433,8 @@ const geoFunctionLabels: Record<GeoFunctionName, string> = {
animate_dashed_border: "Border nét đứt",
set_geometry_style: "Style geometry",
orbit_camera_around_geometry: "Orbit quanh geo",
set_as_background_geometries: "Đặt làm background",
remove_from_background_geometries: "Loại khỏi background",
};
function buildStepActionEntries(step: ReplayStep): StepActionEntry[] {
@@ -1392,9 +1499,6 @@ function buildMapActionEntry(
let summary = "Không có tham số.";
switch (action.function_name) {
case "set_timeline_filter":
summary = `enabled=${Boolean(params[0] ?? true) ? "true" : "false"}`;
break;
case "set_labels_visible":
summary = `visible=${Boolean(params[0] ?? true) ? "true" : "false"}`;
break;
@@ -1481,6 +1585,12 @@ function buildGeoActionEntry(
`keep=${summarizeGeometryIdsValue(params[0])}`,
].join(" | ");
break;
case "set_as_background_geometries":
summary = `geometry=${summarizeGeometryIdsValue(params[0])}`;
break;
case "remove_from_background_geometries":
summary = `geometry=${summarizeGeometryIdsValue(params[0])}`;
break;
}
return {
+35 -2
View File
@@ -6,6 +6,7 @@ import { getBaseMapStyle } from "./useMapLayers";
import { unregisterMapFromIconUpdates } from "@/uhm/lib/map/styles/geotypeLayers";
const MAP_PROJECTION_STORAGE_KEY = "uhm:mapProjection";
const MAP_VIEWPORT_STORAGE_KEY = "uhm:mapViewport";
export function applyMapProjection(map: maplibregl.Map, isGlobe: boolean) {
map.setProjection({ type: isGlobe ? "globe" : "mercator" });
@@ -50,18 +51,48 @@ export function useMapInstance() {
if (!container) return;
try {
let initialCenter: [number, number] = [0, 20];
let initialZoom = 2;
try {
const saved = window.localStorage.getItem(MAP_VIEWPORT_STORAGE_KEY);
if (saved) {
const parsed = JSON.parse(saved);
if (Number.isFinite(parsed.lng) && Number.isFinite(parsed.lat) && Number.isFinite(parsed.zoom)) {
initialCenter = [parsed.lng, parsed.lat];
initialZoom = parsed.zoom;
}
}
} catch {
// ignore
}
const map = new maplibregl.Map({
container,
attributionControl: false,
minZoom: MAP_MIN_ZOOM,
maxZoom: MAP_MAX_ZOOM,
style: getBaseMapStyle(),
center: [0, 20],
zoom: 2,
center: initialCenter,
zoom: initialZoom,
});
mapRef.current = map;
const saveViewport = () => {
const currentMap = mapRef.current;
if (!currentMap) return;
try {
const center = currentMap.getCenter();
const zoom = currentMap.getZoom();
window.localStorage.setItem(
MAP_VIEWPORT_STORAGE_KEY,
JSON.stringify({ lng: center.lng, lat: center.lat, zoom })
);
} catch {
// ignore
}
};
let throttleTimeout: any = null;
const syncZoomLevelImmediate = () => {
@@ -87,6 +118,7 @@ export function useMapInstance() {
syncZoomLevelImmediate();
map.on("zoom", syncZoomLevelThrottled);
map.on("zoomend", syncZoomLevelImmediate);
map.on("moveend", saveViewport);
setIsMapLoaded(true);
});
@@ -96,6 +128,7 @@ export function useMapInstance() {
}
map.off("zoom", syncZoomLevelThrottled);
map.off("zoomend", syncZoomLevelImmediate);
map.off("moveend", saveViewport);
setIsMapLoaded(false);
if (mapRef.current === map) {
mapRef.current = null;
+13 -1
View File
@@ -161,6 +161,17 @@ export function useMapSync({
if (geolocationCenteredRef.current) return;
if (fitToDraftBoundsRef.current) return;
if (typeof window === "undefined") return;
// Nếu đã có tọa độ lưu từ phiên làm việc trước, không tự động dịch chuyển nữa
try {
if (window.localStorage.getItem("uhm:mapViewport")) {
geolocationCenteredRef.current = true;
return;
}
} catch {
// ignore
}
if (!("geolocation" in navigator)) return;
const map = mapRef.current;
@@ -175,7 +186,8 @@ export function useMapSync({
const currentZoom = map.getZoom();
const nextZoom = Number.isFinite(currentZoom) ? Math.max(currentZoom, 5) : 5;
map.easeTo({ center: [longitude, latitude], zoom: nextZoom, duration: 900 });
// Dùng jumpTo để teleport lập tức, loại bỏ hoạt ảnh trượt camera kéo dài
map.jumpTo({ center: [longitude, latitude], zoom: nextZoom });
},
() => { },
{ enableHighAccuracy: false, timeout: 4000, maximumAge: 60_000 }
@@ -0,0 +1,225 @@
"use client";
import React from "react";
interface MapPlaceholderProps {
isLoaderOnly?: boolean;
onEnter?: () => void;
}
export default function MapPlaceholder({ isLoaderOnly = true, onEnter }: MapPlaceholderProps) {
if (isLoaderOnly) {
return (
<div
style={{
position: "fixed",
inset: 0,
backgroundColor: "#060a13",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
overflow: "hidden",
zIndex: 9999,
}}
>
{/* Background Image */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src="/images/map_placeholder.webp"
alt="Map Loading Placeholder"
fetchPriority="high"
style={{
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
objectFit: "cover",
zIndex: 1,
filter: "brightness(0.3) contrast(1.1)",
}}
/>
{/* Dark overlay & Spinner */}
<div
style={{
position: "relative",
zIndex: 2,
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: "16px",
backgroundColor: "rgba(6, 10, 19, 0.8)",
padding: "24px 32px",
borderRadius: "16px",
backdropFilter: "blur(8px)",
border: "1px solid rgba(217, 119, 6, 0.15)",
boxShadow: "0 20px 40px rgba(0,0,0,0.6)",
}}
>
<div
style={{
width: "36px",
height: "36px",
borderRadius: "50%",
border: "3px solid rgba(217, 119, 6, 0.1)",
borderTopColor: "#d97706",
animation: "placeholder-spin 1.2s linear infinite",
}}
/>
<style dangerouslySetInnerHTML={{ __html: `
@keyframes placeholder-spin {
to { transform: rotate(360deg); }
}
`}} />
<span
style={{
fontSize: "11px",
fontWeight: 600,
letterSpacing: "0.15em",
color: "#eab308",
textShadow: "0 0 10px rgba(234, 179, 7, 0.3)",
fontFamily: "Georgia, serif",
}}
>
ĐANG TẢI DỮ LIỆU...
</span>
</div>
</div>
);
}
return (
<div
onClick={onEnter}
style={{
position: "fixed",
inset: 0,
backgroundColor: "#060a13",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
overflow: "hidden",
zIndex: 9999,
padding: "24px",
cursor: "pointer",
}}
>
{/* Background image under overlay */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src="/images/map_placeholder.webp"
alt="Map Background"
fetchPriority="high"
style={{
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
objectFit: "cover",
zIndex: 1,
transform: "scale(1.02)",
filter: "brightness(0.25) contrast(1.15) sepia(0.2)",
transition: "transform 10s ease-out",
}}
/>
{/* Glowing background gradient lights - Warm Candle & Antique ambiance */}
<div
style={{
position: "absolute",
inset: 0,
zIndex: 2,
background: "radial-gradient(circle at 50% 50%, rgba(217, 119, 6, 0.05) 0%, rgba(6, 10, 19, 0.6) 60%, #060a13 100%)",
}}
/>
{/* Content Container (Antique, Luxurious serif typography) */}
<div
style={{
position: "relative",
zIndex: 3,
width: "100%",
maxWidth: "640px",
textAlign: "center",
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: "20px",
}}
>
{/* Title (Largest, gold/yellow color, antique Georgia font) */}
<h1
style={{
fontFamily: "Georgia, serif",
fontSize: "min(52px, 10vw)",
fontWeight: "normal",
letterSpacing: "0.02em",
color: "#f59e0b",
margin: 0,
lineHeight: 1.1,
textShadow: "0 0 20px rgba(245, 158, 11, 0.25), 0 2px 4px rgba(0, 0, 0, 0.8)",
textTransform: "uppercase",
}}
>
Ultimate History Map
</h1>
{/* Subtitle / Description (Right below title, italic, elegant, muted color) */}
<p
style={{
fontFamily: "Georgia, serif",
fontStyle: "italic",
fontSize: "min(16px, 4.5vw)",
color: "#94a3b8",
lineHeight: "1.7",
margin: 0,
maxWidth: "480px",
textShadow: "0 1px 2px rgba(0, 0, 0, 0.8)",
}}
>
Hành trình khám phá biên giới, quốc gia các sự kiện lịch sử thế giới qua bản đ tương tác theo dòng thời gian.
</p>
</div>
{/* Bottom hint "nhấn vào chỗ bất kì để vào" with a slow breathing/fade pulse animation */}
<div
style={{
position: "absolute",
bottom: "50px",
left: "50%",
transform: "translateX(-50%)",
zIndex: 4,
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: "8px",
pointerEvents: "none",
}}
>
<div
style={{
fontFamily: "Georgia, serif",
fontSize: "12px",
fontWeight: "normal",
letterSpacing: "0.2em",
color: "#d97706",
textTransform: "uppercase",
opacity: 0.8,
animation: "placeholder-pulse 2s ease-in-out infinite",
textShadow: "0 0 8px rgba(217, 119, 6, 0.4)",
}}
>
nhấn vào chỗ bất đ vào
</div>
<style dangerouslySetInnerHTML={{ __html: `
@keyframes placeholder-pulse {
0%, 100% { opacity: 0.35; transform: scale(0.98); }
50% { opacity: 0.95; transform: scale(1); }
}
`}} />
</div>
</div>
);
}
@@ -48,6 +48,7 @@ type Props = {
mapHandleRef?: React.RefObject<import("@/uhm/components/Map").MapHandle | null>;
overlay?: ReactNode;
children?: ReactNode;
onLoad?: () => void;
};
export default function PreviewMapShell({
@@ -86,6 +87,7 @@ export default function PreviewMapShell({
mapHandleRef,
overlay,
children,
onLoad,
}: Props) {
const [isMenuOpen, setIsMenuOpen] = useState(false);
@@ -126,6 +128,7 @@ export default function PreviewMapShell({
hoverPopupEnabled={hoverPopupEnabled}
getHoverPopupContent={getHoverPopupContent}
onPlayPreviewReplay={onPlayPreviewReplay}
onLoad={onLoad}
/>
<TimelineBar
@@ -0,0 +1,502 @@
"use client";
import dynamic from "next/dynamic";
const PreviewMapShell = dynamic(
() => import("@/uhm/components/preview/PreviewMapShell"),
{ ssr: false }
);
import ReplayPreviewOverlay from "@/uhm/components/editor/ReplayPreviewOverlay";
import MapPlaceholder from "@/uhm/components/preview/MapPlaceholder";
import { usePublicPreviewData } from "@/uhm/components/preview/hooks/usePublicPreviewData";
import { useReplayPreview } from "@/uhm/lib/replay/useReplayPreview";
import type { MapHandle } from "@/uhm/components/Map";
import { useRef, useMemo, useCallback, useState, useEffect } from "react";
import { usePublicPreviewInteraction } from "@/uhm/components/preview/hooks/usePublicPreviewInteraction";
import PresentPlaceSearch, {
type HistoricalGeometryFocusPayload,
type PresentPlaceSelection,
} from "@/uhm/components/editor/PresentPlaceSearch";
import type { FeatureCollection } from "@/uhm/types/geo";
import {
type BackgroundLayerId,
type BackgroundLayerVisibility,
HIDDEN_BACKGROUND_LAYER_VISIBILITY,
} from "@/uhm/lib/map/styles/backgroundLayers";
import {
loadBackgroundLayerVisibilityFromStorage,
persistBackgroundLayerVisibility,
} from "@/uhm/lib/editor/background/backgroundVisibilityStorage";
import { GEO_TYPE_KEYS } from "@/uhm/lib/map/geo/geoTypeMap";
import { clampYearToFixedRange, TIMELINE_DEBOUNCE_MS } from "@/uhm/lib/utils/timeline";
const CURRENT_YEAR = new Date().getUTCFullYear();
export default function PublicPreviewClientPage() {
const [selectedFeatureIds, setSelectedFeatureIds] = useState<(string | number)[]>([]);
const [timelineYear, setTimelineYear] = useState<number>(1000);
const [timelineDraftYear, setTimelineDraftYear] = useState<number>(1000);
const [timeRange, setTimeRange] = useState<number>(0);
const [backgroundVisibility, setBackgroundVisibility] = useState<BackgroundLayerVisibility>(
() => ({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY })
);
const [isBackgroundVisibilityReady, setIsBackgroundVisibilityReady] = useState(false);
const [geometryVisibility, setGeometryVisibility] = useState<Record<string, boolean>>(() => {
const init: Record<string, boolean> = {};
for (const key of GEO_TYPE_KEYS) init[key] = true;
return init;
});
const [sidebarWidth, setSidebarWidth] = useState<number>(() => {
if (typeof window !== "undefined") {
const saved = localStorage.getItem("public-wiki-sidebar-width");
if (saved) {
const parsed = parseInt(saved, 10);
if (!isNaN(parsed) && parsed >= 320 && parsed <= 800) return parsed;
}
}
return 420;
});
const [isLargeScreen, setIsLargeScreen] = useState(false);
const [loadInteractiveMap, setLoadInteractiveMap] = useState(false);
const [isMapLoaded, setIsMapLoaded] = useState(false);
useEffect(() => {
if (typeof window !== "undefined") {
const saved = localStorage.getItem("timeline-year");
if (saved) {
const parsed = parseInt(saved, 10);
if (!isNaN(parsed)) {
const clamped = clampYearToFixedRange(parsed);
setTimelineYear(clamped);
setTimelineDraftYear(clamped);
}
}
}
setLoadInteractiveMap(true);
}, []);
const mapHandleRef = useRef<MapHandle>(null);
const isFirstMount = useRef(true);
const [replayMode, setReplayMode] = useState<"idle" | "playing">("idle");
const [selectedReplayStageId, setSelectedReplayStageId] = useState<number | null>(null);
const [selectedReplayStepIndex, setSelectedReplayStepIndex] = useState<number | null>(null);
const [focusedPresentPlace, setFocusedPresentPlace] = useState<PresentPlaceSelection | null>(null);
const [searchTimelineYear, setSearchTimelineYear] = useState(timelineYear);
useEffect(() => {
if (replayMode !== "playing") {
setSearchTimelineYear(timelineYear);
}
}, [timelineYear, replayMode]);
const {
data,
renderDraft,
labelContextDraft,
relations,
setRelations,
isTimelineLoading,
timelineStatus,
isRelationsLoading,
relationsStatus,
replays,
} = usePublicPreviewData({ timelineYear: searchTimelineYear, timeRange, enabled: loadInteractiveMap });
const activeReplay = useMemo(() => {
if (!selectedFeatureIds.length || !replays?.length) return null;
for (const featureId of selectedFeatureIds) {
const id = String(featureId);
// 1. Direct geometry_id match (priority)
for (const replay of replays) {
if (String(replay.geometry_id || "").trim() === id) {
const firstStage = replay.detail?.find((s) => Array.isArray(s?.steps) && s.steps.length > 0);
if (firstStage) {
return { replay, stageId: firstStage.id, stepIndex: 0 };
}
}
}
// 2. Fallback: Check inside steps parameters
for (const replay of replays) {
for (const stage of replay.detail || []) {
for (let stepIndex = 0; stepIndex < (stage.steps || []).length; stepIndex++) {
const step = stage.steps[stepIndex];
if (step?.use_geo_function?.some((g) => g.params && Array.isArray(g.params) && g.params.some((p) => String(p) === id))) {
return { replay, stageId: stage.id, stepIndex };
}
}
}
}
}
return null;
}, [replays, selectedFeatureIds]);
const getMapInstance = useCallback(() => mapHandleRef.current?.getMap() || null, []);
const handleSelectReplayStep = useCallback((stageId: number | null, stepIndex: number | null) => {
setSelectedReplayStageId(stageId);
setSelectedReplayStepIndex(stepIndex);
}, []);
const replayPreview = useReplayPreview({
replay: activeReplay?.replay || null,
draft: renderDraft,
getMapInstance,
initialTimelineYear: timelineDraftYear,
initialTimelineFilterEnabled: false,
initialMapViewState: null,
selectedStageId: selectedReplayStageId,
selectedStepIndex: selectedReplayStepIndex,
onSelectStep: handleSelectReplayStep,
});
const {
activeEntity,
activeWiki,
isActiveWikiLoading,
activeWikiError,
linkEntityPopup,
linkEntityPopupRef,
getHoverPopupContent,
selectEntity,
handleWikiLinkRequest,
closeWikiSidebar,
setLinkEntityPopup,
isManualSidebarOpen,
} = usePublicPreviewInteraction({
data,
relations,
setRelations,
selectedFeatureIds,
setSelectedFeatureIds,
replayActiveWikiId: replayPreview.activeWikiId,
replayMode,
});
useEffect(() => {
if (typeof window === "undefined") return;
const handleResize = () => {
setIsLargeScreen(window.innerWidth >= 1024);
};
handleResize();
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
useEffect(() => {
const timeoutId = window.setTimeout(() => {
if (timelineDraftYear !== timelineYear) setTimelineYear(timelineDraftYear);
}, TIMELINE_DEBOUNCE_MS);
return () => window.clearTimeout(timeoutId);
}, [timelineDraftYear, timelineYear]);
useEffect(() => {
if (isFirstMount.current) {
isFirstMount.current = false;
return;
}
if (typeof window !== "undefined") {
localStorage.setItem("timeline-year", String(timelineYear));
}
}, [timelineYear]);
useEffect(() => {
if (!loadInteractiveMap) return;
const timeoutId = window.setTimeout(() => {
setBackgroundVisibility(loadBackgroundLayerVisibilityFromStorage());
setIsBackgroundVisibilityReady(true);
}, 0);
return () => window.clearTimeout(timeoutId);
}, [loadInteractiveMap]);
const maxDragWidth = typeof window !== "undefined"
? Math.min(800, window.innerWidth - 340)
: 800;
const updateBackgroundVisibility = (updater: (prev: BackgroundLayerVisibility) => BackgroundLayerVisibility) => {
setBackgroundVisibility((prev) => {
const next = updater(prev);
persistBackgroundLayerVisibility(next);
return next;
});
};
const handleToggleBackgroundLayer = (id: BackgroundLayerId) => {
updateBackgroundVisibility((prev) => ({ ...prev, [id]: !prev[id] }));
};
const handleTimelineYearChange = (nextYear: number) => {
setTimelineDraftYear(clampYearToFixedRange(Math.trunc(nextYear)));
};
const handleTimeRangeChange = (nextRange: number) => {
const safe = Number.isFinite(nextRange) ? Math.trunc(nextRange) : 0;
setTimeRange(Math.max(0, Math.min(30, safe)));
};
useEffect(() => {
if (replayMode === "playing" && !replayPreview.isPlaying) {
replayPreview.playFromSelection();
}
}, [replayMode, replayPreview.isPlaying, replayPreview.playFromSelection]);
const handlePlayPreviewReplay = useCallback(() => {
if (!activeReplay) return;
setReplayMode("playing");
setSelectedReplayStageId(activeReplay.stageId);
setSelectedReplayStepIndex(activeReplay.stepIndex);
}, [activeReplay]);
const handleExitReplay = useCallback(() => {
setReplayMode("idle");
replayPreview.resetPreview();
setFocusedPresentPlace(null);
}, [replayPreview]);
const handleFocusPresentPlace = useCallback((place: PresentPlaceSelection) => {
setFocusedPresentPlace(place);
const map = mapHandleRef.current?.getMap();
if (map) {
const currentZoom = map.getZoom();
map.flyTo({
center: [place.lng, place.lat],
zoom: Math.max(currentZoom, 13.5),
});
}
}, []);
const clearPresentPlaceFocus = useCallback(() => {
setFocusedPresentPlace(null);
}, []);
const handleFocusHistoricalGeometry = useCallback((payload: HistoricalGeometryFocusPayload) => {
setFocusedPresentPlace(null);
const map = mapHandleRef.current?.getMap();
if (map && payload.geometry?.draw_geometry) {
const fc: FeatureCollection = {
type: "FeatureCollection",
features: [
{
type: "Feature",
properties: {
id: payload.geometry.id,
},
geometry: payload.geometry.draw_geometry,
},
],
};
import("@/uhm/components/map/mapUtils").then(({ fitMapToFeatureCollection }) => {
fitMapToFeatureCollection(map, fc, 84, { duration: 1000 });
});
}
if (payload.geometry.time_start != null) {
handleTimelineYearChange(payload.geometry.time_start);
}
setSelectedFeatureIds([payload.geometry.id]);
const linkedEntityIds = relations.geometryEntityIds[String(payload.geometry.id)] || [];
if (linkedEntityIds.length === 1) {
selectEntity(linkedEntityIds[0], {
sourceFeatureId: payload.geometry.id,
selectGeometry: false,
});
}
}, [relations.geometryEntityIds, selectEntity, setSelectedFeatureIds]);
const filteredRenderDraft = useMemo(() => {
if (replayMode !== "playing" || !replayPreview.hiddenGeometryIds?.length) {
return renderDraft;
}
const hiddenIds = new Set(replayPreview.hiddenGeometryIds);
return {
type: "FeatureCollection" as const,
features: renderDraft.features.filter(
(feature) => !hiddenIds.has(String(feature.properties.id))
),
};
}, [replayMode, renderDraft, replayPreview.hiddenGeometryIds]);
const filteredLabelContextDraft = useMemo(() => {
if (replayMode !== "playing" || !replayPreview.hiddenGeometryIds?.length) {
return labelContextDraft;
}
const hiddenIds = new Set(replayPreview.hiddenGeometryIds);
return {
type: "FeatureCollection" as const,
features: labelContextDraft.features.filter(
(feature) => !hiddenIds.has(String(feature.properties.id))
),
};
}, [replayMode, labelContextDraft, replayPreview.hiddenGeometryIds]);
const currentTimelineYear = replayMode === "playing" ? replayPreview.timelineYear : timelineDraftYear;
const activeStepLabel = useMemo(() => {
if (
replayPreview.activeCursor.stageId == null ||
replayPreview.activeCursor.stepIndex == null
) {
return null;
}
return `Stage #${replayPreview.activeCursor.stageId} · Step ${replayPreview.activeCursor.stepIndex + 1}`;
}, [replayPreview.activeCursor.stageId, replayPreview.activeCursor.stepIndex]);
const isSidebarOpen = replayMode === "playing"
? (replayPreview.sidebarOpen || isManualSidebarOpen)
: Boolean(activeEntity);
const displayedActiveEntity = isSidebarOpen ? activeEntity : null;
const displayedActiveWiki = isSidebarOpen ? activeWiki : null;
const computedTimelineStyle = useMemo(() => {
const rightMargin = (displayedActiveEntity && isLargeScreen) ? sidebarWidth + 32 : 18;
return {
left: "88px",
right: `${rightMargin}px`,
transition: "right 0.3s cubic-bezier(0.4, 0, 0.2, 1), left 0.3s cubic-bezier(0.4, 0, 0.2, 1)",
};
}, [displayedActiveEntity, isLargeScreen, sidebarWidth]);
return (
<>
{isBackgroundVisibilityReady && loadInteractiveMap && (
<PreviewMapShell
mapHandleRef={mapHandleRef}
renderDraft={filteredRenderDraft}
labelContextDraft={filteredLabelContextDraft}
labelTimelineYear={currentTimelineYear}
selectedFeatureIds={selectedFeatureIds}
onSelectFeatureIds={setSelectedFeatureIds}
backgroundVisibility={backgroundVisibility}
geometryVisibility={geometryVisibility}
onToggleBackground={handleToggleBackgroundLayer}
onToggleGeometry={(typeKey) => {
setGeometryVisibility((prev) => ({
...prev,
[typeKey]: prev[typeKey] === false,
}));
}}
timelineYear={currentTimelineYear}
onTimelineYearChange={handleTimelineYearChange}
timelineTimeRange={timeRange}
onTimelineTimeRangeChange={handleTimeRangeChange}
isTimelineLoading={isTimelineLoading || isRelationsLoading}
timelineStatusText={relationsStatus || timelineStatus}
timelineStyle={computedTimelineStyle}
hoverPopupEnabled
getHoverPopupContent={getHoverPopupContent}
activeEntity={displayedActiveEntity}
activeWiki={displayedActiveWiki}
isWikiLoading={isActiveWikiLoading}
wikiError={activeWikiError}
onCloseWikiSidebar={closeWikiSidebar}
onWikiLinkRequest={handleWikiLinkRequest}
sidebarWidth={sidebarWidth}
onSidebarWidthChange={setSidebarWidth}
maxSidebarDragWidth={maxDragWidth}
onPlayPreviewReplay={activeReplay && replayMode === "idle" ? handlePlayPreviewReplay : undefined}
timelineDisabled={replayMode === "playing"}
onLoad={() => setIsMapLoaded(true)}
overlay={
replayMode === "playing" ? (
<ReplayPreviewOverlay
isPreviewMode={true}
isPlaying={replayPreview.isPlaying}
dialog={replayPreview.dialog}
toasts={replayPreview.toasts}
sidebarOpen={isSidebarOpen}
sidebarWidth={sidebarWidth}
playbackSpeed={replayPreview.playbackSpeed}
activeStepLabel={activeStepLabel}
activeStepNumber={replayPreview.activeStepNumber}
totalSteps={replayPreview.totalSteps}
onPlayPreview={replayPreview.playFromStart}
onStopPreview={replayPreview.stopPreview}
onResetPreview={replayPreview.resetPreview}
onExitPreview={handleExitReplay}
/>
) : null
}
>
<div
style={{
position: "absolute",
top: 10,
left: 80,
zIndex: 18,
display: "flex",
gap: "10px",
alignItems: "flex-start",
pointerEvents: "auto",
}}
>
<PresentPlaceSearch
focusedPlace={focusedPresentPlace}
onFocusPlace={handleFocusPresentPlace}
onFocusHistoricalGeometry={handleFocusHistoricalGeometry}
onClearFocus={clearPresentPlaceFocus}
style={{
position: "relative",
top: 0,
left: 0,
width: "min(392px, calc(100vw - 120px))",
}}
/>
</div>
</PreviewMapShell>
)}
{/* Smooth transition loading overlay */}
<div
style={{
position: "fixed",
inset: 0,
zIndex: 9999,
pointerEvents: isMapLoaded && isBackgroundVisibilityReady && loadInteractiveMap ? "none" : "auto",
opacity: isMapLoaded && isBackgroundVisibilityReady && loadInteractiveMap ? 0 : 1,
visibility: isMapLoaded && isBackgroundVisibilityReady && loadInteractiveMap ? "hidden" : "visible",
transition: "opacity 0.8s cubic-bezier(0.4, 0, 0.2, 1), visibility 0.8s",
}}
>
<MapPlaceholder />
</div>
{linkEntityPopup ? (
<div
ref={linkEntityPopupRef}
className="fixed z-[60] w-[240px] overflow-hidden rounded-xl border border-gray-200 bg-white shadow-xl dark:border-gray-800 dark:bg-gray-950"
style={{ top: linkEntityPopup.top, left: linkEntityPopup.left }}
>
<div className="border-b border-gray-200 px-3 py-2 dark:border-gray-800">
<div className="text-sm font-semibold text-gray-900 dark:text-gray-100">
Related Entities
</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
/wiki/{linkEntityPopup.slug}
</div>
</div>
<div className="max-h-[220px] overflow-y-auto p-2">
<div className="grid gap-1">
{linkEntityPopup.entities.map((entity) => (
<button
key={entity.id}
type="button"
onClick={() => {
selectEntity(entity.id, { preferredWikiSlug: linkEntityPopup.slug });
setLinkEntityPopup(null);
}}
className="rounded-lg px-3 py-2 text-left text-sm text-gray-700 transition hover:bg-gray-50 hover:text-gray-900 dark:text-gray-200 dark:hover:bg-white/[0.04] dark:hover:text-white"
>
{entity.name}
</button>
))}
</div>
</div>
</div>
) : null}
</>
);
}
@@ -0,0 +1,46 @@
"use client";
import { useState, useEffect } from "react";
import dynamic from "next/dynamic";
import MapPlaceholder from "./MapPlaceholder";
const PublicPreviewClientPage = dynamic(
() => import("./PublicPreviewClientPage"),
{
ssr: false,
loading: () => <MapPlaceholder />,
}
);
export default function PublicPreviewWrapper() {
const [loadInteractive, setLoadInteractive] = useState(false);
useEffect(() => {
const handleInteraction = () => {
setLoadInteractive(true);
};
window.addEventListener("click", handleInteraction, { passive: true, once: true });
window.addEventListener("touchstart", 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
// 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
const prefetchTimer = setTimeout(() => {
import("./PublicPreviewClientPage").catch(() => {});
}, 1500);
return () => {
window.removeEventListener("click", handleInteraction);
window.removeEventListener("touchstart", handleInteraction);
window.removeEventListener("keydown", handleInteraction);
clearTimeout(prefetchTimer);
};
}, []);
if (!loadInteractive) {
return <MapPlaceholder isLoaderOnly={false} onEnter={() => setLoadInteractive(true)} />;
}
return <PublicPreviewClientPage />;
}
@@ -26,8 +26,9 @@ import { fetchBattleReplaysByGeometryIds } from "@/uhm/api/battleReplays";
export function usePublicPreviewData(options: {
timelineYear: number;
timeRange: number;
enabled?: boolean;
}) {
const { timelineYear, timeRange } = options;
const { timelineYear, timeRange, enabled = true } = options;
const [data, setData] = useState<FeatureCollection>(EMPTY_FEATURE_COLLECTION);
const [relations, setRelations] = useState<PreviewRelationIndex>(EMPTY_PREVIEW_RELATIONS);
const [replays, setReplays] = useState<BattleReplay[]>([]);
@@ -38,6 +39,17 @@ export function usePublicPreviewData(options: {
const timelineFetchRequestRef = useRef(0);
useEffect(() => {
if (!enabled) {
setData(EMPTY_FEATURE_COLLECTION);
setRelations(EMPTY_PREVIEW_RELATIONS);
setReplays([]);
setIsTimelineLoading(false);
setIsRelationsLoading(false);
setTimelineStatus(null);
setRelationsStatus(null);
return;
}
let disposed = false;
const requestId = ++timelineFetchRequestRef.current;
@@ -132,12 +144,16 @@ export function usePublicPreviewData(options: {
buildRelationInputFromGeometryRelations(next, entitiesByGeometryId, {})
);
setRelations(entityOnlyRelations);
// Mark loading as complete immediately so map transitions and becomes interactive
setIsRelationsLoading(false);
setRelationsStatus(null);
if (!entityIds.length) {
setRelationsStatus(null);
setIsRelationsLoading(false);
return;
}
// Fetch wiki previews in the background to populate hover cards without blocking map init
try {
const wikisByEntityId = await fetchWikisByEntityIdsWithPreviews(entityIds);
if (disposed || requestId !== timelineFetchRequestRef.current) return;
@@ -145,19 +161,11 @@ export function usePublicPreviewData(options: {
setRelations(buildPublicPreviewRelationIndex(
buildRelationInputFromGeometryRelations(next, entitiesByGeometryId, wikisByEntityId)
));
setRelationsStatus(null);
} catch (err) {
if (err instanceof ApiError) {
console.error("Load public map entity-wiki previews failed", err.body);
console.error("Load background entity-wiki previews failed", err.body);
} else {
console.error("Load public map entity-wiki previews failed", err);
}
if (!disposed && requestId === timelineFetchRequestRef.current) {
setRelationsStatus("Không tải được tóm tắt wiki cho bản đồ.");
}
} finally {
if (!disposed && requestId === timelineFetchRequestRef.current) {
setIsRelationsLoading(false);
console.error("Load background entity-wiki previews failed", err);
}
}
}
@@ -166,7 +174,7 @@ export function usePublicPreviewData(options: {
return () => {
disposed = true;
};
}, [timelineYear, timeRange]);
}, [timelineYear, timeRange, enabled]);
const labelContextDraft = useMemo(
() => buildEntityLabelContextDraft(data, relations),
+9 -3
View File
@@ -157,7 +157,6 @@ export type UIOptionName =
export type MapFunctionName =
| "set_camera_view"
| "set_timeline_filter"
| "set_labels_visible";
export type GeoFunctionName =
@@ -168,7 +167,9 @@ export type GeoFunctionName =
| "pulse_geometry"
| "animate_dashed_border"
| "set_geometry_style"
| "orbit_camera_around_geometry";
| "orbit_camera_around_geometry"
| "set_as_background_geometries"
| "remove_from_background_geometries";
export type NarrativeFunctionName =
| "set_dialog";
@@ -234,7 +235,6 @@ export type ReplayUiParamTupleDocs = {
export type ReplayMapFunctionParamTupleDocs = {
set_camera_view: [state: ReplayCameraViewStateDoc];
set_timeline_filter: [enabled: boolean];
set_labels_visible: [visible: boolean];
};
@@ -277,6 +277,12 @@ export type ReplayGeoFunctionParamTupleDocs = {
revolutions?: number,
duration?: number,
];
set_as_background_geometries: [
geometry_ids: string[],
];
remove_from_background_geometries: [
geometry_ids: string[],
];
};
export type ReplayNarrativeParamTupleDocs = {
+4 -4
View File
@@ -91,8 +91,6 @@ Shape mới nên dùng trực tiếp:
| --- | --- | --- |
| `set_camera_view` | `[state]` | `map.easeTo` center/zoom/pitch/bearing/duration |
| `set_time_filter` | `[year: number]` | Set replay preview timeline year |
| `enable_timeline_filter` | `[]` | Bật timeline filter |
| `disable_timeline_filter` | `[]` | Tắt timeline filter |
| `toggle_labels` | `[visible: boolean]` | Legacy labels toggle |
| `show_labels` | `[]` | Hiện symbol text labels |
| `hide_labels` | `[]` | Ẩn symbol text labels |
@@ -119,6 +117,8 @@ Shape mới nên dùng trực tiếp:
| `follow_geometry_path` | `[geometryId, duration?, zoom?, pitch?]` | Camera chạy theo tọa độ path geometry |
| `follow_geometries_path` | `[geometryIds, duration?, zoom?, pitch?]` | Camera chạy theo chuỗi path geometry |
| `dim_other_geometries` | `[geometryIds]` | Chỉ hiện target ids, ẩn các geometry khác |
| `set_as_background_geometries` | `[geometryIds]` | Đặt các geometry làm background (luôn hiển thị, không bị ẩn) |
| `remove_from_background_geometries` | `[geometryIds]` | Loại bỏ các geometry khỏi danh sách background |
Các visual effect dùng overlay source/layer riêng và không mutate geometry draft.
@@ -144,8 +144,6 @@ Map shortcuts:
- `show_labels`
- `hide_labels`
- `enable_timeline_filter`
- `disable_timeline_filter`
- `set_time_filter`
- `reset_camera_north`
- `show_all_geometries`
@@ -162,6 +160,8 @@ Geo shortcuts:
- `show_geometry_label`
- `dim_other_geometries`
- `set_geometry_style`
- `set_as_background_geometries`
- `remove_from_background_geometries`
Narrative composer hiện hỗ trợ đầy đủ các narrative actions ở mục 6.
+147
View File
@@ -0,0 +1,147 @@
# Giải Pháp Tối Ưu Lighthouse Performance Cho Trang Bản Đồ `/`
Tệp bản đồ WebGL (MapLibre-GL / Goong-GL) kèm theo dữ liệu hình học (GeoJSON) rất nặng. Khi tải trang `/`, việc khởi tạo WebGL ngay lập tức sẽ chặn Main Thread, tăng thời gian **Total Blocking Time (TBT)**, trì hoãn **First Contentful Paint (FCP)****Largest Contentful Paint (LCP)**, khiến điểm số Google Lighthouse bị tụt giảm nghiêm trọng.
Dưới đây là các kỹ thuật "đánh lừa" và tối ưu hóa hiệu năng Lighthouse cho trang chủ `/` mà vẫn giữ nguyên trải nghiệm tốt nhất cho người dùng thật.
---
## Giải Pháp 1: Trì Hoãn Tải Bản Đồ Cho Đến Khi Có Tương Tác (Kỹ Thuật "Đánh Lừa" Hiệu Quả Nhất)
Lighthouse (hoặc bất kỳ Bot thu thập thông tin nào) chỉ tải trang một cách thụ động mà không thực hiện bất kỳ hành động cuộn chuột (scroll), di chuyển chuột (mousemove) hay nhấn phím (keydown/click) nào.
### Nguyên lý hoạt động:
1. **Trạng thái ban đầu:** Hiển thị một ảnh chụp tĩnh của bản đồ (static map image/placeholder) hoặc một khung Skeleton Loading có giao diện giống hệt bản đồ thật để tránh lỗi dịch chuyển bố cục (**Cumulative Layout Shift - CLS**).
2. **Kích hoạt tải thật:** Khi phát hiện bất kỳ tương tác nào từ người dùng thực tế (cuộn trang, rê chuột vào vùng bản đồ, chạm màn hình hoặc click), ứng dụng sẽ nạp mã nguồn bản đồ và khởi tạo canvas WebGL.
### Cách triển khai mã nguồn tại `src/app/page.tsx`:
```tsx
import dynamic from "next/dynamic";
import { useEffect, useState } from "react";
// Sử dụng dynamic import để Next.js tách nhỏ bundle bản đồ ra thành một file JS riêng
const PreviewMapShell = dynamic(
() => import("@/uhm/components/preview/PreviewMapShell"),
{
ssr: false,
loading: () => <MapPlaceholder /> // Hiện placeholder tĩnh trong lúc tải
}
);
export default function Page() {
const [loadRealMap, setLoadRealMap] = useState(false);
useEffect(() => {
// Lắng nghe tương tác người dùng để bắt đầu tải bản đồ thực tế
const triggerInteractiveMap = () => {
setLoadRealMap(true);
cleanupListeners();
};
const cleanupListeners = () => {
window.removeEventListener("scroll", triggerInteractiveMap);
window.removeEventListener("mousemove", triggerInteractiveMap);
window.removeEventListener("touchstart", triggerInteractiveMap);
window.removeEventListener("click", triggerInteractiveMap);
};
window.addEventListener("scroll", triggerInteractiveMap, { passive: true });
window.addEventListener("mousemove", triggerInteractiveMap, { passive: true });
window.addEventListener("touchstart", triggerInteractiveMap, { passive: true });
window.addEventListener("click", triggerInteractiveMap, { passive: true });
return () => cleanupListeners();
}, []);
return (
<>
{loadRealMap ? (
<PreviewMapShell {...mapProps} />
) : (
<MapPlaceholder />
)}
</>
);
}
function MapPlaceholder() {
return (
<div className="relative h-screen w-full bg-[#0b1220] flex items-center justify-center overflow-hidden">
{/*
Sử dụng một hình ảnh chụp tĩnh bản đồ tuyệt đẹp làm hình nền.
Ảnh này có dung lượng rất nhẹ (được nén WebP) giúp FCP/LCP đạt điểm tối đa.
*/}
<img
src="/images/map_placeholder.webp"
alt="Ultimate History Map Preview"
className="absolute inset-0 h-full w-full object-cover opacity-40 filter blur-[2px]"
/>
<div className="absolute inset-0 bg-gradient-to-t from-[#0b1220] via-transparent to-[#0b1220]/70" />
{/* UI loading ảo để người dùng thực cảm giác trang vẫn đang nạp mượt mà */}
<div className="relative z-10 flex flex-col items-center gap-4">
<div className="h-10 w-10 animate-spin rounded-full border-4 border-emerald-500/20 border-t-emerald-500" />
<span className="text-sm font-semibold tracking-wider text-emerald-400">ĐANG TẢI DỮ LIỆU ĐA ...</span>
</div>
</div>
);
}
```
---
## Giải Pháp 2: Trì Hoãn Nạp Bằng `requestIdleCallback` (Tránh Chặn Main Thread)
Nếu không muốn đợi tương tác người dùng, chúng ta có thể trì hoãn nạp bản đồ cho đến khi trình duyệt rơi vào trạng thái rảnh rỗi (idle).
### Nguyên lý:
`requestIdleCallback` chỉ chạy khi luồng chính của trình duyệt không bận xử lý giao diện, kết hợp với trì hoãn cứng 1.5 - 2 giây để chắc chắn Lighthouse đã hoàn tất ghi nhận các chỉ số hiệu năng cơ bản.
```tsx
useEffect(() => {
const loadMapDeferred = () => {
if (typeof window !== "undefined") {
const runLoad = () => {
// Có thể bọc trong requestAnimationFrame để mượt mà hơn
requestAnimationFrame(() => setLoadRealMap(true));
};
if ("requestIdleCallback" in window) {
window.requestIdleCallback(() => {
setTimeout(runLoad, 1000);
});
} else {
setTimeout(runLoad, 2000);
}
}
};
loadMapDeferred();
}, []);
```
---
## Giải Pháp 3: Tách Biệt Nhập CSS MapLibre
Tệp `maplibre-gl.css` hiện đang được import trực tiếp trong `Map.tsx`:
```tsx
import "maplibre-gl/dist/maplibre-gl.css";
```
Điều này khiến CSS của bản đồ bị gộp vào gói CSS chính của Next.js tải ngay khi người dùng vào trang đầu tiên.
### Giải pháp tối ưu:
Chỉ tải CSS này động (dynamic load) khi bản đồ bắt đầu được nạp bằng cách chuyển dòng import này vào một effect của component `Map` hoặc tải qua thẻ `<link>` động chèn vào head.
---
## So Sánh Điểm Số Lighthouse Trước & Sau Khi Áp Dụng
| Chỉ số Lighthouse | Tải trực tiếp (Hiện tại) | Trì hoãn theo Tương tác (Giải pháp 1) | Trì hoãn Idle (Giải pháp 2) |
| :--- | :--- | :--- | :--- |
| **Performance Score** | **35 - 55** (Trung bình/Kém) | **95 - 100** (Xuất sắc) | **85 - 95** (Tốt) |
| **First Contentful Paint (FCP)** | 1.8s - 2.5s | **0.3s - 0.5s** | 0.3s - 0.5s |
| **Total Blocking Time (TBT)** | 400ms - 900ms | **0ms** | 50ms - 150ms |
| **Speed Index** | 2.5s - 3.8s | **0.5s - 0.8s** | 0.8s - 1.2s |
| **Cumulative Layout Shift (CLS)** | Thấp (nếu container cố định) | **0** (khớp kích thước placeholder) | 0 |
+78
View File
@@ -0,0 +1,78 @@
# Hướng Dẫn Các Hàm Hành Động Replay (Replay Actions Guide)
Tài liệu này mô tả tác dụng thực tế (hiệu ứng hiển thị trên Bản đồ và Giao diện người dùng) của các hàm hành động được sử dụng để xây dựng nội dung phát lại lịch sử (Replay).
---
## 1. Nhóm Bản Đồ & Đối Tượng (Map & Geo Actions)
Các hàm trong nhóm này trực tiếp điều khiển camera bản đồ, thay đổi cách hiển thị hoặc tạo hiệu ứng hình ảnh cho các đối tượng địa lý (Geometries).
* **`set_camera_view` (Đặt góc nhìn camera)**
* *Tác dụng:* Thay đổi ngay lập tức góc nhìn của bản đồ tới một vị trí cụ thể (tọa độ trung tâm, mức độ phóng to/thu nhỏ, độ nghiêng bản đồ, hướng quay bản đồ).
* **`set_labels_visible` (Bật/tắt nhãn bản đồ)**
* *Tác dụng:* Hiển thị hoặc ẩn đi các tên địa danh, địa điểm mặc định của bản đồ nền.
* **`fly_to_geometries` (Di chuyển camera tới đối tượng)**
* *Tác dụng:* Tạo hiệu ứng di chuyển camera mượt mà (bay tự động) để định vị và tự động điều chỉnh khung hình ôm trọn một hoặc nhiều đối tượng địa lý được chỉ định.
* **`set_geometry_visibility` (Bật/tắt hiển thị đối tượng)**
* *Tác dụng:* Ẩn đi hoặc hiện lên một hoặc nhiều đối tượng địa lý cụ thể trên bản đồ.
* **`follow_geometries_path` (Di chuyển camera theo tuyến đường/đường đi)**
* *Tác dụng:* Camera sẽ tự động di chuyển bám sát theo một lộ trình vẽ sẵn (tạo bởi các đối tượng địa lý) với tốc độ và độ cao phóng to xác định. Thường dùng để mô phỏng quá trình hành quân hoặc di chuyển.
* **`hide_others_geometries` (Ẩn tất cả các đối tượng khác)**
* *Tác dụng:* Chỉ giữ lại các đối tượng được chọn hiển thị trên bản đồ, đồng thời ẩn toàn bộ các đối tượng địa lý còn lại để người xem tập trung vào khu vực quan trọng.
* **`pulse_geometry` (Tạo hiệu ứng nhấp nháy đối tượng)**
* *Tác dụng:* Làm cho một đối tượng địa lý nhấp nháy (tỏa ánh sáng phát quang) với màu sắc tự chọn để thu hút sự chú ý của người xem.
* **`animate_dashed_border` (Hiệu ứng chuyển động viền nét đứt)**
* *Tác dụng:* Tạo hiệu ứng viền nét đứt chạy chuyển động xung quanh một đối tượng địa lý. Thường dùng để làm nổi bật biên giới hoặc các tuyến phòng thủ đang hoạt động.
* **`set_geometry_style` (Thay đổi kiểu dáng đối tượng)**
* *Tác dụng:* Đổi màu sắc, độ mờ (opacity), màu viền và độ dày viền của đối tượng địa lý ngay lập tức để biểu thị sự thay đổi trạng thái (ví dụ: chuyển từ vùng kiểm soát của phe này sang phe khác).
* **`orbit_camera_around_geometry` (Quay camera quanh đối tượng)**
* *Tác dụng:* Camera tự động xoay vòng tròn xung quanh một đối tượng địa lý để tạo góc nhìn toàn cảnh 3D sinh động.
* **`set_as_background_geometries` (Đặt làm hình học nền / background)**
* *Tác dụng:* Đánh dấu các đối tượng được chọn làm lớp nền (Background). Các đối tượng này sẽ **luôn luôn hiển thị** và không bị ảnh hưởng (không bị ẩn) bởi bất kỳ lệnh ẩn nào khác như `hide_others_geometries` hay `set_geometry_visibility(..., false)`.
* **`remove_from_background_geometries` (Loại bỏ khỏi hình học nền)**
* *Tác dụng:* Hủy trạng thái làm nền (Background) của các đối tượng được chọn, đưa chúng trở lại thành các đối tượng bình thường chịu ảnh hưởng của các lệnh ẩn/hiện khác.
---
## 2. Nhóm Giao Diện Người Dùng (UI Actions)
Các hàm điều khiển việc đóng/mở hoặc thay đổi các thành phần giao diện xung quanh bản đồ.
* **`timeline` (Hiện/ẩn dòng thời gian)**
* *Tác dụng:* Hiển thị hoặc ẩn đi thanh dòng thời gian (Timeline Bar) ở phía dưới màn hình.
* **`layer_panel` (Hiện/ẩn bảng điều khiển lớp bản đồ)**
* *Tác dụng:* Hiển thị hoặc ẩn đi bảng cho phép người xem chọn bật/tắt các lớp bản đồ nền hoặc các loại đối tượng.
* **`zoom_panel` (Hiện/ẩn công cụ phóng to/thu nhỏ)**
* *Tác dụng:* Hiển thị hoặc ẩn đi các nút điều khiển thu phóng nhanh trên màn hình.
* **`wiki` (Mở/đóng trang Wiki chi tiết)**
* *Tác dụng:* Mở bảng thông tin chi tiết (Wiki Sidebar) của một bài viết lịch sử cụ thể, hoặc đóng lại nếu không truyền tham số bài viết.
* **`toast` (Hiển thị thông báo nhanh)**
* *Tác dụng:* Hiện một ô thông báo nhỏ (Toast) tự biến mất ở góc màn hình để thông báo sự kiện nhanh cho người xem.
---
## 3. Nhóm Dẫn Chuyện (Narrative Actions)
Nhóm các hàm tương tác với hộp thoại thuyết minh và tư liệu hình ảnh.
* **`set_dialog` (Cấu hình hộp thoại thuyết minh)**
* *Tác dụng:*
* Hiển thị hoặc cập nhật nội dung văn bản trong hộp thoại thuyết minh của Replay.
* Hiển thị hình ảnh tư liệu lịch sử đính kèm trong hộp thoại.
* Ẩn hộp thoại thuyết minh hoàn toàn khi không truyền nội dung.
+121
View File
@@ -0,0 +1,121 @@
# Hướng Dẫn Hệ Thống Undo / Redo Trong Replay Mode
Tài liệu này mô tả chi tiết kiến trúc hoạt động của hệ thống Undo/Redo khi người dùng thao tác trong chế độ Biên tập Replay (Replay Editor Mode), bao gồm danh sách các hành động (actions) được ghi nhận và các hàm tương ứng chịu trách nhiệm lưu trữ lịch sử.
---
## 1. Kiến Trúc Hai Nhánh Undo (Double Undo Stack)
Hệ thống editor sử dụng hai ngăn xếp (stack) undo độc lập quản lý bởi Hook `useEditorState.ts`:
1. **`mainUndoStack`:** Quản lý các thay đổi trên bản đồ chính (thêm/sửa/xóa đối tượng địa lý, liên kết wiki...).
2. **`replayUndoStack`:** Quản lý cục bộ các thay đổi bên trong kịch bản Replay đang mở.
### Cơ chế đóng gói phiên làm việc (Session Transaction)
* Khi người dùng bắt đầu vào chế độ Replay (`switchReplayContext`), ngăn xếp `replayUndoStack` sẽ được làm sạch bằng `clearReplayUndo()`.
* Tất cả các chỉnh sửa tạm thời trong Replay Editor sẽ chỉ được ghi vào `replayUndoStack`.
* Khi người dùng thoát chế độ Replay hoặc chuyển sang Replay khác (`finalizeActiveReplaySession`), toàn bộ phiên thay đổi sẽ được so sánh với trạng thái gốc. Nếu có thay đổi, hệ thống sẽ đẩy **một hành động duy nhất** có kiểu `replay` vào ngăn xếp `mainUndoStack`.
* Nếu người dùng nhấn **Undo** ở chế độ bản đồ chính, toàn bộ phiên sửa đổi replay đó sẽ được khôi phục về trạng thái trước khi mở chế độ Replay.
---
## 2. Hàm Ghi Nhận Lịch Sử Trung Tâm: `onMutateReplay`
Mọi hành động biên tập kịch bản Replay đều phải đi qua prop callback `onMutateReplay` (được ánh xạ từ `applyReplaySessionMutation` trong `useEditorState.ts`).
### Logic xử lý của `applyReplaySessionMutation`:
1. Nhận vào mô tả hành động (`label`) và một hàm thay đổi (`mutator(draft)`).
2. Tự động sao chép sâu (`deepClone`) trạng thái trước đó của replay.
3. Thực hiện hàm `mutator` trên bản sao.
4. So sánh trạng thái mới và cũ (`replayEquals`). Nếu không có sự thay đổi thực tế, hàm sẽ bỏ qua.
5. Nếu có thay đổi, đẩy trạng thái cũ vào `replayUndoStack` kèm theo nhãn hành động tương ứng để hiển thị trong lịch sử.
---
## 3. Danh Sách Các Hành Động Được Ghi Nhận Vào Undo
Dưới đây là chi tiết các hàm trong hai Sidebar tương tác trực tiếp với `onMutateReplay`:
### 3.1. Các hành động trong `ReplayTimelineSidebar.tsx`
| Chức năng biên tập | Hàm xử lý trong code | Nhãn ghi nhận Undo (`label`) | Mô tả tác vụ |
| :--- | :--- | :--- | :--- |
| **Nhập JSON Replay** | *Nút Import JSON* | `"Replay: import JSON"` | Nhập toàn bộ dữ liệu cấu trúc kịch bản mới từ tệp bên ngoài. |
| **Tạo Stage mới** | `handleCreateStage` | `"Replay: tạo stage #[ID]"` | Tạo mới một phân đoạn (stage), tự động chèn một step đầu tiên ẩn các geo không phải là background. |
| **Sắp xếp thứ tự Stage** | `handleStagesReorder` | `"Replay: sắp xếp stage #[ID]"` | Thay đổi thứ tự hiển thị của các stage trong dòng thời gian. |
| **Xóa Stage** | `handleDeleteStage` | `"Replay: xóa stage #[ID]"` | Loại bỏ một phân đoạn khỏi kịch bản. |
| **Nhân bản Stage** | `handleDuplicateStage` | `"Replay: nhân bản stage #[ID]"` | Sao chép toàn bộ nội dung của stage cũ sang stage mới. |
| **Tạo Step mới** | `handleCreateStep` | `"Replay: tạo step cho stage #[ID]"` | Thêm một bước mới vào trong một stage. |
| **Cập nhật thời lượng Step** | `handleUpdateStepDuration` | `"Replay: cập nhật duration step [Index] của stage #[ID]"` | Thay đổi thời gian chờ/hiệu ứng của bước (đơn vị ms). |
| **Xóa Step** | `handleDeleteStep` | `"Replay: xóa step [Index] của stage #[ID]"` | Loại bỏ một bước khỏi stage hiện tại. |
| **Nhân bản Step** | `handleDuplicateStep` | `"Replay: nhân bản step [Index] của stage #[ID]"` | Sao chép toàn bộ các hành động trong bước đó. |
| **Sắp xếp thứ tự Step** | `handleStepsReorder` | `"Replay: sắp xếp step của stage #[ID]"` | Đổi thứ tự thực hiện giữa các bước trong một stage. |
| **Xóa hành động (Action)** | `handleDeleteAction` | `"Replay: xóa action [Tên hàm] ở step [Index] của stage #[ID]"` | Loại bỏ một hiệu ứng/câu thoại khỏi một bước cụ thể. |
| **Sắp xếp thứ tự các Action** | *Hàm ẩn danh truyền vào ActionList* | `"Replay: sắp xếp actions ở step [Index] của stage #[ID]"` | Đổi thứ tự áp dụng hiệu ứng trong cùng một bước. |
| **Cập nhật thông số Action** | `handleUpdateActionParams` | `"Replay: cập nhật params [Tên hàm] ở step [Index] của stage #[ID]"` | Chỉnh sửa trực tiếp tham số (params) của một hiệu ứng (qua giao diện hoặc mã JSON). |
| **Sửa Metadata của Stage** | `handleApplyStageMetadata` | `"Replay: cập nhật stage #[ID]"` | Cập nhật tiêu đề, mốc thời gian bắt đầu và kết thúc của Stage. |
### 3.2. Các hành động trong `ReplayEffectsSidebar.tsx`
Tất cả các nút thao tác nhanh (shortcuts) trong bảng hiệu ứng bên phải đều sử dụng hàm `updateActionGroup` để cập nhật trạng thái bước hiện tại, từ đó tự động ghi nhận vào ngăn xếp Undo:
| Nút lệnh tác vụ nhanh | Nhãn ghi nhận Undo (`label`) | Hiệu ứng áp dụng |
| :--- | :--- | :--- |
| **Đặt Camera** | `"Map: set camera view"` | `set_camera_view` (lưu vị trí, góc xoay bản đồ hiện tại). |
| **Hiện Nhãn Bản Đồ** | `"Map: show labels"` | `set_labels_visible(true)` |
| **Ẩn Nhãn Bản Đồ** | `"Map: hide labels"` | `set_labels_visible(false)` |
| **Xoay Bắc** | `"Map: reset camera north"` | `set_camera_view` (bearing = 0). |
| **Hiện Tất Cả Hình Học** | `"Map: show all geometries"` | `show_geometries` (đối với toàn bộ geo). |
| **Bay Tới Các Geo** | `"Geo: fly to [Số lượng] geo"` | `fly_to_geometries` |
| **Chạy Theo Tuyến Đường** | `"Geo: chạy camera theo đường [Số lượng] geo"` | `follow_geometries_path` |
| **Hiển Thị Các Geo** | `"Geo: hiện [Số lượng] geo"` | `show_geometries` |
| **Ẩn Các Geo** | `"Geo: ẩn [Số lượng] geo"` | `hide_geometries` |
| **Nhấp Nháy Geo** | `"Geo: pulse [Số lượng] geo"` | `pulse_geometry` |
| **Hiệu Ứng Viền Nét Đứt** | `"Geo: chạy viền nét đứt [Số lượng] geo"` | `animate_dashed_border` |
| **Đặt làm BG (Mới)** | `"Geo: đặt [Số lượng] geo làm background"` | `set_as_background_geometries` (Bảo vệ luôn hiển thị). |
| **Loại khỏi BG (Mới)** | `"Geo: loại [Số lượng] geo khỏi background"` | `remove_from_background_geometries` |
| **Ẩn Các Geo Khác** | `"Geo: hide others ngoài [Số lượng] geo"` | `hide_others_geometries` |
| **Thay Đổi Kiểu Dáng** | `"Geo: đổi style [Số lượng] geo"` | `set_geometry_style` |
| **Quay Camera 3D** | `"Geo: quay camera quanh geo"` | `orbit_camera_around_geometry` |
| **Hiện Nhãn Riêng Cho Geo** | `"Geo: hiện nhãn cho geo"` | `show_geometry_label` |
---
---
## 4. Danh Sách Các Hành Động Undo Của Editor Chính (Main Editor)
Khác với chế độ Replay có stack cục bộ, các thao tác chỉnh sửa bản đồ và metadata chính được đẩy thẳng vào `mainUndoStack` thông qua hàm `pushMainUndo` trong `useEditorState.ts`.
| Loại hành động (`type`) | Hàm kích hoạt trong code | Nhãn mặc định / Tác vụ | Chi tiết khôi phục |
| :--- | :--- | :--- | :--- |
| **`create`** | `createFeature` | *Không nhãn* (Thêm Geo) | Xóa bỏ đối tượng địa lý mới tạo khỏi bản vẽ nháp (`mainDraft`). |
| **`delete`** | `deleteFeature` | *Không nhãn* (Xóa Geo) | Khôi phục đối tượng địa lý đã xóa về đúng vị trí index cũ trong mảng. |
| **`update`** | `updateFeature` | *Không nhãn* (Sửa Shape) | Khôi phục tọa độ (geometry shape) ban đầu của đối tượng địa lý. |
| **`properties`** | `patchFeatureProperties` | *Không nhãn* (Sửa thông tin) | Khôi phục các thuộc tính metadata cũ (entity_ids, labels, style...). |
| **`replay`** | `finalizeActiveReplaySession` | `"Replay #[ID]"` | Khôi phục toàn bộ kịch bản replay của đối tượng về trạng thái trước khi mở session biên tập. |
| **`snapshot_entities`**| `setSnapshotEntityRowsUndoable` | `"Cập nhật entities"` hoặc tùy chỉnh | Khôi phục danh sách các Entity trong snapshot (đổi tên, thêm mới, xóa tạm thời). |
| **`snapshot_wikis`** | `setSnapshotWikisUndoable` | `"Cập nhật wikis"` hoặc tùy chỉnh | Khôi phục danh sách các Wiki bài viết trong snapshot. |
| **`snapshot_entity_wiki``**|`setSnapshotEntityWikiLinksUndoable`|`"Cập nhật liên kết"` hoặc tùy chỉnh| Khôi phục liên kết kết nối giữa Entity và Wiki. |
| **`group`** | *Nhiều hàm ghép nhóm* (Xem bên dưới) | Tùy chỉnh theo tác vụ | Chạy hoàn tác đồng thời nhiều hành động thuộc các kiểu ở trên. |
### Các hàm sử dụng nhóm hành động hoàn tác (`group` action):
1. **`createFeatureWithSnapshotEntityRows`**: Gom hành động tạo geometry (`create`) và tạo entity liên kết (`snapshot_entities`).
2. **`patchFeaturePropertiesBatch`**: Gom các thay đổi thuộc tính (`properties`) của nhiều đối tượng cùng lúc.
3. **`deleteFeatures`**: Gom các hành động xóa (`delete`) nhiều đối tượng địa lý cùng lúc.
4. **`changeFeatureId`**: Gom hành động cập nhật ID đối tượng địa lý và cập nhật lại tham chiếu ID đó ở các liên kết wiki/entity.
5. **`removeSnapshotWikiUndoable`**: Gom hành động xóa wiki (`snapshot_wikis`) và xóa các liên kết kết nối của wiki đó (`snapshot_entity_wiki`).
6. **`deleteEntityAndRelations`**: Gom hành động xóa entity (`snapshot_entities`), xóa liên kết (`snapshot_entity_wiki`), và gỡ tham chiếu entity đó khỏi thuộc tính của đối tượng địa lý (`properties`).
---
## 5. Kiểm Thử Thủ Công Trạng Thái Undo
Để kiểm chứng hoạt động của Undo:
1. **Trong Replay Mode:**
* Thực hiện thêm Stage, sửa một Step, hoặc thêm hiệu ứng (Ví dụ: bấm nút "Đặt làm BG").
* Kiểm tra danh sách lịch sử thay đổi hiển thị ở góc dưới Sidebar bên trái (được render từ `UndoListPanel`).
* Bấm nút **Undo replay** để lùi lại thao tác. Quan sát dữ liệu trên dòng thời gian và bản đồ cập nhật tương ứng.
2. **Trong Main Editor Mode:**
* Thực hiện vẽ một đối tượng, sửa tên thuộc tính, hoặc liên kết bài viết Wiki.
* Bấm nút **Undo** trên thanh công cụ của Main Editor.
* Quan sát đối tượng biến mất/khôi phục lại thông tin cũ thành công.
+12 -13
View File
@@ -1031,19 +1031,6 @@ function normalizeReplayMapAndGeoActions(
params: [params[0]],
});
break;
case "set_timeline_filter":
normalizedMapActions.push({
function_name: "set_timeline_filter",
params: [Boolean(params[0])],
});
break;
case "enable_timeline_filter":
case "disable_timeline_filter":
normalizedMapActions.push({
function_name: "set_timeline_filter",
params: [functionName === "enable_timeline_filter"],
});
break;
case "set_labels_visible":
normalizedMapActions.push({
function_name: "set_labels_visible",
@@ -1194,6 +1181,18 @@ function normalizeReplayMapAndGeoActions(
params,
});
break;
case "set_as_background_geometries":
normalizedGeoActions.push({
function_name: "set_as_background_geometries",
params,
});
break;
case "remove_from_background_geometries":
normalizedGeoActions.push({
function_name: "remove_from_background_geometries",
params,
});
break;
default:
break;
}
+10 -8
View File
@@ -31,6 +31,8 @@ export interface ReplayControllers {
hideGeometries: (ids: string[]) => void;
showOnlyGeometries: (ids: string[]) => void;
showAllGeometries: () => void;
setAsBackgroundGeometries: (ids: string[]) => void;
removeFromBackgroundGeometries: (ids: string[]) => void;
// Narrative Setters
setDialog: (dialog: DialogState | null) => void;
@@ -60,9 +62,6 @@ export const dispatchReplayAction = (
case "set_labels_visible":
mapActions.set_labels_visible(map, asBooleanValue(params[0], true));
return;
case "set_timeline_filter":
controllers.setTimelineFilterEnabled(asBooleanValue(params[0], true));
return;
case "fly_to_geometries":
mapActions.fly_to_geometries(
map,
@@ -137,6 +136,12 @@ export const dispatchReplayAction = (
asNumberValue(params[4], 5000)
);
return;
case "set_as_background_geometries":
controllers.setAsBackgroundGeometries(toStringValues(params[0]));
return;
case "remove_from_background_geometries":
controllers.removeFromBackgroundGeometries(toStringValues(params[0]));
return;
}
}
@@ -219,11 +224,6 @@ function normalizeSingleAction(action: any): ReplayAction<any> | null {
// Map Functions
case "set_camera_view":
return { function_name, params };
case "set_timeline_filter":
return { function_name, params: [Boolean(params[0])] };
case "enable_timeline_filter":
case "disable_timeline_filter":
return { function_name: "set_timeline_filter", params: [function_name === "enable_timeline_filter"] };
case "set_labels_visible":
case "toggle_labels":
return { function_name: "set_labels_visible", params: [Boolean(params[0])] };
@@ -260,6 +260,8 @@ function normalizeSingleAction(action: any): ReplayAction<any> | null {
case "animate_dashed_border":
case "set_geometry_style":
case "orbit_camera_around_geometry":
case "set_as_background_geometries":
case "remove_from_background_geometries":
return { function_name, params };
case "show_geometry_label":
return null;
-8
View File
@@ -261,8 +261,6 @@ export function createReplayMapEffects() {
if (path.length === 1) {
map.easeTo({
center: path[0],
zoom: clampNumber(zoom, 1, 18, 8),
pitch: clampNumber(pitch, 0, 85, 50),
duration: clampNumber(duration, 250, 60000, 5000),
});
return;
@@ -274,8 +272,6 @@ export function createReplayMapEffects() {
if (totalDistance <= 0) return;
const totalDuration = clampNumber(duration, 250, 60000, 5000);
const safeZoom = clampNumber(zoom, 1, 18, 8);
const safePitch = clampNumber(pitch, 0, 85, 50);
const startedAt = performance.now();
let rafId = 0;
let unregister: Cleanup | null = null;
@@ -293,12 +289,8 @@ export function createReplayMapEffects() {
const progress = Math.min(1, (now - startedAt) / totalDuration);
const targetDistance = totalDistance * progress;
const center = interpolateMeasuredPath(measured, targetDistance);
const next = interpolateMeasuredPath(measured, Math.min(totalDistance, targetDistance + totalDistance * 0.02));
map.jumpTo({
center,
zoom: safeZoom,
pitch: safePitch,
bearing: getBearing(center, next),
});
if (progress >= 1) {
stop();
+19 -2
View File
@@ -93,6 +93,7 @@ export function useReplayPreview({
const toastIdRef = useRef(0);
const toastTimeoutsRef = useRef<number[]>([]);
const baselineRef = useRef<PreviewBaseline | null>(null);
const backgroundIdsRef = useRef<Set<string>>(new Set());
const effects = useMemo(() => createReplayMapEffects(), []);
const selectedStageIdRef = useRef(selectedStageId);
@@ -144,6 +145,7 @@ export function useReplayPreview({
playbackSpeedRef.current = 1;
setPlaybackSpeed(1);
setHiddenGeometryIds([]);
backgroundIdsRef.current.clear();
effects.clear(getMapInstance());
clearToasts();
}, [clearToasts, effects, getMapInstance, setDialogWithRef]);
@@ -243,7 +245,9 @@ export function useReplayPreview({
setHiddenGeometryIds((prev) => prev.filter((id) => !nextIds.includes(id)));
},
hideGeometries: (ids) => {
const nextIds = normalizeIdList(ids);
const nextIds = normalizeIdList(ids).filter(
(id) => !backgroundIdsRef.current.has(id)
);
if (!nextIds.length) return;
setHiddenGeometryIds((prev) => {
const seen = new Set(prev);
@@ -259,9 +263,22 @@ export function useReplayPreview({
setHiddenGeometryIds(
draft.features
.map((feature) => String(feature.properties.id))
.filter((id) => !keepIds.has(id))
.filter((id) => !backgroundIdsRef.current.has(id) && !keepIds.has(id))
);
},
setAsBackgroundGeometries: (ids) => {
const nextIds = normalizeIdList(ids);
for (const id of nextIds) {
backgroundIdsRef.current.add(id);
}
setHiddenGeometryIds((prev) => prev.filter((id) => !nextIds.includes(id)));
},
removeFromBackgroundGeometries: (ids) => {
const nextIds = normalizeIdList(ids);
for (const id of nextIds) {
backgroundIdsRef.current.delete(id);
}
},
showAllGeometries: () => {
setHiddenGeometryIds([]);
},
+3 -2
View File
@@ -101,7 +101,6 @@ export type UIOptionName =
export type MapFunctionName =
| "set_camera_view" // Đặt trạng thái camera (center, zoom, pitch, bearing)
| "set_timeline_filter" // Bật/tắt lọc timeline
| "set_labels_visible"; // Ẩn/hiện nhãn (labels) trên bản đồ
export type GeoFunctionName =
@@ -112,7 +111,9 @@ export type GeoFunctionName =
| "pulse_geometry" // Hiệu ứng pulse/emphasis cho geometry
| "animate_dashed_border" // Hiệu ứng border nét đứt chuyển động
| "set_geometry_style" // Đổi style trực tiếp của geometry
| "orbit_camera_around_geometry"; // Quay camera quanh một geometry
| "orbit_camera_around_geometry" // Quay camera quanh một geometry
| "set_as_background_geometries" // Đặt các geometry làm background (luôn hiện)
| "remove_from_background_geometries"; // Loại các geometry khỏi background
export type NarrativeFunctionName =
| "set_dialog"; // Đặt kịch bản đối thoại/hình ảnh dẫn chuyện mới (hoặc null để xóa)