feat: MapContainer 통합 (항적조회, 리플레이, 추적 모드)

- TopBar, 항적조회 뷰어, 리플레이 타임라인 통합
- 배경지도 전환 레이어 가시성 토글
- 추적 모드 훅 연동

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
HeungTak Lee 2026-02-05 06:37:46 +09:00
부모 83f5f72b0e
커밋 f273080d5e

파일 보기

@ -7,7 +7,7 @@ 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 { useMapStore, BASE_MAP_TYPES } from '../stores/mapStore';
import useShipStore from '../stores/shipStore';
import useShipData from '../hooks/useShipData';
import useShipLayer from '../hooks/useShipLayer';
@ -15,8 +15,20 @@ 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 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 useTrackingMode from '../hooks/useTrackingMode';
import './measure/measure.scss';
import './MapContainer.scss';
@ -33,10 +45,13 @@ const HOVER_THROTTLE_MS = 50;
export default function MapContainer() {
const mapRef = useRef(null);
const mapInstanceRef = useRef(null);
const { map, setMap, setZoom, center } = useMapStore();
const { showLegend } = useShipStore();
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 });
@ -44,20 +59,35 @@ export default function MapContainer() {
// 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 헬퍼
* 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 {
@ -72,6 +102,26 @@ export default function MapContainer() {
}
}, [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 호버 툴팁
*/
@ -79,6 +129,8 @@ export default function MapContainer() {
//
if (evt.dragging) {
useShipStore.getState().setHoverInfo(null);
useTrackQueryStore.getState().setHighlightedVesselId(null);
useReplayStore.getState().setHighlightedVesselId(null);
return;
}
@ -89,17 +141,107 @@ export default function MapContainer() {
hoverTimerRef.current = null;
const pixel = evt.pixel;
const ship = pickShip(pixel);
const { clientX, clientY } = evt.originalEvent;
const pickResult = pickAny(pixel);
if (ship) {
// evt.originalEvent
const { clientX, clientY } = evt.originalEvent;
useShipStore.getState().setHoverInfo({ ship, x: clientX, y: clientY });
} else {
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);
}, [pickShip]);
}, [pickAny]);
/**
* OpenLayers dblclick 상세 모달
@ -115,10 +257,13 @@ export default function MapContainer() {
}, [pickShip]);
/**
* pointerout 툴팁 숨김
* pointerout 툴팁 숨김 + 하이라이트 클리어
*/
const handlePointerOut = useCallback(() => {
useShipStore.getState().setHoverInfo(null);
useTrackQueryStore.getState().setHighlightedVesselId(null);
useTrackQueryStore.getState().clearHoveredPoint();
useReplayStore.getState().setHighlightedVesselId(null);
}, []);
/**
@ -177,8 +322,14 @@ export default function MapContainer() {
useEffect(() => {
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({
@ -192,14 +343,16 @@ export default function MapContainer() {
target: mapRef.current,
layers: [
worldMap,
encMap,
darkMap,
eastAsiaMap,
korMap,
],
view: new View({
center: fromLonLat(center),
zoom: 7,
minZoom: 0,
maxZoom: 17,
minZoom: 5, //
maxZoom: 15, // 15 ( 12 )
}),
controls: defaultControls({
attribution: false,
@ -270,6 +423,8 @@ export default function MapContainer() {
//
setMap(map);
mapInstanceRef.current = map;
// TrackQueryViewer
window.__mainMap__ = map;
//
return () => {
@ -277,12 +432,14 @@ export default function MapContainer() {
mapInstanceRef.current.setTarget(null);
mapInstanceRef.current = null;
}
window.__mainMap__ = null;
};
}, []);
return (
<>
<div id="map" ref={mapRef} className="map-container" />
<TopBar />
{showLegend && <ShipLegend />}
{hoverInfo && (
<ShipTooltip ship={hoverInfo.ship} x={hoverInfo.x} y={hoverInfo.y} />
@ -291,6 +448,20 @@ export default function MapContainer() {
<ShipDetailModal key={modal.id} modal={modal} />
))}
<ShipContextMenu />
<GlobalTrackQueryViewer />
{replayCompleted && (
<ReplayTimeline
fromDate={replayQuery?.startTime}
toDate={replayQuery?.endTime}
onClose={() => {
useReplayStore.getState().reset();
useAnimationStore.getState().reset();
unregisterReplayLayers();
showLiveShips(); //
shipBatchRenderer.immediateRender();
}}
/>
)}
</>
);
}