feat: 추적 모드 반경 필터링 구현

- useTrackingMode 훅 (함정 중심 지도 이동 + 반경 원)
- useRadiusFilter 훅 (Bounding Box + Haversine 거리 계산)
- shipStore 반경 필터 연동

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
HeungTak Lee 2026-02-05 06:37:38 +09:00
부모 b209c9498c
커밋 83f5f72b0e
3개의 변경된 파일350개의 추가작업 그리고 2개의 파일을 삭제

파일 보기

@ -0,0 +1,167 @@
/**
* 반경 필터링
* 선박 모드일 추적 함정 중심으로 반경 선박만 필터링
*
* 성능 최적화:
* - Bounding Box 사전 필터링 (빠른 사각형 체크)
* - 정확한 원형 거리 계산
*/
import { useCallback, useMemo } from 'react';
import useTrackingModeStore, {
isWithinRadius,
NM_TO_METERS,
} from '../stores/trackingModeStore';
/**
* 경도 1도당 대략적인 미터 (위도에 따라 다름)
* 중위도(35) 기준 91km
*/
const LON_DEGREE_METERS = 91000;
const LAT_DEGREE_METERS = 111000; // 위도 1도당 약 111km
/**
* 반경 필터링
* @returns {Object} { filterByRadius, isRadiusFilterActive, getRadiusCenter, radiusNM }
*/
export default function useRadiusFilter() {
const mode = useTrackingModeStore((s) => s.mode);
const trackedShip = useTrackingModeStore((s) => s.trackedShip);
const radiusNM = useTrackingModeStore((s) => s.radiusNM);
// 반경 필터 활성화 여부
const isRadiusFilterActive = mode === 'ship' && trackedShip !== null;
// 반경 중심 좌표
const radiusCenter = useMemo(() => {
if (!isRadiusFilterActive || !trackedShip) return null;
return {
lon: trackedShip.longitude,
lat: trackedShip.latitude,
};
}, [isRadiusFilterActive, trackedShip]);
/**
* Bounding Box 계산 (사전 필터링용)
* 반경을 감싸는 사각형 영역
*/
const boundingBox = useMemo(() => {
if (!radiusCenter) return null;
const radiusMeters = radiusNM * NM_TO_METERS;
const lonDelta = radiusMeters / LON_DEGREE_METERS;
const latDelta = radiusMeters / LAT_DEGREE_METERS;
return {
minLon: radiusCenter.lon - lonDelta,
maxLon: radiusCenter.lon + lonDelta,
minLat: radiusCenter.lat - latDelta,
maxLat: radiusCenter.lat + latDelta,
};
}, [radiusCenter, radiusNM]);
/**
* 선박이 Bounding Box 내에 있는지 빠른 체크
*/
const isInBoundingBox = useCallback((ship) => {
if (!boundingBox) return true;
if (!ship.longitude || !ship.latitude) return false;
return (
ship.longitude >= boundingBox.minLon &&
ship.longitude <= boundingBox.maxLon &&
ship.latitude >= boundingBox.minLat &&
ship.latitude <= boundingBox.maxLat
);
}, [boundingBox]);
/**
* 선박 배열을 반경 선박만 필터링
* @param {Array} ships - 선박 배열
* @returns {Array} 반경 선박만
*/
const filterByRadius = useCallback((ships) => {
// 반경 필터 비활성화 시 전체 반환
if (!isRadiusFilterActive || !radiusCenter) {
return ships;
}
return ships.filter((ship) => {
// 1단계: Bounding Box 체크 (빠른 사전 필터)
if (!isInBoundingBox(ship)) return false;
// 2단계: 정확한 원형 거리 체크
return isWithinRadius(ship, radiusCenter.lon, radiusCenter.lat, radiusNM);
});
}, [isRadiusFilterActive, radiusCenter, radiusNM, isInBoundingBox]);
/**
* 단일 선박이 반경 내에 있는지 확인
* @param {Object} ship
* @returns {boolean}
*/
const isShipInRadius = useCallback((ship) => {
if (!isRadiusFilterActive || !radiusCenter) return true;
if (!isInBoundingBox(ship)) return false;
return isWithinRadius(ship, radiusCenter.lon, radiusCenter.lat, radiusNM);
}, [isRadiusFilterActive, radiusCenter, radiusNM, isInBoundingBox]);
/**
* Map의 features를 반경 선박만 필터링
* @param {Map} featuresMap - featureId -> ship Map
* @returns {Map} 반경 선박만
*/
const filterFeaturesMapByRadius = useCallback((featuresMap) => {
if (!isRadiusFilterActive || !radiusCenter) {
return featuresMap;
}
const filteredMap = new Map();
featuresMap.forEach((ship, featureId) => {
if (isInBoundingBox(ship) && isWithinRadius(ship, radiusCenter.lon, radiusCenter.lat, radiusNM)) {
filteredMap.set(featureId, ship);
}
});
return filteredMap;
}, [isRadiusFilterActive, radiusCenter, radiusNM, isInBoundingBox]);
return {
filterByRadius,
filterFeaturesMapByRadius,
isShipInRadius,
isRadiusFilterActive,
radiusCenter,
radiusNM,
boundingBox,
};
}
/**
* 반경 필터 유틸리티 (비훅 버전)
* shipStore나 다른 스토어에서 직접 사용
*/
export function getRadiusFilterState() {
const state = useTrackingModeStore.getState();
const { mode, trackedShip, radiusNM } = state;
const isActive = mode === 'ship' && trackedShip !== null;
if (!isActive || !trackedShip) {
return { isActive: false, center: null, radiusNM: 0 };
}
return {
isActive: true,
center: { lon: trackedShip.longitude, lat: trackedShip.latitude },
radiusNM,
};
}
/**
* 선박이 반경 내에 있는지 확인 (비훅 버전)
*/
export function checkShipInRadius(ship) {
const { isActive, center, radiusNM } = getRadiusFilterState();
if (!isActive || !center) return true;
return isWithinRadius(ship, center.lon, center.lat, radiusNM);
}

