- 리플레이 전용 범례(ReplayLegend): 재생 시점 선종별 카운트, 필터 동기화 - 궤적 표시 성능 최적화: 거리 필터(100m), 역비례 프레임 계산, 배속별 동일 시각적 길이 - 궤적 필터 동기화: 선종 OFF 즉시 제거, 프로그레스 드래그 시 클리어, 배속 변경 시 리셋 - 리플레이 로딩 프로그레스: 화면 중앙 원형 오버레이, 머지 타임스탬프 기반 진행률 - 선박 메뉴에서 필터 패널 직접 열기, 좌측 하단 필터/레이어 버튼 비활성화 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
471 lines
16 KiB
JavaScript
471 lines
16 KiB
JavaScript
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 (
|
|
<>
|
|
<div id="map" ref={mapRef} className="map-container" />
|
|
<TopBar />
|
|
{showLegend && (replayCompleted ? <ReplayLegend /> : <ShipLegend />)}
|
|
{hoverInfo && (
|
|
<ShipTooltip ship={hoverInfo.ship} x={hoverInfo.x} y={hoverInfo.y} />
|
|
)}
|
|
{detailModals.map((modal) => (
|
|
<ShipDetailModal key={modal.id} modal={modal} />
|
|
))}
|
|
<ShipContextMenu />
|
|
<GlobalTrackQueryViewer />
|
|
<ReplayLoadingOverlay />
|
|
{replayCompleted && (
|
|
<ReplayTimeline
|
|
fromDate={replayQuery?.startTime}
|
|
toDate={replayQuery?.endTime}
|
|
onClose={() => {
|
|
useReplayStore.getState().reset();
|
|
useAnimationStore.getState().reset();
|
|
unregisterReplayLayers();
|
|
showLiveShips(); // 라이브 선박 다시 표시
|
|
shipBatchRenderer.immediateRender();
|
|
}}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|