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(() => {