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