feat: MapContainer 통합 (항적조회, 리플레이, 추적 모드)
- TopBar, 항적조회 뷰어, 리플레이 타임라인 통합 - 배경지도 전환 레이어 가시성 토글 - 추적 모드 훅 연동 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
부모
83f5f72b0e
커밋
f273080d5e
@ -7,7 +7,7 @@ import { defaults as defaultInteractions, DragBox } from 'ol/interaction';
|
|||||||
import { platformModifierKeyOnly } from 'ol/events/condition';
|
import { platformModifierKeyOnly } from 'ol/events/condition';
|
||||||
|
|
||||||
import { createBaseLayers } from './layers/baseLayer';
|
import { createBaseLayers } from './layers/baseLayer';
|
||||||
import { useMapStore } from '../stores/mapStore';
|
import { useMapStore, BASE_MAP_TYPES } from '../stores/mapStore';
|
||||||
import useShipStore from '../stores/shipStore';
|
import useShipStore from '../stores/shipStore';
|
||||||
import useShipData from '../hooks/useShipData';
|
import useShipData from '../hooks/useShipData';
|
||||||
import useShipLayer from '../hooks/useShipLayer';
|
import useShipLayer from '../hooks/useShipLayer';
|
||||||
@ -15,8 +15,20 @@ import ShipLegend from '../components/ship/ShipLegend';
|
|||||||
import ShipTooltip from '../components/ship/ShipTooltip';
|
import ShipTooltip from '../components/ship/ShipTooltip';
|
||||||
import ShipDetailModal from '../components/ship/ShipDetailModal';
|
import ShipDetailModal from '../components/ship/ShipDetailModal';
|
||||||
import ShipContextMenu from '../components/ship/ShipContextMenu';
|
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 { 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 useMeasure from './measure/useMeasure';
|
||||||
|
import useTrackingMode from '../hooks/useTrackingMode';
|
||||||
import './measure/measure.scss';
|
import './measure/measure.scss';
|
||||||
import './MapContainer.scss';
|
import './MapContainer.scss';
|
||||||
|
|
||||||
@ -33,10 +45,13 @@ const HOVER_THROTTLE_MS = 50;
|
|||||||
export default function MapContainer() {
|
export default function MapContainer() {
|
||||||
const mapRef = useRef(null);
|
const mapRef = useRef(null);
|
||||||
const mapInstanceRef = useRef(null);
|
const mapInstanceRef = useRef(null);
|
||||||
const { map, setMap, setZoom, center } = useMapStore();
|
const baseLayersRef = useRef(null); // 배경지도 레이어 참조
|
||||||
const { showLegend } = useShipStore();
|
const { map, setMap, setZoom, center, baseMapType } = useMapStore();
|
||||||
|
const showLegend = useShipStore((s) => s.showLegend);
|
||||||
const hoverInfo = useShipStore((s) => s.hoverInfo);
|
const hoverInfo = useShipStore((s) => s.hoverInfo);
|
||||||
const detailModals = useShipStore((s) => s.detailModals);
|
const detailModals = useShipStore((s) => s.detailModals);
|
||||||
|
const replayCompleted = useReplayStore((s) => s.queryCompleted);
|
||||||
|
const replayQuery = useReplayStore((s) => s.currentQuery);
|
||||||
|
|
||||||
// STOMP 선박 데이터 연결
|
// STOMP 선박 데이터 연결
|
||||||
useShipData({ autoConnect: true });
|
useShipData({ autoConnect: true });
|
||||||
@ -44,20 +59,35 @@ export default function MapContainer() {
|
|||||||
// Deck.gl 선박 레이어
|
// Deck.gl 선박 레이어
|
||||||
const { deckRef } = useShipLayer(map);
|
const { deckRef } = useShipLayer(map);
|
||||||
|
|
||||||
|
// 리플레이 레이어
|
||||||
|
useReplayLayer();
|
||||||
|
|
||||||
// 측정 도구
|
// 측정 도구
|
||||||
useMeasure();
|
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);
|
const hoverTimerRef = useRef(null);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* deck.pickObject 헬퍼
|
* deck.pickObject 헬퍼 (라이브 선박 전용)
|
||||||
*/
|
*/
|
||||||
const pickShip = useCallback((pixel) => {
|
const pickShip = useCallback((pixel) => {
|
||||||
const deck = deckRef.current;
|
const deck = deckRef.current;
|
||||||
if (!deck) return null;
|
if (!deck) return null;
|
||||||
|
|
||||||
// deck.layerManager가 초기화되기 전에 pickObject 호출하면 assertion error 발생
|
|
||||||
if (!deck.layerManager) return null;
|
if (!deck.layerManager) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -72,6 +102,26 @@ export default function MapContainer() {
|
|||||||
}
|
}
|
||||||
}, [deckRef]);
|
}, [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 → 호버 툴팁
|
* OpenLayers pointermove → 호버 툴팁
|
||||||
*/
|
*/
|
||||||
@ -79,6 +129,8 @@ export default function MapContainer() {
|
|||||||
// 드래그 중이면 무시
|
// 드래그 중이면 무시
|
||||||
if (evt.dragging) {
|
if (evt.dragging) {
|
||||||
useShipStore.getState().setHoverInfo(null);
|
useShipStore.getState().setHoverInfo(null);
|
||||||
|
useTrackQueryStore.getState().setHighlightedVesselId(null);
|
||||||
|
useReplayStore.getState().setHighlightedVesselId(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -89,17 +141,107 @@ export default function MapContainer() {
|
|||||||
hoverTimerRef.current = null;
|
hoverTimerRef.current = null;
|
||||||
|
|
||||||
const pixel = evt.pixel;
|
const pixel = evt.pixel;
|
||||||
const ship = pickShip(pixel);
|
const { clientX, clientY } = evt.originalEvent;
|
||||||
|
const pickResult = pickAny(pixel);
|
||||||
|
|
||||||
if (ship) {
|
if (!pickResult || !pickResult.layer) {
|
||||||
// evt.originalEvent에서 화면 좌표 가져오기
|
// 아무것도 픽킹되지 않음
|
||||||
const { clientX, clientY } = evt.originalEvent;
|
|
||||||
useShipStore.getState().setHoverInfo({ ship, x: clientX, y: clientY });
|
|
||||||
} else {
|
|
||||||
useShipStore.getState().setHoverInfo(null);
|
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);
|
}, HOVER_THROTTLE_MS);
|
||||||
}, [pickShip]);
|
}, [pickAny]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* OpenLayers dblclick → 상세 모달
|
* OpenLayers dblclick → 상세 모달
|
||||||
@ -115,10 +257,13 @@ export default function MapContainer() {
|
|||||||
}, [pickShip]);
|
}, [pickShip]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* pointerout → 툴팁 숨김
|
* pointerout → 툴팁 숨김 + 하이라이트 클리어
|
||||||
*/
|
*/
|
||||||
const handlePointerOut = useCallback(() => {
|
const handlePointerOut = useCallback(() => {
|
||||||
useShipStore.getState().setHoverInfo(null);
|
useShipStore.getState().setHoverInfo(null);
|
||||||
|
useTrackQueryStore.getState().setHighlightedVesselId(null);
|
||||||
|
useTrackQueryStore.getState().clearHoveredPoint();
|
||||||
|
useReplayStore.getState().setHighlightedVesselId(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -177,8 +322,14 @@ export default function MapContainer() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!mapRef.current || mapInstanceRef.current) return;
|
if (!mapRef.current || mapInstanceRef.current) return;
|
||||||
|
|
||||||
// 베이스 레이어 생성
|
// 현재 배경지도 타입 가져오기
|
||||||
const { worldMap, eastAsiaMap, korMap } = createBaseLayers();
|
const currentBaseMapType = useMapStore.getState().baseMapType;
|
||||||
|
|
||||||
|
// 베이스 레이어 생성 (3가지 배경지도 + 상세지도)
|
||||||
|
const { worldMap, encMap, darkMap, eastAsiaMap, korMap } = createBaseLayers(currentBaseMapType);
|
||||||
|
|
||||||
|
// 배경지도 레이어 참조 저장
|
||||||
|
baseLayersRef.current = { worldMap, encMap, darkMap };
|
||||||
|
|
||||||
// 스케일라인 컨트롤 (해리 단위)
|
// 스케일라인 컨트롤 (해리 단위)
|
||||||
const scaleLineControl = new ScaleLine({
|
const scaleLineControl = new ScaleLine({
|
||||||
@ -192,14 +343,16 @@ export default function MapContainer() {
|
|||||||
target: mapRef.current,
|
target: mapRef.current,
|
||||||
layers: [
|
layers: [
|
||||||
worldMap,
|
worldMap,
|
||||||
|
encMap,
|
||||||
|
darkMap,
|
||||||
eastAsiaMap,
|
eastAsiaMap,
|
||||||
korMap,
|
korMap,
|
||||||
],
|
],
|
||||||
view: new View({
|
view: new View({
|
||||||
center: fromLonLat(center),
|
center: fromLonLat(center),
|
||||||
zoom: 7,
|
zoom: 7,
|
||||||
minZoom: 0,
|
minZoom: 5, // 야간지도 타일 최소 레벨
|
||||||
maxZoom: 17,
|
maxZoom: 15, // 줌 확장은 15까지 (타일은 12레벨까지만 로드)
|
||||||
}),
|
}),
|
||||||
controls: defaultControls({
|
controls: defaultControls({
|
||||||
attribution: false,
|
attribution: false,
|
||||||
@ -270,6 +423,8 @@ export default function MapContainer() {
|
|||||||
// 스토어에 맵 인스턴스 저장
|
// 스토어에 맵 인스턴스 저장
|
||||||
setMap(map);
|
setMap(map);
|
||||||
mapInstanceRef.current = map;
|
mapInstanceRef.current = map;
|
||||||
|
// TrackQueryViewer 등에서 줌 감지용
|
||||||
|
window.__mainMap__ = map;
|
||||||
|
|
||||||
// 클린업
|
// 클린업
|
||||||
return () => {
|
return () => {
|
||||||
@ -277,12 +432,14 @@ export default function MapContainer() {
|
|||||||
mapInstanceRef.current.setTarget(null);
|
mapInstanceRef.current.setTarget(null);
|
||||||
mapInstanceRef.current = null;
|
mapInstanceRef.current = null;
|
||||||
}
|
}
|
||||||
|
window.__mainMap__ = null;
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div id="map" ref={mapRef} className="map-container" />
|
<div id="map" ref={mapRef} className="map-container" />
|
||||||
|
<TopBar />
|
||||||
{showLegend && <ShipLegend />}
|
{showLegend && <ShipLegend />}
|
||||||
{hoverInfo && (
|
{hoverInfo && (
|
||||||
<ShipTooltip ship={hoverInfo.ship} x={hoverInfo.x} y={hoverInfo.y} />
|
<ShipTooltip ship={hoverInfo.ship} x={hoverInfo.x} y={hoverInfo.y} />
|
||||||
@ -291,6 +448,20 @@ export default function MapContainer() {
|
|||||||
<ShipDetailModal key={modal.id} modal={modal} />
|
<ShipDetailModal key={modal.id} modal={modal} />
|
||||||
))}
|
))}
|
||||||
<ShipContextMenu />
|
<ShipContextMenu />
|
||||||
|
<GlobalTrackQueryViewer />
|
||||||
|
{replayCompleted && (
|
||||||
|
<ReplayTimeline
|
||||||
|
fromDate={replayQuery?.startTime}
|
||||||
|
toDate={replayQuery?.endTime}
|
||||||
|
onClose={() => {
|
||||||
|
useReplayStore.getState().reset();
|
||||||
|
useAnimationStore.getState().reset();
|
||||||
|
unregisterReplayLayers();
|
||||||
|
showLiveShips(); // 라이브 선박 다시 표시
|
||||||
|
shipBatchRenderer.immediateRender();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user