파일 보기

@ -0,0 +1,179 @@
/**
* 추적 모드
* - 선박 모드일 추적 함정 중심으로 지도 이동
* - 반경 레이어 생성 업데이트
*/
import { useEffect, useRef, useCallback } from 'react';
import { fromLonLat } from 'ol/proj';
import { circular } from 'ol/geom/Polygon';
import VectorLayer from 'ol/layer/Vector';
import VectorSource from 'ol/source/Vector';
import Feature from 'ol/Feature';
import { Fill, Stroke, Style } from 'ol/style';
import { useMapStore } from '../stores/mapStore';
import useShipStore from '../stores/shipStore';
import useTrackingModeStore, { NM_TO_METERS } from '../stores/trackingModeStore';
/**
* 추적 모드
* MapContainer에서 호출
*/
export default function useTrackingMode() {
const map = useMapStore((s) => s.map);
const features = useShipStore((s) => s.features);
const mode = useTrackingModeStore((s) => s.mode);
const trackedShipId = useTrackingModeStore((s) => s.trackedShipId);
const trackedShip = useTrackingModeStore((s) => s.trackedShip);
const radiusNM = useTrackingModeStore((s) => s.radiusNM);
const updateTrackedShip = useTrackingModeStore((s) => s.updateTrackedShip);
// 반경 원 레이어 ref
const radiusLayerRef = useRef(null);
const radiusFeatureRef = useRef(null);
// 이전 좌표 (중복 업데이트 방지)
const prevCoordsRef = useRef(null);
/**
* 반경 레이어 생성
*/
const createRadiusLayer = useCallback(() => {
if (!map) return;
// 기존 레이어 제거
if (radiusLayerRef.current) {
map.removeLayer(radiusLayerRef.current);
}
const source = new VectorSource();
const layer = new VectorLayer({
source,
style: new Style({
fill: new Fill({
color: 'rgba(0, 150, 255, 0.08)', // 매우 투명한 파란색
}),
stroke: new Stroke({
color: 'rgba(0, 150, 255, 0.4)',
width: 2,
lineDash: [8, 4],
}),
}),
zIndex: 5, // 선박 레이어보다 낮게
});
map.addLayer(layer);
radiusLayerRef.current = layer;
return layer;
}, [map]);
/**
* 반경 업데이트
*/
const updateRadiusCircle = useCallback((lon, lat) => {
if (!radiusLayerRef.current) return;
const source = radiusLayerRef.current.getSource();
source.clear();
// 원형 폴리곤 생성 (WGS84 좌표에서 미터 단위 반경)
const radiusMeters = radiusNM * NM_TO_METERS;
const circle = circular([lon, lat], radiusMeters, 64);
// EPSG:3857로 변환
circle.transform('EPSG:4326', 'EPSG:3857');
const feature = new Feature({ geometry: circle });
radiusFeatureRef.current = feature;
source.addFeature(feature);
}, [radiusNM]);
/**
* 지도 중심 이동 (애니메이션)
*/
const centerMapOnShip = useCallback((lon, lat, animate = true) => {
if (!map) return;
const center = fromLonLat([lon, lat]);
if (animate) {
map.getView().animate({
center,
duration: 300,
});
} else {
map.getView().setCenter(center);
}
}, [map]);
// 추적 함정 데이터 실시간 업데이트
useEffect(() => {
if (mode !== 'ship' || !trackedShipId) return;
// features에서 추적 함정의 최신 데이터 가져오기
const latestShip = features.get(trackedShipId);
if (!latestShip) return;
// 좌표가 변경되었는지 확인
const newCoords = `${latestShip.longitude},${latestShip.latitude}`;
if (prevCoordsRef.current === newCoords) return;
prevCoordsRef.current = newCoords;
updateTrackedShip(latestShip);
}, [mode, trackedShipId, features, updateTrackedShip]);
// 선박 모드 활성화 시 레이어 생성 및 초기 위치 설정
useEffect(() => {
if (mode !== 'ship' || !trackedShip || !map) {
// 선박 모드 비활성화 시 레이어 제거
if (radiusLayerRef.current && map) {
map.removeLayer(radiusLayerRef.current);
radiusLayerRef.current = null;
radiusFeatureRef.current = null;
}
prevCoordsRef.current = null;
return;
}
const { longitude, latitude } = trackedShip;
if (!longitude || !latitude) return;
// 레이어가 없으면 생성
if (!radiusLayerRef.current) {
createRadiusLayer();
}
// 반경 원 업데이트
updateRadiusCircle(longitude, latitude);
// 지도 중심 이동
centerMapOnShip(longitude, latitude);
}, [mode, trackedShip, map, createRadiusLayer, updateRadiusCircle, centerMapOnShip]);
// 반경 변경 시 원 업데이트
useEffect(() => {
if (mode !== 'ship' || !trackedShip) return;
const { longitude, latitude } = trackedShip;
if (!longitude || !latitude) return;
updateRadiusCircle(longitude, latitude);
}, [radiusNM, mode, trackedShip, updateRadiusCircle]);
// 컴포넌트 언마운트 시 정리
useEffect(() => {
return () => {
if (radiusLayerRef.current && map) {
map.removeLayer(radiusLayerRef.current);
radiusLayerRef.current = null;
}
};
}, [map]);
return {
isTrackingActive: mode === 'ship' && trackedShip !== null,
trackedShip,
radiusNM,
};
}

