From 83f5f72b0e4a8f342acba377989f19d62bc1e29d Mon Sep 17 00:00:00 2001 From: HeungTak Lee Date: Thu, 5 Feb 2026 06:37:38 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=B6=94=EC=A0=81=20=EB=AA=A8=EB=93=9C?= =?UTF-8?q?=20=EB=B0=98=EA=B2=BD=20=ED=95=84=ED=84=B0=EB=A7=81=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useTrackingMode 훅 (함정 중심 지도 이동 + 반경 원) - useRadiusFilter 훅 (Bounding Box + Haversine 거리 계산) - shipStore 반경 필터 연동 Co-Authored-By: Claude Opus 4.5 --- src/hooks/useRadiusFilter.js | 167 ++++++++++++++++++++++++++++++++ src/hooks/useTrackingMode.js | 179 +++++++++++++++++++++++++++++++++++ src/stores/shipStore.js | 6 +- 3 files changed, 350 insertions(+), 2 deletions(-) create mode 100644 src/hooks/useRadiusFilter.js create mode 100644 src/hooks/useTrackingMode.js diff --git a/src/hooks/useRadiusFilter.js b/src/hooks/useRadiusFilter.js new file mode 100644 index 00000000..85725c75 --- /dev/null +++ b/src/hooks/useRadiusFilter.js @@ -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); +} diff --git a/src/hooks/useTrackingMode.js b/src/hooks/useTrackingMode.js new file mode 100644 index 00000000..8046cb92 --- /dev/null +++ b/src/hooks/useTrackingMode.js @@ -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, + }; +} diff --git a/src/stores/shipStore.js b/src/stores/shipStore.js index e9ab0a9e..4a89b65d 100644 --- a/src/stores/shipStore.js +++ b/src/stores/shipStore.js @@ -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 };