diff --git a/src/hooks/useCourseValidation.ts b/src/hooks/useCourseValidation.ts new file mode 100644 index 0000000..7d8f7f4 --- /dev/null +++ b/src/hooks/useCourseValidation.ts @@ -0,0 +1,225 @@ +import { useCallback, useEffect, useRef } from 'react'; +import { Position } from 'geojson'; +import useRunStore from '../store/run'; +import { + validateLocationWithTopology, + detectCourseDeviation, + calculateSafetyDistance, +} from '../utils/courseValidation'; +import { CourseValidationOptions } from '../types/courseValidation.types'; + +interface UseCourseValidationOptions { + validationOptions?: CourseValidationOptions; + enableRealTimeValidation?: boolean; + validationInterval?: number; // 밀리초 +} + +export function useCourseValidation(options: UseCourseValidationOptions = {}) { + const { + validationOptions = { tolerance: 50, enableDistanceCalculation: true }, + enableRealTimeValidation = true, + validationInterval = 5000, // 5초마다 검증 + } = options; + + const { + courseTopology, + location, + isTracking, + routeCoordinates, + isOnCourse, + courseDeviation, + validationHistory, + lastValidationTime, + updateCourseValidation, + clearValidationHistory, + } = useRunStore(); + + // 이전 좌표를 저장할 ref + const lastValidatedCoordinateRef = useRef(null); + + // 실시간 위치 검증 (트래킹 중이고 좌표가 실제로 이동했을 때만) + const validateCurrentLocation = useCallback(() => { + if ( + !location || + !location.coords || + !courseTopology || + !isTracking || + routeCoordinates.length === 0 + ) { + return null; + } + + // routeCoordinates의 마지막 위치를 사용 (실제 이동한 위치) + const lastCoordinate = routeCoordinates[routeCoordinates.length - 1]; + const currentPosition: Position = [lastCoordinate[0], lastCoordinate[1]]; + + // 이전에 검증한 좌표와 같은지 확인 + const lastValidated = lastValidatedCoordinateRef.current; + if ( + lastValidated && + Math.abs(lastValidated[0] - currentPosition[0]) < 0.000001 && + Math.abs(lastValidated[1] - currentPosition[1]) < 0.000001 + ) { + // 좌표가 거의 변하지 않았으면 검증하지 않음 + return null; + } + + // 현재 좌표를 저장 + lastValidatedCoordinateRef.current = currentPosition; + + // 이전 위치 가져오기 (검증 히스토리에서) + const previousResult = validationHistory[validationHistory.length - 1]; + const previousLocation = previousResult?.nearestPointOnCourse || null; + + // 코스 이탈 감지 + const deviationResult = detectCourseDeviation( + currentPosition, + previousLocation, + courseTopology, + validationOptions, + ); + + // 디버깅 로그 + console.log('🔍 [CourseValidation] 검증 실행:', { + currentPosition, + isTracking, + routeCoordinatesLength: routeCoordinates.length, + isDeviating: deviationResult.isDeviating, + severity: deviationResult.deviationSeverity, + distance: deviationResult.validationResult.distanceFromCourse, + }); + + // 검증 결과 업데이트 + updateCourseValidation(deviationResult.validationResult); + + return deviationResult; + }, [ + location, + courseTopology, + isTracking, + routeCoordinates, + validationHistory, + validationOptions, + updateCourseValidation, + ]); + + // 안전 거리 계산 + const getSafetyDistance = useCallback(() => { + if (!location || !location.coords || !courseTopology) { + return null; + } + + const currentPosition: Position = [ + location.coords.longitude, + location.coords.latitude, + ]; + return calculateSafetyDistance(currentPosition, courseTopology); + }, [location, courseTopology]); + + // 코스 복귀 여부 확인 + const checkCourseReturn = useCallback(() => { + if (!isOnCourse || courseDeviation.isDeviating) { + return false; + } + + // 최근 3번의 검증이 모두 코스 내부에 있는지 확인 + const recentValidations = validationHistory.slice(-3); + return ( + recentValidations.length >= 3 && + recentValidations.every((v) => v.isOnCourse) + ); + }, [isOnCourse, courseDeviation.isDeviating, validationHistory]); + + // 연속 이탈 시간 계산 + const getDeviationDuration = useCallback(() => { + if (!courseDeviation.isDeviating || !lastValidationTime) { + return 0; + } + + const deviationStartIndex = validationHistory.findIndex( + (v) => !v.isOnCourse, + ); + if (deviationStartIndex === -1) { + return 0; + } + + const now = new Date(); + const deviationStartTime = new Date( + lastValidationTime.getTime() - + (validationHistory.length - deviationStartIndex) * validationInterval, + ); + + return now.getTime() - deviationStartTime.getTime(); + }, [ + courseDeviation.isDeviating, + lastValidationTime, + validationHistory, + validationInterval, + ]); + + // routeCoordinates 변경 시에만 검증 실행 (트래킹 중일 때만) + useEffect(() => { + if ( + !enableRealTimeValidation || + !courseTopology || + !location || + !isTracking || + routeCoordinates.length === 0 + ) { + return; + } + + // routeCoordinates가 변경될 때만 검증 실행 + validateCurrentLocation(); + }, [ + enableRealTimeValidation, + courseTopology, + location, + isTracking, + routeCoordinates, + ]); + + // 코스 변경 시 검증 히스토리 초기화 및 즉시 검증 실행 (트래킹 중일 때만) + useEffect(() => { + if (courseTopology) { + clearValidationHistory(); + // 이전 좌표 ref 초기화 + lastValidatedCoordinateRef.current = null; + // 코스 토폴로지가 로드된 후 즉시 검증 실행 (트래킹 중이고 좌표가 있을 때만) + if ( + location && + location.coords && + isTracking && + routeCoordinates.length > 0 + ) { + validateCurrentLocation(); + } + } + }, [ + courseTopology, + clearValidationHistory, + location, + isTracking, + routeCoordinates, + ]); + + return { + // 상태 + isOnCourse, + courseDeviation, + validationHistory, + lastValidationTime, + + // 액션 + validateCurrentLocation, + getSafetyDistance, + checkCourseReturn, + getDeviationDuration, + clearValidationHistory, + + // 유틸리티 + isDeviating: courseDeviation.isDeviating, + deviationSeverity: courseDeviation.severity, + distanceFromCourse: courseDeviation.distanceFromCourse, + }; +} diff --git a/src/hooks/useInitialLocation.ts b/src/hooks/useInitialLocation.ts index 1276308..554aa78 100644 --- a/src/hooks/useInitialLocation.ts +++ b/src/hooks/useInitialLocation.ts @@ -1,17 +1,37 @@ import { useState, useEffect } from 'react'; import * as Location from 'expo-location'; -import type { Position } from 'geojson'; -export function useInitialLocation() { - const [location, setLocation] = useState(null); +interface UseInitialLocationOptions { + requestPermission?: boolean; // 권한 요청 여부 (기본값: true) +} + +export function useInitialLocation(options: UseInitialLocationOptions = {}) { + const { requestPermission = true } = options; + const [location, setLocation] = useState( + null, + ); const [loading, setLoading] = useState(true); const [, setError] = useState(null); useEffect(() => { (async () => { - const { status } = await Location.requestForegroundPermissionsAsync(); - if (status !== 'granted') { - setError('위치 접근 권한이 거절되었습니다.'); + // 권한 상태 체크 + const { status: currentStatus } = + await Location.getForegroundPermissionsAsync(); + + // 권한이 없고 요청이 필요한 경우에만 권한 요청 + if (currentStatus !== 'granted' && requestPermission) { + const { status } = await Location.requestForegroundPermissionsAsync(); + if (status !== 'granted') { + setError('위치 접근 권한이 거절되었습니다.'); + setLoading(false); + return; + } + } + + // 권한이 없으면 위치 가져오기 시도하지 않음 + if (currentStatus !== 'granted') { + setError('위치 접근 권한이 필요합니다.'); setLoading(false); return; } @@ -27,14 +47,11 @@ export function useInitialLocation() { } if (fetchedLocation) { - setLocation([ - fetchedLocation.coords.longitude, - fetchedLocation.coords.latitude, - ]); + setLocation(fetchedLocation); } setLoading(false); })(); - }, []); + }, [requestPermission]); return { location, loading }; } diff --git a/src/hooks/useLocationManager.ts b/src/hooks/useLocationManager.ts index 0dee0c7..8f1e085 100644 --- a/src/hooks/useLocationManager.ts +++ b/src/hooks/useLocationManager.ts @@ -13,7 +13,10 @@ export function useLocationManager() { useEffect(() => { if (initialLocation) { - currentUserLocation.current = initialLocation; + currentUserLocation.current = [ + initialLocation.coords.longitude, + initialLocation.coords.latitude, + ]; } }, [initialLocation]); diff --git a/src/hooks/useLocationTracking.ts b/src/hooks/useLocationTracking.ts index d021b3e..13a299a 100644 --- a/src/hooks/useLocationTracking.ts +++ b/src/hooks/useLocationTracking.ts @@ -1,6 +1,7 @@ import { useEffect, useCallback } from 'react'; import * as Location from 'expo-location'; import useRunStore from '@/store/run'; +import { useInitialLocation } from '@/hooks/useInitialLocation'; import { LOCATION_UPDATE_INTERVAL_MS, LOCATION_DISTANCE_INTERVAL_M, @@ -25,22 +26,16 @@ export function useLocationTracking() { resetLocationTracking, } = useRunStore(); - useEffect(() => { - const getInitialLocation = async () => { - const { status } = await Location.requestForegroundPermissionsAsync(); - if (status !== 'granted') { - setLocationErrorMsg('Permission to access location was denied'); - return; - } + // useInitialLocation 훅 사용 (Run 스크린 접속 시마다 새로 실행됨, 권한 상태 체크 후 필요시에만 요청) + const { location: initialLocation, loading: locationLoading } = + useInitialLocation({ requestPermission: true }); - const lastKnownPosition = await Location.getLastKnownPositionAsync(); - if (lastKnownPosition) { - setLocation(lastKnownPosition); - } - }; - - getInitialLocation(); - }, [setLocationErrorMsg, setLocation]); + // 초기 위치를 store에 설정 + useEffect(() => { + if (initialLocation && !location) { + setLocation(initialLocation); + } + }, [initialLocation, location]); const startTracking = useCallback(async () => { if (isTracking) return; @@ -58,6 +53,14 @@ export function useLocationTracking() { }, (newLocation) => { const { latitude, longitude } = newLocation.coords; + + // 디버깅 로그 + console.log('📍 [LocationTracking] 위치 업데이트:', { + latitude, + longitude, + timestamp: new Date().toISOString(), + }); + setLocation(newLocation); const newCoordinate: Position = [longitude, latitude]; @@ -72,6 +75,9 @@ export function useLocationTracking() { } else { setRouteCoordinates([...currentCoords, newCoordinate]); } + + // 위치 업데이트 시 즉시 코스 검증 실행을 위한 플래그 설정 + // useCourseValidation의 useEffect가 이를 감지하여 검증 실행 }, ); @@ -112,20 +118,36 @@ export function useLocationTracking() { }, [isTracking, pauseTracking, startTracking]); const refreshLocation = useCallback(async () => { - const { status } = await Location.requestForegroundPermissionsAsync(); - if (status !== 'granted') { - setLocationErrorMsg('Permission to access location was denied'); - return; + // 권한 상태 체크 + const { status: currentStatus } = + await Location.getForegroundPermissionsAsync(); + + // 권한이 없으면 권한 요청 + if (currentStatus !== 'granted') { + const { status } = await Location.requestForegroundPermissionsAsync(); + if (status !== 'granted') { + setLocationErrorMsg('위치 접근 권한이 거절되었습니다.'); + return; + } } try { - const currentPosition = await Location.getCurrentPositionAsync({ - accuracy: Location.Accuracy.BestForNavigation, - }); - setLocation(currentPosition); - setLocationErrorMsg(null); + // useInitialLocation과 동일한 로직 사용 + let fetchedLocation: Location.LocationObject | null = null; + fetchedLocation = await Location.getLastKnownPositionAsync({}); + if (!fetchedLocation) { + fetchedLocation = await Location.getCurrentPositionAsync({}); + } + + if (fetchedLocation) { + setLocation(fetchedLocation); + setLocationErrorMsg(null); + } else { + setLocationErrorMsg('현재 위치를 가져올 수 없습니다.'); + } } catch (error) { - setLocationErrorMsg('Failed to get current location'); + console.error('위치 새로고침 오류', error); + setLocationErrorMsg('현재 위치를 가져올 수 없습니다.'); } }, [setLocationErrorMsg, setLocation]); @@ -134,6 +156,7 @@ export function useLocationTracking() { location, errorMsg, isTracking, + locationLoading, startTracking, pauseTracking, stopTracking, diff --git a/src/pages/Run/_components/ControlContainer.tsx b/src/pages/Run/_components/ControlContainer.tsx index 79399a7..f753558 100644 --- a/src/pages/Run/_components/ControlContainer.tsx +++ b/src/pages/Run/_components/ControlContainer.tsx @@ -1,12 +1,32 @@ +import React from 'react'; import { View, TouchableOpacity } from 'react-native'; import styled from '@emotion/native'; import { theme } from '@/styles/theme'; -import { Lock, Unlock, Play, Pause, Square } from 'lucide-react-native'; +import { + Lock, + Unlock, + Play, + Pause, + Square, + LocateFixed, +} from 'lucide-react-native'; import useRunStore from '@/store/run'; -import { useLocationTracking } from '@/hooks/useLocationTracking'; import { calculateRunStats } from '@/utils/runStats'; +import type { Position } from 'geojson'; -export default function ControlContainer() { +interface ControlContainerProps { + onCurrentLocationPress: () => void; + isTracking: boolean; + toggleTracking: () => void; + routeCoordinates: Position[]; +} + +const ControlContainer: React.FC = ({ + onCurrentLocationPress, + isTracking, + toggleTracking, + routeCoordinates, +}) => { const { isLocked, startTime, @@ -17,8 +37,6 @@ export default function ControlContainer() { resumeRun, toggleLock, } = useRunStore(); - const { isTracking, toggleTracking, routeCoordinates } = - useLocationTracking(); const handleLockPress = () => { toggleLock(); @@ -75,16 +93,24 @@ export default function ControlContainer() { + + + ); -} +}; + +export default ControlContainer; const ControlContainerWrapper = styled.View({ position: 'absolute', bottom: 220, - left: 20, - right: 20, + left: 0, + right: 0, + flexDirection: 'row', alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: 20, }); const ControlButtonGroup = styled.View(({ theme }) => ({ @@ -112,3 +138,19 @@ const ControlButton = styled.TouchableOpacity<{ alignItems: 'center', marginHorizontal: 4, })); + +const LocationButton = styled.TouchableOpacity(({ theme }) => ({ + position: 'absolute', + right: 20, + width: 48, + height: 48, + borderRadius: 24, + backgroundColor: 'rgba(255, 255, 255, 0.95)', + justifyContent: 'center', + alignItems: 'center', + shadowColor: theme.colors.gray[900], + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.15, + shadowRadius: 8, + elevation: 8, +})); diff --git a/src/pages/Run/_components/RunMap.tsx b/src/pages/Run/_components/RunMap.tsx index 14d974c..e68139f 100644 --- a/src/pages/Run/_components/RunMap.tsx +++ b/src/pages/Run/_components/RunMap.tsx @@ -1,28 +1,35 @@ +import { View } from 'react-native'; import styled from '@emotion/native'; import Mapbox from '@rnmapbox/maps'; import { theme } from '@/styles/theme'; import Map from '@/components/Map'; import { LockOverlay } from '@/components/Overlay'; import { useRunMap } from '@/hooks/useRunMap'; -import { useLocationTracking } from '@/hooks/useLocationTracking'; -import { useLocationManager } from '@/hooks/useLocationManager'; +import type { Position } from 'geojson'; +import * as Location from 'expo-location'; export default function RunMap({ mapRef: externalMapRef, cameraRef: externalCameraRef, + routeCoordinates, + locationObject, }: { mapRef?: React.RefObject; cameraRef?: React.RefObject; + routeCoordinates: Position[]; + locationObject: Location.LocationObject | null; }) { - const { routeCoordinates } = useLocationTracking(); - const { location: locationObject } = useLocationManager(); - - const location = locationObject - ? ([locationObject.coords.longitude, locationObject.coords.latitude] as [ - number, - number, - ]) - : null; + // 트래킹 중일 때는 routeCoordinates의 마지막 위치를 사용, 그렇지 않으면 현재 위치 사용 + // 실시간 위치 업데이트를 위해 locationObject를 우선 사용 + const location = + locationObject && locationObject.coords + ? ([locationObject.coords.longitude, locationObject.coords.latitude] as [ + number, + number, + ]) + : routeCoordinates.length > 0 + ? (routeCoordinates[routeCoordinates.length - 1] as [number, number]) + : null; const { routeGeoJSON, courseShapeGeoJSON, courseShapePolygons, isLocked } = useRunMap(externalCameraRef, location, routeCoordinates); @@ -37,6 +44,7 @@ export default function RunMap({ mapRef={externalMapRef} cameraRef={externalCameraRef} initialLocation={location} + showUserLocation={false} > {routeGeoJSON.geometry.coordinates.length > 1 && ( @@ -73,6 +81,25 @@ export default function RunMap({ )} + {/* 현재 위치 마커 - 트래킹 폴리라인과 동일한 위치 사용 */} + + + + {/* 코스 노드 표시 */} {/* {courseTopology?.nodes.map((node, index) => ( ; export default function Run({ route, navigation }: Props) { const courseId = route.params?.courseId; - const { routeCoordinates, isTracking, resetLocationTracking } = - useLocationTracking(); - const { location, errorMsg, flyToCurrentUserLocation } = useLocationManager(); + const { + routeCoordinates, + isTracking, + resetLocationTracking, + location, + errorMsg, + locationLoading, + refreshLocation, + toggleTracking, + } = useLocationTracking(); const cameraRef = useRef(null!); const mapRef = useRef(null); @@ -42,6 +49,23 @@ export default function Run({ route, navigation }: Props) { useRunStats(routeCoordinates, isTracking); const { loadCourseTopology } = useCourseTopologyApi(courseId); + // 코스 검증 훅 + const { + isOnCourse, + courseDeviation, + isDeviating, + deviationSeverity, + distanceFromCourse, + validateCurrentLocation, + } = useCourseValidation({ + validationOptions: { + tolerance: 5, // 5미터 허용 오차 (매우 엄격하게) + enableDistanceCalculation: true, + }, + enableRealTimeValidation: true, // 실시간 검증 활성화 + validationInterval: 1000, // 1초마다 검증 + }); + const { showExitModal, showBackModal, @@ -57,12 +81,24 @@ export default function Run({ route, navigation }: Props) { useCallback(() => { resetLocationTracking(); resetRunState(); + // 같은 courseId로 다시 진입할 때도 경로 데이터를 다시 로드 if (courseId) { loadCourseTopology(); } }, [resetLocationTracking, resetRunState, courseId, loadCourseTopology]), ); + // 위치가 로드되지 않았을 때 자동으로 새로고침 + useEffect(() => { + if (!location && !errorMsg && !locationLoading) { + const timer = setTimeout(() => { + refreshLocation(); + }, 2000); // 2초 후에 새로고침 시도 + + return () => clearTimeout(timer); + } + }, [location, errorMsg, locationLoading, refreshLocation]); + // 하드웨어 뒤로가기 버튼 제어 useFocusEffect( useCallback(() => { @@ -81,17 +117,55 @@ export default function Run({ route, navigation }: Props) { ); const handleCurrentLocationPress = () => { - flyToCurrentUserLocation(cameraRef); + // 트래킹 중일 때는 routeCoordinates의 마지막 위치로, 그렇지 않으면 현재 위치로 이동 + if (routeCoordinates.length > 0) { + const lastCoordinate = routeCoordinates[routeCoordinates.length - 1]; + cameraRef.current?.flyTo(lastCoordinate, 1000); + } else if (location && location.coords) { + const currentPosition = [ + location.coords.longitude, + location.coords.latitude, + ] as [number, number]; + cameraRef.current?.flyTo(currentPosition, 1000); + } }; + // 코스 이탈 상태에 따른 메시지 생성 (실시간 업데이트) + const deviationMessage = useMemo(() => { + if (!isDeviating) return null; + + const distance = distanceFromCourse ? Math.round(distanceFromCourse) : 0; + + switch (deviationSeverity) { + case 'high': + return `경로에서 ${distance}m 이탈했습니다. 경로로 돌아가세요.`; + case 'medium': + return `경로에서 ${distance}m 벗어났습니다.`; + case 'low': + return `경로 근처에서 ${distance}m 떨어져 있습니다.`; + default: + return '경로를 벗어났습니다.'; + } + }, [isDeviating, distanceFromCourse, deviationSeverity]); + return ( +
{errorMsg ? ( {errorMsg} - ) : location ? ( + ) : locationLoading ? ( + + Getting location... + + ) : location && location.coords ? ( <> - + {loading && } {topologyError && ( )} + {/* 코스 이탈 알림 */} + {isDeviating && ( + + + + {deviationMessage} + + + )} ) : ( - Getting location... + + 위치를 가져올 수 없습니다 + + 새로고침 + + )} - - - ( + ({ severity }) => ({ + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + backgroundColor: + severity === 'high' + ? 'rgba(239, 68, 68, 0.15)' // 빨간색 오버레이 + : severity === 'medium' + ? 'rgba(245, 158, 11, 0.1)' // 주황색 오버레이 + : 'rgba(59, 130, 246, 0.05)', // 파란색 오버레이 + zIndex: 999, + pointerEvents: 'none', // 터치 이벤트 통과 + }), +); + +const DeviationAlert = styled.View<{ severity: 'low' | 'medium' | 'high' }>( + ({ severity }) => ({ + position: 'absolute', + top: 10, + left: 20, + right: 20, + backgroundColor: + severity === 'high' + ? '#ef4444' + : severity === 'medium' + ? '#f59e0b' + : '#3b82f6', + paddingHorizontal: 20, + paddingVertical: 16, + borderRadius: 12, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + zIndex: 1000, + shadowColor: '#000', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.3, + shadowRadius: 8, + elevation: 8, + }), +); + +const DeviationText = styled.Text({ + color: '#ffffff', + fontSize: 16, + fontWeight: '700', + marginLeft: 12, + flex: 1, + textAlign: 'center', +}); + +const LocationLoadingContainer = styled.View({ + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: 20, +}); + +const RefreshButton = styled.TouchableOpacity({ + padding: 12, + borderRadius: 8, + borderWidth: 1, + borderColor: '#007AFF', +}); diff --git a/src/store/run.ts b/src/store/run.ts index 90f4526..92d1889 100644 --- a/src/store/run.ts +++ b/src/store/run.ts @@ -3,6 +3,7 @@ import * as Location from 'expo-location'; import type { Position } from 'geojson'; import type { CourseTopologyResponse } from '@/types/courses.types'; import type { RunStats } from '@/utils/runStats'; +import type { CourseValidationResult } from '@/types/courseValidation.types'; // UI 상태 그룹 interface UIState { @@ -41,12 +42,25 @@ interface LocationTrackingState { errorMsg: string | null; } +// 코스 검증 상태 그룹 +interface CourseValidationState { + isOnCourse: boolean; + courseDeviation: { + isDeviating: boolean; + severity: 'low' | 'medium' | 'high'; + distanceFromCourse: number | null; + }; + validationHistory: CourseValidationResult[]; + lastValidationTime: Date | null; +} + interface RunState extends UIState, ErrorState, RunningState, CourseState, - LocationTrackingState { + LocationTrackingState, + CourseValidationState { // UI 액션들 setUI: (ui: Partial) => void; setModal: (modal: 'exit' | 'back' | null) => void; @@ -75,6 +89,11 @@ interface RunState clearRouteCoordinates: () => void; resetLocationTracking: () => void; + // 코스 검증 액션들 + setCourseValidation: (validation: Partial) => void; + updateCourseValidation: (result: CourseValidationResult) => void; + clearValidationHistory: () => void; + // 전체 리셋 resetRunState: () => void; } @@ -118,6 +137,17 @@ const initialLocationTrackingState: LocationTrackingState = { errorMsg: null, }; +const initialCourseValidationState: CourseValidationState = { + isOnCourse: false, // 초기값을 false로 변경 + courseDeviation: { + isDeviating: false, + severity: 'low', + distanceFromCourse: null, + }, + validationHistory: [], + lastValidationTime: null, +}; + const useRunStore = create((set, get) => ({ // 초기 상태 ...initialUIState, @@ -125,6 +155,7 @@ const useRunStore = create((set, get) => ({ ...initialRunningState, ...initialCourseState, ...initialLocationTrackingState, + ...initialCourseValidationState, // UI 액션들 setUI: (ui) => set((state) => ({ ...state, ...ui })), @@ -206,6 +237,39 @@ const useRunStore = create((set, get) => ({ console.log(); }, + // 코스 검증 액션들 + setCourseValidation: (validation) => + set((state) => ({ ...state, ...validation })), + + updateCourseValidation: (result) => + set((state) => { + const now = new Date(); + const newHistory = [...state.validationHistory, result].slice(-50); // 최근 50개만 유지 + + return { + isOnCourse: result.isOnCourse, + courseDeviation: { + isDeviating: !result.isOnCourse, + severity: !result.isOnCourse + ? result.distanceFromCourse && result.distanceFromCourse > 100 + ? 'high' + : result.distanceFromCourse && result.distanceFromCourse > 50 + ? 'medium' + : 'low' + : 'low', + distanceFromCourse: result.distanceFromCourse || null, + }, + validationHistory: newHistory, + lastValidationTime: now, + }; + }), + + clearValidationHistory: () => + set({ + validationHistory: [], + lastValidationTime: null, + }), + resetRunState: () => { set({ ...initialUIState, @@ -213,6 +277,7 @@ const useRunStore = create((set, get) => ({ ...initialRunningState, ...initialCourseState, ...initialLocationTrackingState, + ...initialCourseValidationState, }); }, })); diff --git a/src/types/courseValidation.types.ts b/src/types/courseValidation.types.ts new file mode 100644 index 0000000..f1f094e --- /dev/null +++ b/src/types/courseValidation.types.ts @@ -0,0 +1,16 @@ +import { Position } from 'geojson'; + +export interface CourseValidationResult { + isOnCourse: boolean; + distanceFromCourse?: number; // 코스로부터의 거리 (미터) + nearestPointOnCourse?: Position; // 코스에서 가장 가까운 점 +} + +export interface CoursePolygon { + coordinates: [number, number][][][]; // MultiPolygon 형태의 좌표 배열 +} + +export interface CourseValidationOptions { + tolerance?: number; // 허용 오차 (미터, 기본값: 50) + enableDistanceCalculation?: boolean; // 거리 계산 활성화 여부 +} diff --git a/src/utils/courseValidation.ts b/src/utils/courseValidation.ts new file mode 100644 index 0000000..ecd231d --- /dev/null +++ b/src/utils/courseValidation.ts @@ -0,0 +1,476 @@ +import * as turf from '@turf/turf'; +import { Position } from 'geojson'; +import { + CourseValidationResult, + CoursePolygon, + CourseValidationOptions, +} from '../types/courseValidation.types'; +import { CourseTopologyResponse } from '../types/courses.types'; + +/** + * CourseTopologyResponse의 shape 데이터를 Turf.js MultiPolygon으로 변환 + */ +export function convertTopologyToMultiPolygon( + topology: CourseTopologyResponse, +): any { + const coordinates = topology.shape.map((polygon) => + polygon.map((ring) => + ring.map( + (coord) => + [Number((coord as any)[0]), Number((coord as any)[1])] as [ + number, + number, + ], + ), + ), + ); + + return turf.multiPolygon(coordinates); +} + +/** + * 실시간 위치가 코스 영역 내에 있는지 확인 + */ +export function validateLocationOnCourse( + currentLocation: Position, + coursePolygon: CoursePolygon, + options: CourseValidationOptions = {}, +): CourseValidationResult { + const { + tolerance = 50, // 기본 50미터 허용 오차 + enableDistanceCalculation = true, + } = options; + + try { + // 현재 위치를 Turf Point로 변환 + const currentPoint = turf.point([currentLocation[0], currentLocation[1]]); + + // 코스 폴리곤을 Turf MultiPolygon으로 변환 + const multiPolygon = turf.multiPolygon(coursePolygon.coordinates as any); + + // 점이 폴리곤 내부에 있는지 확인 + const isInside = turf.booleanPointInPolygon(currentPoint, multiPolygon); + + // 거리 계산 + let distanceFromCourse = 0; + let nearestPointOnCourse: [number, number] | undefined; + + if (enableDistanceCalculation) { + // 모든 폴리곤의 모든 링에 대해 최단 거리 계산 + let minDistance = Infinity; + let nearestPoint: any = null; + + coursePolygon.coordinates.forEach((polygon) => { + polygon.forEach((ring) => { + const lineString = turf.lineString(ring as any); + const nearest = turf.nearestPointOnLine(lineString, currentPoint); + const distance = turf.distance(currentPoint, nearest, { + units: 'meters', + }); + + if (distance < minDistance) { + minDistance = distance; + nearestPoint = nearest; + } + }); + }); + + distanceFromCourse = minDistance; + if (nearestPoint) { + nearestPointOnCourse = [ + nearestPoint.geometry.coordinates[0], + nearestPoint.geometry.coordinates[1], + ] as [number, number]; + } + } + + // tolerance를 고려한 최종 판정 + const isOnCourse = isInside || distanceFromCourse <= tolerance; + + // 디버깅 로그 + console.log('🔍 코스 검증 결과:', { + currentLocation: [ + currentLocation[0].toFixed(6), + currentLocation[1].toFixed(6), + ], + isInside, + distanceFromCourse: distanceFromCourse.toFixed(2) + 'm', + tolerance: tolerance + 'm', + finalResult: isOnCourse ? '경로 내부' : '경로 외부', + }); + + const result: CourseValidationResult = { + isOnCourse, + distanceFromCourse, + nearestPointOnCourse, + }; + + return result; + } catch (error) { + console.error('코스 검증 중 오류 발생:', error); + return { + isOnCourse: false, + distanceFromCourse: Infinity, + }; + } +} + +/** + * CourseTopologyResponse를 사용하여 위치 검증 + */ +export function validateLocationWithTopology( + currentLocation: Position, + topology: CourseTopologyResponse, + options: CourseValidationOptions = {}, +): CourseValidationResult { + // 🔍 라인 데이터를 그대로 사용하여 검증 + return validateLocationWithLines(currentLocation, topology, options); +} + +/** + * 라인 데이터를 사용하여 위치 검증 + */ +function validateLocationWithLines( + currentLocation: Position, + topology: CourseTopologyResponse, + options: CourseValidationOptions = {}, +): CourseValidationResult { + const { + tolerance = 50, // 기본 50미터 허용 오차 + enableDistanceCalculation = true, + } = options; + + try { + const currentPoint = turf.point([currentLocation[0], currentLocation[1]]); + + // 모든 라인에 대해 검증 + let isOnCourse = false; + let minDistance = Infinity; + let nearestPointOnCourse: [number, number] | undefined; + + // 모든 라인 세그먼트를 하나의 연속된 라인으로 연결 + const allCoords: [number, number][] = []; + + topology.shape.forEach((polygon, polygonIndex) => { + polygon.forEach((ring, ringIndex) => { + if (ring.length >= 2) { + // 각 ring은 [경도, 위도] 형태의 2개 좌표 + const lng = Number(ring[0]); + const lat = Number(ring[1]); + + if (!isNaN(lng) && !isNaN(lat)) { + allCoords.push([lng, lat]); + } + } + }); + }); + + if (allCoords.length < 2) { + console.warn('⚠️ 유효한 좌표가 부족합니다:', { allCoords }); + return { + isOnCourse: false, + distanceFromCourse: Infinity, + }; + } + + // 좌표 유효성 검사 + const validCoords = allCoords.filter( + (coord) => + !isNaN(coord[0]) && + !isNaN(coord[1]) && + isFinite(coord[0]) && + isFinite(coord[1]), + ); + + if (validCoords.length < 2) { + console.warn('⚠️ 유효한 좌표가 부족합니다:', { validCoords }); + return { + isOnCourse: false, + distanceFromCourse: Infinity, + }; + } + + // 좌표 배열을 명시적으로 변환 (Turf.js는 [lng, lat] 형식 기대) + const convertedCoords = validCoords.map( + (coord) => [Number(coord[0]), Number(coord[1])] as [number, number], + ); + + // 좌표 배열을 완전히 새로 생성 + const freshCoords: [number, number][] = []; + for (let i = 0; i < convertedCoords.length; i++) { + const coord = convertedCoords[i]; + if (Array.isArray(coord) && coord.length === 2) { + freshCoords.push([Number(coord[0]), Number(coord[1])]); + } + } + + // 좌표 배열을 완전히 새로 생성하여 타입 안전성 보장 + const safeCoords: [number, number][] = []; + for (let i = 0; i < freshCoords.length; i++) { + const coord = freshCoords[i]; + if (Array.isArray(coord) && coord.length === 2) { + const lng = Number(coord[0]); + const lat = Number(coord[1]); + if (!isNaN(lng) && !isNaN(lat) && isFinite(lng) && isFinite(lat)) { + safeCoords.push([lng, lat]); + } + } + } + + if (safeCoords.length < 2) { + return { + isOnCourse: false, + distanceFromCourse: Infinity, + }; + } + + let lineString; + try { + lineString = turf.lineString(safeCoords); + } catch (lineError) { + return { + isOnCourse: false, + distanceFromCourse: Infinity, + }; + } + + // 점이 라인 위에 있는지 확인 (매우 엄격하게) + let isOnLine; + try { + isOnLine = turf.booleanPointOnLine(currentPoint, lineString, { + epsilon: 0.00001, // 매우 엄격한 오차 허용 + }); + } catch (pointError) { + return { + isOnCourse: false, + distanceFromCourse: Infinity, + }; + } + + // 거리 계산 - Turf.js 사용 (안전한 방법) + if (enableDistanceCalculation) { + try { + // 현재 위치를 안전하게 Point로 생성 + const currentLng = Number(currentLocation[0]); + const currentLat = Number(currentLocation[1]); + + if ( + isNaN(currentLng) || + isNaN(currentLat) || + !isFinite(currentLng) || + !isFinite(currentLat) + ) { + minDistance = 0; + } else { + const currentPoint = turf.point([currentLng, currentLat]); + + // 현재 위치에서 가장 가까운 라인 좌표까지의 거리 계산 + let minDist = Infinity; + let nearestPoint: [number, number] | undefined; + + for (const coord of safeCoords) { + const coordLng = Number(coord[0]); + const coordLat = Number(coord[1]); + + if ( + !isNaN(coordLng) && + !isNaN(coordLat) && + isFinite(coordLng) && + isFinite(coordLat) + ) { + const coordPoint = turf.point([coordLng, coordLat]); + const distance = turf.distance(currentPoint, coordPoint, { + units: 'meters', + }); + + if (distance < minDist) { + minDist = distance; + nearestPoint = coord; + } + } + } + + minDistance = minDist; + nearestPointOnCourse = nearestPoint; + } + } catch (distanceError) { + minDistance = 0; // 거리 계산 실패 시 0으로 설정 + } + } + + // tolerance를 고려한 최종 판정 + const distanceFromCourse = minDistance === Infinity ? 0 : minDistance; + + // 더 엄격한 검증: 거리가 tolerance 이내여야 함 (isOnLine 무시) + const finalIsOnCourse = distanceFromCourse <= tolerance; + + return { + isOnCourse: finalIsOnCourse, + distanceFromCourse, + nearestPointOnCourse, + }; + } catch (error) { + return { + isOnCourse: false, + distanceFromCourse: Infinity, + }; + } +} + +// 경계 계산 헬퍼 함수 +function calculateBounds(shape: [number, number][][]) { + let minLng = Infinity, + maxLng = -Infinity; + let minLat = Infinity, + maxLat = -Infinity; + + shape.forEach((polygon) => { + polygon.forEach((ring) => { + // 각 ring은 [경도, 위도] 형태의 2개 좌표 + if (ring.length >= 2) { + const lng = Number(ring[0]); + const lat = Number(ring[1]); + + // NaN 체크 + if (!isNaN(lng) && !isNaN(lat)) { + minLng = Math.min(minLng, lng); + maxLng = Math.max(maxLng, lng); + minLat = Math.min(minLat, lat); + maxLat = Math.max(maxLat, lat); + } + } + }); + }); + + // 유효한 좌표가 없는 경우 + if ( + minLng === Infinity || + maxLng === -Infinity || + minLat === Infinity || + maxLat === -Infinity + ) { + return { + minLng: 'N/A', + maxLng: 'N/A', + minLat: 'N/A', + maxLat: 'N/A', + centerLng: 'N/A', + centerLat: 'N/A', + }; + } + + return { + minLng: minLng.toFixed(6), + maxLng: maxLng.toFixed(6), + minLat: minLat.toFixed(6), + maxLat: maxLat.toFixed(6), + centerLng: ((minLng + maxLng) / 2).toFixed(6), + centerLat: ((minLat + maxLat) / 2).toFixed(6), + }; +} + +/** + * 연속적인 위치 추적에서 코스 이탈 감지 + */ +export function detectCourseDeviation( + currentLocation: Position, + previousLocation: Position | null, + topology: CourseTopologyResponse, + options: CourseValidationOptions = {}, +): { + isDeviating: boolean; + validationResult: CourseValidationResult; + deviationSeverity: 'low' | 'medium' | 'high'; +} { + const validationResult = validateLocationWithTopology( + currentLocation, + topology, + options, + ); + + let isDeviating = false; + let deviationSeverity: 'low' | 'medium' | 'high' = 'low'; + + if (!validationResult.isOnCourse) { + isDeviating = true; + + // 이전 위치와의 거리를 고려하여 이탈 심각도 결정 + if (previousLocation && validationResult.distanceFromCourse) { + const distanceFromPrevious = turf.distance( + turf.point([previousLocation[0], previousLocation[1]]), + turf.point([currentLocation[0], currentLocation[1]]), + { units: 'meters' }, + ); + + if (validationResult.distanceFromCourse > 100) { + deviationSeverity = 'high'; + } else if (validationResult.distanceFromCourse > 50) { + deviationSeverity = 'medium'; + } else { + deviationSeverity = 'low'; + } + } + } + + return { + isDeviating, + validationResult, + deviationSeverity, + }; +} + +/** + * 코스 경계에서의 안전 거리 계산 + */ +export function calculateSafetyDistance( + currentLocation: Position, + topology: CourseTopologyResponse, +): number { + try { + const currentPoint = turf.point([currentLocation[0], currentLocation[1]]); + const multiPolygon = turf.multiPolygon( + topology.shape.map((polygon) => + polygon.map((ring) => + ring.map( + (coord) => + [Number((coord as any)[0]), Number((coord as any)[1])] as [ + number, + number, + ], + ), + ), + ), + ); + + // 폴리곤 경계까지의 최단 거리 계산 + let minDistance = Infinity; + + // 각 폴리곤의 각 링에 대해 거리 계산 + topology.shape.forEach((polygon) => { + polygon.forEach((ring) => { + const lineString = turf.lineString( + ring.map( + (coord) => + [Number((coord as any)[0]), Number((coord as any)[1])] as [ + number, + number, + ], + ), + ); + const nearestPoint = turf.nearestPointOnLine(lineString, currentPoint); + const distance = turf.distance(currentPoint, nearestPoint, { + units: 'meters', + }); + + if (distance < minDistance) { + minDistance = distance; + } + }); + }); + + return minDistance; + } catch (error) { + console.error('안전 거리 계산 중 오류 발생:', error); + return Infinity; + } +}