gc-wing/apps/web/src/widgets/map3d/hooks/useProjectionToggle.ts
htlee c5d89c5641 refactor(map): Map3D.tsx hooks 추출 완료 (4558줄 → 510줄)
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>
2026-02-16 00:41:11 +09:00

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