feat: 추적 모드 반경 필터링 구현
- useTrackingMode 훅 (함정 중심 지도 이동 + 반경 원) - useRadiusFilter 훅 (Bounding Box + Haversine 거리 계산) - shipStore 반경 필터 연동 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
부모
b209c9498c
커밋
83f5f72b0e
167
src/hooks/useRadiusFilter.js
Normal file
167
src/hooks/useRadiusFilter.js
Normal file
@ -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);
|
||||
}
|
||||
179
src/hooks/useTrackingMode.js
Normal file
179
src/hooks/useTrackingMode.js
Normal file
@ -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, // 선박명
|
||||
showSpeedVector: true, // 속도벡터
|
||||
showShipSize: true, // 선박크기
|
||||
showSignalStatus: true, // 신호상태
|
||||
showSignalStatus: false, // 신호상태
|
||||
},
|
||||
|
||||
/** STOMP 연결 상태 */
|
||||
@ -683,7 +683,9 @@ const useShipStore = create(subscribeWithSelector((set, get) => ({
|
||||
}
|
||||
|
||||
// 새 모달 초기 위치: 마지막 모달 위치 + 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 newModal = { ship: displayShip, id: displayShip.featureId, initialPos };
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user