import { useEffect, useRef, useCallback } from 'react'; import Map from 'ol/Map'; import View from 'ol/View'; import { fromLonLat, transformExtent } from 'ol/proj'; import { defaults as defaultControls, ScaleLine } from 'ol/control'; import { defaults as defaultInteractions, DragBox } from 'ol/interaction'; import { platformModifierKeyOnly } from 'ol/events/condition'; import { createBaseLayers } from './layers/baseLayer'; import { useMapStore, BASE_MAP_TYPES } from '../stores/mapStore'; import useShipStore from '../stores/shipStore'; import useShipData from '../hooks/useShipData'; import useShipLayer from '../hooks/useShipLayer'; import ShipLegend from '../components/ship/ShipLegend'; import ReplayLegend from '../components/ship/ReplayLegend'; import ShipTooltip from '../components/ship/ShipTooltip'; import ShipDetailModal from '../components/ship/ShipDetailModal'; import ShipContextMenu from '../components/ship/ShipContextMenu'; import TopBar from '../components/map/TopBar'; import GlobalTrackQueryViewer from '../tracking/components/GlobalTrackQueryViewer'; import useReplayLayer from '../replay/hooks/useReplayLayer'; import { shipBatchRenderer } from './ShipBatchRenderer'; import useReplayStore from '../replay/stores/replayStore'; import useAnimationStore from '../replay/stores/animationStore'; import ReplayTimeline from '../replay/components/ReplayTimeline'; import ReplayLoadingOverlay from '../replay/components/ReplayLoadingOverlay'; import { unregisterReplayLayers } from '../replay/utils/replayLayerRegistry'; import { showLiveShips } from '../utils/liveControl'; import { useTrackQueryStore } from '../tracking/stores/trackQueryStore'; import { LAYER_IDS as TRACK_QUERY_LAYER_IDS } from '../tracking/utils/trackQueryLayerUtils'; import useMeasure from './measure/useMeasure'; import useTrackingMode from '../hooks/useTrackingMode'; import './measure/measure.scss'; import './MapContainer.scss'; /** 호버 쓰로틀 간격 (ms) */ const HOVER_THROTTLE_MS = 50; /** * 지도 컨테이너 컴포넌트 * - OpenLayers 맵 초기화 및 관리 * - STOMP 선박 데이터 연결 * - Deck.gl 선박 레이어 렌더링 * - 선박 호버 툴팁 / 더블클릭 상세 모달 */ export default function MapContainer() { const mapRef = useRef(null); const mapInstanceRef = useRef(null); const baseLayersRef = useRef(null); // 배경지도 레이어 참조 const { map, setMap, setZoom, center, baseMapType } = useMapStore(); const showLegend = useShipStore((s) => s.showLegend); const hoverInfo = useShipStore((s) => s.hoverInfo); const detailModals = useShipStore((s) => s.detailModals); const replayCompleted = useReplayStore((s) => s.queryCompleted); const replayQuery = useReplayStore((s) => s.currentQuery); // STOMP 선박 데이터 연결 useShipData({ autoConnect: true }); // Deck.gl 선박 레이어 const { deckRef } = useShipLayer(map); // 리플레이 레이어 useReplayLayer(); // 측정 도구 useMeasure(); // 추적 모드 (함정 중심 지도 이동 + 반경 원) useTrackingMode(); // 배경지도 타입 변경 시 레이어 가시성 토글 useEffect(() => { if (!baseLayersRef.current) return; const { worldMap, encMap, darkMap } = baseLayersRef.current; worldMap.setVisible(baseMapType === BASE_MAP_TYPES.NORMAL); encMap.setVisible(baseMapType === BASE_MAP_TYPES.ENC); darkMap.setVisible(baseMapType === BASE_MAP_TYPES.DARK); }, [baseMapType]); // 호버 쓰로틀 타이머 const hoverTimerRef = useRef(null); /** * deck.pickObject 헬퍼 (라이브 선박 전용) */ const pickShip = useCallback((pixel) => { const deck = deckRef.current; if (!deck) return null; if (!deck.layerManager) return null; try { const result = deck.pickObject({ x: pixel[0], y: pixel[1], layerIds: ['ship-icon-layer'], }); return result?.object || null; } catch { return null; } }, [deckRef]); /** * deck.pickObject 헬퍼 (모든 레이어) */ const pickAny = useCallback((pixel) => { const deck = deckRef.current; if (!deck) return null; if (!deck.layerManager) return null; try { // layerIds를 지정하지 않으면 모든 pickable 레이어에서 픽킹 const result = deck.pickObject({ x: pixel[0], y: pixel[1], }); return result || null; } catch { return null; } }, [deckRef]); /** * OpenLayers pointermove → 호버 툴팁 */ const handlePointerMove = useCallback((evt) => { // 드래그 중이면 무시 if (evt.dragging) { useShipStore.getState().setHoverInfo(null); useTrackQueryStore.getState().setHighlightedVesselId(null); useReplayStore.getState().setHighlightedVesselId(null); return; } // 쓰로틀 if (hoverTimerRef.current) return; hoverTimerRef.current = setTimeout(() => { hoverTimerRef.current = null; const pixel = evt.pixel; const { clientX, clientY } = evt.originalEvent; const pickResult = pickAny(pixel); if (!pickResult || !pickResult.layer) { // 아무것도 픽킹되지 않음 useShipStore.getState().setHoverInfo(null); useTrackQueryStore.getState().setHighlightedVesselId(null); useTrackQueryStore.getState().clearHoveredPoint(); useReplayStore.getState().setHighlightedVesselId(null); return; } const layerId = pickResult.layer.id; const obj = pickResult.object; // 라이브 선박 if (layerId === 'ship-icon-layer') { useShipStore.getState().setHoverInfo({ ship: obj, x: clientX, y: clientY }); useTrackQueryStore.getState().setHighlightedVesselId(null); useTrackQueryStore.getState().clearHoveredPoint(); useReplayStore.getState().setHighlightedVesselId(null); return; } // 항적조회 경로 (PathLayer) if (layerId === TRACK_QUERY_LAYER_IDS.PATH) { useShipStore.getState().setHoverInfo(null); useTrackQueryStore.getState().setHighlightedVesselId(obj?.vesselId || null); useTrackQueryStore.getState().clearHoveredPoint(); useReplayStore.getState().setHighlightedVesselId(null); return; } // 항적조회 포인트 (ScatterplotLayer) if (layerId === TRACK_QUERY_LAYER_IDS.POINTS) { useShipStore.getState().setHoverInfo(null); useTrackQueryStore.getState().setHighlightedVesselId(obj?.vesselId || null); useReplayStore.getState().setHighlightedVesselId(null); // 포인트 호버 정보 설정 if (obj) { useTrackQueryStore.getState().setHoveredPoint({ vesselId: obj.vesselId, position: obj.position, timestamp: obj.timestamp, speed: obj.speed, index: obj.index, }, clientX, clientY); } else { useTrackQueryStore.getState().clearHoveredPoint(); } return; } // 항적조회 가상 선박 아이콘 if (layerId === TRACK_QUERY_LAYER_IDS.VIRTUAL_SHIP) { const tooltipShip = { shipName: obj.shipName, targetId: obj.vesselId?.split('_').pop() || obj.vesselId, signalKindCode: obj.shipKindCode, sog: obj.speed || 0, cog: obj.heading || 0, }; useShipStore.getState().setHoverInfo({ ship: tooltipShip, x: clientX, y: clientY }); useTrackQueryStore.getState().setHighlightedVesselId(obj?.vesselId || null); useTrackQueryStore.getState().clearHoveredPoint(); useReplayStore.getState().setHighlightedVesselId(null); return; } // 리플레이 경로 (PathLayer) if (layerId === 'track-path-layer') { useShipStore.getState().setHoverInfo(null); useTrackQueryStore.getState().setHighlightedVesselId(null); useTrackQueryStore.getState().clearHoveredPoint(); useReplayStore.getState().setHighlightedVesselId(obj?.vesselId || null); return; } // 리플레이 가상 선박 아이콘 if (layerId === 'track-virtual-ship-layer') { const tooltipShip = { shipName: obj.shipName, targetId: obj.vesselId?.split('_').pop() || obj.vesselId, signalKindCode: obj.shipKindCode, sog: obj.speed || 0, cog: obj.heading || 0, }; useShipStore.getState().setHoverInfo({ ship: tooltipShip, x: clientX, y: clientY }); useTrackQueryStore.getState().setHighlightedVesselId(null); useTrackQueryStore.getState().clearHoveredPoint(); useReplayStore.getState().setHighlightedVesselId(obj?.vesselId || null); return; } // 기타: 모두 클리어 useShipStore.getState().setHoverInfo(null); useTrackQueryStore.getState().setHighlightedVesselId(null); useTrackQueryStore.getState().clearHoveredPoint(); useReplayStore.getState().setHighlightedVesselId(null); }, HOVER_THROTTLE_MS); }, [pickAny]); /** * OpenLayers dblclick → 상세 모달 */ const handleDblClick = useCallback((evt) => { const pixel = evt.pixel; const ship = pickShip(pixel); if (ship) { evt.stopPropagation(); useShipStore.getState().openDetailModal(ship); } }, [pickShip]); /** * pointerout → 툴팁 숨김 + 하이라이트 클리어 */ const handlePointerOut = useCallback(() => { useShipStore.getState().setHoverInfo(null); useTrackQueryStore.getState().setHighlightedVesselId(null); useTrackQueryStore.getState().clearHoveredPoint(); useReplayStore.getState().setHighlightedVesselId(null); }, []); /** * singleclick → 빈 영역 클릭 시 선택/메뉴 해제 */ const handleSingleClick = useCallback((evt) => { const ship = pickShip(evt.pixel); if (!ship) { useShipStore.getState().clearSelectedShips(); useShipStore.getState().closeContextMenu(); } }, [pickShip]); // OL 이벤트 바인딩 useEffect(() => { if (!map) return; map.on('pointermove', handlePointerMove); map.on('dblclick', handleDblClick); map.on('singleclick', handleSingleClick); // pointerout은 뷰포트 DOM 이벤트로 처리 const viewport = map.getViewport(); viewport.addEventListener('pointerout', handlePointerOut); // 우클릭 컨텍스트 메뉴 const handleContextMenu = (e) => { e.preventDefault(); const pixel = map.getEventPixel(e); const ship = pickShip(pixel); const state = useShipStore.getState(); if (ship) { state.openContextMenu({ x: e.clientX, y: e.clientY, ships: [ship] }); } else if (state.selectedShipIds.length > 0) { const selectedShips = state.getSelectedShips(); state.openContextMenu({ x: e.clientX, y: e.clientY, ships: selectedShips }); } }; viewport.addEventListener('contextmenu', handleContextMenu); return () => { map.un('pointermove', handlePointerMove); map.un('dblclick', handleDblClick); map.un('singleclick', handleSingleClick); viewport.removeEventListener('pointerout', handlePointerOut); viewport.removeEventListener('contextmenu', handleContextMenu); if (hoverTimerRef.current) { clearTimeout(hoverTimerRef.current); hoverTimerRef.current = null; } }; }, [map, handlePointerMove, handleDblClick, handleSingleClick, handlePointerOut, pickShip]); useEffect(() => { if (!mapRef.current || mapInstanceRef.current) return; // 현재 배경지도 타입 가져오기 const currentBaseMapType = useMapStore.getState().baseMapType; // 베이스 레이어 생성 (3가지 배경지도 + 상세지도) const { worldMap, encMap, darkMap, eastAsiaMap, korMap } = createBaseLayers(currentBaseMapType); // 배경지도 레이어 참조 저장 baseLayersRef.current = { worldMap, encMap, darkMap }; // 스케일라인 컨트롤 (해리 단위) const scaleLineControl = new ScaleLine({ units: 'nautical', bar: true, text: true, }); // 지도 인스턴스 생성 const map = new Map({ target: mapRef.current, layers: [ worldMap, encMap, darkMap, eastAsiaMap, korMap, ], view: new View({ center: fromLonLat(center), zoom: 7, minZoom: 5, // 야간지도 타일 최소 레벨 maxZoom: 15, // 줌 확장은 15까지 (타일은 12레벨까지만 로드) }), controls: defaultControls({ attribution: false, zoom: false, rotate: false, }).extend([scaleLineControl]), interactions: defaultInteractions({ doubleClickZoom: false, }), }); // Ctrl+Drag 박스 선택 인터랙션 const dragBox = new DragBox({ condition: platformModifierKeyOnly }); map.addInteraction(dragBox); dragBox.on('boxend', () => { const extent3857 = dragBox.getGeometry().getExtent(); const [minLon, minLat, maxLon, maxLat] = transformExtent(extent3857, 'EPSG:3857', 'EPSG:4326'); const state = useShipStore.getState(); const { features, darkSignalIds, isIntegrate, kindVisibility, sourceVisibility, nationalVisibility, darkSignalVisible } = state; // 국적 코드 매핑 (shipStore.js와 동일) const mapNational = (code) => { if (!code) return 'OTHER'; const c = code.toUpperCase(); if (c === 'KR' || c === 'KOR' || c === '440') return 'KR'; if (c === 'CN' || c === 'CHN' || c === '412' || c === '413' || c === '414') return 'CN'; if (c === 'JP' || c === 'JPN' || c === '431' || c === '432') return 'JP'; if (c === 'KP' || c === 'PRK' || c === '445') return 'KP'; return 'OTHER'; }; const matchedIds = []; features.forEach((ship, featureId) => { // 단독 레이더 제외 if (ship.signalSourceCode === '000005' && !ship.integrate) return; // 통합 모드 ON: isPriority만 if (isIntegrate && ship.integrate && !ship.isPriority) return; // 다크시그널: 독립 필터 if (darkSignalIds.has(featureId)) { if (!darkSignalVisible) return; } else { if (!kindVisibility[ship.signalKindCode]) return; if (!sourceVisibility[ship.signalSourceCode]) return; const mappedNational = mapNational(ship.nationalCode); if (!nationalVisibility[mappedNational]) return; } const lon = parseFloat(ship.longitude); const lat = parseFloat(ship.latitude); if (lon >= minLon && lon <= maxLon && lat >= minLat && lat <= maxLat) { matchedIds.push(featureId); } }); state.setSelectedShipIds(matchedIds); }); // 줌 변경 이벤트 map.getView().on('change:resolution', () => { const zoom = Math.round(map.getView().getZoom()); setZoom(zoom); }); // 스토어에 맵 인스턴스 저장 setMap(map); mapInstanceRef.current = map; // TrackQueryViewer 등에서 줌 감지용 window.__mainMap__ = map; // 클린업 return () => { if (mapInstanceRef.current) { mapInstanceRef.current.setTarget(null); mapInstanceRef.current = null; } window.__mainMap__ = null; }; }, []); return ( <>
{showLegend && (replayCompleted ? : )} {hoverInfo && ( )} {detailModals.map((modal) => ( ))} {replayCompleted && ( { useReplayStore.getState().reset(); useAnimationStore.getState().reset(); unregisterReplayLayers(); showLiveShips(); // 라이브 선박 다시 표시 shipBatchRenderer.immediateRender(); }} /> )} ); }