"use client"; import { useCallback, useEffect, useMemo, useRef, useState, forwardRef, useImperativeHandle } from "react"; import type { RefObject, Dispatch, SetStateAction } from "react"; import { type MapFeaturePayload, type MapHandle } from "@/uhm/components/Map"; import type { MapHoverPopupContent } from "@/uhm/components/map/useMapHoverPopup"; import PresentPlaceSearch, { type HistoricalGeometryFocusPayload, type PresentPlaceSelection } from "@/uhm/components/editor/PresentPlaceSearch"; import ReplayPreviewOverlay from "@/uhm/components/editor/ReplayPreviewOverlay"; import ReplayPreviewLayerPanel from "@/uhm/components/editor/ReplayPreviewLayerPanel"; import PublicWikiSidebar from "@/uhm/components/wiki/PublicWikiSidebar"; import TimelineBar from "@/uhm/components/ui/TimelineBar"; import RelatedEntityPopup from "./RelatedEntityPopup"; import PinnedWikiPopup from "./PinnedWikiPopup"; import { fitMapToFeatureCollection } from "@/uhm/components/map/mapUtils"; import { fetchWikiById, type Wiki } from "@/uhm/api/wikis"; import type { Entity } from "@/uhm/api/entities"; import type { FeatureCollection } from "@/uhm/types/geo"; import type { BattleReplay, EntityWikiLinkSnapshot } from "@/uhm/types/projects"; import type { WikiSnapshot } from "@/uhm/types/wiki"; import { type BackgroundLayerVisibility } from "@/uhm/lib/map/styles/backgroundLayers"; import { normalizeFeatureEntityIds } from "@/uhm/lib/editor/snapshot/editorSnapshot"; import type { PreviewRelationIndex } from "@/uhm/lib/preview/types"; import type { Feature } from "@/uhm/lib/editor/state/useEditorState"; type Props = { projectId: string; mode: "preview" | "replay_preview"; onModeChange: (mode: "preview" | "replay_preview") => void; onExitPreview: () => void; draft: FeatureCollection; replays: BattleReplay[]; entities: Entity[]; wikis: WikiSnapshot[]; entityWikiLinks?: EntityWikiLinkSnapshot[]; backgroundVisibility: BackgroundLayerVisibility; onBackgroundVisibilityChange: (vis: BackgroundLayerVisibility) => void; geometryVisibility: Record; onGeometryVisibilityChange: (vis: Record) => void; viewMode?: "local" | "global"; onViewModeChange?: (mode: "local" | "global") => void; globalGeometries?: FeatureCollection; isGlobalLoading?: boolean; baseline?: FeatureCollection; activeReplay?: BattleReplay | null; selectedStageId?: number | null; selectedStepIndex?: number | null; autoplayMode?: "start" | "selection" | null; replayPreview: any; mapHandleRef?: RefObject; previewRelations: PreviewRelationIndex; previewActiveEntityId: string | null; setPreviewActiveEntityId: (id: string | null) => void; previewEntityFocusToken?: number; setPreviewEntityFocusToken: Dispatch>; previewSidebarWidth: number; setPreviewSidebarWidth: Dispatch>; previewWikiCache: Record; setPreviewWikiCache: Dispatch>>; isLargeScreen?: boolean; setIsLargeScreen?: Dispatch>; }; export type PreviewLayoutHandle = { handleFeatureClick: (payload: MapFeaturePayload | null) => void; getHoverPopupContent: (feature: Feature) => MapHoverPopupContent | null; handlePlaySelectedReplay: (replay: BattleReplay) => void; }; const PreviewLayout = forwardRef(({ projectId, mode, onModeChange, onExitPreview, wikis, backgroundVisibility, onBackgroundVisibilityChange, geometryVisibility, onGeometryVisibilityChange, activeReplay, autoplayMode = null, replayPreview, mapHandleRef, previewRelations, previewActiveEntityId, setPreviewActiveEntityId, setPreviewEntityFocusToken, previewSidebarWidth, setPreviewSidebarWidth, previewWikiCache, setPreviewWikiCache, isLargeScreen, }: Props, ref) => { const isReplayPreviewMode = mode === "replay_preview"; // State for local active replay (when played from standard preview click) const [localActiveReplay, setLocalActiveReplay] = useState(null); const currentActiveReplay = activeReplay !== undefined ? activeReplay : localActiveReplay; // Preview specific UI states const [previewWikiError, setPreviewWikiError] = useState(null); const [isPreviewWikiLoading, setIsPreviewWikiLoading] = useState(false); const [previewPinnedWikiPopupAnchor, setPreviewPinnedWikiPopupAnchor] = useState(null); const [isPreviewEntitySidebarOpen, setIsPreviewEntitySidebarOpen] = useState(false); const [previewLinkEntityPopup, setPreviewLinkEntityPopup] = useState<{ slug: string; entities: Entity[]; top: number; left: number; } | null>(null); // Focused present place (for PresentPlaceSearch) const [focusedPresentPlace, setFocusedPresentPlace] = useState(null); // Clear preview states when currentActiveReplay or mode changes useEffect(() => { setPreviewWikiCache({}); setPreviewWikiError(null); setIsPreviewWikiLoading(false); setPreviewPinnedWikiPopupAnchor(null); setPreviewActiveEntityId(null); setIsPreviewEntitySidebarOpen(false); setPreviewLinkEntityPopup(null); }, [currentActiveReplay, mode, setPreviewActiveEntityId, setPreviewWikiCache]); const autoplayedReplayIdRef = useRef(null); // Autoplay replay on mount/session load useEffect(() => { if (!isReplayPreviewMode || !currentActiveReplay || !autoplayMode) { autoplayedReplayIdRef.current = null; return; } if (autoplayedReplayIdRef.current === currentActiveReplay.id) return; autoplayedReplayIdRef.current = currentActiveReplay.id; if (autoplayMode === "selection") { replayPreview.playFromSelection(); } else { replayPreview.playFromStart(); } }, [autoplayMode, isReplayPreviewMode, currentActiveReplay, replayPreview]); const { timelineYear: replayPreviewTimelineYear, resetPreview: resetReplayPreview, playbackSpeed: replayPreviewPlaybackSpeed, activeCursor: replayPreviewActiveCursor, activeWikiId: replayPreviewActiveWikiId, sidebarOpen: replayPreviewSidebarOpen, openWikiPanelById: openReplayPreviewWikiPanelById, closeWikiPanel: closeReplayPreviewWikiPanel, } = replayPreview; // Timeline bar parameters const activeTimelineYear = isReplayPreviewMode ? replayPreviewTimelineYear : replayPreviewTimelineYear; // Timeline bar visibility const timelineBarVisible = !isReplayPreviewMode || replayPreview.timelineVisible; // Replay step active label const replayPreviewActiveStepLabel = useMemo(() => { if ( replayPreviewActiveCursor.stageId == null || replayPreviewActiveCursor.stepIndex == null ) { return null; } return `Stage #${replayPreviewActiveCursor.stageId} · Step ${replayPreviewActiveCursor.stepIndex + 1}`; }, [replayPreviewActiveCursor.stageId, replayPreviewActiveCursor.stepIndex]); // Active wiki snapshot const replayPreviewActiveWikiSnapshot = useMemo(() => { if (!replayPreviewActiveWikiId) return null; return wikis.find((item) => item.id === replayPreviewActiveWikiId) || null; }, [replayPreviewActiveWikiId, wikis]); // Load active wiki content if needed useEffect(() => { if (!mode || !replayPreviewSidebarOpen) { setPreviewWikiError(null); setIsPreviewWikiLoading(false); return; } const activeWikiId = String(replayPreviewActiveWikiId || "").trim(); if (!activeWikiId.length) { setPreviewWikiError(null); setIsPreviewWikiLoading(false); return; } const localWiki = wikis.find((item) => item.id === activeWikiId) || null; if (!localWiki) { setPreviewWikiError("Không tìm thấy wiki trong snapshot preview."); setIsPreviewWikiLoading(false); return; } if (typeof localWiki.doc === "string") { setPreviewWikiError(null); setIsPreviewWikiLoading(false); return; } if (previewWikiCache[activeWikiId]) { setPreviewWikiError(null); setIsPreviewWikiLoading(false); return; } let disposed = false; setPreviewWikiError(null); setIsPreviewWikiLoading(true); void fetchWikiById(activeWikiId) .then((row) => { if (disposed) return; setPreviewWikiCache((prev) => ({ ...prev, [activeWikiId]: row })); }) .catch((err) => { if (disposed) return; setPreviewWikiError(err instanceof Error ? err.message : "Không tải được wiki preview."); }) .finally(() => { if (!disposed) { setIsPreviewWikiLoading(false); } }); return () => { disposed = true; }; }, [ mode, previewWikiCache, replayPreviewActiveWikiId, replayPreviewSidebarOpen, wikis, ]); // Active wiki fully built const replayPreviewActiveWiki = useMemo(() => { const snapshotWiki = replayPreviewActiveWikiSnapshot; if (!snapshotWiki) return null; if (typeof snapshotWiki.doc === "string") { return { id: snapshotWiki.id, project_id: projectId, title: snapshotWiki.title, slug: snapshotWiki.slug ?? null, content: snapshotWiki.doc || "", }; } return previewWikiCache[snapshotWiki.id] || null; }, [previewWikiCache, projectId, replayPreviewActiveWikiSnapshot]); // Active entity const replayPreviewActiveEntityId = useMemo(() => { const activeWikiEntityIds = replayPreviewActiveWikiId ? previewRelations.wikiEntityIdsById[String(replayPreviewActiveWikiId)] || [] : []; if ( previewActiveEntityId && (!activeWikiEntityIds.length || activeWikiEntityIds.includes(previewActiveEntityId)) ) { return previewActiveEntityId; } return activeWikiEntityIds[0] || previewActiveEntityId; }, [previewActiveEntityId, previewRelations.wikiEntityIdsById, replayPreviewActiveWikiId]); const replayPreviewActiveEntity = replayPreviewActiveEntityId ? previewRelations.entitiesById[replayPreviewActiveEntityId] || null : null; const isReplayPreviewWikiSidebarOpen = mode && (replayPreviewSidebarOpen || isPreviewEntitySidebarOpen); // Handle replay preview entity selection const selectReplayPreviewEntity = useCallback(( entityId: string, options?: { sourceFeatureId?: string | number | null; preferredWikiId?: string | null; preferredWikiSlug?: string | null; focusMap?: boolean; selectGeometry?: boolean; } ) => { const id = String(entityId || "").trim(); const entity = previewRelations.entitiesById[id] || null; if (!entity) return; const linkedWikis = previewRelations.entityWikisById[id] || []; const preferredWikiId = String(options?.preferredWikiId || "").trim(); const preferredWikiSlug = String(options?.preferredWikiSlug || "").trim(); const nextWiki = linkedWikis.find((wiki) => preferredWikiId && wiki.id === preferredWikiId) || linkedWikis.find((wiki) => preferredWikiSlug && String(wiki.slug || "").trim() === preferredWikiSlug) || linkedWikis[0] || null; setPreviewActiveEntityId(id); setIsPreviewEntitySidebarOpen(true); setPreviewWikiError(null); setPreviewPinnedWikiPopupAnchor(null); setPreviewLinkEntityPopup(null); if (options?.focusMap === true) { setPreviewEntityFocusToken((prev) => (prev ?? 0) + 1); } if (nextWiki) { openReplayPreviewWikiPanelById(nextWiki.id); } }, [ openReplayPreviewWikiPanelById, previewRelations.entitiesById, previewRelations.entityWikisById, setPreviewActiveEntityId, setPreviewEntityFocusToken, ]); // Handle close sidebar const closeReplayPreviewSidebar = useCallback(() => { closeReplayPreviewWikiPanel(); setPreviewActiveEntityId(null); setIsPreviewEntitySidebarOpen(false); setPreviewWikiError(null); setPreviewLinkEntityPopup(null); }, [closeReplayPreviewWikiPanel, setPreviewActiveEntityId]); // Play selected battle replay const handlePlaySelectedReplay = useCallback((replay: BattleReplay) => { setLocalActiveReplay(replay); onModeChange("replay_preview"); }, [onModeChange]); // Exit Replay Preview mode const handleExitReplayPreview = useCallback(() => { resetReplayPreview(); if (activeReplay !== undefined) { // Started directly from parent onExitPreview(); } else { // Started locally setLocalActiveReplay(null); onModeChange("preview"); } }, [activeReplay, onExitPreview, onModeChange, resetReplayPreview]); // Map feature click handler const handlePreviewMapFeatureClick = useCallback((payload: MapFeaturePayload | null) => { setPreviewLinkEntityPopup(null); if (!payload) { setPreviewPinnedWikiPopupAnchor(null); return; } const entityIds = previewRelations.geometryEntityIds[String(payload.featureId)] || []; const rows = entityIds.flatMap((entityId) => { const entity = previewRelations.entitiesById[entityId] || null; if (!entity) return []; const linkedWikis = previewRelations.entityWikisById[entity.id] || []; if (!linkedWikis.length) { return [{ entity, wiki: null as Wiki | null }]; } return linkedWikis.map((wiki) => ({ entity, wiki })); }); if (!rows.length) { setPreviewPinnedWikiPopupAnchor(null); return; } if (rows.length === 1) { const row = rows[0]; selectReplayPreviewEntity(row.entity.id, { sourceFeatureId: payload.featureId, preferredWikiId: row.wiki?.id, focusMap: false, selectGeometry: false, }); setPreviewPinnedWikiPopupAnchor(null); return; } setPreviewPinnedWikiPopupAnchor(payload); }, [ previewRelations.entitiesById, previewRelations.entityWikisById, previewRelations.geometryEntityIds, selectReplayPreviewEntity, ]); // Hover popup content provider const getPreviewHoverPopupContent = useCallback((feature: Feature) => { const entityIds = normalizeFeatureEntityIds(feature); const entitiesForFeature = entityIds .map((entityId) => previewRelations.entitiesById[entityId] || null) .filter((entity): entity is Entity => Boolean(entity)); if (!entitiesForFeature.length) return null; return { rows: entitiesForFeature.flatMap((entity) => { const linkedWikis = previewRelations.entityWikisById[entity.id] || []; if (!linkedWikis.length) { return [{ title: entity.name || String(entity.id), quote: "" }]; } return linkedWikis.map((wiki) => ({ title: entity.name || String(entity.id), quote: extractWikiBlockquoteText(wiki.content), })); }), }; }, [previewRelations.entitiesById, previewRelations.entityWikisById]); // Wiki inner links click handler const handleReplayPreviewWikiLinkRequest = useCallback(({ slug, rect }: { slug: string; rect: DOMRect }) => { const nextSlug = String(slug || "").trim(); if (!nextSlug.length) return; const localWiki = wikis.find((item) => String(item.slug || "").trim() === nextSlug) || null; if (!localWiki) { setPreviewWikiError(`Wiki /wiki/${nextSlug} không có trong snapshot preview.`); return; } const linkedEntityIds = previewRelations.wikiEntityIdsBySlug[nextSlug] || []; const linkedEntities = linkedEntityIds .map((entityId) => previewRelations.entitiesById[entityId] || null) .filter((entity): entity is Entity => Boolean(entity)); if (linkedEntities.length === 1) { selectReplayPreviewEntity(linkedEntities[0].id, { preferredWikiId: localWiki.id, focusMap: false, }); return; } if (!linkedEntities.length) return; const popupWidth = 240; const popupHeight = Math.min(240, linkedEntities.length * 44 + 20); const { top, left } = computeFixedPopupPosition(rect, popupWidth, popupHeight); setPreviewLinkEntityPopup({ slug: nextSlug, entities: linkedEntities, top, left, }); }, [ previewRelations.entitiesById, previewRelations.wikiEntityIdsBySlug, selectReplayPreviewEntity, wikis, ]); // Search and focus place 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), }); } }, [mapHandleRef]); const clearPresentPlaceFocus = useCallback(() => { setFocusedPresentPlace(null); }, []); const handleFocusHistoricalGeometry = useCallback((payload: HistoricalGeometryFocusPayload) => { setFocusedPresentPlace(null); setPreviewEntityFocusToken((prev) => (prev ?? 0) + 1); 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 }); } const linkedEntityIds = previewRelations.geometryEntityIds[String(payload.geometry.id)] || []; if (linkedEntityIds.length === 1) { selectReplayPreviewEntity(linkedEntityIds[0], { sourceFeatureId: payload.geometry.id, focusMap: false, selectGeometry: false, }); } }, [mapHandleRef, previewRelations.geometryEntityIds, selectReplayPreviewEntity, setPreviewEntityFocusToken]); const effectiveGeometryVisibility = useMemo(() => { return geometryVisibility; }, [geometryVisibility]); const computedTimelineStyle = useMemo(() => { const rightMargin = (isReplayPreviewWikiSidebarOpen && isLargeScreen) ? previewSidebarWidth + 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)", }; }, [isReplayPreviewWikiSidebarOpen, isLargeScreen, previewSidebarWidth]); // Popup PinnedWikiPopup rows const previewPinnedWikiPopupRows = useMemo(() => { if (!previewPinnedWikiPopupAnchor) return []; const entityIds = previewRelations.geometryEntityIds[String(previewPinnedWikiPopupAnchor.featureId)] || []; return entityIds.flatMap((entityId) => { const entity = previewRelations.entitiesById[entityId] || null; if (!entity) return []; const linkedWikis = previewRelations.entityWikisById[entity.id] || []; if (!linkedWikis.length) { return [{ entity, wiki: null as Wiki | null, quote: "" }]; } return linkedWikis.map((wiki) => ({ entity, wiki, quote: extractWikiBlockquoteText(wiki.content), })); }); }, [previewPinnedWikiPopupAnchor, previewRelations]); useImperativeHandle(ref, () => ({ handleFeatureClick: handlePreviewMapFeatureClick, getHoverPopupContent: getPreviewHoverPopupContent, handlePlaySelectedReplay, }), [handlePreviewMapFeatureClick, getPreviewHoverPopupContent, handlePlaySelectedReplay]); return ( <> {isReplayPreviewMode ? ( ) : null} {isReplayPreviewWikiSidebarOpen ? ( ) : null} {previewPinnedWikiPopupAnchor && previewPinnedWikiPopupRows.length > 0 ? ( setPreviewPinnedWikiPopupAnchor(null)} onSelectRow={(entityId, wikiId) => { selectReplayPreviewEntity(entityId, { sourceFeatureId: previewPinnedWikiPopupAnchor.featureId, preferredWikiId: wikiId, focusMap: false, selectGeometry: false, }); }} /> ) : null} {timelineBarVisible ? ( { // Standard timeline bar year change replayPreview.setTimelineYear(year); }} timeRange={0} onTimeRangeChange={() => {}} isLoading={false} disabled={isReplayPreviewMode} statusText={null} filterEnabled={replayPreview.timelineFilterEnabled} onFilterEnabledChange={replayPreview.setTimelineFilterEnabled} style={computedTimelineStyle} /> ) : null} {previewLinkEntityPopup ? ( setPreviewLinkEntityPopup(null)} onSelectEntity={(entityId) => { selectReplayPreviewEntity(entityId, { preferredWikiSlug: previewLinkEntityPopup.slug, focusMap: false, }); setPreviewLinkEntityPopup(null); }} /> ) : null} ); }); export default PreviewLayout; // ========================================== // Helper functions // ========================================== function extractWikiBlockquoteText(content: string | null | undefined): string { if (!content) return ""; const blockquoteMatch = content.match(/]*>([\s\S]*?)<\/blockquote>/i); const rawText = blockquoteMatch?.[1]?.trim() || ""; if (!rawText) return ""; return rawText .replace(/<[^>]*>/g, "") .replace(/ /gi, " ") .replace(/\u00a0/g, " ") .replace(/&/gi, "&") .replace(/</gi, "<") .replace(/>/gi, ">") .replace(/"/gi, '"') .replace(/'/g, "'") .replace(/\s+/g, " ") .trim(); } function computeFixedPopupPosition(rect: DOMRect, width: number, height: number) { const margin = 12; const viewportWidth = typeof window !== "undefined" ? window.innerWidth : 1440; const viewportHeight = typeof window !== "undefined" ? window.innerHeight : 900; const preferredLeft = rect.right + margin; const maxLeft = Math.max(margin, viewportWidth - width - margin); const left = Math.min(preferredLeft, maxLeft); const preferredTop = rect.top; const maxTop = Math.max(margin, viewportHeight - height - margin); const top = Math.max(margin, Math.min(preferredTop, maxTop)); return { top, left }; } function clampNumber(value: number, min: number, max: number): number { if (!Number.isFinite(value)) return min; if (value < min) return min; if (value > max) return max; return value; }