From f745bb16d7a2c11432391d4a82c61813e7e0ce61 Mon Sep 17 00:00:00 2001 From: htlee Date: Sun, 15 Feb 2026 14:42:07 +0900 Subject: [PATCH] feat(map3d): add projection mode transition loading overlay --- apps/web/src/app/styles.css | 74 +++++++++++++++++++ .../web/src/pages/dashboard/DashboardPage.tsx | 14 ++++ apps/web/src/widgets/map3d/Map3D.tsx | 71 +++++++++++++++++- 3 files changed, 158 insertions(+), 1 deletion(-) diff --git a/apps/web/src/app/styles.css b/apps/web/src/app/styles.css index bd6a490..de52f4f 100644 --- a/apps/web/src/app/styles.css +++ b/apps/web/src/app/styles.css @@ -596,6 +596,80 @@ body { font-weight: 600; } +.map-loader-overlay { + position: absolute; + inset: 0; + z-index: 950; + display: flex; + align-items: center; + justify-content: center; + background: rgba(2, 6, 23, 0.42); + pointer-events: auto; +} + +.map-loader-overlay__panel { + width: min(72vw, 320px); + background: rgba(15, 23, 42, 0.94); + border: 1px solid var(--border); + border-radius: 12px; + padding: 14px 16px; + display: grid; + gap: 10px; + justify-items: center; +} + +.map-loader-overlay__spinner { + width: 28px; + height: 28px; + border: 3px solid rgba(148, 163, 184, 0.28); + border-top-color: var(--accent); + border-radius: 50%; + animation: map-loader-spin 0.7s linear infinite; +} + +.map-loader-overlay__text { + font-size: 12px; + color: var(--text); + letter-spacing: 0.2px; +} + +.map-loader-overlay__bar { + width: 100%; + height: 6px; + border-radius: 999px; + background: rgba(148, 163, 184, 0.2); + overflow: hidden; + position: relative; +} + +.map-loader-overlay__fill { + width: 28%; + height: 100%; + border-radius: inherit; + background: var(--accent); + animation: map-loader-fill 1.2s ease-in-out infinite; +} + +@keyframes map-loader-spin { + to { + transform: rotate(360deg); + } +} + +@keyframes map-loader-fill { + 0% { + transform: translateX(-40%); + } + + 50% { + transform: translateX(220%); + } + + 100% { + transform: translateX(-40%); + } +} + .close-btn { position: absolute; top: 6px; diff --git a/apps/web/src/pages/dashboard/DashboardPage.tsx b/apps/web/src/pages/dashboard/DashboardPage.tsx index ee949ac..c2f9453 100644 --- a/apps/web/src/pages/dashboard/DashboardPage.tsx +++ b/apps/web/src/pages/dashboard/DashboardPage.tsx @@ -115,6 +115,8 @@ export function DashboardPage() { showSeamark: false, }); + const [isProjectionLoading, setIsProjectionLoading] = useState(false); + const [clock, setClock] = useState(() => new Date().toLocaleString("ko-KR", { hour12: false })); useEffect(() => { const id = window.setInterval(() => setClock(new Date().toLocaleString("ko-KR", { hour12: false })), 1000); @@ -472,6 +474,17 @@ export function DashboardPage() {
+ {isProjectionLoading ? ( +
+
+
+
지도 모드 동기화 중...
+
+
+
+
+
+ ) : null} {selectedLegacyVessel ? ( diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index b5f7de9..241a0a7 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -42,6 +42,7 @@ type Props = { pairLinks?: PairLink[]; fcLinks?: FcLink[]; fleetCircles?: FleetCircle[]; + onProjectionLoadingChange?: (loading: boolean) => void; }; const SHIP_ICON_MAPPING = { @@ -570,6 +571,7 @@ export function Map3D({ pairLinks, fcLinks, fleetCircles, + onProjectionLoadingChange, }: Props) { const containerRef = useRef(null); const mapRef = useRef(null); @@ -580,8 +582,41 @@ export function Map3D({ const baseMapRef = useRef(baseMap); const projectionRef = useRef(projection); const globeShipIconLoadingRef = useRef(false); + const projectionBusyRef = useRef(false); + const projectionBusyTimerRef = useRef | null>(null); + const projectionPrevRef = useRef(projection); const [mapSyncEpoch, setMapSyncEpoch] = useState(0); + const clearProjectionBusyTimer = useCallback(() => { + if (projectionBusyTimerRef.current == null) return; + clearTimeout(projectionBusyTimerRef.current); + projectionBusyTimerRef.current = null; + }, []); + + const setProjectionLoading = useCallback( + (loading: boolean) => { + if (projectionBusyRef.current === loading) return; + projectionBusyRef.current = loading; + + if (loading) { + clearProjectionBusyTimer(); + projectionBusyTimerRef.current = setTimeout(() => { + if (projectionBusyRef.current) { + setProjectionLoading(false); + console.warn("Projection loading fallback timeout reached."); + } + }, 18000); + } else { + clearProjectionBusyTimer(); + } + + if (onProjectionLoadingChange) { + onProjectionLoadingChange(loading); + } + }, + [onProjectionLoadingChange, clearProjectionBusyTimer], + ); + const pulseMapSync = () => { setMapSyncEpoch((prev) => prev + 1); requestAnimationFrame(() => { @@ -590,6 +625,15 @@ export function Map3D({ }); }; + useEffect(() => { + return () => { + clearProjectionBusyTimer(); + if (projectionBusyRef.current) { + setProjectionLoading(false); + } + }; + }, [clearProjectionBusyTimer, setProjectionLoading]); + useEffect(() => { showSeamarkRef.current = settings.showSeamark; }, [settings.showSeamark]); @@ -816,6 +860,10 @@ export function Map3D({ let cancelled = false; let retries = 0; const maxRetries = 18; + const isTransition = projectionPrevRef.current !== projection; + projectionPrevRef.current = projection; + + if (isTransition) setProjectionLoading(true); const disposeMercatorOverlay = () => { const current = overlayRef.current; @@ -852,6 +900,9 @@ export function Map3D({ const syncProjectionAndDeck = () => { if (cancelled) return; + if (!isTransition) { + return; + } if (!map.isStyleLoaded()) { if (!cancelled && retries < maxRetries) { @@ -889,6 +940,7 @@ export function Map3D({ window.requestAnimationFrame(() => syncProjectionAndDeck()); return; } + if (isTransition) setProjectionLoading(false); console.warn("Projection switch failed:", e); } @@ -932,22 +984,39 @@ export function Map3D({ } catch { // ignore } + if (isTransition) { + const mercatorReady = projection === "mercator" && !!overlayRef.current; + const globeReady = projection === "globe" && !!map.getLayer("deck-globe"); + if (mercatorReady || globeReady) { + setProjectionLoading(false); + } else if (!cancelled && retries < maxRetries) { + retries += 1; + window.requestAnimationFrame(() => syncProjectionAndDeck()); + return; + } else { + setProjectionLoading(false); + } + } pulseMapSync(); }; + if (!isTransition) return; + if (map.isStyleLoaded()) syncProjectionAndDeck(); else { const stop = onMapStyleReady(map, syncProjectionAndDeck); return () => { cancelled = true; stop(); + if (isTransition) setProjectionLoading(false); }; } return () => { cancelled = true; + if (isTransition) setProjectionLoading(false); }; - }, [projection, clearGlobeNativeLayers, ensureMercatorOverlay, removeLayerIfExists]); + }, [projection, clearGlobeNativeLayers, ensureMercatorOverlay, removeLayerIfExists, setProjectionLoading]); // Base map toggle useEffect(() => {