feat(map3d): add projection mode transition loading overlay

This commit is contained in:
htlee 2026-02-15 14:42:07 +09:00
부모 2514591703
커밋 f745bb16d7
3개의 변경된 파일158개의 추가작업 그리고 1개의 파일을 삭제

파일 보기

@ -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;

파일 보기

@ -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() {
</div>
<div className="map-area">
{isProjectionLoading ? (
<div className="map-loader-overlay" role="status" aria-live="polite">
<div className="map-loader-overlay__panel">
<div className="map-loader-overlay__spinner" />
<div className="map-loader-overlay__text"> ...</div>
<div className="map-loader-overlay__bar">
<div className="map-loader-overlay__fill" />
</div>
</div>
</div>
) : null}
<Map3D
targets={targetsForMap}
zones={zones}
@ -486,6 +499,7 @@ export function DashboardPage() {
pairLinks={pairLinksForMap}
fcLinks={fcLinksForMap}
fleetCircles={fleetCirclesForMap}
onProjectionLoadingChange={setIsProjectionLoading}
/>
<MapLegend />
{selectedLegacyVessel ? (

파일 보기

@ -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<HTMLDivElement | null>(null);
const mapRef = useRef<maplibregl.Map | null>(null);
@ -580,8 +582,41 @@ export function Map3D({
const baseMapRef = useRef<BaseMapId>(baseMap);
const projectionRef = useRef<MapProjectionId>(projection);
const globeShipIconLoadingRef = useRef(false);
const projectionBusyRef = useRef(false);
const projectionBusyTimerRef = useRef<ReturnType<typeof window.setTimeout> | null>(null);
const projectionPrevRef = useRef<MapProjectionId>(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(() => {