refactor: update timeline persistence, optimize hook synchronization, and refine sidebar state management

This commit is contained in:
taDuc
2026-05-30 10:35:47 +07:00
parent b94f5f44cb
commit e81dd69f19
23 changed files with 485 additions and 201 deletions
@@ -1,5 +1,3 @@
"use client";
import React from "react"; import React from "react";
import Image from "next/image"; import Image from "next/image";
import Link from "next/link"; import Link from "next/link";
@@ -79,4 +79,4 @@ export default function Page() {
</div> </div>
</div> </div>
); );
} }
+1 -6
View File
@@ -743,12 +743,7 @@ span.flatpickr-weekday,
} }
html { html {
scrollbar-gutter: stable; overflow-y: auto;
overflow-y: scroll;
}
html {
overflow-y: scroll;
} }
::-webkit-scrollbar { ::-webkit-scrollbar {
+61 -56
View File
@@ -31,8 +31,8 @@ const CURRENT_YEAR = new Date().getUTCFullYear();
export default function Page() { export default function Page() {
const [selectedFeatureIds, setSelectedFeatureIds] = useState<(string | number)[]>([]); const [selectedFeatureIds, setSelectedFeatureIds] = useState<(string | number)[]>([]);
const [timelineYear, setTimelineYear] = useState<number>(() => clampYearToFixedRange(CURRENT_YEAR)); const [timelineYear, setTimelineYear] = useState<number>(1000);
const [timelineDraftYear, setTimelineDraftYear] = useState<number>(() => clampYearToFixedRange(CURRENT_YEAR)); const [timelineDraftYear, setTimelineDraftYear] = useState<number>(1000);
const [timeRange, setTimeRange] = useState<number>(0); const [timeRange, setTimeRange] = useState<number>(0);
const [backgroundVisibility, setBackgroundVisibility] = useState<BackgroundLayerVisibility>( const [backgroundVisibility, setBackgroundVisibility] = useState<BackgroundLayerVisibility>(
() => ({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY }) () => ({ ...HIDDEN_BACKGROUND_LAYER_VISIBILITY })
@@ -56,6 +56,7 @@ export default function Page() {
const [isLargeScreen, setIsLargeScreen] = useState(false); const [isLargeScreen, setIsLargeScreen] = useState(false);
const mapHandleRef = useRef<MapHandle>(null); const mapHandleRef = useRef<MapHandle>(null);
const isFirstMount = useRef(true);
const [replayMode, setReplayMode] = useState<"idle" | "playing">("idle"); const [replayMode, setReplayMode] = useState<"idle" | "playing">("idle");
const [selectedReplayStageId, setSelectedReplayStageId] = useState<number | null>(null); const [selectedReplayStageId, setSelectedReplayStageId] = useState<number | null>(null);
const [selectedReplayStepIndex, setSelectedReplayStepIndex] = useState<number | null>(null); const [selectedReplayStepIndex, setSelectedReplayStepIndex] = useState<number | null>(null);
@@ -139,6 +140,7 @@ export default function Page() {
handleWikiLinkRequest, handleWikiLinkRequest,
closeWikiSidebar, closeWikiSidebar,
setLinkEntityPopup, setLinkEntityPopup,
isManualSidebarOpen,
} = usePublicPreviewInteraction({ } = usePublicPreviewInteraction({
data, data,
relations, relations,
@@ -166,6 +168,30 @@ export default function Page() {
return () => window.clearTimeout(timeoutId); return () => window.clearTimeout(timeoutId);
}, [timelineDraftYear, timelineYear]); }, [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(() => { useEffect(() => {
const timeoutId = window.setTimeout(() => { const timeoutId = window.setTimeout(() => {
setBackgroundVisibility(loadBackgroundLayerVisibilityFromStorage()); setBackgroundVisibility(loadBackgroundLayerVisibilityFromStorage());
@@ -297,6 +323,32 @@ export default function Page() {
const currentTimelineYear = replayMode === "playing" ? replayPreview.timelineYear : timelineDraftYear; 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 ( return (
<> <>
{isBackgroundVisibilityReady ? ( {isBackgroundVisibilityReady ? (
@@ -322,11 +374,11 @@ export default function Page() {
onTimelineTimeRangeChange={handleTimeRangeChange} onTimelineTimeRangeChange={handleTimeRangeChange}
isTimelineLoading={isTimelineLoading || isRelationsLoading} isTimelineLoading={isTimelineLoading || isRelationsLoading}
timelineStatusText={relationsStatus || timelineStatus} timelineStatusText={relationsStatus || timelineStatus}
timelineStyle={activeEntity && isLargeScreen ? { right: `${sidebarWidth + 32}px` } : undefined} timelineStyle={computedTimelineStyle}
hoverPopupEnabled hoverPopupEnabled
getHoverPopupContent={getHoverPopupContent} getHoverPopupContent={getHoverPopupContent}
activeEntity={replayMode === "playing" ? (replayPreview.sidebarOpen ? activeEntity : null) : activeEntity} activeEntity={displayedActiveEntity}
activeWiki={replayMode === "playing" ? (replayPreview.sidebarOpen ? activeWiki : null) : activeWiki} activeWiki={displayedActiveWiki}
isWikiLoading={isActiveWikiLoading} isWikiLoading={isActiveWikiLoading}
wikiError={activeWikiError} wikiError={activeWikiError}
onCloseWikiSidebar={closeWikiSidebar} onCloseWikiSidebar={closeWikiSidebar}
@@ -343,10 +395,10 @@ export default function Page() {
isPlaying={replayPreview.isPlaying} isPlaying={replayPreview.isPlaying}
dialog={replayPreview.dialog} dialog={replayPreview.dialog}
toasts={replayPreview.toasts} toasts={replayPreview.toasts}
sidebarOpen={replayPreview.sidebarOpen} sidebarOpen={isSidebarOpen}
sidebarWidth={sidebarWidth} sidebarWidth={sidebarWidth}
playbackSpeed={replayPreview.playbackSpeed} playbackSpeed={replayPreview.playbackSpeed}
activeStepLabel="" activeStepLabel={activeStepLabel}
activeStepNumber={replayPreview.activeStepNumber} activeStepNumber={replayPreview.activeStepNumber}
totalSteps={replayPreview.totalSteps} totalSteps={replayPreview.totalSteps}
onPlayPreview={replayPreview.playFromStart} onPlayPreview={replayPreview.playFromStart}
@@ -361,7 +413,7 @@ export default function Page() {
style={{ style={{
position: "absolute", position: "absolute",
top: 10, top: 10,
left: 18, left: 80,
zIndex: 18, zIndex: 18,
display: "flex", display: "flex",
gap: "10px", gap: "10px",
@@ -369,53 +421,6 @@ export default function Page() {
pointerEvents: "auto", pointerEvents: "auto",
}} }}
> >
<button
type="button"
onClick={() => {
window.location.href = "/user";
}}
title="Tham gia hệ thống"
aria-label="Tham gia hệ thống"
style={{
width: "46px",
height: "46px",
backgroundColor: "#1e293b",
color: "#f8fafc",
border: "1px solid rgba(255, 255, 255, 0.1)",
borderRadius: "12px",
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
transition: "all 0.2s ease",
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.15)",
backdropFilter: "blur(8px)",
flexShrink: 0,
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = "#334155";
e.currentTarget.style.borderColor = "rgba(255, 255, 255, 0.2)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = "#1e293b";
e.currentTarget.style.borderColor = "rgba(255, 255, 255, 0.1)";
}}
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
</button>
<PresentPlaceSearch <PresentPlaceSearch
focusedPlace={focusedPresentPlace} focusedPlace={focusedPresentPlace}
onFocusPlace={handleFocusPresentPlace} onFocusPlace={handleFocusPresentPlace}
@@ -425,7 +430,7 @@ export default function Page() {
position: "relative", position: "relative",
top: 0, top: 0,
left: 0, left: 0,
width: "min(392px, calc(100vw - 90px))", width: "min(392px, calc(100vw - 120px))",
}} }}
/> />
</div> </div>
+4 -4
View File
@@ -37,7 +37,7 @@ const ALL_NAV_ITEMS: NavItem[] = [
]; ];
const OTHERS_ITEMS: NavItem[] = [ const OTHERS_ITEMS: NavItem[] = [
{ icon: <ShootingStarIcon />, name: "Hỗ trợ", path: "/user/quick-qa" }, { icon: <ShootingStarIcon />, name: "Hỗ trợ", path: "/faq" },
]; ];
const AppSidebar: React.FC = () => { const AppSidebar: React.FC = () => {
@@ -356,12 +356,12 @@ const AppSidebar: React.FC = () => {
</li> </li>
<li> <li>
<Link <Link
href="/user/about-us" href="/about-us"
className={`menu-item group ${ className={`menu-item group ${
isActive("/user/about-us") ? "menu-item-active" : "menu-item-icon-inactive" isActive("/about-us") ? "menu-item-active" : "menu-item-icon-inactive"
} ${!isExpanded ? "lg:justify-center px-2" : "lg:justify-start"}`} } ${!isExpanded ? "lg:justify-center px-2" : "lg:justify-start"}`}
> >
<span className={`menu-item-icon ${isActive("/user/about-us") ? "text-gray-950" : "text-gray-400"}`}> <span className={`menu-item-icon ${isActive("/about-us") ? "text-gray-950" : "text-gray-400"}`}>
<ShootingStarIcon /> <ShootingStarIcon />
</span> </span>
{isSidebarVisible && <span className="block truncate text-[14px]">Về chúng tôi</span>} {isSidebarVisible && <span className="block truncate text-[14px]">Về chúng tôi</span>}
+15 -15
View File
@@ -145,21 +145,21 @@ const Map = memo(forwardRef<MapHandle, MapProps>(function Map({
// Ref danh sách geometry thuộc local project để context menu phân biệt global-only feature. // Ref danh sách geometry thuộc local project để context menu phân biệt global-only feature.
const localFeatureIdsRef = useRef<MapProps["localFeatureIds"]>(localFeatureIds); const localFeatureIdsRef = useRef<MapProps["localFeatureIds"]>(localFeatureIds);
useEffect(() => { modeRef.current = mode; }, [mode]); modeRef.current = mode;
useEffect(() => { renderDraftRef.current = renderDraft; }, [renderDraft]); renderDraftRef.current = renderDraft;
useEffect(() => { onSelectFeatureIdsRef.current = onSelectFeatureIds; }, [onSelectFeatureIds]); onSelectFeatureIdsRef.current = onSelectFeatureIds;
useEffect(() => { onSetModeRef.current = onSetMode; }, [onSetMode]); onSetModeRef.current = onSetMode;
useEffect(() => { onFeatureClickRef.current = onFeatureClick; }, [onFeatureClick]); onFeatureClickRef.current = onFeatureClick;
useEffect(() => { getHoverPopupContentRef.current = getHoverPopupContent; }, [getHoverPopupContent]); getHoverPopupContentRef.current = getHoverPopupContent;
useEffect(() => { onCreateRef.current = onCreateFeature; }, [onCreateFeature]); onCreateRef.current = onCreateFeature;
useEffect(() => { onAddFeatureToProjectRef.current = onAddFeatureToProject; }, [onAddFeatureToProject]); onAddFeatureToProjectRef.current = onAddFeatureToProject;
useEffect(() => { onDeleteRef.current = onDeleteFeature; }, [onDeleteFeature]); onDeleteRef.current = onDeleteFeature;
useEffect(() => { onHideRef.current = onHideFeature; }, [onHideFeature]); onHideRef.current = onHideFeature;
useEffect(() => { onUpdateRef.current = onUpdateFeature; }, [onUpdateFeature]); onUpdateRef.current = onUpdateFeature;
useEffect(() => { imageOverlayRef.current = imageOverlay; }, [imageOverlay]); imageOverlayRef.current = imageOverlay;
useEffect(() => { onImageOverlayChangeRef.current = onImageOverlayChange; }, [onImageOverlayChange]); onImageOverlayChangeRef.current = onImageOverlayChange;
useEffect(() => { onBindGeometriesRef.current = onBindGeometries; }, [onBindGeometries]); onBindGeometriesRef.current = onBindGeometries;
useEffect(() => { localFeatureIdsRef.current = localFeatureIds; }, [localFeatureIds]); localFeatureIdsRef.current = localFeatureIds;
// Hook sở hữu lifecycle MapLibre instance và các control camera/projection. // Hook sở hữu lifecycle MapLibre instance và các control camera/projection.
const { const {
@@ -155,7 +155,7 @@ const narrativeActionDefinitions: NarrativeActionDefinitionMap = {
{ name: "image_url", label: "Ảnh tư liệu", kind: "text", placeholder: "https://... (URL ảnh)" }, { name: "image_url", label: "Ảnh tư liệu", kind: "text", placeholder: "https://... (URL ảnh)" },
{ name: "text", label: "Nội dung", kind: "rich-text", placeholder: "Nội dung dẫn chuyện..." }, { name: "text", label: "Nội dung", kind: "rich-text", placeholder: "Nội dung dẫn chuyện..." },
], ],
create: () => ({ function_name: "set_dialog", params: [{ avatar: "", text: "", image_url: "", image_caption: "" }] }), create: () => ({ function_name: "set_dialog", params: [{ text: "", image_url: "" }] }),
deserialize: (params) => { deserialize: (params) => {
const data: any = params[0]; const data: any = params[0];
if (data === null) { if (data === null) {
@@ -176,9 +176,7 @@ const narrativeActionDefinitions: NarrativeActionDefinitionMap = {
return [null]; return [null];
} }
const data: any = { const data: any = {
avatar: "",
text: asString(values.text), text: asString(values.text),
image_caption: "",
}; };
if (values.image_url) { if (values.image_url) {
data.image_url = asString(values.image_url); data.image_url = asString(values.image_url);
@@ -221,7 +221,7 @@ export default function ReplayPreviewLayerPanel({
alignItems: "center", alignItems: "center",
boxShadow: "0 20px 48px rgba(2, 6, 23, 0.45)", boxShadow: "0 20px 48px rgba(2, 6, 23, 0.45)",
backdropFilter: "blur(12px)", backdropFilter: "blur(12px)",
maxHeight: "calc(100vh - 180px)", maxHeight: "100%",
overflowY: "auto", overflowY: "auto",
overflowX: "hidden", overflowX: "hidden",
}} }}
@@ -1364,9 +1364,6 @@ function buildNarrativeActionEntry(
const plainText = dialog.text.replace(/<[^>]*>/g, ""); const plainText = dialog.text.replace(/<[^>]*>/g, "");
parts.push(`text=${summarizeValue(plainText, "")}`); parts.push(`text=${summarizeValue(plainText, "")}`);
} }
if (dialog.avatar) {
parts.push(`avatar=${summarizeValue(dialog.avatar, "")}`);
}
if (dialog.image_url) { if (dialog.image_url) {
parts.push(`image=${summarizeValue(dialog.image_url, "")}`); parts.push(`image=${summarizeValue(dialog.image_url, "")}`);
} }
+50 -54
View File
@@ -65,6 +65,12 @@ export function useMapInteraction({
const previousModeRef = useRef<EditorMode>(mode); const previousModeRef = useRef<EditorMode>(mode);
const mapCleanupFnsRef = useRef<Array<() => void>>([]); const mapCleanupFnsRef = useRef<Array<() => void>>([]);
const allowGeometryEditingRef = useRef(allowGeometryEditing);
allowGeometryEditingRef.current = allowGeometryEditing;
const allowFeatureSelectionRef = useRef(allowFeatureSelection);
allowFeatureSelectionRef.current = allowFeatureSelection;
useEffect(() => { useEffect(() => {
if (!editingEngineRef.current) { if (!editingEngineRef.current) {
editingEngineRef.current = createEditingEngine({ editingEngineRef.current = createEditingEngine({
@@ -151,41 +157,33 @@ export function useMapInteraction({
const selectEngine = initSelect( const selectEngine = initSelect(
map, map,
() => modeRef.current, () => modeRef.current,
allowGeometryEditing (id: string | number | (string | number)[]) => {
? (id: string | number | (string | number)[]) => { editingEngineRef.current?.clearEditing();
editingEngineRef.current?.clearEditing(); onSelectFeatureIdsRef.current?.([]);
onSelectFeatureIdsRef.current?.([]); onDeleteRef.current?.(id);
onDeleteRef.current?.(id); },
} (feature) => {
: undefined, const rawId = feature.id ?? feature.properties?.id;
allowGeometryEditing const originalFeature = renderDraftRef.current.features.find(
? (feature) => { (item) => String(item.properties.id) === String(rawId)
const rawId = feature.id ?? feature.properties?.id; );
const originalFeature = renderDraftRef.current.features.find( editingEngineRef.current?.beginEditing(
(item) => String(item.properties.id) === String(rawId) (originalFeature || feature) as unknown as maplibregl.MapGeoJSONFeature
); );
editingEngineRef.current?.beginEditing( },
(originalFeature || feature) as unknown as maplibregl.MapGeoJSONFeature (id: string | number) => {
); const originalFeature = renderDraftRef.current.features.find(
} (item) => String(item.properties.id) === String(id)
: undefined, );
allowGeometryEditing if (!originalFeature) return;
? (id: string | number) => {
const originalFeature = renderDraftRef.current.features.find(
(item) => String(item.properties.id) === String(id)
);
if (!originalFeature) return;
const nextFeature = buildDuplicatedFeatureShapeOnly(originalFeature); const nextFeature = buildDuplicatedFeatureShapeOnly(originalFeature);
onCreateRef.current?.(nextFeature); onCreateRef.current?.(nextFeature);
} },
: undefined, (id: string | number) => {
allowGeometryEditing onHideRef.current?.(id);
? (id: string | number) => { onSelectFeatureIdsRef.current?.([]);
onHideRef.current?.(id); },
onSelectFeatureIdsRef.current?.([]);
}
: undefined,
(ids) => onSelectFeatureIdsRef.current?.(ids), (ids) => onSelectFeatureIdsRef.current?.(ids),
(id: string | number) => onSetModeRef.current?.("replay", id), (id: string | number) => onSetModeRef.current?.("replay", id),
() => Boolean(editingEngineRef.current?.editingRef.current), () => Boolean(editingEngineRef.current?.editingRef.current),
@@ -206,27 +204,25 @@ export function useMapInteraction({
feature: currentFeature, feature: currentFeature,
}); });
}, },
onAddFeatureToProjectRef?.current (feature) => {
? (feature) => { if (!onAddFeatureToProjectRef?.current) return;
const rawId = feature.id ?? feature.properties?.id; const rawId = feature.id ?? feature.properties?.id;
if (rawId === undefined || rawId === null) return; if (rawId === undefined || rawId === null) return;
const originalFeature = renderDraftRef.current.features.find( const originalFeature = renderDraftRef.current.features.find(
(item) => String(item.properties.id) === String(rawId) (item) => String(item.properties.id) === String(rawId)
); );
if (!originalFeature) return; if (!originalFeature) return;
onAddFeatureToProjectRef.current?.(originalFeature); onAddFeatureToProjectRef.current?.(originalFeature);
} },
: undefined, (id) => {
onAddFeatureToProjectRef?.current if (!onAddFeatureToProjectRef?.current) return true;
? (id) => { const localIds = localFeatureIdsRef?.current;
if (!onAddFeatureToProjectRef?.current) return true; if (!Array.isArray(localIds)) return true;
const localIds = localFeatureIdsRef?.current; return localIds.some((localId) => String(localId) === String(id));
if (!Array.isArray(localIds)) return true; },
return localIds.some((localId) => String(localId) === String(id)); () => allowFeatureSelectionRef.current,
} () => allowGeometryEditingRef.current
: undefined,
() => allowFeatureSelection
); );
const cleanupPoint = initPoint( const cleanupPoint = initPoint(
+13 -13
View File
@@ -80,20 +80,20 @@ export function useMapSync({
const focusPaddingRef = useRef<number | maplibregl.PaddingOptions | undefined>(focusPadding); const focusPaddingRef = useRef<number | maplibregl.PaddingOptions | undefined>(focusPadding);
const isPreviewModeRef = useRef(isPreviewMode); const isPreviewModeRef = useRef(isPreviewMode);
const fitBoundsAppliedRef = useRef(false); renderDraftRef.current = renderDraft;
labelContextDraftRef.current = labelContextDraft;
labelTimelineYearRef.current = labelTimelineYear;
backgroundVisibilityRef.current = backgroundVisibility;
geometryVisibilityRef.current = geometryVisibility;
selectedFeatureIdsRef.current = selectedFeatureIds;
applyGeometryBindingFilterRef.current = applyGeometryBindingFilter;
fitToDraftBoundsRef.current = fitToDraftBounds;
imageOverlayRef.current = imageOverlay || null;
focusFeatureCollectionRef.current = focusFeatureCollection;
focusPaddingRef.current = focusPadding;
isPreviewModeRef.current = isPreviewMode;
useEffect(() => { renderDraftRef.current = renderDraft; }, [renderDraft]); const fitBoundsAppliedRef = useRef(false);
useEffect(() => { labelContextDraftRef.current = labelContextDraft; }, [labelContextDraft]);
useEffect(() => { labelTimelineYearRef.current = labelTimelineYear; }, [labelTimelineYear]);
useEffect(() => { backgroundVisibilityRef.current = backgroundVisibility; }, [backgroundVisibility]);
useEffect(() => { geometryVisibilityRef.current = geometryVisibility; }, [geometryVisibility]);
useEffect(() => { selectedFeatureIdsRef.current = selectedFeatureIds; }, [selectedFeatureIds]);
useEffect(() => { applyGeometryBindingFilterRef.current = applyGeometryBindingFilter; }, [applyGeometryBindingFilter]);
useEffect(() => { fitToDraftBoundsRef.current = fitToDraftBounds; }, [fitToDraftBounds]);
useEffect(() => { imageOverlayRef.current = imageOverlay || null; }, [imageOverlay]);
useEffect(() => { focusFeatureCollectionRef.current = focusFeatureCollection; }, [focusFeatureCollection]);
useEffect(() => { focusPaddingRef.current = focusPadding; }, [focusPadding]);
useEffect(() => { isPreviewModeRef.current = isPreviewMode; }, [isPreviewMode]);
useEffect(() => { useEffect(() => {
fitBoundsAppliedRef.current = false; fitBoundsAppliedRef.current = false;
+151 -11
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import type { CSSProperties, ReactNode } from "react"; import { type CSSProperties, type ReactNode, useState } from "react";
import Map, { type MapFeaturePayload } from "@/uhm/components/Map"; import Map, { type MapFeaturePayload } from "@/uhm/components/Map";
import ReplayPreviewLayerPanel from "@/uhm/components/editor/ReplayPreviewLayerPanel"; import ReplayPreviewLayerPanel from "@/uhm/components/editor/ReplayPreviewLayerPanel";
import PublicWikiSidebar from "@/uhm/components/wiki/PublicWikiSidebar"; import PublicWikiSidebar from "@/uhm/components/wiki/PublicWikiSidebar";
@@ -87,6 +87,24 @@ export default function PreviewMapShell({
overlay, overlay,
children, children,
}: Props) { }: Props) {
const [isMenuOpen, setIsMenuOpen] = useState(false);
const menuOptionStyle: CSSProperties = {
width: 46,
height: 46,
backgroundColor: "#1e293b",
color: "#cbd5e1",
border: "1px solid rgba(255, 255, 255, 0.08)",
borderRadius: 12,
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
transition: "all 0.2s ease",
boxShadow: "0 2px 8px rgba(0, 0, 0, 0.12)",
backdropFilter: "blur(6px)",
};
return ( return (
<div className="relative min-h-screen overflow-hidden bg-gray-950 text-gray-100"> <div className="relative min-h-screen overflow-hidden bg-gray-950 text-gray-100">
<div className="relative min-h-screen"> <div className="relative min-h-screen">
@@ -123,22 +141,144 @@ export default function PreviewMapShell({
style={timelineStyle} style={timelineStyle}
/> />
<style dangerouslySetInnerHTML={{ __html: `
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
`}} />
<aside <aside
style={{ style={{
position: "absolute", position: "absolute",
top: "50%", top: 10,
bottom: 20,
left: 18, left: 18,
transform: "translateY(-50%)", zIndex: 18,
zIndex: 16, display: "flex",
pointerEvents: "auto", flexDirection: "column",
gap: 12,
width: 58,
pointerEvents: "none",
}} }}
> >
<ReplayPreviewLayerPanel <div
backgroundVisibility={backgroundVisibility} style={{
geometryVisibility={geometryVisibility} display: "flex",
onToggleBackground={onToggleBackground} flexDirection: "column",
onToggleGeometry={onToggleGeometry} gap: 8,
/> alignItems: "center",
pointerEvents: "auto",
}}
>
<button
type="button"
onClick={() => setIsMenuOpen(!isMenuOpen)}
title={isMenuOpen ? "Đóng cài đặt" : "Tham gia hệ thống / Trợ giúp"}
aria-label="Cài đặt"
style={{
width: 46,
height: 46,
backgroundColor: "#1e293b",
color: "#f8fafc",
border: "1px solid rgba(255, 255, 255, 0.1)",
borderRadius: 12,
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
transition: "all 0.2s ease",
boxShadow: "0 4px 12px rgba(0, 0, 0, 0.15)",
backdropFilter: "blur(8px)",
flexShrink: 0,
}}
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
</button>
{isMenuOpen && (
<div
style={{
display: "flex",
flexDirection: "column",
gap: 8,
alignItems: "center",
animation: "slideDown 0.2s ease-out",
}}
>
<button
type="button"
onClick={() => { window.location.href = "/user"; }}
title="Quản trị & Chỉnh sửa (Edit)"
style={menuOptionStyle}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 20h9M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z" />
</svg>
</button>
<button
type="button"
onClick={() => { window.location.href = "/faq"; }}
title="Hỏi đáp & Hướng dẫn (FAQ)"
style={menuOptionStyle}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2zM22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" />
</svg>
</button>
<button
type="button"
onClick={() => { window.location.href = "/about-us"; }}
title="Về chúng tôi (About Us)"
style={menuOptionStyle}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="16" x2="12" y2="12" />
<line x1="12" y1="8" x2="12.01" y2="8" />
</svg>
</button>
</div>
)}
</div>
<div
style={{
flexGrow: 1,
flexShrink: 1,
minHeight: 0,
display: "flex",
flexDirection: "column",
pointerEvents: "auto",
}}
>
<ReplayPreviewLayerPanel
backgroundVisibility={backgroundVisibility}
geometryVisibility={geometryVisibility}
onToggleBackground={onToggleBackground}
onToggleGeometry={onToggleGeometry}
/>
</div>
</aside> </aside>
{overlay} {overlay}
@@ -42,6 +42,11 @@ export function usePublicPreviewInteraction(options: {
const { data, relations, setRelations, selectedFeatureIds, setSelectedFeatureIds, replayActiveWikiId, replayMode } = options; const { data, relations, setRelations, selectedFeatureIds, setSelectedFeatureIds, replayActiveWikiId, replayMode } = options;
const [activeEntityId, setActiveEntityId] = useState<string | null>(null); const [activeEntityId, setActiveEntityId] = useState<string | null>(null);
const [activeWikiSlug, setActiveWikiSlug] = useState<string | null>(null); const [activeWikiSlug, setActiveWikiSlug] = useState<string | null>(null);
const [isManualSidebarOpen, setIsManualSidebarOpen] = useState(false);
useEffect(() => {
setIsManualSidebarOpen(false);
}, [replayMode]);
const [wikiCache, setWikiCache] = useState<Record<string, CachedWiki>>({}); const [wikiCache, setWikiCache] = useState<Record<string, CachedWiki>>({});
const [hoverWikiPreviewByEntityId, setHoverWikiPreviewByEntityId] = useState<Record<string, HoverWikiPreview>>({}); const [hoverWikiPreviewByEntityId, setHoverWikiPreviewByEntityId] = useState<Record<string, HoverWikiPreview>>({});
const [isActiveWikiLoading, setIsActiveWikiLoading] = useState(false); const [isActiveWikiLoading, setIsActiveWikiLoading] = useState(false);
@@ -104,6 +109,7 @@ export function usePublicPreviewInteraction(options: {
setActiveWikiSlug(nextWikiSlug); setActiveWikiSlug(nextWikiSlug);
setActiveWikiError(null); setActiveWikiError(null);
setLinkEntityPopup(null); setLinkEntityPopup(null);
setIsManualSidebarOpen(true);
if (selectOptions?.selectGeometry && selectOptions?.sourceFeatureId != null) { if (selectOptions?.selectGeometry && selectOptions?.sourceFeatureId != null) {
setSelectedFeatureIds([selectOptions.sourceFeatureId]); setSelectedFeatureIds([selectOptions.sourceFeatureId]);
} }
@@ -402,6 +408,7 @@ export function usePublicPreviewInteraction(options: {
setActiveWikiError(null); setActiveWikiError(null);
setLinkEntityPopup(null); setLinkEntityPopup(null);
setSelectedFeatureIds([]); setSelectedFeatureIds([]);
setIsManualSidebarOpen(false);
}, [setSelectedFeatureIds]); }, [setSelectedFeatureIds]);
return { return {
@@ -416,6 +423,8 @@ export function usePublicPreviewInteraction(options: {
handleWikiLinkRequest, handleWikiLinkRequest,
closeWikiSidebar, closeWikiSidebar,
setLinkEntityPopup, setLinkEntityPopup,
isManualSidebarOpen,
setIsManualSidebarOpen,
}; };
} }
+4 -6
View File
@@ -145,10 +145,8 @@ export type EntityWikiLinkSnapshot = {
* Không còn wrapper `function_name: "UI"` trong shape mới. * Không còn wrapper `function_name: "UI"` trong shape mới.
*/ */
export type DialogState = { export type DialogState = {
avatar: string; // Avatar image URL
text: string; // Subtitle / spoken narrative text text: string; // Subtitle / spoken narrative text
image_url?: string; // Optional image URL image_url?: string; // Optional image URL
image_caption?: string;// Optional caption
}; };
export type UIOptionName = export type UIOptionName =
@@ -254,27 +252,27 @@ export type ReplayGeoFunctionParamTupleDocs = {
hide_others_geometries: [ hide_others_geometries: [
geometry_ids: string[], geometry_ids: string[],
]; ];
pulse_geometry: [ pulse_geometry: [ //beta feature
geometry_id: string, geometry_id: string,
color?: string, color?: string,
repeat?: number, repeat?: number,
duration?: number, duration?: number,
]; ];
animate_dashed_border: [ animate_dashed_border: [//beta feature
geometry_id: string, geometry_id: string,
color?: string, color?: string,
width?: number, width?: number,
speed?: number, speed?: number,
duration?: number, duration?: number,
]; ];
set_geometry_style: [ set_geometry_style: [//beta feature
geometry_ids: string[], geometry_ids: string[],
fill_color?: string, fill_color?: string,
fill_opacity?: number, fill_opacity?: number,
line_color?: string, line_color?: string,
line_width?: number, line_width?: number,
]; ];
orbit_camera_around_geometry: [ orbit_camera_around_geometry: [//beta feature
geometry_id: string, geometry_id: string,
zoom?: number, zoom?: number,
pitch?: number, pitch?: number,
+3 -2
View File
@@ -126,13 +126,14 @@ Các visual effect dùng overlay source/layer riêng và không mutate geometry
| Action | Params | Runtime hiện tại | | Action | Params | Runtime hiện tại |
| --- | --- | --- | | --- | --- | --- |
| `set_dialog` | `[data: { text: string; image_url?: string } \| null]` | Set dialog box text and optional image, or clear it if null |
| `set_title` | `[title: string]` | Set title overlay | | `set_title` | `[title: string]` | Set title overlay |
| `clear_title` | `[]` | Clear title | | `clear_title` | `[]` | Clear title |
| `set_descriptions` | `[text: string]` | Set description overlay | | `set_descriptions` | `[text: string]` | Set description overlay |
| `clear_descriptions` | `[]` | Clear descriptions | | `clear_descriptions` | `[]` | Clear descriptions |
| `show_dialog_box` | `[avatar, text, side, speaker?]` | Hiện dialog, side là `left` hoặc `right` | | `show_dialog_box` | `[avatar, text, side, speaker?]` | Legacy: hiện dialog, nay được normalize sang `set_dialog` |
| `clear_dialog_box` | `[]` | Clear dialog | | `clear_dialog_box` | `[]` | Clear dialog |
| `display_historical_image` | `[url, caption?]` | Hiện image overlay lịch sử | | `display_historical_image` | `[url, caption?]` | Legacy: hiện image overlay, nay được normalize sang `set_dialog` |
| `clear_historical_image` | `[]` | Clear image | | `clear_historical_image` | `[]` | Clear image |
| `set_step_subtitle` | `[subtitle: string | null]` | Set subtitle | | `set_step_subtitle` | `[subtitle: string | null]` | Set subtitle |
| `clear_step_subtitle` | `[]` | Clear subtitle | | `clear_step_subtitle` | `[]` | Clear subtitle |
@@ -0,0 +1,117 @@
# Hướng Dẫn Phân Chia Tính Năng Giữa Route Trang Chủ `/` Và Editor Preview Mode
Tài liệu này làm rõ sự khác biệt kiến trúc, cấu trúc tệp tin, phân chia trách nhiệm và các lưu ý kỹ thuật khi phát triển/chỉnh sửa tính năng giữa trang bản đồ tổng quan công cộng (Route `/`) và Chế độ xem trước của Trình biên tập (Editor Preview Mode).
---
## 1. Bản Đồ Tổng Quan Kiến Trúc (Architecture Map)
Hệ thống có hai môi trường tương tác bản đồ độc lập sử dụng chung một số thành phần lõi:
```mermaid
graph TD
A[Bản đồ số Lịch sử] --> B[Trang chủ công cộng Route /]
A --> C[Môi trường Editor /editor/id]
B --> D[PreviewMapShell.tsx]
D --> E[ReplayPreviewLayerPanel.tsx]
C --> F[PreviewLayout.tsx]
F --> E
```
### 1.1 Môi trường Trang Chủ công cộng (Route `/`)
* **Tệp tin chính**: `src/app/page.tsx`
* **Vỏ bọc bố cục (Shell Component)**: `src/uhm/components/preview/PreviewMapShell.tsx`
* **Mục đích**: Dành cho khách vãng lai khám phá bản đồ lịch sử thế giới công cộng, tra cứu địa danh lịch sử, chạy thử các replay được xuất bản công khai.
* **Đặc trưng**:
* Chứa thanh tìm kiếm `PresentPlaceSearch` nằm ở vị trí tuyệt đối (`left: 80px`, `top: 10px`).
***Menu Cài đặt** gấp gọn ở góc trên bên trái, cung cấp 3 liên kết nhanh: Quản trị & Chỉnh sửa (`/user`), Hỏi đáp (`/faq`), và Giới thiệu (`/about-us`).
* Trạng thái dòng thời gian (`timelineYear`) mặc định là **1000** và được đồng bộ tự động với `localStorage` (`timeline-year`).
### 1.2 Môi trường Xem trước của Trình biên tập (Editor Preview Mode)
* **Tệp tin chính**: `src/app/editor/[id]/page.tsx`
* **Vỏ bọc bố cục**: `src/uhm/components/preview/PreviewLayout.tsx`
* **Mục đích**: Dành cho Nhà sử học / Người biên tập xem trước bản nháp (snapshot draft) của dự án hiện tại trước khi commit hoặc nộp lên hệ thống.
* **Đặc trưng**:
* Tích hợp sâu vào Zustand Store (`useEditorStore`) để chia sẻ trạng thái chỉnh sửa hình học, liên kết thực thể (entity binding), và cấu hình replay.
* Hỗ trợ nút chuyển đổi dữ liệu cục bộ/toàn cầu (Local/Global View Mode) và đồng bộ tọa độ camera của trình chỉnh sửa.
---
## 2. Quy Tắc Phân Chia Tính Năng & Trách Nhiệm
Để tránh phá vỡ giao diện hoặc logic của môi trường còn lại khi chỉnh sửa, các Agent hoặc Developer cần tuân thủ quy tắc sau:
| Tính năng / Thành phần | Trang chủ (Route `/`) | Editor Preview | Lưu ý sửa đổi |
| :--- | :--- | :--- | :--- |
| **Thanh Tìm kiếm (`PresentPlaceSearch`)** | Khai báo tuyệt đối trực tiếp tại `src/app/page.tsx` | Khai báo bên trong `PreviewLayout.tsx` | Đảm bảo chiều rộng linh hoạt bằng cách sử dụng `min-width` / `max-width` thích hợp. |
| **Menu Cài đặt (Bánh răng)** | Nằm tại `PreviewMapShell.tsx` (chứa 3 link: Edit, FAQ, About Us) | Không hiển thị | Menu này chỉ phục vụ điều hướng công cộng. |
| **Layer Control Panel** | Nằm bên trong `PreviewMapShell.tsx` | Nằm bên trong `PreviewLayout.tsx` | Dùng component chung `ReplayPreviewLayerPanel.tsx`. |
| **Trạng thái Timeline** | Mặc định năm 1000, tự động tải/lưu qua `localStorage` | Không lưu `localStorage` (theo trạng thái nháp) | Chỉ áp dụng logic lưu trữ tại `src/app/page.tsx`. |
| **Bộ lọc Timeline (Toggle switch)** | Không hiển thị | Hiển thị và hoạt động trên cả hai chế độ (soạn thảo bình thường và xem trước) | Đảm bảo bộ lọc hoạt động đồng bộ với thực thể nháp (local draft). Tránh áp dụng lọc/truy vấn cho thực thể toàn cầu (global geometries) để không gây DDoS cho API backend. |
---
## 3. Các Cơ Chế Kỹ Thuật Đặc Biệt (Lưu ý cho các Agent khác)
### 3.1 Cơ chế chống Hydration Mismatch & Race Condition khi dùng `localStorage`
Tại Route `/`, `timelineYear` được lưu trong `localStorage`. Do Next.js chạy SSR trên server (nơi không có `window``localStorage`), ta phải xử lý tránh lệch HTML bằng cách:
1. Khởi tạo state bằng giá trị tĩnh (`1000`) trên cả server và client.
2. Dùng một `useEffect` chạy khi mount trên client để đọc dữ liệu từ `localStorage` ra nếu có.
3. Dùng một `useRef(true)` làm cờ hiệu `isFirstMount` để ngăn chặn `useEffect` ghi đè giá trị mặc định `1000` vào `localStorage` trước khi client kịp đọc dữ liệu cũ ra:
```typescript
const isFirstMount = useRef(true);
// 1. Đọc dữ liệu khi mount
useEffect(() => {
if (typeof window !== "undefined") {
const saved = localStorage.getItem("timeline-year");
if (saved) {
const parsed = parseInt(saved, 10);
if (!isNaN(parsed)) {
setTimelineYear(parsed);
setTimelineDraftYear(parsed);
}
}
}
}, []);
// 2. Ghi đè dữ liệu khi người dùng kéo thay đổi mốc năm
useEffect(() => {
if (isFirstMount.current) {
isFirstMount.current = false;
return;
}
if (typeof window !== "undefined") {
localStorage.setItem("timeline-year", String(timelineYear));
}
}, [timelineYear]);
```
### 3.2 Cơ chế Bố cục Flexbox của Sidebar góc trái
Thanh công cụ bên trái của trang chủ chứa cả **Menu Cài đặt****Layer Control Panel**. Để đảm bảo chúng không bao giờ đè lên nhau trên các màn hình có chiều cao thấp:
* Sử dụng một container `<aside>` định vị tuyệt đối với thuộc tính flex dọc (`display: flex`, `flexDirection: column`, `height` giới hạn trong viewport).
* Đặt thuộc tính `flexShrink: 1``minHeight: 0` cho vùng bao bọc `ReplayPreviewLayerPanel`.
* Tại `ReplayPreviewLayerPanel.tsx`, thuộc tính `maxHeight` của thẻ bọc chính được thiết lập là `100%``overflowY: "auto"`.
* **Kết quả**: Khi Menu Cài đặt mở rộng các nút tùy chọn xuống dưới, Layer Control Panel bên dưới sẽ tự động co nhỏ lại tương ứng và xuất hiện thanh cuộn nếu danh sách các lớp bản đồ vượt quá chiều cao còn lại.
### 3.3 Cơ chế Tự Động Co Giãn cho TimelineBar
Để thanh Timeline kéo dài tối đa chiều ngang nhưng không bị đè bởi Layer Control Panel (ở bên trái) và Wiki Sidebar (ở bên phải) khi mở rộng:
* Thuộc tính `left` được đặt cố định là `"88px"` để luôn đứng cách bên phải Layer Control Panel một khoảng an toàn (18px lề + 58px chiều rộng panel + 12px padding).
* Thuộc tính `right` được tính toán động thông qua hàm `useMemo`:
* **Khi đóng Sidebar**: `right` bằng `18px`.
* **Khi mở Sidebar**: `right` bằng `${sidebarWidth + 32}px`.
* Bằng cách đặt cả hai neo `left``right` mà không thiết lập `width` cố định hay `maxWidth`, trình duyệt sẽ tự động co giãn thanh Timeline giống như một phần tử `flex: 1` nằm giữa hai bên.
* Đồng thời thêm thuộc tính hiệu ứng `transition: "right 0.3s, left 0.3s"` giúp việc co giãn diễn ra mượt mà cùng tốc độ với thanh Sidebar.
---
## 4. Checklist Khi Chỉnh Sửa Cho Các Agent Tiếp Theo
* [ ] **Chỉnh sửa UI Sidebar / Layer Panel**: Đảm bảo kiểm tra giao diện trên cả màn hình desktop rộng và màn hình laptop/tablet có chiều cao nhỏ.
* [ ] **Sử dụng `localStorage`**: Tuyệt đối không đọc trực tiếp `localStorage` trong hàm khởi tạo `useState(() => localStorage.getItem(...))` vì sẽ gây ra lỗi Hydration Mismatch của Next.js SSR. Hãy luôn khởi tạo bằng giá trị tĩnh và cập nhật lại trong `useEffect` sau khi trang đã mount.
* [ ] **Cập nhật Style**: Sử dụng hệ thống Tailwind CSS có sẵn hoặc các thuộc tính inline CSS an toàn. Hạn chế tối đa việc ghi đè trực tiếp các lớp CSS toàn cục có thể ảnh hưởng xuyên suốt cả dự án.
* [ ] **Kiểm tra bộ lọc Timeline (Timeline Filter)**: Đảm bảo nút bật/tắt bộ lọc ở bên trái Timeline hoạt động chính xác trong cả chế độ soạn thảo bình thường và các chế độ Xem trước (chỉ trừ Replay Preview khi dòng thời gian bị khóa cứng theo replay). Khi bật bộ lọc, các hình học nháp (local draft) phải được lọc đồng bộ theo mốc năm đang hiển thị. Tuyệt đối không gộp/lọc các thực thể toàn cầu (global geometries) để tránh gọi API nặng nề gây DDoS cho backend.
* [ ] **Đảm bảo TypeScript xanh**: Luôn kiểm tra build bằng lệnh `npx tsc --noEmit` trước khi hoàn tất công việc để chắc chắn không xảy ra lỗi kiểu dữ liệu hoặc import sai đường dẫn tương đối.
@@ -1208,10 +1208,8 @@ function normalizeReplayMapAndGeoActions(
function normalizeReplayNarrativeActions(actions: unknown): ReplayAction<NarrativeFunctionName>[] { function normalizeReplayNarrativeActions(actions: unknown): ReplayAction<NarrativeFunctionName>[] {
if (!Array.isArray(actions)) return []; if (!Array.isArray(actions)) return [];
let avatar = "";
let text = ""; let text = "";
let image_url = ""; let image_url = "";
let image_caption = "";
let hasDialog = false; let hasDialog = false;
let isCleared = false; let isCleared = false;
@@ -1226,10 +1224,8 @@ function normalizeReplayNarrativeActions(actions: unknown): ReplayAction<Narrati
const data = params[0]; const data = params[0];
if (data && typeof data === "object") { if (data && typeof data === "object") {
hasDialog = true; hasDialog = true;
avatar = String((data as any).avatar || avatar);
text = String((data as any).text || text); text = String((data as any).text || text);
image_url = String((data as any).image_url || image_url); image_url = String((data as any).image_url || image_url);
image_caption = String((data as any).image_caption || image_caption);
} else if (data === null) { } else if (data === null) {
isCleared = true; isCleared = true;
} }
@@ -1237,7 +1233,6 @@ function normalizeReplayNarrativeActions(actions: unknown): ReplayAction<Narrati
} }
case "show_dialog_box": case "show_dialog_box":
hasDialog = true; hasDialog = true;
avatar = String(params[0] || avatar);
text = String(params[1] || text); text = String(params[1] || text);
break; break;
case "set_title": case "set_title":
@@ -1263,7 +1258,6 @@ function normalizeReplayNarrativeActions(actions: unknown): ReplayAction<Narrati
case "display_historical_image": case "display_historical_image":
hasDialog = true; hasDialog = true;
image_url = String(params[0] || image_url); image_url = String(params[0] || image_url);
image_caption = String(params[1] || image_caption);
break; break;
case "clear_dialog_box": case "clear_dialog_box":
case "clear_title": case "clear_title":
@@ -1279,15 +1273,11 @@ function normalizeReplayNarrativeActions(actions: unknown): ReplayAction<Narrati
if (hasDialog) { if (hasDialog) {
const dialogData: any = { const dialogData: any = {
avatar,
text, text,
}; };
if (image_url) { if (image_url) {
dialogData.image_url = image_url; dialogData.image_url = image_url;
} }
if (image_caption) {
dialogData.image_caption = image_caption;
}
return [{ return [{
function_name: "set_dialog", function_name: "set_dialog",
params: [dialogData], params: [dialogData],
+6 -3
View File
@@ -22,7 +22,8 @@ export function initSelect(
onFeatureClick?: (payload: SelectFeatureClickPayload | null) => void, onFeatureClick?: (payload: SelectFeatureClickPayload | null) => void,
onAddToProject?: (feature: maplibregl.MapGeoJSONFeature) => void, onAddToProject?: (feature: maplibregl.MapGeoJSONFeature) => void,
isLocalFeature?: (id: string | number) => boolean, isLocalFeature?: (id: string | number) => boolean,
allowFeatureSelection?: () => boolean allowFeatureSelection?: () => boolean,
allowGeometryEditing?: () => boolean
) { ) {
const FEATURE_STATE_SOURCES = [ const FEATURE_STATE_SOURCES = [
@@ -363,7 +364,9 @@ export function initSelect(
} }
} }
if (isLocalTarget && !isClickOutsideSelection) { const canEditGeometry = allowGeometryEditing ? allowGeometryEditing() : true;
if (isLocalTarget && !isClickOutsideSelection && canEditGeometry) {
if ( if (
effectiveCount === 1 && effectiveCount === 1 &&
clickedFeature.source === "countries" && clickedFeature.source === "countries" &&
@@ -407,7 +410,7 @@ export function initSelect(
} }
} }
if (isLocalTarget && onDelete && effectiveCount > 0) { if (isLocalTarget && onDelete && effectiveCount > 0 && canEditGeometry) {
items.push({ items.push({
group: "delete", group: "delete",
label: effectiveCount > 1 ? `Xóa ${effectiveCount} mục` : "Xóa", label: effectiveCount > 1 ? `Xóa ${effectiveCount} mục` : "Xóa",
+9 -4
View File
@@ -140,10 +140,15 @@ export const mapActions = {
}, },
restore_label_visibility: (map: maplibregl.Map, state: Record<string, "visible" | "none">) => { restore_label_visibility: (map: maplibregl.Map, state: Record<string, "visible" | "none">) => {
for (const [layerId, visibility] of Object.entries(state)) { const style = map.getStyle();
if (!map.getLayer(layerId)) continue; if (!style) return;
map.setLayoutProperty(layerId, "visibility", visibility); style.layers.forEach((layer) => {
} const layout = "layout" in layer ? layer.layout : undefined;
if (layer.type === "symbol" && layout && typeof layout === "object" && "text-field" in layout) {
const visibility = state[layer.id] ?? "visible";
map.setLayoutProperty(layer.id, "visibility", visibility);
}
});
}, },
}; };
+2 -4
View File
@@ -175,10 +175,8 @@ export const dispatchReplayAction = (
// merge with existing dialog state if available // merge with existing dialog state if available
const existing = controllers.getDialog ? controllers.getDialog() : null; const existing = controllers.getDialog ? controllers.getDialog() : null;
narrativeActions.set_dialog(controllers.setDialog, { narrativeActions.set_dialog(controllers.setDialog, {
avatar: nextDialog.avatar ?? existing?.avatar ?? "",
text: nextDialog.text ?? existing?.text ?? "", text: nextDialog.text ?? existing?.text ?? "",
image_url: nextDialog.image_url ?? existing?.image_url, image_url: nextDialog.image_url ?? existing?.image_url,
image_caption: nextDialog.image_caption ?? existing?.image_caption,
}); });
} }
return; return;
@@ -270,13 +268,13 @@ function normalizeSingleAction(action: any): ReplayAction<any> | null {
case "set_dialog": case "set_dialog":
return { function_name, params }; return { function_name, params };
case "show_dialog_box": case "show_dialog_box":
return { function_name: "set_dialog", params: [{ avatar: params[0], text: params[1] }] }; return { function_name: "set_dialog", params: [{ text: params[1] }] };
case "set_title": case "set_title":
case "set_descriptions": case "set_descriptions":
case "set_step_subtitle": case "set_step_subtitle":
return { function_name: "set_dialog", params: [{ text: params[0] }] }; return { function_name: "set_dialog", params: [{ text: params[0] }] };
case "display_historical_image": case "display_historical_image":
return { function_name: "set_dialog", params: [{ image_url: params[0], image_caption: params[1] }] }; return { function_name: "set_dialog", params: [{ image_url: params[0] }] };
case "clear_dialog_box": case "clear_dialog_box":
case "clear_title": case "clear_title":
case "clear_descriptions": case "clear_descriptions":
+37 -1
View File
@@ -61,6 +61,8 @@ export function useReplayPreview({
setMapProjection, setMapProjection,
}: UseReplayPreviewOptions) { }: UseReplayPreviewOptions) {
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
const isPlayingRef = useRef(false);
isPlayingRef.current = isPlaying;
const [dialog, setDialog] = useState<DialogState | null>(null); const [dialog, setDialog] = useState<DialogState | null>(null);
const dialogRef = useRef<DialogState | null>(null); const dialogRef = useRef<DialogState | null>(null);
const setDialogWithRef = useCallback((d: DialogState | null) => { const setDialogWithRef = useCallback((d: DialogState | null) => {
@@ -127,7 +129,6 @@ export function useReplayPreview({
toastTimeoutsRef.current = []; toastTimeoutsRef.current = [];
}; };
}, [effects, getMapInstance]); }, [effects, getMapInstance]);
const clearToasts = useCallback(() => { const clearToasts = useCallback(() => {
toastTimeoutsRef.current.forEach((timeoutId) => window.clearTimeout(timeoutId)); toastTimeoutsRef.current.forEach((timeoutId) => window.clearTimeout(timeoutId));
toastTimeoutsRef.current = []; toastTimeoutsRef.current = [];
@@ -179,6 +180,7 @@ export function useReplayPreview({
setTimelineFilterEnabled(baseline.timelineFilterEnabled); setTimelineFilterEnabled(baseline.timelineFilterEnabled);
const map = getMapInstance(); const map = getMapInstance();
if (map) { if (map) {
map.stop(); // Stop ongoing camera animations/transitions immediately
mapActions.restore_label_visibility(map, baseline.labelVisibility); mapActions.restore_label_visibility(map, baseline.labelVisibility);
if (baseline.mapViewState) { if (baseline.mapViewState) {
if (setMapProjection) { if (setMapProjection) {
@@ -209,6 +211,11 @@ export function useReplayPreview({
restorePreviewState(); restorePreviewState();
}, [restorePreviewState]); }, [restorePreviewState]);
useEffect(() => {
runIdRef.current += 1;
restorePreviewState();
}, [replay?.id, restorePreviewState]);
const controllersRef = useRef<Parameters<typeof dispatchReplayAction>[0] | null>(null); const controllersRef = useRef<Parameters<typeof dispatchReplayAction>[0] | null>(null);
controllersRef.current = { controllersRef.current = {
map: getMapInstance(), map: getMapInstance(),
@@ -265,6 +272,29 @@ export function useReplayPreview({
const playFromIndex = useCallback(async (startIndex: number) => { const playFromIndex = useCallback(async (startIndex: number) => {
console.log("playFromIndex starting at:", startIndex, "flatSteps count:", flatSteps.length); console.log("playFromIndex starting at:", startIndex, "flatSteps count:", flatSteps.length);
if (!flatSteps.length) return; if (!flatSteps.length) return;
const map = getMapInstance();
if (map) {
map.stop(); // Stop ongoing camera animations/transitions immediately
if (baselineRef.current && !isPlayingRef.current) {
const center = map.getCenter();
const projection = map.getProjection();
baselineRef.current.mapViewState = initialMapViewState || {
center: { lng: center.lng, lat: center.lat },
zoom: map.getZoom(),
pitch: map.getPitch(),
bearing: map.getBearing(),
projection: String(projection?.type || "mercator"),
};
baselineRef.current.labelVisibility = mapActions.get_label_visibility(map);
baselineRef.current.timelineYear = timelineYear;
baselineRef.current.timelineFilterEnabled = timelineFilterEnabled;
baselineRef.current.timelineVisible = timelineVisible;
baselineRef.current.layerPanelVisible = layerPanelVisible;
baselineRef.current.zoomPanelVisible = zoomPanelVisible;
}
}
const safeStartIndex = Math.max(0, Math.min(flatSteps.length - 1, startIndex)); const safeStartIndex = Math.max(0, Math.min(flatSteps.length - 1, startIndex));
resetPresentation(); resetPresentation();
effects.clear(getMapInstance()); effects.clear(getMapInstance());
@@ -324,6 +354,12 @@ export function useReplayPreview({
getMapInstance, getMapInstance,
initialTimelineFilterEnabled, initialTimelineFilterEnabled,
initialTimelineYear, initialTimelineYear,
initialMapViewState,
timelineYear,
timelineFilterEnabled,
timelineVisible,
layerPanelVisible,
zoomPanelVisible,
onSelectStep, onSelectStep,
resetPresentation, resetPresentation,
restorePreviewState, restorePreviewState,
-2
View File
@@ -88,10 +88,8 @@ export type EditorSnapshot = {
}; };
export type DialogState = { export type DialogState = {
avatar: string; // Avatar image URL
text: string; // Subtitle / spoken narrative text text: string; // Subtitle / spoken narrative text
image_url?: string; // Optional image URL image_url?: string; // Optional image URL
image_caption?: string;// Optional caption
}; };
export type UIOptionName = export type UIOptionName =