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