Skip to content
225 changes: 225 additions & 0 deletions src/hooks/useCourseValidation.ts
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
Comment on lines +60 to +61

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

좌표 비교에 사용된 0.000001은 매직 넘버입니다. 1 코드의 의도를 명확히 하고 유지보수성을 높이기 위해 의미 있는 이름의 상수로 추출하는 것을 권장합니다. 예를 들어 COORDINATE_COMPARISON_TOLERANCE 와 같이 정의할 수 있습니다.

Style Guide References

Footnotes

  1. 매직 넘버는 이름 있는 상수로 치환해야 합니다.

) {
// 좌표가 거의 변하지 않았으면 검증하지 않음
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

디버깅 목적으로 추가된 console.log 구문이 남아있습니다. 프로덕션 코드에 포함되지 않도록 제거해주세요.

Comment on lines +83 to +90

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

디버깅 목적으로 사용된 console.log 구문이 남아있습니다. 프로덕션 코드에 포함되지 않도록 제거하는 것이 좋습니다.


// 검증 결과 업데이트
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

코스 복귀 판정에 사용된 숫자 3은 매직 넘버입니다. 1 RECENT_VALIDATION_COUNT 와 같은 상수로 정의하여 가독성을 높이는 것이 좋습니다.

Style Guide References

Footnotes

  1. 매직 넘버는 이름 있는 상수로 치환해야 합니다.

);
}, [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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

getDeviationDuration 함수의 연속 이탈 시간 계산 로직이 부정확할 수 있습니다. 이 함수는 validationInterval을 기반으로 시간을 추정하지만, 실제 검증은 routeCoordinates가 변경될 때마다 비동기적으로 발생합니다. 이로 인해 계산된 시간과 실제 이탈 시간 사이에 오차가 발생할 수 있습니다.

보다 정확한 계산을 위해 validationHistory에 각 검증 결과와 함께 타임스탬프를 저장하고, 이를 기반으로 시간을 계산하는 방식을 고려해 보세요.

}, [
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,
};
}
39 changes: 28 additions & 11 deletions src/hooks/useInitialLocation.ts
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

권한 요청 로직에 버그가 있습니다. 23-30행에서 권한을 요청하고 사용자가 승인하더라도, currentStatus 변수가 갱신되지 않아 33행의 조건문에 걸려 함수가 조기 종료될 수 있습니다. 이로 인해 권한이 있어도 위치 정보를 가져오지 못하는 문제가 발생할 수 있습니다.

아래와 같이 수정하여 status 변수를 재할당하고 로직을 간소화하는 것을 권장합니다.

(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;
      }

Expand All @@ -27,14 +47,11 @@ export function useInitialLocation() {
}

if (fetchedLocation) {
setLocation([
fetchedLocation.coords.longitude,
fetchedLocation.coords.latitude,
]);
setLocation(fetchedLocation);
}
setLoading(false);
})();
}, []);
}, [requestPermission]);

return { location, loading };
}
5 changes: 4 additions & 1 deletion src/hooks/useLocationManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ export function useLocationManager() {

useEffect(() => {
if (initialLocation) {
currentUserLocation.current = initialLocation;
currentUserLocation.current = [
initialLocation.coords.longitude,
initialLocation.coords.latitude,
];
}
}, [initialLocation]);

Expand Down
Loading