-
Notifications
You must be signed in to change notification settings - Fork 1
Feature: 경로 이탈 알림 기능 #45
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
e22baf7
dcd017f
ab6b4ae
b772269
9b844b6
6c3f848
7c7cb14
6f54dce
0626483
11075d1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<Position | null>(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, | ||
| }); | ||
|
Comment on lines
+83
to
+90
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Comment on lines
+83
to
+90
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
|
|
||
| // 검증 결과 업데이트 | ||
| 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) | ||
|
Comment on lines
+128
to
+129
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| ); | ||
| }, [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(); | ||
|
Comment on lines
+147
to
+152
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| }, [ | ||
| 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, | ||
| }; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<Position | null>(null); | ||
| interface UseInitialLocationOptions { | ||
| requestPermission?: boolean; // 권한 요청 여부 (기본값: true) | ||
| } | ||
|
|
||
| export function useInitialLocation(options: UseInitialLocationOptions = {}) { | ||
| const { requestPermission = true } = options; | ||
| const [location, setLocation] = useState<Location.LocationObject | null>( | ||
| null, | ||
| ); | ||
| const [loading, setLoading] = useState(true); | ||
| const [, setError] = useState<string | null>(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; | ||
| } | ||
|
Comment on lines
+23
to
37
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 권한 요청 로직에 버그가 있습니다. 23-30행에서 권한을 요청하고 사용자가 승인하더라도, 아래와 같이 수정하여 (async () => {
let { status } = await Location.getForegroundPermissionsAsync();
if (status !== 'granted' && requestPermission) {
const permissionResult = await Location.requestForegroundPermissionsAsync();
status = permissionResult.status;
}
if (status !== 'granted') {
setError('위치 접근 권한이 필요합니다.');
setLoading(false);
return;
}
// ... 위치 정보 가져오기 로직
})(); let { status } = await Location.getForegroundPermissionsAsync();
// 권한이 없고 요청이 필요한 경우에만 권한 요청
if (status !== 'granted' && requestPermission) {
const permissionResult = await Location.requestForegroundPermissionsAsync();
status = permissionResult.status;
}
// 권한이 없으면 위치 가져오기 시도하지 않음
if (status !== '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 }; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
좌표 비교에 사용된
0.000001은 매직 넘버입니다. 1 코드의 의도를 명확히 하고 유지보수성을 높이기 위해 의미 있는 이름의 상수로 추출하는 것을 권장합니다. 예를 들어COORDINATE_COMPARISON_TOLERANCE와 같이 정의할 수 있습니다.Style Guide References
Footnotes
매직 넘버는 이름 있는 상수로 치환해야 합니다. ↩