28개 useEffect + 30+ useCallback을 10개 커스텀 hook으로 추출: - useMapInit: MapLibre 인스턴스 생성 + Deck 오버레이 - useProjectionToggle: Mercator↔Globe 전환 - useBaseMapToggle: 베이스맵 전환 + 수심/해도 - useZonesLayer: 수역 GeoJSON 레이어 - usePredictionVectors: 예측 벡터 레이어 - useGlobeShips: Globe 선박 아이콘/라벨/호버/클릭 - useGlobeOverlays: Globe pair/fc/fleet/range 레이어 - useGlobeInteraction: Globe 마우스 이벤트 + 툴팁 - useDeckLayers: Mercator + Globe Deck 레이어 - useFlyTo: 카메라 이동 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
325 lines
9.7 KiB
TypeScript
325 lines
9.7 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 { DECK_VIEW_ID } from '../constants';
|
|
import { kickRepaint, onMapStyleReady, extractProjectionType } from '../lib/mapCore';
|
|
import { removeLayerIfExists } from '../lib/layerHelpers';
|
|
|
|
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;
|
|
clearGlobeNativeLayers: () => void;
|
|
ensureMercatorOverlay: () => MapboxOverlay | null;
|
|
onProjectionLoadingChange?: (loading: boolean) => void;
|
|
pulseMapSync: () => void;
|
|
setMapSyncEpoch: (updater: (prev: number) => number) => void;
|
|
},
|
|
): () => void {
|
|
const { projection, clearGlobeNativeLayers, 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();
|
|
}, 4000);
|
|
},
|
|
[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 = [
|
|
'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-fill',
|
|
'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 disposeMercatorOverlays = () => {
|
|
const disposeOne = (target: MapboxOverlay | null, toNull: 'base' | 'interaction') => {
|
|
if (!target) return;
|
|
try {
|
|
target.setProps({ layers: [] } as never);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
try {
|
|
map.removeControl(target as never);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
try {
|
|
target.finalize();
|
|
} catch {
|
|
// ignore
|
|
}
|
|
if (toNull === 'base') {
|
|
overlayRef.current = null;
|
|
} else {
|
|
overlayInteractionRef.current = null;
|
|
}
|
|
};
|
|
|
|
disposeOne(overlayRef.current, 'base');
|
|
disposeOne(overlayInteractionRef.current, 'interaction');
|
|
};
|
|
|
|
const disposeGlobeDeckLayer = () => {
|
|
const current = globeDeckLayerRef.current;
|
|
if (!current) return;
|
|
removeLayerIfExists(map, current.id);
|
|
try {
|
|
current.requestFinalize();
|
|
} catch {
|
|
// ignore
|
|
}
|
|
globeDeckLayerRef.current = null;
|
|
};
|
|
|
|
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') {
|
|
disposeMercatorOverlays();
|
|
clearGlobeNativeLayers();
|
|
} else {
|
|
disposeGlobeDeckLayer();
|
|
clearGlobeNativeLayers();
|
|
}
|
|
|
|
try {
|
|
if (shouldSwitchProjection) {
|
|
map.setProjection({ type: next });
|
|
}
|
|
map.setRenderWorldCopies(next !== 'globe');
|
|
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);
|
|
}
|
|
|
|
if (projection === 'globe') {
|
|
disposeGlobeDeckLayer();
|
|
|
|
if (!globeDeckLayerRef.current) {
|
|
globeDeckLayerRef.current = new MaplibreDeckCustomLayer({
|
|
id: 'deck-globe',
|
|
viewId: DECK_VIEW_ID,
|
|
deckProps: { layers: [] },
|
|
});
|
|
}
|
|
|
|
const layer = globeDeckLayerRef.current;
|
|
const layerId = layer?.id;
|
|
if (layer && layerId && map.isStyleLoaded() && !map.getLayer(layerId)) {
|
|
try {
|
|
map.addLayer(layer);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
if (!map.getLayer(layerId) && !cancelled && retries < maxRetries) {
|
|
retries += 1;
|
|
window.requestAnimationFrame(() => syncProjectionAndDeck());
|
|
return;
|
|
}
|
|
}
|
|
} else {
|
|
disposeGlobeDeckLayer();
|
|
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, clearGlobeNativeLayers, ensureMercatorOverlay, reorderGlobeFeatureLayers, setProjectionLoading]);
|
|
|
|
return reorderGlobeFeatureLayers;
|
|
}
|