파일 보기

@ -258,7 +258,7 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({
showShipName: true, // 선박명 showShipName: true, // 선박명
showSpeedVector: true, // 속도벡터 showSpeedVector: true, // 속도벡터
showShipSize: true, // 선박크기 showShipSize: true, // 선박크기
showSignalStatus: true, // 신호상태 showSignalStatus: false, // 신호상태
}, },
/** STOMP 연결 상태 */ /** STOMP 연결 상태 */
@ -683,7 +683,9 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({
} }
// 새 모달 초기 위치: 마지막 모달 위치 + 140px 우측 // 새 모달 초기 위치: 마지막 모달 위치 + 140px 우측
const basePos = state.lastModalPos || { x: 0, y: 100 }; // 최초 모달은 화면 중앙 근처에서 시작 (가로: 화면 중앙 - 200px, 세로: 100px)
const defaultX = typeof window !== 'undefined' ? Math.max(100, (window.innerWidth / 2) - 200) : 400;
const basePos = state.lastModalPos || { x: defaultX - 140, y: 100 };
const initialPos = { x: basePos.x + 140, y: basePos.y }; const initialPos = { x: basePos.x + 140, y: basePos.y };
const newModal = { ship: displayShip, id: displayShip.featureId, initialPos }; const newModal = { ship: displayShip, id: displayShip.featureId, initialPos };