297 lines
8.9 KiB
React
297 lines
8.9 KiB
React
|
|
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 } 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 ShipTooltip from '../components/ship/ShipTooltip';
|
||
|
|
import ShipDetailModal from '../components/ship/ShipDetailModal';
|
||
|
|
import ShipContextMenu from '../components/ship/ShipContextMenu';
|
||
|
|
|
||
|
|
import useMeasure from './measure/useMeasure';
|
||
|
|
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 { map, setMap, setZoom, center } = useMapStore();
|
||
|
|
const { showLegend } = useShipStore();
|
||
|
|
const hoverInfo = useShipStore((s) => s.hoverInfo);
|
||
|
|
const detailModals = useShipStore((s) => s.detailModals);
|
||
|
|
|
||
|
|
// STOMP 선박 데이터 연결
|
||
|
|
useShipData({ autoConnect: true });
|
||
|
|
|
||
|
|
// Deck.gl 선박 레이어
|
||
|
|
const { deckRef } = useShipLayer(map);
|
||
|
|
|
||
|
|
// 측정 도구
|
||
|
|
useMeasure();
|
||
|
|
|
||
|
|
// 호버 쓰로틀 타이머
|
||
|
|
const hoverTimerRef = useRef(null);
|
||
|
|
|
||
|
|
/**
|
||
|
|
* deck.pickObject 헬퍼
|
||
|
|
*/
|
||
|
|
const pickShip = useCallback((pixel) => {
|
||
|
|
const deck = deckRef.current;
|
||
|
|
if (!deck) return null;
|
||
|
|
|
||
|
|
// deck.layerManager가 초기화되기 전에 pickObject 호출하면 assertion error 발생
|
||
|
|
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]);
|
||
|
|
|
||
|
|
/**
|
||
|
|
* OpenLayers pointermove → 호버 툴팁
|
||
|
|
*/
|
||
|
|
const handlePointerMove = useCallback((evt) => {
|
||
|
|
// 드래그 중이면 무시
|
||
|
|
if (evt.dragging) {
|
||
|
|
useShipStore.getState().setHoverInfo(null);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// 쓰로틀
|
||
|
|
if (hoverTimerRef.current) return;
|
||
|
|
|
||
|
|
hoverTimerRef.current = setTimeout(() => {
|
||
|
|
hoverTimerRef.current = null;
|
||
|
|
|
||
|
|
const pixel = evt.pixel;
|
||
|
|
const ship = pickShip(pixel);
|
||
|
|
|
||
|
|
if (ship) {
|
||
|
|
// evt.originalEvent에서 화면 좌표 가져오기
|
||
|
|
const { clientX, clientY } = evt.originalEvent;
|
||
|
|
useShipStore.getState().setHoverInfo({ ship, x: clientX, y: clientY });
|
||
|
|
} else {
|
||
|
|
useShipStore.getState().setHoverInfo(null);
|
||
|
|
}
|
||
|
|
}, HOVER_THROTTLE_MS);
|
||
|
|
}, [pickShip]);
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 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);
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 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 { worldMap, eastAsiaMap, korMap } = createBaseLayers();
|
||
|
|
|
||
|
|
// 스케일라인 컨트롤 (해리 단위)
|
||
|
|
const scaleLineControl = new ScaleLine({
|
||
|
|
units: 'nautical',
|
||
|
|
bar: true,
|
||
|
|
text: true,
|
||
|
|
});
|
||
|
|
|
||
|
|
// 지도 인스턴스 생성
|
||
|
|
const map = new Map({
|
||
|
|
target: mapRef.current,
|
||
|
|
layers: [
|
||
|
|
worldMap,
|
||
|
|
eastAsiaMap,
|
||
|
|
korMap,
|
||
|
|
],
|
||
|
|
view: new View({
|
||
|
|
center: fromLonLat(center),
|
||
|
|
zoom: 7,
|
||
|
|
minZoom: 0,
|
||
|
|
maxZoom: 17,
|
||
|
|
}),
|
||
|
|
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;
|
||
|
|
|
||
|
|
// 클린업
|
||
|
|
return () => {
|
||
|
|
if (mapInstanceRef.current) {
|
||
|
|
mapInstanceRef.current.setTarget(null);
|
||
|
|
mapInstanceRef.current = null;
|
||
|
|
}
|
||
|
|
};
|
||
|
|
}, []);
|
||
|
|
|
||
|
|
return (
|
||
|
|
<>
|
||
|
|
<div id="map" ref={mapRef} className="map-container" />
|
||
|
|
{showLegend && <ShipLegend />}
|
||
|
|
{hoverInfo && (
|
||
|
|
<ShipTooltip ship={hoverInfo.ship} x={hoverInfo.x} y={hoverInfo.y} />
|
||
|
|
)}
|
||
|
|
{detailModals.map((modal) => (
|
||
|
|
<ShipDetailModal key={modal.id} modal={modal} />
|
||
|
|
))}
|
||
|
|
<ShipContextMenu />
|
||
|
|
</>
|
||
|
|
);
|
||
|
|
}
|