diff --git a/next.config.ts b/next.config.ts index 916db6c..28c32e3 100644 --- a/next.config.ts +++ b/next.config.ts @@ -26,7 +26,7 @@ const nextConfig: NextConfig = { }, ], }, - output: 'standalone', + // output: 'standalone', webpack(config) { config.module.rules.push({ test: /\.svg$/, diff --git a/public/images/map_placeholder.webp b/public/images/map_placeholder.webp new file mode 100644 index 0000000..460ceff Binary files /dev/null and b/public/images/map_placeholder.webp differ diff --git a/src/app/globals.css b/src/app/globals.css index d75a64f..ba4377f 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -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; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 347cd73..81fda39 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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 ( - - + + {children} diff --git a/src/app/page.tsx b/src/app/page.tsx index 18df3f9..c3a3c15 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -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(1000); - const [timelineDraftYear, setTimelineDraftYear] = useState(1000); - const [timeRange, setTimeRange] = useState(0); - const [backgroundVisibility, setBackgroundVisibility] = useState( - () => ({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY }) - ); - const [isBackgroundVisibilityReady, setIsBackgroundVisibilityReady] = useState(false); - const [geometryVisibility, setGeometryVisibility] = useState>(() => { - const init: Record = {}; - for (const key of GEO_TYPE_KEYS) init[key] = true; - return init; - }); - const [sidebarWidth, setSidebarWidth] = useState(() => { - 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(null); - const isFirstMount = useRef(true); - const [replayMode, setReplayMode] = useState<"idle" | "playing">("idle"); - const [selectedReplayStageId, setSelectedReplayStageId] = useState(null); - const [selectedReplayStepIndex, setSelectedReplayStepIndex] = useState(null); - const [focusedPresentPlace, setFocusedPresentPlace] = useState(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 ? ( - { - 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" ? ( - - ) : null - } - > -
- -
-
- ) : ( -
- )} +
+ {/* Preload LCP image */} + - {linkEntityPopup ? ( -
-
-
- Related Entities -
-
- /wiki/{linkEntityPopup.slug} -
-
-
-
- {linkEntityPopup.entities.map((entity) => ( - - ))} -
-
+ {/* Permanent, static LCP image that is NEVER hidden or unmounted */} + {/* eslint-disable-next-line @next/next/no-img-element */} + Map Background + + {/* Header (SSR & SEO) */} +
+ +
+ + {/* Main Content & Semantic Heading (SSR & SEO) */} +
+
+

Ultimate History Map - Bản Đồ Tương Tác Lịch Sử

+

+ Dự án Ultimate History Map cung cấp cái nhìn trực quan và 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. +

+

+ 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ử và chiến trận (Replay). + - Tra cứu thông tin sự kiện lịch sử (Wiki & Entities). +

- ) : null} - + + {/* Stateful Interactive Client Component */} + +
+ + {/* Footer (SSR & SEO) */} +
+

© {new Date().getFullYear()} Ultimate History Map. All rights reserved.

+
+
); } diff --git a/src/uhm/api/battleReplays.ts b/src/uhm/api/battleReplays.ts index 61e1070..a2a6cae 100644 --- a/src/uhm/api/battleReplays.ts +++ b/src/uhm/api/battleReplays.ts @@ -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> { const uniqueIds = Array.from(new Set( diff --git a/src/uhm/api/relations.ts b/src/uhm/api/relations.ts index ab20d5a..3ac9728 100644 --- a/src/uhm/api/relations.ts +++ b/src/uhm/api/relations.ts @@ -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; diff --git a/src/uhm/components/Map.tsx b/src/uhm/components/Map.tsx index 7678185..02062d2 100644 --- a/src/uhm/components/Map.tsx +++ b/src/uhm/components/Map.tsx @@ -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(function Map({ @@ -114,6 +113,7 @@ const Map = memo(forwardRef(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(mode); @@ -161,6 +161,11 @@ const Map = memo(forwardRef(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(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(function Map({ }, [hasImageOverlay, isMapLoaded, mapRef]); return ( -
-
+
+ {/* Opaque map placeholder image for LCP optimization */} + Map Loading Placeholder + +
{fatalInitError ? (
- - onAppendActions( - [{ function_name: "set_timeline_filter", params: [true] }], - "Map: enable timeline filter" - ) - } - /> - - onAppendActions( - [{ function_name: "set_timeline_filter", params: [false] }], - "Map: disable timeline filter" - ) - } - />
@@ -877,6 +857,28 @@ function GeoFunctionShortcutPanel({ ) } /> + + onAppendActions( + [{ function_name: "set_as_background_geometries", params: [selectedIds] }], + `Geo: đặt ${selectedCount} geo làm background` + ) + } + /> + + onAppendActions( + [{ function_name: "remove_from_background_geometries", params: [selectedIds] }], + `Geo: loại ${selectedCount} geo khỏi background` + ) + } + />
diff --git a/src/uhm/components/editor/ReplayTimelineSidebar.tsx b/src/uhm/components/editor/ReplayTimelineSidebar.tsx index 5eee78e..5d9ffc2 100644 --- a/src/uhm/components/editor/ReplayTimelineSidebar.tsx +++ b/src/uhm/components/editor/ReplayTimelineSidebar.tsx @@ -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(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) => { + 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 { + const bgIds = new Set(); + 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({ ? `Có ${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."}
-
+
+ + -
+
= { const mapFunctionLabels: Record = { 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 = { 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 { diff --git a/src/uhm/components/map/useMapInstance.ts b/src/uhm/components/map/useMapInstance.ts index 8780ba0..da73066 100644 --- a/src/uhm/components/map/useMapInstance.ts +++ b/src/uhm/components/map/useMapInstance.ts @@ -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; diff --git a/src/uhm/components/map/useMapSync.ts b/src/uhm/components/map/useMapSync.ts index c88c04f..95b666f 100644 --- a/src/uhm/components/map/useMapSync.ts +++ b/src/uhm/components/map/useMapSync.ts @@ -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 } diff --git a/src/uhm/components/preview/MapPlaceholder.tsx b/src/uhm/components/preview/MapPlaceholder.tsx new file mode 100644 index 0000000..1ab18b0 --- /dev/null +++ b/src/uhm/components/preview/MapPlaceholder.tsx @@ -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 ( +
+ {/* Background Image */} + {/* eslint-disable-next-line @next/next/no-img-element */} + Map Loading Placeholder + + {/* Dark overlay & Spinner */} +
+
+