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, overlayRef: MutableRefObject, overlayInteractionRef: MutableRefObject, globeDeckLayerRef: MutableRefObject, projectionBusyRef: MutableRefObject, 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 | null>(null); const projectionPrevRef = useRef(projection); const projectionRef = useRef(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; }