ship-gis/src/map/MapContainer.jsx
HeungTak Lee 19b2cff39e feat: 리플레이 범례/궤적 최적화/로딩 프로그레스/UI 개선
- 리플레이 전용 범례(ReplayLegend): 재생 시점 선종별 카운트, 필터 동기화
- 궤적 표시 성능 최적화: 거리 필터(100m), 역비례 프레임 계산, 배속별 동일 시각적 길이
- 궤적 필터 동기화: 선종 OFF 즉시 제거, 프로그레스 드래그 시 클리어, 배속 변경 시 리셋
- 리플레이 로딩 프로그레스: 화면 중앙 원형 오버레이, 머지 타임스탬프 기반 진행률
- 선박 메뉴에서 필터 패널 직접 열기, 좌측 하단 필터/레이어 버튼 비활성화

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-05 09:59:30 +09:00

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();
}}
/>
)}
</>
);
}