import { useCallback, useEffect, useRef, type MutableRefObject, type Dispatch, type SetStateAction } from 'react'; import maplibregl, { type StyleSpecification } from 'maplibre-gl'; import { MapboxOverlay } from '@deck.gl/mapbox'; import { MaplibreDeckCustomLayer } from '../MaplibreDeckCustomLayer'; import type { BaseMapId, MapProjectionId, MapViewState } from '../types'; import { DECK_VIEW_ID, ANCHORED_SHIP_ICON_ID } from '../constants'; import { kickRepaint, onMapStyleReady } from '../lib/mapCore'; import { ensureSeamarkOverlay } from '../layers/seamark'; import { resolveMapStyle } from '../layers/bathymetry'; export function useMapInit( containerRef: MutableRefObject, mapRef: MutableRefObject, overlayRef: MutableRefObject, overlayInteractionRef: MutableRefObject, globeDeckLayerRef: MutableRefObject, baseMapRef: MutableRefObject, projectionRef: MutableRefObject, opts: { baseMap: BaseMapId; projection: MapProjectionId; showSeamark: boolean; onViewBboxChange?: (bbox: [number, number, number, number]) => void; setMapSyncEpoch: Dispatch>; initialView?: MapViewState | null; onViewStateChange?: (view: MapViewState) => void; }, ) { const { onViewBboxChange, setMapSyncEpoch, showSeamark } = opts; const showSeamarkRef = useRef(showSeamark); const onViewStateChangeRef = useRef(opts.onViewStateChange); useEffect(() => { onViewStateChangeRef.current = opts.onViewStateChange; }, [opts.onViewStateChange]); useEffect(() => { showSeamarkRef.current = showSeamark; }, [showSeamark]); const ensureMercatorOverlay = useCallback(() => { const map = mapRef.current; if (!map) return null; if (overlayRef.current) return overlayRef.current; try { const next = new MapboxOverlay({ interleaved: true, layers: [] } as unknown as never); map.addControl(next); overlayRef.current = next; return next; } catch (e) { console.warn('Deck overlay create failed:', e); return null; } }, []); const pulseMapSync = useCallback(() => { setMapSyncEpoch((prev) => prev + 1); requestAnimationFrame(() => { kickRepaint(mapRef.current); setMapSyncEpoch((prev) => prev + 1); }); }, [setMapSyncEpoch]); useEffect(() => { if (!containerRef.current || mapRef.current) return; let map: maplibregl.Map | null = null; let cancelled = false; let viewSaveTimer: ReturnType | null = null; const controller = new AbortController(); (async () => { let style: string | StyleSpecification = '/map/styles/osm-seamark.json'; try { style = await resolveMapStyle(baseMapRef.current, controller.signal); } catch (e) { console.warn('Map style init failed, falling back to local raster style:', e); style = '/map/styles/osm-seamark.json'; } if (cancelled || !containerRef.current) return; const iv = opts.initialView; map = new maplibregl.Map({ container: containerRef.current, style, center: iv?.center ?? [126.5, 34.2], zoom: iv?.zoom ?? 7, pitch: iv?.pitch ?? 45, bearing: iv?.bearing ?? 0, maxPitch: 85, dragRotate: true, pitchWithRotate: true, touchPitch: true, scrollZoom: { around: 'center' }, }); map.addControl(new maplibregl.NavigationControl({ showZoom: true, showCompass: true }), 'top-left'); map.addControl(new maplibregl.ScaleControl({ maxWidth: 120, unit: 'metric' }), 'bottom-left'); // MapLibre 내부 placement TypeError 방어 + globe easing 경고 억제 // symbol layer 추가/제거와 render cycle 간 타이밍 이슈로 발생하는 에러 억제 // globe projection에서 scrollZoom이 easeTo(around)를 호출하면 경고 발생 → 구조적 한계로 억제 { const origRender = (map as unknown as { _render: (arg?: number) => void })._render; const origWarn = console.warn; (map as unknown as { _render: (arg?: number) => void })._render = function (arg?: number) { // globe 모드에서 scrollZoom의 easeTo around 경고 억제 // eslint-disable-next-line no-console console.warn = function (...args: unknown[]) { if (typeof args[0] === 'string' && (args[0] as string).includes('Easing around a point')) return; origWarn.apply(console, args as [unknown, ...unknown[]]); }; try { origRender.call(this, arg); } catch (e) { if (e instanceof TypeError && (e.message?.includes("reading 'get'") || e.message?.includes('placement'))) { return; } throw e; } finally { // eslint-disable-next-line no-console console.warn = origWarn; } }; } // Globe 모드 전환 시 지연을 제거하기 위해 ship.svg를 미리 로드 { const SHIP_IMG_ID = 'ship-globe-icon'; const localMap = map; void localMap .loadImage('/assets/ship.svg') .then((response) => { if (cancelled || !localMap) return; const img = (response as { data?: HTMLImageElement | ImageBitmap } | undefined)?.data; if (!img) return; try { if (!localMap.hasImage(SHIP_IMG_ID)) localMap.addImage(SHIP_IMG_ID, img, { pixelRatio: 2, sdf: true }); if (!localMap.hasImage(ANCHORED_SHIP_ICON_ID)) localMap.addImage(ANCHORED_SHIP_ICON_ID, img, { pixelRatio: 2, sdf: true }); } catch { // ignore — fallback canvas icon이 useGlobeShips에서 사용됨 } }) .catch(() => { // ignore — useGlobeShips에서 fallback 처리 }); } mapRef.current = map; // 양쪽 overlay를 모두 초기화 — projection 전환 시 재생성 비용 제거 ensureMercatorOverlay(); globeDeckLayerRef.current = new MaplibreDeckCustomLayer({ id: 'deck-globe', viewId: DECK_VIEW_ID, deckProps: { layers: [] }, }); function applyProjection() { if (!map) return; const next = projectionRef.current; if (next === 'mercator') return; try { map.setProjection({ type: next }); map.setRenderWorldCopies(next !== 'globe'); } catch (e) { console.warn('Projection apply failed:', e); } } onMapStyleReady(map, () => { applyProjection(); // deck-globe를 항상 추가 (projection과 무관) const deckLayer = globeDeckLayerRef.current; if (deckLayer && !map!.getLayer(deckLayer.id)) { try { map!.addLayer(deckLayer); } catch { // ignore } } if (!showSeamarkRef.current) return; try { ensureSeamarkOverlay(map!, 'bathymetry-lines'); } catch { // ignore } }); const emitBbox = () => { const cb = onViewBboxChange; if (!cb || !map) return; const b = map.getBounds(); cb([b.getWest(), b.getSouth(), b.getEast(), b.getNorth()]); }; map.on('load', emitBbox); map.on('moveend', emitBbox); // 60초 인터벌로 뷰 상태 저장 (mercator일 때만) viewSaveTimer = setInterval(() => { const cb = onViewStateChangeRef.current; if (!cb || !map || projectionRef.current !== 'mercator') return; const c = map.getCenter(); cb({ center: [c.lng, c.lat], zoom: map.getZoom(), bearing: map.getBearing(), pitch: map.getPitch() }); }, 60_000); map.once('load', () => { if (showSeamarkRef.current) { try { ensureSeamarkOverlay(map!, 'bathymetry-lines'); } catch { // ignore } try { const opacity = showSeamarkRef.current ? 0.85 : 0; map!.setPaintProperty('seamark', 'raster-opacity', opacity); } catch { // ignore } } // 종속 hook들(useMapStyleSettings 등)이 저장된 설정을 적용하도록 트리거 setMapSyncEpoch((prev) => prev + 1); }); })(); return () => { cancelled = true; controller.abort(); if (viewSaveTimer) clearInterval(viewSaveTimer); // 최종 뷰 상태 저장 (mercator일 때만 — globe 위치는 영속화하지 않음) const cb = onViewStateChangeRef.current; if (cb && map && projectionRef.current === 'mercator') { const c = map.getCenter(); cb({ center: [c.lng, c.lat], zoom: map.getZoom(), bearing: map.getBearing(), pitch: map.getPitch() }); } try { globeDeckLayerRef.current?.requestFinalize(); } catch { // ignore } if (map) { map.remove(); map = null; } if (overlayRef.current) { overlayRef.current.finalize(); overlayRef.current = null; } if (overlayInteractionRef.current) { overlayInteractionRef.current.finalize(); overlayInteractionRef.current = null; } globeDeckLayerRef.current = null; mapRef.current = null; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return { ensureMercatorOverlay, pulseMapSync }; }