- guardedSetVisibility 도입: 현재 값과 동일하면 setLayoutProperty 호출 생략하여 style._changed 트리거 방지 → symbol 재배치로 인한 text-allow-overlap:false 라벨 사라짐 현상 해결 - useGlobeShips 기존 레이어 else 블록의 중복 expression 재설정 제거 (data-driven 표현식은 addLayer 시 1회 설정으로 충분) - _render 래퍼에서 globe scrollZoom easing 경고 억제 - fleet-circles-ml-fill 레이어 완전 제거 (vertex 65535 초과 원인) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
291 lines
8.9 KiB
TypeScript
291 lines
8.9 KiB
TypeScript
import { useCallback, useEffect, useRef, type MutableRefObject } from 'react';
|
|
import type maplibregl from 'maplibre-gl';
|
|
import { MapboxOverlay } from '@deck.gl/mapbox';
|
|
import { MaplibreDeckCustomLayer } from '../MaplibreDeckCustomLayer';
|
|
import type { MapProjectionId } from '../types';
|
|
import { kickRepaint, onMapStyleReady, extractProjectionType } from '../lib/mapCore';
|
|
|
|
export function useProjectionToggle(
|
|
mapRef: MutableRefObject<maplibregl.Map | null>,
|
|
overlayRef: MutableRefObject<MapboxOverlay | null>,
|
|
overlayInteractionRef: MutableRefObject<MapboxOverlay | null>,
|
|
globeDeckLayerRef: MutableRefObject<MaplibreDeckCustomLayer | null>,
|
|
projectionBusyRef: MutableRefObject<boolean>,
|
|
opts: {
|
|
projection: MapProjectionId;
|
|
ensureMercatorOverlay: () => MapboxOverlay | null;
|
|
onProjectionLoadingChange?: (loading: boolean) => void;
|
|
pulseMapSync: () => void;
|
|
setMapSyncEpoch: (updater: (prev: number) => number) => void;
|
|
},
|
|
): () => void {
|
|
const { projection, ensureMercatorOverlay, onProjectionLoadingChange, pulseMapSync, setMapSyncEpoch } = opts;
|
|
|
|
const projectionBusyTokenRef = useRef(0);
|
|
const projectionBusyTimerRef = useRef<ReturnType<typeof window.setTimeout> | null>(null);
|
|
const projectionPrevRef = useRef<MapProjectionId>(projection);
|
|
const projectionRef = useRef<MapProjectionId>(projection);
|
|
|
|
useEffect(() => {
|
|
projectionRef.current = projection;
|
|
}, [projection]);
|
|
|
|
const clearProjectionBusyTimer = useCallback(() => {
|
|
if (projectionBusyTimerRef.current == null) return;
|
|
clearTimeout(projectionBusyTimerRef.current);
|
|
projectionBusyTimerRef.current = null;
|
|
}, []);
|
|
|
|
// eslint-disable-next-line react-hooks/preserve-manual-memoization
|
|
const endProjectionLoading = useCallback(() => {
|
|
if (!projectionBusyRef.current) return;
|
|
projectionBusyRef.current = false;
|
|
clearProjectionBusyTimer();
|
|
if (onProjectionLoadingChange) {
|
|
onProjectionLoadingChange(false);
|
|
}
|
|
setMapSyncEpoch((prev) => prev + 1);
|
|
kickRepaint(mapRef.current);
|
|
}, [clearProjectionBusyTimer, onProjectionLoadingChange, setMapSyncEpoch]);
|
|
|
|
const setProjectionLoading = useCallback(
|
|
// eslint-disable-next-line react-hooks/preserve-manual-memoization
|
|
(loading: boolean) => {
|
|
if (projectionBusyRef.current === loading) return;
|
|
if (!loading) {
|
|
endProjectionLoading();
|
|
return;
|
|
}
|
|
|
|
clearProjectionBusyTimer();
|
|
projectionBusyRef.current = true;
|
|
const token = ++projectionBusyTokenRef.current;
|
|
if (onProjectionLoadingChange) {
|
|
onProjectionLoadingChange(true);
|
|
}
|
|
|
|
projectionBusyTimerRef.current = setTimeout(() => {
|
|
if (!projectionBusyRef.current || projectionBusyTokenRef.current !== token) return;
|
|
console.debug('Projection loading fallback timeout reached.');
|
|
endProjectionLoading();
|
|
}, 2000);
|
|
},
|
|
[clearProjectionBusyTimer, endProjectionLoading, onProjectionLoadingChange],
|
|
);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
clearProjectionBusyTimer();
|
|
endProjectionLoading();
|
|
};
|
|
}, [clearProjectionBusyTimer, endProjectionLoading]);
|
|
|
|
// eslint-disable-next-line react-hooks/preserve-manual-memoization
|
|
const reorderGlobeFeatureLayers = useCallback(() => {
|
|
const map = mapRef.current;
|
|
if (!map || projectionRef.current !== 'globe') return;
|
|
if (projectionBusyRef.current) return;
|
|
if (!map.isStyleLoaded()) return;
|
|
|
|
const ordering = [
|
|
'subcables-hitarea',
|
|
'subcables-casing',
|
|
'subcables-line',
|
|
'subcables-glow',
|
|
'subcables-points',
|
|
'subcables-label',
|
|
'zones-fill',
|
|
'zones-line',
|
|
'zones-label',
|
|
'predict-vectors-outline',
|
|
'predict-vectors',
|
|
'predict-vectors-hl-outline',
|
|
'predict-vectors-hl',
|
|
'ships-globe-halo',
|
|
'ships-globe-outline',
|
|
'ships-globe',
|
|
'ships-globe-label',
|
|
'ships-globe-hover-halo',
|
|
'ships-globe-hover-outline',
|
|
'ships-globe-hover',
|
|
'pair-lines-ml',
|
|
'fc-lines-ml',
|
|
'pair-range-ml',
|
|
'fleet-circles-ml',
|
|
];
|
|
|
|
for (const layerId of ordering) {
|
|
try {
|
|
if (map.getLayer(layerId)) map.moveLayer(layerId);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
kickRepaint(map);
|
|
}, []);
|
|
|
|
// Projection toggle (mercator <-> globe)
|
|
useEffect(() => {
|
|
const map = mapRef.current;
|
|
if (!map) return;
|
|
let cancelled = false;
|
|
let retries = 0;
|
|
const maxRetries = 18;
|
|
const isTransition = projectionPrevRef.current !== projection;
|
|
projectionPrevRef.current = projection;
|
|
let settleScheduled = false;
|
|
let settleCleanup: (() => void) | null = null;
|
|
|
|
const startProjectionSettle = () => {
|
|
if (!isTransition || settleScheduled) return;
|
|
settleScheduled = true;
|
|
|
|
const finalize = () => {
|
|
if (!cancelled && isTransition) setProjectionLoading(false);
|
|
};
|
|
|
|
const finalizeSoon = () => {
|
|
if (cancelled || !isTransition || projectionBusyRef.current === false) return;
|
|
if (!map.isStyleLoaded()) {
|
|
requestAnimationFrame(finalizeSoon);
|
|
return;
|
|
}
|
|
requestAnimationFrame(finalize);
|
|
};
|
|
|
|
const onIdle = () => finalizeSoon();
|
|
try {
|
|
map.on('idle', onIdle);
|
|
const styleReadyCleanup = onMapStyleReady(map, finalizeSoon);
|
|
settleCleanup = () => {
|
|
map.off('idle', onIdle);
|
|
styleReadyCleanup();
|
|
};
|
|
} catch {
|
|
requestAnimationFrame(finalize);
|
|
settleCleanup = null;
|
|
}
|
|
|
|
finalizeSoon();
|
|
};
|
|
|
|
if (isTransition) setProjectionLoading(true);
|
|
|
|
// 파괴하지 않고 레이어만 비움 — 양쪽 파이프라인 항상 유지
|
|
const quietMercatorOverlays = () => {
|
|
try { overlayRef.current?.setProps({ layers: [] } as never); } catch { /* ignore */ }
|
|
try { overlayInteractionRef.current?.setProps({ layers: [] } as never); } catch { /* ignore */ }
|
|
};
|
|
|
|
const quietGlobeDeckLayer = () => {
|
|
try { globeDeckLayerRef.current?.setProps({ layers: [] } as never); } catch { /* ignore */ }
|
|
};
|
|
|
|
const syncProjectionAndDeck = () => {
|
|
if (cancelled) return;
|
|
if (!isTransition) {
|
|
return;
|
|
}
|
|
|
|
if (!map.isStyleLoaded()) {
|
|
if (!cancelled && retries < maxRetries) {
|
|
retries += 1;
|
|
window.requestAnimationFrame(() => syncProjectionAndDeck());
|
|
}
|
|
return;
|
|
}
|
|
|
|
const next = projection;
|
|
const currentProjection = extractProjectionType(map);
|
|
const shouldSwitchProjection = currentProjection !== next;
|
|
|
|
if (projection === 'globe') {
|
|
quietMercatorOverlays();
|
|
} else {
|
|
quietGlobeDeckLayer();
|
|
}
|
|
|
|
try {
|
|
if (shouldSwitchProjection) {
|
|
map.setProjection({ type: next });
|
|
}
|
|
map.setRenderWorldCopies(next !== 'globe');
|
|
|
|
// Globe에서는 easeTo around 미지원 → scrollZoom 동작 전환
|
|
try {
|
|
map.scrollZoom.disable();
|
|
if (next === 'globe') {
|
|
map.scrollZoom.enable();
|
|
} else {
|
|
map.scrollZoom.enable({ around: 'center' });
|
|
}
|
|
} catch { /* ignore */ }
|
|
|
|
if (shouldSwitchProjection && currentProjection !== next && !cancelled && retries < maxRetries) {
|
|
retries += 1;
|
|
window.requestAnimationFrame(() => syncProjectionAndDeck());
|
|
return;
|
|
}
|
|
} catch (e) {
|
|
if (!cancelled && retries < maxRetries) {
|
|
retries += 1;
|
|
window.requestAnimationFrame(() => syncProjectionAndDeck());
|
|
return;
|
|
}
|
|
if (isTransition) setProjectionLoading(false);
|
|
console.warn('Projection switch failed:', e);
|
|
}
|
|
|
|
// 양쪽 overlay가 항상 존재하므로 재생성 불필요
|
|
// deck-globe가 map에서 빠져있을 경우에만 재추가
|
|
if (projection === 'globe') {
|
|
const layer = globeDeckLayerRef.current;
|
|
const layerId = layer?.id;
|
|
if (layer && layerId && map.isStyleLoaded() && !map.getLayer(layerId)) {
|
|
try {
|
|
map.addLayer(layer);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
} else {
|
|
ensureMercatorOverlay();
|
|
}
|
|
|
|
reorderGlobeFeatureLayers();
|
|
kickRepaint(map);
|
|
try {
|
|
map.resize();
|
|
} catch {
|
|
// ignore
|
|
}
|
|
if (isTransition) {
|
|
startProjectionSettle();
|
|
}
|
|
pulseMapSync();
|
|
};
|
|
|
|
if (!isTransition) return;
|
|
|
|
if (map.isStyleLoaded()) syncProjectionAndDeck();
|
|
else {
|
|
const stop = onMapStyleReady(map, syncProjectionAndDeck);
|
|
return () => {
|
|
cancelled = true;
|
|
if (settleCleanup) settleCleanup();
|
|
stop();
|
|
if (isTransition) setProjectionLoading(false);
|
|
};
|
|
}
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
if (settleCleanup) settleCleanup();
|
|
if (isTransition) setProjectionLoading(false);
|
|
};
|
|
}, [projection, ensureMercatorOverlay, reorderGlobeFeatureLayers, setProjectionLoading]);
|
|
|
|
return reorderGlobeFeatureLayers;
|
|
}
|