feat(map3d): add projection mode transition loading overlay
This commit is contained in:
부모
2514591703
커밋
f745bb16d7
@ -596,6 +596,80 @@ body {
|
|||||||
font-weight: 600;
|
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 {
|
.close-btn {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 6px;
|
top: 6px;
|
||||||
|
|||||||
@ -115,6 +115,8 @@ export function DashboardPage() {
|
|||||||
showSeamark: false,
|
showSeamark: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [isProjectionLoading, setIsProjectionLoading] = useState(false);
|
||||||
|
|
||||||
const [clock, setClock] = useState(() => new Date().toLocaleString("ko-KR", { hour12: false }));
|
const [clock, setClock] = useState(() => new Date().toLocaleString("ko-KR", { hour12: false }));
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const id = window.setInterval(() => setClock(new Date().toLocaleString("ko-KR", { hour12: false })), 1000);
|
const id = window.setInterval(() => setClock(new Date().toLocaleString("ko-KR", { hour12: false })), 1000);
|
||||||
@ -472,6 +474,17 @@ export function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="map-area">
|
<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
|
<Map3D
|
||||||
targets={targetsForMap}
|
targets={targetsForMap}
|
||||||
zones={zones}
|
zones={zones}
|
||||||
@ -486,6 +499,7 @@ export function DashboardPage() {
|
|||||||
pairLinks={pairLinksForMap}
|
pairLinks={pairLinksForMap}
|
||||||
fcLinks={fcLinksForMap}
|
fcLinks={fcLinksForMap}
|
||||||
fleetCircles={fleetCirclesForMap}
|
fleetCircles={fleetCirclesForMap}
|
||||||
|
onProjectionLoadingChange={setIsProjectionLoading}
|
||||||
/>
|
/>
|
||||||
<MapLegend />
|
<MapLegend />
|
||||||
{selectedLegacyVessel ? (
|
{selectedLegacyVessel ? (
|
||||||
|
|||||||
@ -42,6 +42,7 @@ type Props = {
|
|||||||
pairLinks?: PairLink[];
|
pairLinks?: PairLink[];
|
||||||
fcLinks?: FcLink[];
|
fcLinks?: FcLink[];
|
||||||
fleetCircles?: FleetCircle[];
|
fleetCircles?: FleetCircle[];
|
||||||
|
onProjectionLoadingChange?: (loading: boolean) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const SHIP_ICON_MAPPING = {
|
const SHIP_ICON_MAPPING = {
|
||||||
@ -570,6 +571,7 @@ export function Map3D({
|
|||||||
pairLinks,
|
pairLinks,
|
||||||
fcLinks,
|
fcLinks,
|
||||||
fleetCircles,
|
fleetCircles,
|
||||||
|
onProjectionLoadingChange,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const mapRef = useRef<maplibregl.Map | null>(null);
|
const mapRef = useRef<maplibregl.Map | null>(null);
|
||||||
@ -580,8 +582,41 @@ export function Map3D({
|
|||||||
const baseMapRef = useRef<BaseMapId>(baseMap);
|
const baseMapRef = useRef<BaseMapId>(baseMap);
|
||||||
const projectionRef = useRef<MapProjectionId>(projection);
|
const projectionRef = useRef<MapProjectionId>(projection);
|
||||||
const globeShipIconLoadingRef = useRef(false);
|
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 [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 = () => {
|
const pulseMapSync = () => {
|
||||||
setMapSyncEpoch((prev) => prev + 1);
|
setMapSyncEpoch((prev) => prev + 1);
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
@ -590,6 +625,15 @@ export function Map3D({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
clearProjectionBusyTimer();
|
||||||
|
if (projectionBusyRef.current) {
|
||||||
|
setProjectionLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [clearProjectionBusyTimer, setProjectionLoading]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
showSeamarkRef.current = settings.showSeamark;
|
showSeamarkRef.current = settings.showSeamark;
|
||||||
}, [settings.showSeamark]);
|
}, [settings.showSeamark]);
|
||||||
@ -816,6 +860,10 @@ export function Map3D({
|
|||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
let retries = 0;
|
let retries = 0;
|
||||||
const maxRetries = 18;
|
const maxRetries = 18;
|
||||||
|
const isTransition = projectionPrevRef.current !== projection;
|
||||||
|
projectionPrevRef.current = projection;
|
||||||
|
|
||||||
|
if (isTransition) setProjectionLoading(true);
|
||||||
|
|
||||||
const disposeMercatorOverlay = () => {
|
const disposeMercatorOverlay = () => {
|
||||||
const current = overlayRef.current;
|
const current = overlayRef.current;
|
||||||
@ -852,6 +900,9 @@ export function Map3D({
|
|||||||
|
|
||||||
const syncProjectionAndDeck = () => {
|
const syncProjectionAndDeck = () => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
|
if (!isTransition) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!map.isStyleLoaded()) {
|
if (!map.isStyleLoaded()) {
|
||||||
if (!cancelled && retries < maxRetries) {
|
if (!cancelled && retries < maxRetries) {
|
||||||
@ -889,6 +940,7 @@ export function Map3D({
|
|||||||
window.requestAnimationFrame(() => syncProjectionAndDeck());
|
window.requestAnimationFrame(() => syncProjectionAndDeck());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (isTransition) setProjectionLoading(false);
|
||||||
console.warn("Projection switch failed:", e);
|
console.warn("Projection switch failed:", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -932,22 +984,39 @@ export function Map3D({
|
|||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// 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();
|
pulseMapSync();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!isTransition) return;
|
||||||
|
|
||||||
if (map.isStyleLoaded()) syncProjectionAndDeck();
|
if (map.isStyleLoaded()) syncProjectionAndDeck();
|
||||||
else {
|
else {
|
||||||
const stop = onMapStyleReady(map, syncProjectionAndDeck);
|
const stop = onMapStyleReady(map, syncProjectionAndDeck);
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
stop();
|
stop();
|
||||||
|
if (isTransition) setProjectionLoading(false);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
if (isTransition) setProjectionLoading(false);
|
||||||
};
|
};
|
||||||
}, [projection, clearGlobeNativeLayers, ensureMercatorOverlay, removeLayerIfExists]);
|
}, [projection, clearGlobeNativeLayers, ensureMercatorOverlay, removeLayerIfExists, setProjectionLoading]);
|
||||||
|
|
||||||
// Base map toggle
|
// Base map toggle
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user