From e22baf70a440e53e7026aa28c9047a5e5a7ccce1 Mon Sep 17 00:00:00 2001 From: Monixc Date: Thu, 11 Sep 2025 15:34:46 +0900 Subject: [PATCH 1/9] =?UTF-8?q?feat:=20=EC=BD=94=EC=8A=A4=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=ED=9B=85=20=EB=B0=8F=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useCourseValidation 훅을 새로 추가하여 실시간 위치 검증 및 코스 이탈 감지 기능 구현 - Run 컴포넌트에서 코스 검증 훅을 사용하여 이탈 상태에 따른 알림 메시지 표시 - run.ts 스토어에 코스 검증 상태 및 관련 액션 추가 - courseValidation.types.ts 및 courseValidation.ts 파일 생성하여 타입 정의 및 검증 로직 구현 --- src/hooks/useCourseValidation.ts | 169 ++++++++++ src/pages/Run/index.tsx | 138 +++++++- src/store/run.ts | 67 +++- src/types/courseValidation.types.ts | 16 + src/utils/courseValidation.ts | 476 ++++++++++++++++++++++++++++ 5 files changed, 863 insertions(+), 3 deletions(-) create mode 100644 src/hooks/useCourseValidation.ts create mode 100644 src/types/courseValidation.types.ts create mode 100644 src/utils/courseValidation.ts diff --git a/src/hooks/useCourseValidation.ts b/src/hooks/useCourseValidation.ts new file mode 100644 index 0000000..e6f8a19 --- /dev/null +++ b/src/hooks/useCourseValidation.ts @@ -0,0 +1,169 @@ +import { useCallback, useEffect } 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, + isOnCourse, + courseDeviation, + validationHistory, + lastValidationTime, + updateCourseValidation, + clearValidationHistory, + } = useRunStore(); + + // 실시간 위치 검증 + const validateCurrentLocation = useCallback(() => { + if (!location || !courseTopology) { + return null; + } + + const currentPosition: Position = [ + location.coords.longitude, + location.coords.latitude, + ]; + + // 이전 위치 가져오기 (검증 히스토리에서) + const previousResult = validationHistory[validationHistory.length - 1]; + const previousLocation = previousResult?.nearestPointOnCourse || null; + + // 코스 이탈 감지 + const deviationResult = detectCourseDeviation( + currentPosition, + previousLocation, + courseTopology, + validationOptions, + ); + + // 검증 결과 업데이트 + updateCourseValidation(deviationResult.validationResult); + + return deviationResult; + }, [ + location, + courseTopology, + validationHistory, + validationOptions, + updateCourseValidation, + ]); + + // 안전 거리 계산 + const getSafetyDistance = useCallback(() => { + if (!location || !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, + ]); + + // 실시간 검증 활성화/비활성화 + useEffect(() => { + if (!enableRealTimeValidation || !courseTopology || !location) { + return; + } + + const interval = setInterval(() => { + validateCurrentLocation(); + }, validationInterval); + + return () => clearInterval(interval); + }, [ + enableRealTimeValidation, + courseTopology, + location, + validateCurrentLocation, + validationInterval, + ]); + + // 코스 변경 시 검증 히스토리 초기화 + useEffect(() => { + if (courseTopology) { + clearValidationHistory(); + } + }, [courseTopology, clearValidationHistory]); + + return { + // 상태 + isOnCourse, + courseDeviation, + validationHistory, + lastValidationTime, + + // 액션 + validateCurrentLocation, + getSafetyDistance, + checkCourseReturn, + getDeviationDuration, + clearValidationHistory, + + // 유틸리티 + isDeviating: courseDeviation.isDeviating, + deviationSeverity: courseDeviation.severity, + distanceFromCourse: courseDeviation.distanceFromCourse, + }; +} diff --git a/src/pages/Run/index.tsx b/src/pages/Run/index.tsx index f5182e6..252de81 100644 --- a/src/pages/Run/index.tsx +++ b/src/pages/Run/index.tsx @@ -1,7 +1,12 @@ import { useRef, useCallback } from 'react'; -import { Text } from 'react-native'; +import { Text, View } from 'react-native'; import styled from '@emotion/native'; -import { ArrowLeft, LocateFixed } from 'lucide-react-native'; +import { + ArrowLeft, + LocateFixed, + AlertTriangle, + CheckCircle, +} from 'lucide-react-native'; import { useFocusEffect } from '@react-navigation/native'; import type { NativeStackScreenProps } from '@react-navigation/native-stack'; import { LoadingOverlay, ErrorOverlay } from '@/components/Overlay'; @@ -11,6 +16,7 @@ import { useLocationManager } from '@/hooks/useLocationManager'; import { useRunStats } from '@/hooks/useRunStats'; import { useRunModals } from '@/hooks/useRunModals'; import { useCourseTopologyApi } from '@/hooks/api/useCourseTopologyApi'; +import { useCourseValidation } from '@/hooks/useCourseValidation'; import useRunStore from '@/store/run'; import RunMap from './_components/RunMap'; import StatsContainer from './_components/StatsContainer'; @@ -48,6 +54,22 @@ export default function Run({ route, navigation }: Props) { useRunStats(routeCoordinates, isTracking); const { loadCourseTopology } = useCourseTopologyApi(courseId); + // 코스 검증 훅 + const { + isOnCourse, + courseDeviation, + isDeviating, + deviationSeverity, + distanceFromCourse, + } = useCourseValidation({ + validationOptions: { + tolerance: 5, // 5미터 허용 오차 (매우 엄격하게) + enableDistanceCalculation: true, + }, + enableRealTimeValidation: true, // 실시간 검증 활성화 + validationInterval: 1000, // 1초마다 검증 (매우 자주) + }); + const { showExitModal, showBackModal, @@ -63,6 +85,24 @@ export default function Run({ route, navigation }: Props) { flyToCurrentUserLocation(cameraRef); }; + // 코스 이탈 상태에 따른 메시지 생성 + const getDeviationMessage = () => { + 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 '경로를 벗어났습니다.'; + } + }; + return ( @@ -78,6 +118,22 @@ export default function Run({ route, navigation }: Props) { onRetry={loadCourseTopology} /> )} + {/* 코스 이탈 알림 */} + {isDeviating && ( + + + + {getDeviationMessage()} + + + )} + {/* 코스 내부 상태 표시 */} + {isOnCourse && !isDeviating && ( + + + 경로 내부 + + )} ) : ( Getting location... @@ -144,3 +200,81 @@ const StyledContainer = styled.View({ flex: 1, width: '100%', }); + +const DeviationOverlay = styled.View<{ severity: 'low' | 'medium' | 'high' }>( + ({ 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: 100, + left: 20, + right: 20, + backgroundColor: + severity === 'high' + ? '#ef4444' + : severity === 'medium' + ? '#f59e0b' + : '#3b82f6', + paddingHorizontal: 20, + paddingVertical: 16, + borderRadius: 12, + flexDirection: 'row', + alignItems: '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 OnCourseIndicator = styled.View({ + position: 'absolute', + top: 100, + right: 20, + backgroundColor: '#ffffff', + paddingHorizontal: 12, + paddingVertical: 8, + borderRadius: 20, + flexDirection: 'row', + alignItems: 'center', + zIndex: 1000, + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.2, + shadowRadius: 2, + elevation: 3, +}); + +const OnCourseText = styled.Text({ + color: '#10b981', + fontSize: 12, + fontWeight: '600', + marginLeft: 4, +}); 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; + } +} From dcd017ff0eb36e03b00e106ec487fc91468037fa Mon Sep 17 00:00:00 2001 From: Monixc Date: Thu, 11 Sep 2025 15:50:22 +0900 Subject: [PATCH 2/9] =?UTF-8?q?fix:=20=EA=B2=BD=EB=A1=9C=20=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EB=A1=9C=EB=93=9C=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - courseId가 변경될 때마다 경로 데이터를 다시 로드하도록 useFocusEffect 내의 로직 수정 - 불필요한 import 제거 및 코드 정리 --- src/pages/Run/index.tsx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/pages/Run/index.tsx b/src/pages/Run/index.tsx index 252de81..9998d10 100644 --- a/src/pages/Run/index.tsx +++ b/src/pages/Run/index.tsx @@ -1,5 +1,5 @@ import { useRef, useCallback } from 'react'; -import { Text, View } from 'react-native'; +import { Text } from 'react-native'; import styled from '@emotion/native'; import { ArrowLeft, @@ -44,16 +44,20 @@ export default function Run({ route, navigation }: Props) { startTime, } = useRunStore(); + useRunStats(routeCoordinates, isTracking); + const { loadCourseTopology } = useCourseTopologyApi(courseId); + useFocusEffect( useCallback(() => { resetLocationTracking(); resetRunState(); - }, [resetLocationTracking, resetRunState]), + // 같은 courseId로 다시 진입할 때도 경로 데이터를 다시 로드 + if (courseId) { + loadCourseTopology(); + } + }, [resetLocationTracking, resetRunState, courseId, loadCourseTopology]), ); - useRunStats(routeCoordinates, isTracking); - const { loadCourseTopology } = useCourseTopologyApi(courseId); - // 코스 검증 훅 const { isOnCourse, From b772269106301b2602a7d7ec292d592fd7ca6bf1 Mon Sep 17 00:00:00 2001 From: Monixc Date: Fri, 12 Sep 2025 17:50:59 +0900 Subject: [PATCH 3/9] =?UTF-8?q?style:=20=ED=97=A4=EB=8D=94=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20?= =?UTF-8?q?=EC=A0=9C=EC=96=B4=20=EC=BB=A8=ED=85=8C=EC=9D=B4=EB=84=88=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Run 페이지에 헤더 컴포넌트를 추가하여 사용자 경험 개선 - ControlContainer에서 FloatingButton을 제거하고, 현재 위치 버튼을 추가하여 UI 간소화 - ControlContainer의 props를 통해 현재 위치 버튼 클릭 핸들러 전달 --- .../Run/_components/ControlContainer.tsx | 48 +++++++++++++++++-- src/pages/Run/index.tsx | 25 ++-------- 2 files changed, 46 insertions(+), 27 deletions(-) diff --git a/src/pages/Run/_components/ControlContainer.tsx b/src/pages/Run/_components/ControlContainer.tsx index 79399a7..e555f79 100644 --- a/src/pages/Run/_components/ControlContainer.tsx +++ b/src/pages/Run/_components/ControlContainer.tsx @@ -1,12 +1,26 @@ +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'; -export default function ControlContainer() { +interface ControlContainerProps { + onCurrentLocationPress: () => void; +} + +const ControlContainer: React.FC = ({ + onCurrentLocationPress, +}) => { const { isLocked, startTime, @@ -75,16 +89,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 +134,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/index.tsx b/src/pages/Run/index.tsx index c682165..1e2d11b 100644 --- a/src/pages/Run/index.tsx +++ b/src/pages/Run/index.tsx @@ -10,6 +10,7 @@ import { import { useFocusEffect } from '@react-navigation/native'; import type { NativeStackScreenProps } from '@react-navigation/native-stack'; import { LoadingOverlay, ErrorOverlay } from '@/components/Overlay'; +import Header from '@/components/Header'; import type { TabParamList } from '@/types/navigation.types'; import { useLocationTracking } from '@/hooks/useLocationTracking'; import { useLocationManager } from '@/hooks/useLocationManager'; @@ -23,7 +24,6 @@ import StatsContainer from './_components/StatsContainer'; import ControlContainer from './_components/ControlContainer'; import Modal from '@/components/Modal'; import Mapbox from '@rnmapbox/maps'; -import FloatingButton from '@/components/FloatingButton'; type Props = NativeStackScreenProps; @@ -127,6 +127,7 @@ export default function Run({ route, navigation }: Props) { return ( +
{errorMsg ? ( {errorMsg} @@ -162,27 +163,7 @@ export default function Run({ route, navigation }: Props) { )} - - - + Date: Fri, 12 Sep 2025 17:59:59 +0900 Subject: [PATCH 4/9] =?UTF-8?q?style:=20Run=20=ED=8E=98=EC=9D=B4=EC=A7=80?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EB=B6=88=ED=95=84=EC=9A=94=ED=95=9C=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20UI=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OnCourseIndicator 컴포넌트를 제거하여 코드 간소화 - DeviationAlert의 위치 조정 및 정렬 방식 변경으로 UI 개선 - 사용하지 않는 import 문 제거 --- src/pages/Run/index.tsx | 42 +++-------------------------------------- 1 file changed, 3 insertions(+), 39 deletions(-) diff --git a/src/pages/Run/index.tsx b/src/pages/Run/index.tsx index 1e2d11b..cf2bdc1 100644 --- a/src/pages/Run/index.tsx +++ b/src/pages/Run/index.tsx @@ -1,12 +1,7 @@ import { useRef, useCallback } from 'react'; import { Text, BackHandler } from 'react-native'; import styled from '@emotion/native'; -import { - ArrowLeft, - LocateFixed, - AlertTriangle, - CheckCircle, -} from 'lucide-react-native'; +import { ArrowLeft, LocateFixed, AlertTriangle } from 'lucide-react-native'; import { useFocusEffect } from '@react-navigation/native'; import type { NativeStackScreenProps } from '@react-navigation/native-stack'; import { LoadingOverlay, ErrorOverlay } from '@/components/Overlay'; @@ -150,13 +145,6 @@ export default function Run({ route, navigation }: Props) { )} - {/* 코스 내부 상태 표시 */} - {isOnCourse && !isDeviating && ( - - - 경로 내부 - - )} ) : ( Getting location... @@ -225,7 +213,7 @@ const DeviationOverlay = styled.View<{ severity: 'low' | 'medium' | 'high' }>( const DeviationAlert = styled.View<{ severity: 'low' | 'medium' | 'high' }>( ({ severity }) => ({ position: 'absolute', - top: 100, + top: 10, left: 20, right: 20, backgroundColor: @@ -239,6 +227,7 @@ const DeviationAlert = styled.View<{ severity: 'low' | 'medium' | 'high' }>( borderRadius: 12, flexDirection: 'row', alignItems: 'center', + justifyContent: 'center', zIndex: 1000, shadowColor: '#000', shadowOffset: { width: 0, height: 4 }, @@ -256,28 +245,3 @@ const DeviationText = styled.Text({ flex: 1, textAlign: 'center', }); - -const OnCourseIndicator = styled.View({ - position: 'absolute', - top: 100, - right: 20, - backgroundColor: '#ffffff', - paddingHorizontal: 12, - paddingVertical: 8, - borderRadius: 20, - flexDirection: 'row', - alignItems: 'center', - zIndex: 1000, - shadowColor: '#000', - shadowOffset: { width: 0, height: 1 }, - shadowOpacity: 0.2, - shadowRadius: 2, - elevation: 3, -}); - -const OnCourseText = styled.Text({ - color: '#10b981', - fontSize: 12, - fontWeight: '600', - marginLeft: 4, -}); From 6c3f8481c835593c6c47d85dbba33b0754c85738 Mon Sep 17 00:00:00 2001 From: Monixc Date: Fri, 12 Sep 2025 18:10:16 +0900 Subject: [PATCH 5/9] =?UTF-8?q?refactor:=20=EC=9C=84=EC=B9=98=20=EC=B6=94?= =?UTF-8?q?=EC=A0=81=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useLocationTracking 훅에서 위치 관련 데이터를 통합하여 코드 간소화 - 현재 위치 버튼 클릭 시 트래킹 중인 경우 마지막 위치로 이동하도록 로직 수정 - RunMap 컴포넌트에서 위치 마커를 트래킹 중인 마지막 위치로 표시하도록 변경 --- src/pages/Run/_components/RunMap.tsx | 41 ++++++++++++++++++++++------ src/pages/Run/index.tsx | 25 +++++++++++++---- 2 files changed, 51 insertions(+), 15 deletions(-) diff --git a/src/pages/Run/_components/RunMap.tsx b/src/pages/Run/_components/RunMap.tsx index 14d974c..5055ee9 100644 --- a/src/pages/Run/_components/RunMap.tsx +++ b/src/pages/Run/_components/RunMap.tsx @@ -1,3 +1,4 @@ +import { View } from 'react-native'; import styled from '@emotion/native'; import Mapbox from '@rnmapbox/maps'; import { theme } from '@/styles/theme'; @@ -5,7 +6,6 @@ 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'; export default function RunMap({ mapRef: externalMapRef, @@ -14,15 +14,18 @@ export default function RunMap({ mapRef?: React.RefObject; cameraRef?: React.RefObject; }) { - const { routeCoordinates } = useLocationTracking(); - const { location: locationObject } = useLocationManager(); + const { routeCoordinates, location: locationObject } = useLocationTracking(); - const location = locationObject - ? ([locationObject.coords.longitude, locationObject.coords.latitude] as [ - number, - number, - ]) - : null; + // 트래킹 중일 때는 routeCoordinates의 마지막 위치를 사용, 그렇지 않으면 현재 위치 사용 + const location = + routeCoordinates.length > 0 + ? (routeCoordinates[routeCoordinates.length - 1] as [number, number]) + : locationObject + ? ([ + locationObject.coords.longitude, + locationObject.coords.latitude, + ] as [number, number]) + : null; const { routeGeoJSON, courseShapeGeoJSON, courseShapePolygons, isLocked } = useRunMap(externalCameraRef, location, routeCoordinates); @@ -37,6 +40,7 @@ export default function RunMap({ mapRef={externalMapRef} cameraRef={externalCameraRef} initialLocation={location} + showUserLocation={false} > {routeGeoJSON.geometry.coordinates.length > 1 && ( @@ -73,6 +77,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, + } = useLocationTracking(); const cameraRef = useRef(null!); const mapRef = useRef(null); @@ -99,7 +102,17 @@ 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) { + const currentPosition = [ + location.coords.longitude, + location.coords.latitude, + ] as [number, number]; + cameraRef.current?.flyTo(currentPosition, 1000); + } }; // 코스 이탈 상태에 따른 메시지 생성 From 7c7cb14081c5652a723efe579c6cfb4466d738ab Mon Sep 17 00:00:00 2001 From: Monixc Date: Fri, 12 Sep 2025 20:54:20 +0900 Subject: [PATCH 6/9] =?UTF-8?q?feat:=20=EC=8B=A4=EC=8B=9C=EA=B0=84=20?= =?UTF-8?q?=EC=9C=84=EC=B9=98=20=EA=B2=80=EC=A6=9D=20=EB=B0=8F=20UI=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useCourseValidation 훅에 트래킹 상태 및 경로 좌표를 추가하여 실시간 위치 검증 로직 개선 - Run 페이지에서 위치 로딩 상태에 따른 UI 업데이트 및 새로고침 기능 추가 - RunMap 컴포넌트에서 실시간 위치 업데이트를 위한 로직 수정 - 코스 이탈 상태에 따른 메시지를 useMemo로 최적화하여 성능 개선 --- src/hooks/useCourseValidation.ts | 94 ++++++++++++++++++++++------ src/pages/Run/_components/RunMap.tsx | 15 ++--- src/pages/Run/index.tsx | 55 +++++++++++++--- 3 files changed, 129 insertions(+), 35 deletions(-) diff --git a/src/hooks/useCourseValidation.ts b/src/hooks/useCourseValidation.ts index e6f8a19..7d8f7f4 100644 --- a/src/hooks/useCourseValidation.ts +++ b/src/hooks/useCourseValidation.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; import { Position } from 'geojson'; import useRunStore from '../store/run'; import { @@ -24,6 +24,8 @@ export function useCourseValidation(options: UseCourseValidationOptions = {}) { const { courseTopology, location, + isTracking, + routeCoordinates, isOnCourse, courseDeviation, validationHistory, @@ -32,16 +34,38 @@ export function useCourseValidation(options: UseCourseValidationOptions = {}) { clearValidationHistory, } = useRunStore(); - // 실시간 위치 검증 + // 이전 좌표를 저장할 ref + const lastValidatedCoordinateRef = useRef(null); + + // 실시간 위치 검증 (트래킹 중이고 좌표가 실제로 이동했을 때만) const validateCurrentLocation = useCallback(() => { - if (!location || !courseTopology) { + if ( + !location || + !location.coords || + !courseTopology || + !isTracking || + routeCoordinates.length === 0 + ) { return null; } - const currentPosition: Position = [ - location.coords.longitude, - location.coords.latitude, - ]; + // 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]; @@ -55,6 +79,16 @@ export function useCourseValidation(options: UseCourseValidationOptions = {}) { validationOptions, ); + // 디버깅 로그 + console.log('🔍 [CourseValidation] 검증 실행:', { + currentPosition, + isTracking, + routeCoordinatesLength: routeCoordinates.length, + isDeviating: deviationResult.isDeviating, + severity: deviationResult.deviationSeverity, + distance: deviationResult.validationResult.distanceFromCourse, + }); + // 검증 결과 업데이트 updateCourseValidation(deviationResult.validationResult); @@ -62,6 +96,8 @@ export function useCourseValidation(options: UseCourseValidationOptions = {}) { }, [ location, courseTopology, + isTracking, + routeCoordinates, validationHistory, validationOptions, updateCourseValidation, @@ -69,7 +105,7 @@ export function useCourseValidation(options: UseCourseValidationOptions = {}) { // 안전 거리 계산 const getSafetyDistance = useCallback(() => { - if (!location || !courseTopology) { + if (!location || !location.coords || !courseTopology) { return null; } @@ -121,31 +157,51 @@ export function useCourseValidation(options: UseCourseValidationOptions = {}) { validationInterval, ]); - // 실시간 검증 활성화/비활성화 + // routeCoordinates 변경 시에만 검증 실행 (트래킹 중일 때만) useEffect(() => { - if (!enableRealTimeValidation || !courseTopology || !location) { + if ( + !enableRealTimeValidation || + !courseTopology || + !location || + !isTracking || + routeCoordinates.length === 0 + ) { return; } - const interval = setInterval(() => { - validateCurrentLocation(); - }, validationInterval); - - return () => clearInterval(interval); + // routeCoordinates가 변경될 때만 검증 실행 + validateCurrentLocation(); }, [ enableRealTimeValidation, courseTopology, location, - validateCurrentLocation, - validationInterval, + isTracking, + routeCoordinates, ]); - // 코스 변경 시 검증 히스토리 초기화 + // 코스 변경 시 검증 히스토리 초기화 및 즉시 검증 실행 (트래킹 중일 때만) useEffect(() => { if (courseTopology) { clearValidationHistory(); + // 이전 좌표 ref 초기화 + lastValidatedCoordinateRef.current = null; + // 코스 토폴로지가 로드된 후 즉시 검증 실행 (트래킹 중이고 좌표가 있을 때만) + if ( + location && + location.coords && + isTracking && + routeCoordinates.length > 0 + ) { + validateCurrentLocation(); + } } - }, [courseTopology, clearValidationHistory]); + }, [ + courseTopology, + clearValidationHistory, + location, + isTracking, + routeCoordinates, + ]); return { // 상태 diff --git a/src/pages/Run/_components/RunMap.tsx b/src/pages/Run/_components/RunMap.tsx index 5055ee9..b5f6606 100644 --- a/src/pages/Run/_components/RunMap.tsx +++ b/src/pages/Run/_components/RunMap.tsx @@ -17,14 +17,15 @@ export default function RunMap({ const { routeCoordinates, location: locationObject } = useLocationTracking(); // 트래킹 중일 때는 routeCoordinates의 마지막 위치를 사용, 그렇지 않으면 현재 위치 사용 + // 실시간 위치 업데이트를 위해 locationObject를 우선 사용 const location = - routeCoordinates.length > 0 - ? (routeCoordinates[routeCoordinates.length - 1] as [number, number]) - : locationObject - ? ([ - locationObject.coords.longitude, - locationObject.coords.latitude, - ] as [number, number]) + 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 } = diff --git a/src/pages/Run/index.tsx b/src/pages/Run/index.tsx index 4c8bd93..d880c5c 100644 --- a/src/pages/Run/index.tsx +++ b/src/pages/Run/index.tsx @@ -1,4 +1,4 @@ -import { useRef, useCallback } from 'react'; +import { useRef, useCallback, useMemo, useEffect } from 'react'; import { Text, BackHandler } from 'react-native'; import styled from '@emotion/native'; import { ArrowLeft, AlertTriangle } from 'lucide-react-native'; @@ -30,6 +30,8 @@ export default function Run({ route, navigation }: Props) { resetLocationTracking, location, errorMsg, + locationLoading, + refreshLocation, } = useLocationTracking(); const cameraRef = useRef(null!); const mapRef = useRef(null); @@ -53,13 +55,14 @@ export default function Run({ route, navigation }: Props) { isDeviating, deviationSeverity, distanceFromCourse, + validateCurrentLocation, } = useCourseValidation({ validationOptions: { tolerance: 5, // 5미터 허용 오차 (매우 엄격하게) enableDistanceCalculation: true, }, enableRealTimeValidation: true, // 실시간 검증 활성화 - validationInterval: 1000, // 1초마다 검증 (매우 자주) + validationInterval: 1000, // 1초마다 검증 }); const { @@ -84,6 +87,17 @@ export default function Run({ route, navigation }: Props) { }, [resetLocationTracking, resetRunState, courseId, loadCourseTopology]), ); + // 위치가 로드되지 않았을 때 자동으로 새로고침 + useEffect(() => { + if (!location && !errorMsg && !locationLoading) { + const timer = setTimeout(() => { + refreshLocation(); + }, 2000); // 2초 후에 새로고침 시도 + + return () => clearTimeout(timer); + } + }, [location, errorMsg, locationLoading, refreshLocation]); + // 하드웨어 뒤로가기 버튼 제어 useFocusEffect( useCallback(() => { @@ -106,7 +120,7 @@ export default function Run({ route, navigation }: Props) { if (routeCoordinates.length > 0) { const lastCoordinate = routeCoordinates[routeCoordinates.length - 1]; cameraRef.current?.flyTo(lastCoordinate, 1000); - } else if (location) { + } else if (location && location.coords) { const currentPosition = [ location.coords.longitude, location.coords.latitude, @@ -115,8 +129,8 @@ export default function Run({ route, navigation }: Props) { } }; - // 코스 이탈 상태에 따른 메시지 생성 - const getDeviationMessage = () => { + // 코스 이탈 상태에 따른 메시지 생성 (실시간 업데이트) + const deviationMessage = useMemo(() => { if (!isDeviating) return null; const distance = distanceFromCourse ? Math.round(distanceFromCourse) : 0; @@ -131,7 +145,7 @@ export default function Run({ route, navigation }: Props) { default: return '경로를 벗어났습니다.'; } - }; + }, [isDeviating, distanceFromCourse, deviationSeverity]); return ( @@ -139,7 +153,11 @@ export default function Run({ route, navigation }: Props) { {errorMsg ? ( {errorMsg} - ) : location ? ( + ) : locationLoading ? ( + + Getting location... + + ) : location && location.coords ? ( <> {loading && } @@ -154,13 +172,18 @@ export default function Run({ route, navigation }: Props) { - {getDeviationMessage()} + {deviationMessage} )} ) : ( - Getting location... + + 위치를 가져올 수 없습니다 + + 새로고침 + + )} @@ -258,3 +281,17 @@ const DeviationText = styled.Text({ 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', +}); From 6f54dce5b96d14d02f7a164303a98666c78df462 Mon Sep 17 00:00:00 2001 From: Monixc Date: Fri, 12 Sep 2025 20:54:33 +0900 Subject: [PATCH 7/9] =?UTF-8?q?feat:=20=EC=B4=88=EA=B8=B0=20=EC=9C=84?= =?UTF-8?q?=EC=B9=98=20=ED=9B=85=20=EA=B0=9C=EC=84=A0=20=EB=B0=8F=20?= =?UTF-8?q?=EA=B6=8C=ED=95=9C=20=EC=9A=94=EC=B2=AD=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useInitialLocation 훅에 권한 요청 옵션 추가하여 유연성 향상 - 위치 권한 상태를 체크하고, 필요 시에만 권한 요청하도록 로직 수정 - useLocationManager 및 useLocationTracking 훅에서 초기 위치 설정 및 권한 요청 로직 통합 - 디버깅 로그 추가로 위치 업데이트 및 초기 위치 가져오기 과정에서의 가시성 향상 --- src/hooks/useInitialLocation.ts | 40 +++++++++++---- src/hooks/useLocationManager.ts | 7 ++- src/hooks/useLocationTracking.ts | 86 ++++++++++++++++++++++---------- 3 files changed, 96 insertions(+), 37 deletions(-) diff --git a/src/hooks/useInitialLocation.ts b/src/hooks/useInitialLocation.ts index 1276308..4466d05 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; } @@ -22,19 +42,17 @@ export function useInitialLocation() { if (!fetchedLocation) { fetchedLocation = await Location.getCurrentPositionAsync({}); } + console.log('fetchedLocation', fetchedLocation); } catch (error) { console.error('위치 가져오기 오류', error); } 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..b84d972 100644 --- a/src/hooks/useLocationManager.ts +++ b/src/hooks/useLocationManager.ts @@ -1,6 +1,7 @@ import { useRef, useEffect } from 'react'; import type { Position } from 'geojson'; import type Mapbox from '@rnmapbox/maps'; +import * as Location from 'expo-location'; import { useInitialLocation } from '@/hooks/useInitialLocation'; import { useLocationTracking } from '@/hooks/useLocationTracking'; import { FLY_TO_USER_LOCATION_DURATION } from '@/constants/draw'; @@ -13,7 +14,11 @@ export function useLocationManager() { useEffect(() => { if (initialLocation) { - currentUserLocation.current = initialLocation; + // LocationObject를 Position으로 변환 + currentUserLocation.current = [ + initialLocation.coords.longitude, + initialLocation.coords.latitude, + ]; } }, [initialLocation]); diff --git a/src/hooks/useLocationTracking.ts b/src/hooks/useLocationTracking.ts index d021b3e..c44ac7b 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,24 @@ 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); + console.log( + '📍 [LocationTracking] Run 스크린 접속 시 현재 위치 가져옴:', + { + latitude: initialLocation.coords.latitude, + longitude: initialLocation.coords.longitude, + timestamp: new Date().toISOString(), + }, + ); + } + }, [initialLocation, location]); const startTracking = useCallback(async () => { if (isTracking) return; @@ -58,6 +61,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 +83,9 @@ export function useLocationTracking() { } else { setRouteCoordinates([...currentCoords, newCoordinate]); } + + // 위치 업데이트 시 즉시 코스 검증 실행을 위한 플래그 설정 + // useCourseValidation의 useEffect가 이를 감지하여 검증 실행 }, ); @@ -112,20 +126,41 @@ 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); + console.log('📍 [LocationTracking] 위치 새로고침:', { + latitude: fetchedLocation.coords.latitude, + longitude: fetchedLocation.coords.longitude, + timestamp: new Date().toISOString(), + }); + } else { + setLocationErrorMsg('현재 위치를 가져올 수 없습니다.'); + } } catch (error) { - setLocationErrorMsg('Failed to get current location'); + console.error('위치 새로고침 오류', error); + setLocationErrorMsg('현재 위치를 가져올 수 없습니다.'); } }, [setLocationErrorMsg, setLocation]); @@ -134,6 +169,7 @@ export function useLocationTracking() { location, errorMsg, isTracking, + locationLoading, startTracking, pauseTracking, stopTracking, From 0626483e8381693eae0c1ac9fb68194d2f9037f7 Mon Sep 17 00:00:00 2001 From: Monixc Date: Fri, 12 Sep 2025 21:08:30 +0900 Subject: [PATCH 8/9] =?UTF-8?q?fix:=20=EC=A4=91=EB=B3=B5=20=ED=98=B8?= =?UTF-8?q?=EC=B6=9C=20=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/useInitialLocation.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/hooks/useInitialLocation.ts b/src/hooks/useInitialLocation.ts index 4466d05..554aa78 100644 --- a/src/hooks/useInitialLocation.ts +++ b/src/hooks/useInitialLocation.ts @@ -42,7 +42,6 @@ export function useInitialLocation(options: UseInitialLocationOptions = {}) { if (!fetchedLocation) { fetchedLocation = await Location.getCurrentPositionAsync({}); } - console.log('fetchedLocation', fetchedLocation); } catch (error) { console.error('위치 가져오기 오류', error); } From 11075d1882cbcec27521b4293340587520912d0a Mon Sep 17 00:00:00 2001 From: Monixc Date: Fri, 12 Sep 2025 21:09:02 +0900 Subject: [PATCH 9/9] =?UTF-8?q?fix:=20=EC=A4=91=EB=B3=B5=20=ED=98=B8?= =?UTF-8?q?=EC=B6=9C=20=EB=AC=B8=EC=A0=9C=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/useLocationManager.ts | 2 -- src/hooks/useLocationTracking.ts | 13 ------------- src/pages/Run/_components/ControlContainer.tsx | 10 +++++++--- src/pages/Run/_components/RunMap.tsx | 9 ++++++--- src/pages/Run/index.tsx | 15 +++++++++++++-- 5 files changed, 26 insertions(+), 23 deletions(-) diff --git a/src/hooks/useLocationManager.ts b/src/hooks/useLocationManager.ts index b84d972..8f1e085 100644 --- a/src/hooks/useLocationManager.ts +++ b/src/hooks/useLocationManager.ts @@ -1,7 +1,6 @@ import { useRef, useEffect } from 'react'; import type { Position } from 'geojson'; import type Mapbox from '@rnmapbox/maps'; -import * as Location from 'expo-location'; import { useInitialLocation } from '@/hooks/useInitialLocation'; import { useLocationTracking } from '@/hooks/useLocationTracking'; import { FLY_TO_USER_LOCATION_DURATION } from '@/constants/draw'; @@ -14,7 +13,6 @@ export function useLocationManager() { useEffect(() => { if (initialLocation) { - // LocationObject를 Position으로 변환 currentUserLocation.current = [ initialLocation.coords.longitude, initialLocation.coords.latitude, diff --git a/src/hooks/useLocationTracking.ts b/src/hooks/useLocationTracking.ts index c44ac7b..13a299a 100644 --- a/src/hooks/useLocationTracking.ts +++ b/src/hooks/useLocationTracking.ts @@ -34,14 +34,6 @@ export function useLocationTracking() { useEffect(() => { if (initialLocation && !location) { setLocation(initialLocation); - console.log( - '📍 [LocationTracking] Run 스크린 접속 시 현재 위치 가져옴:', - { - latitude: initialLocation.coords.latitude, - longitude: initialLocation.coords.longitude, - timestamp: new Date().toISOString(), - }, - ); } }, [initialLocation, location]); @@ -150,11 +142,6 @@ export function useLocationTracking() { if (fetchedLocation) { setLocation(fetchedLocation); setLocationErrorMsg(null); - console.log('📍 [LocationTracking] 위치 새로고침:', { - latitude: fetchedLocation.coords.latitude, - longitude: fetchedLocation.coords.longitude, - timestamp: new Date().toISOString(), - }); } else { setLocationErrorMsg('현재 위치를 가져올 수 없습니다.'); } diff --git a/src/pages/Run/_components/ControlContainer.tsx b/src/pages/Run/_components/ControlContainer.tsx index e555f79..f753558 100644 --- a/src/pages/Run/_components/ControlContainer.tsx +++ b/src/pages/Run/_components/ControlContainer.tsx @@ -11,15 +11,21 @@ import { 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'; interface ControlContainerProps { onCurrentLocationPress: () => void; + isTracking: boolean; + toggleTracking: () => void; + routeCoordinates: Position[]; } const ControlContainer: React.FC = ({ onCurrentLocationPress, + isTracking, + toggleTracking, + routeCoordinates, }) => { const { isLocked, @@ -31,8 +37,6 @@ const ControlContainer: React.FC = ({ resumeRun, toggleLock, } = useRunStore(); - const { isTracking, toggleTracking, routeCoordinates } = - useLocationTracking(); const handleLockPress = () => { toggleLock(); diff --git a/src/pages/Run/_components/RunMap.tsx b/src/pages/Run/_components/RunMap.tsx index b5f6606..e68139f 100644 --- a/src/pages/Run/_components/RunMap.tsx +++ b/src/pages/Run/_components/RunMap.tsx @@ -5,17 +5,20 @@ 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 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, location: locationObject } = useLocationTracking(); - // 트래킹 중일 때는 routeCoordinates의 마지막 위치를 사용, 그렇지 않으면 현재 위치 사용 // 실시간 위치 업데이트를 위해 locationObject를 우선 사용 const location = diff --git a/src/pages/Run/index.tsx b/src/pages/Run/index.tsx index d880c5c..94b8764 100644 --- a/src/pages/Run/index.tsx +++ b/src/pages/Run/index.tsx @@ -32,6 +32,7 @@ export default function Run({ route, navigation }: Props) { errorMsg, locationLoading, refreshLocation, + toggleTracking, } = useLocationTracking(); const cameraRef = useRef(null!); const mapRef = useRef(null); @@ -159,7 +160,12 @@ export default function Run({ route, navigation }: Props) { ) : location && location.coords ? ( <> - + {loading && } {topologyError && ( - +