refactor: improve map preview transition and layout stability with opacity animations and adjusted loading delay
Build and Release / release (push) Successful in 36s

This commit is contained in:
taDuc
2026-06-14 01:10:07 +07:00
parent 0462ed1ef5
commit 05af7f19f5
4 changed files with 208 additions and 206 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ export const metadata: Metadata = {
title: 'Ultimate History Map', title: 'Ultimate History Map',
description: 'Bản đồ tương tác lịch sử thế giới qua các thời kỳ', description: 'Bản đồ tương tác lịch sử thế giới qua các thời kỳ',
}; };
const inter = Inter({ const inter = Inter({
subsets: ['latin', 'vietnamese'], subsets: ['latin', 'vietnamese'],
weight: ['400', '500', '600', '700'], weight: ['400', '500', '600', '700'],
@@ -141,8 +141,8 @@ export default function MapPlaceholder({ onEnter }: MapPlaceholderProps) {
<style dangerouslySetInnerHTML={{ <style dangerouslySetInnerHTML={{
__html: ` __html: `
@keyframes placeholder-pulse { @keyframes placeholder-pulse {
0%, 100% { opacity: 0.35; transform: scale(0.98); } 0%, 100% { opacity: 0.35; }
50% { opacity: 0.95; transform: scale(1); } 50% { opacity: 0.95; }
} }
`}} /> `}} />
</div> </div>
@@ -130,7 +130,7 @@ export default function PublicPreviewClientPage({
} else { } else {
const timer = setTimeout(() => { const timer = setTimeout(() => {
setLoadInteractiveMap(true); setLoadInteractiveMap(true);
}, 2000); }, 2500);
return () => clearTimeout(timer); return () => clearTimeout(timer);
} }
}, [instantLoad]); }, [instantLoad]);
@@ -794,102 +794,213 @@ export default function PublicPreviewClientPage({
return ( return (
<> <>
{isBackgroundVisibilityReady && loadInteractiveMap && ( <div
<PreviewMapShell style={{
mapHandleRef={mapHandleRef} position: "absolute",
renderDraft={filteredRenderDraft} inset: 0,
labelContextDraft={filteredLabelContextDraft} visibility: userHasEntered ? "visible" : "hidden",
labelTimelineYear={currentTimelineYear} opacity: userHasEntered ? 1 : 0,
selectedFeatureIds={selectedFeatureIds} transition: "opacity 0.8s cubic-bezier(0.4, 0, 0.2, 1), visibility 0.8s",
onSelectFeatureIds={setSelectedFeatureIds} }}
instantLoad={instantLoad} >
onToggleInstantLoad={toggleInstantLoad} {isBackgroundVisibilityReady && loadInteractiveMap && (
isLayerPanelVisible={isLayerPanelVisible} <PreviewMapShell
onLayerPanelVisibleChange={setIsLayerPanelVisible} mapHandleRef={mapHandleRef}
backgroundVisibility={backgroundVisibility} renderDraft={filteredRenderDraft}
geometryVisibility={geometryVisibility} labelContextDraft={filteredLabelContextDraft}
onToggleBackground={handleToggleBackgroundLayer} labelTimelineYear={currentTimelineYear}
onToggleGeometry={(typeKey) => { selectedFeatureIds={selectedFeatureIds}
setGeometryVisibility((prev) => ({ onSelectFeatureIds={setSelectedFeatureIds}
...prev, instantLoad={instantLoad}
[typeKey]: prev[typeKey] === false, onToggleInstantLoad={toggleInstantLoad}
})); isLayerPanelVisible={isLayerPanelVisible}
}} onLayerPanelVisibleChange={setIsLayerPanelVisible}
timelineYear={currentTimelineYear} backgroundVisibility={backgroundVisibility}
onTimelineYearChange={handleTimelineYearChange} geometryVisibility={geometryVisibility}
timelineTimeRange={timeRange} onToggleBackground={handleToggleBackgroundLayer}
onTimelineTimeRangeChange={handleTimeRangeChange} onToggleGeometry={(typeKey) => {
isTimelineLoading={isTimelineLoading || isRelationsLoading} setGeometryVisibility((prev) => ({
timelineStatusText={relationsStatus || timelineStatus} ...prev,
timelineStyle={computedTimelineStyle} [typeKey]: prev[typeKey] === false,
onFeatureClick={handleMapFeatureClick} }));
hoverPopupEnabled }}
getHoverPopupContent={getHoverPopupContent} timelineYear={currentTimelineYear}
activeEntity={displayedActiveEntity} onTimelineYearChange={handleTimelineYearChange}
activeWiki={displayedActiveWiki} timelineTimeRange={timeRange}
isWikiLoading={isActiveWikiLoading} onTimelineTimeRangeChange={handleTimeRangeChange}
wikiError={activeWikiError} isTimelineLoading={isTimelineLoading || isRelationsLoading}
onCloseWikiSidebar={handleCloseWikiSidebar} timelineStatusText={relationsStatus || timelineStatus}
onWikiLinkRequest={handlePanelWikiLinkRequest} timelineStyle={computedTimelineStyle}
onWikiLinkEntitySelectionRequest={handlePanelWikiLinkEntitySelectionRequest} onFeatureClick={handleMapFeatureClick}
sidebarWidth={sidebarWidth} hoverPopupEnabled
onSidebarWidthChange={setSidebarWidth} getHoverPopupContent={getHoverPopupContent}
maxSidebarDragWidth={maxDragWidth} activeEntity={displayedActiveEntity}
sidebarHeight={sidebarHeight} activeWiki={displayedActiveWiki}
onSidebarHeightChange={handleSidebarHeightChange} isWikiLoading={isActiveWikiLoading}
showViewportControls={false} wikiError={activeWikiError}
onPlayPreviewReplay={activeReplay && replayMode === "idle" ? handlePlayPreviewReplay : undefined} onCloseWikiSidebar={handleCloseWikiSidebar}
timelineDisabled={replayMode !== "idle"} onWikiLinkRequest={handlePanelWikiLinkRequest}
hasAnyBottomPanel={isWikiChooserOpen || isGeometryChooserOpen} onWikiLinkEntitySelectionRequest={handlePanelWikiLinkEntitySelectionRequest}
overlay={ sidebarWidth={sidebarWidth}
replayMode !== "idle" ? ( onSidebarWidthChange={setSidebarWidth}
<ReplayPreviewOverlay maxSidebarDragWidth={maxDragWidth}
isPreviewMode={true} sidebarHeight={sidebarHeight}
isPlaying={replayPreview.isPlaying} onSidebarHeightChange={handleSidebarHeightChange}
dialog={replayPreview.dialog} showViewportControls={false}
toasts={replayPreview.toasts} onPlayPreviewReplay={activeReplay && replayMode === "idle" ? handlePlayPreviewReplay : undefined}
sidebarOpen={isSidebarOpen} timelineDisabled={replayMode !== "idle"}
sidebarWidth={sidebarWidth} hasAnyBottomPanel={isWikiChooserOpen || isGeometryChooserOpen}
playbackSpeed={replayPreview.playbackSpeed} overlay={
activeStepLabel={activeStepLabel} replayMode !== "idle" ? (
activeStepNumber={replayPreview.activeStepNumber} <ReplayPreviewOverlay
totalSteps={replayPreview.totalSteps} isPreviewMode={true}
playButtonLabel={replayMode === "paused" ? "Tiếp tục" : "Phát lại"} isPlaying={replayPreview.isPlaying}
onPlayPreview={handleResumePreviewReplay} dialog={replayPreview.dialog}
onStopPreview={handleStopPreviewReplay} toasts={replayPreview.toasts}
onResetPreview={handleResetPreviewReplay} sidebarOpen={isSidebarOpen}
onExitPreview={handleExitReplay} sidebarWidth={sidebarWidth}
playbackSpeed={replayPreview.playbackSpeed}
activeStepLabel={activeStepLabel}
activeStepNumber={replayPreview.activeStepNumber}
totalSteps={replayPreview.totalSteps}
playButtonLabel={replayMode === "paused" ? "Tiếp tục" : "Phát lại"}
onPlayPreview={handleResumePreviewReplay}
onStopPreview={handleStopPreviewReplay}
onResetPreview={handleResetPreviewReplay}
onExitPreview={handleExitReplay}
/>
) : null
}
>
<div style={searchBarWrapperStyle}>
<PresentPlaceSearch
focusedPlace={focusedPresentPlace}
onFocusPlace={handleFocusPresentPlace}
onFocusHistoricalGeometry={handleFocusHistoricalGeometry}
onFocusWiki={handleFocusWiki}
onClearFocus={clearPresentPlaceFocus}
style={{
position: "relative",
top: 0,
left: 0,
transform: "none",
width: searchBarWidth,
}}
/> />
) : null {isLargeScreen ? (
} <PublicMapZoomPanel
> mapHandleRef={mapHandleRef}
<div style={searchBarWrapperStyle}> onPlayPreviewReplay={activeReplay && replayMode === "idle" ? handlePlayPreviewReplay : undefined}
<PresentPlaceSearch onResumePreviewReplay={replayMode === "paused" ? handleResumePreviewReplay : undefined}
focusedPlace={focusedPresentPlace} onStopPreviewReplay={replayMode === "playing" ? handleStopPreviewReplay : undefined}
onFocusPlace={handleFocusPresentPlace} />
onFocusHistoricalGeometry={handleFocusHistoricalGeometry} ) : null}
onFocusWiki={handleFocusWiki} </div>
onClearFocus={clearPresentPlaceFocus} <FirstVisitGuideModal />
style={{ </PreviewMapShell>
position: "relative", )}
top: 0,
left: 0, {linkEntityPopup ? (
transform: "none", <div
width: searchBarWidth, ref={linkEntityPopupRef}
className="fixed z-[60] w-[240px] overflow-hidden rounded-xl border border-gray-200 bg-white shadow-xl dark:border-gray-800 dark:bg-gray-950"
style={{ top: linkEntityPopup.top, left: linkEntityPopup.left }}
>
<div className="border-b border-gray-200 px-3 py-2 dark:border-gray-800">
<div className="text-sm font-semibold text-gray-900 dark:text-gray-100">
Related Entities
</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
/wiki/{linkEntityPopup.slug}
</div>
</div>
<div className="max-h-[220px] overflow-y-auto p-2">
{linkEntityPopup.entities.length ? (
<div className="grid gap-1">
{linkEntityPopup.entities.map((entity) => (
<button
key={entity.id}
type="button"
onClick={() => {
setWikiSelectionPanelAnchor(null);
setRightPanelMode("wiki");
selectEntity(entity.id, { preferredWikiSlug: linkEntityPopup.slug });
setLinkEntityPopup(null);
}}
className="rounded-lg px-3 py-2 text-left text-sm text-gray-700 transition hover:bg-gray-50 hover:text-gray-900 dark:text-gray-200 dark:hover:bg-white/[0.04] dark:hover:text-white"
>
{entity.name}
</button>
))}
</div>
) : (
<div className="px-3 py-2 text-sm text-gray-500 dark:text-gray-400">
Không entity liên quan.
</div>
)}
</div>
</div>
) : null}
{isGeometryChooserOpen && geometrySelectionPanel ? (
<aside
className={isLargeScreen ? "fixed bottom-4 right-4 top-4 left-auto z-20 max-w-[calc(100vw-2rem)]" : "fixed bottom-0 left-0 right-0 top-auto z-20"}
style={isLargeScreen ? {
width: `min(${sidebarWidth}px, calc(100vw - 2rem))`,
} : {
height: `${sidebarHeight || 400}px`,
maxHeight: "90vh",
width: "100%",
maxWidth: "100%",
}}
>
<GeometrySelectionPanel
wikiSlug={geometrySelectionPanel.wikiSlug}
rows={geometrySelectionPanel.rows}
isLoading={geometrySelectionPanel.isLoading}
error={geometrySelectionPanel.error}
onClose={() => {
setGeometrySelectionPanel(null);
setRightPanelMode(null);
}}
onSelectEntity={handleGeometrySelectionEntitySelect}
/>
</aside>
) : null}
{isWikiChooserOpen ? (
<aside
className={isLargeScreen ? "fixed bottom-4 right-4 top-4 left-auto z-20 max-w-[calc(100vw-2rem)]" : "fixed bottom-0 left-0 right-0 top-auto z-20"}
style={isLargeScreen ? {
width: `min(${sidebarWidth}px, calc(100vw - 2rem))`,
} : {
height: `${sidebarHeight || 400}px`,
maxHeight: "90vh",
width: "100%",
maxWidth: "100%",
}}
>
<WikiSelectionPanel
rows={wikiSelectionPanelRows}
onClose={() => {
setWikiSelectionPanelAnchor(null);
setRightPanelMode(null);
}}
onSelectRow={(entityId, wikiId) => {
const wiki = wikiSelectionPanelRows.find((row) => row.entity.id === entityId && row.wiki.id === wikiId)?.wiki || null;
const sourceFeatureId = wikiSelectionPanelAnchor?.featureId ?? null;
setWikiSelectionPanelAnchor(null);
setRightPanelMode("wiki");
selectEntity(entityId, {
sourceFeatureId,
preferredWikiSlug: wiki?.slug,
selectGeometry: false,
});
}} }}
/> />
{isLargeScreen ? ( </aside>
<PublicMapZoomPanel ) : null}
mapHandleRef={mapHandleRef} </div>
onPlayPreviewReplay={activeReplay && replayMode === "idle" ? handlePlayPreviewReplay : undefined}
onResumePreviewReplay={replayMode === "paused" ? handleResumePreviewReplay : undefined}
onStopPreviewReplay={replayMode === "playing" ? handleStopPreviewReplay : undefined}
/>
) : null}
</div>
<FirstVisitGuideModal />
</PreviewMapShell>
)}
{/* Smooth transition loading overlay */} {/* Smooth transition loading overlay */}
<div <div
@@ -905,107 +1016,6 @@ export default function PublicPreviewClientPage({
> >
<MapPlaceholder onEnter={onEnter} /> <MapPlaceholder onEnter={onEnter} />
</div> </div>
{linkEntityPopup ? (
<div
ref={linkEntityPopupRef}
className="fixed z-[60] w-[240px] overflow-hidden rounded-xl border border-gray-200 bg-white shadow-xl dark:border-gray-800 dark:bg-gray-950"
style={{ top: linkEntityPopup.top, left: linkEntityPopup.left }}
>
<div className="border-b border-gray-200 px-3 py-2 dark:border-gray-800">
<div className="text-sm font-semibold text-gray-900 dark:text-gray-100">
Related Entities
</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
/wiki/{linkEntityPopup.slug}
</div>
</div>
<div className="max-h-[220px] overflow-y-auto p-2">
{linkEntityPopup.entities.length ? (
<div className="grid gap-1">
{linkEntityPopup.entities.map((entity) => (
<button
key={entity.id}
type="button"
onClick={() => {
setWikiSelectionPanelAnchor(null);
setRightPanelMode("wiki");
selectEntity(entity.id, { preferredWikiSlug: linkEntityPopup.slug });
setLinkEntityPopup(null);
}}
className="rounded-lg px-3 py-2 text-left text-sm text-gray-700 transition hover:bg-gray-50 hover:text-gray-900 dark:text-gray-200 dark:hover:bg-white/[0.04] dark:hover:text-white"
>
{entity.name}
</button>
))}
</div>
) : (
<div className="px-3 py-2 text-sm text-gray-500 dark:text-gray-400">
Không entity liên quan.
</div>
)}
</div>
</div>
) : null}
{isGeometryChooserOpen && geometrySelectionPanel ? (
<aside
className={isLargeScreen ? "fixed bottom-4 right-4 top-4 left-auto z-20 max-w-[calc(100vw-2rem)]" : "fixed bottom-0 left-0 right-0 top-auto z-20"}
style={isLargeScreen ? {
width: `min(${sidebarWidth}px, calc(100vw - 2rem))`,
} : {
height: `${sidebarHeight || 400}px`,
maxHeight: "90vh",
width: "100%",
maxWidth: "100%",
}}
>
<GeometrySelectionPanel
wikiSlug={geometrySelectionPanel.wikiSlug}
rows={geometrySelectionPanel.rows}
isLoading={geometrySelectionPanel.isLoading}
error={geometrySelectionPanel.error}
onClose={() => {
setGeometrySelectionPanel(null);
setRightPanelMode(null);
}}
onSelectEntity={handleGeometrySelectionEntitySelect}
/>
</aside>
) : null}
{isWikiChooserOpen ? (
<aside
className={isLargeScreen ? "fixed bottom-4 right-4 top-4 left-auto z-20 max-w-[calc(100vw-2rem)]" : "fixed bottom-0 left-0 right-0 top-auto z-20"}
style={isLargeScreen ? {
width: `min(${sidebarWidth}px, calc(100vw - 2rem))`,
} : {
height: `${sidebarHeight || 400}px`,
maxHeight: "90vh",
width: "100%",
maxWidth: "100%",
}}
>
<WikiSelectionPanel
rows={wikiSelectionPanelRows}
onClose={() => {
setWikiSelectionPanelAnchor(null);
setRightPanelMode(null);
}}
onSelectRow={(entityId, wikiId) => {
const wiki = wikiSelectionPanelRows.find((row) => row.entity.id === entityId && row.wiki.id === wikiId)?.wiki || null;
const sourceFeatureId = wikiSelectionPanelAnchor?.featureId ?? null;
setWikiSelectionPanelAnchor(null);
setRightPanelMode("wiki");
selectEntity(entityId, {
sourceFeatureId,
preferredWikiSlug: wiki?.slug,
selectGeometry: false,
});
}}
/>
</aside>
) : null}
</> </>
); );
} }
@@ -88,14 +88,6 @@ export default function PublicPreviewWrapper() {
}; };
}, [handleEnter, mapEntered]); }, [handleEnter, mapEntered]);
if (!mapEntered && !instantLoad) {
return (
<MapPlaceholder
onEnter={handleEnter}
/>
);
}
return ( return (
<PublicPreviewClientPage <PublicPreviewClientPage
userHasEntered={mapEntered} userHasEntered={mapEntered}