From 1b962545ef9888eadc3f553e0d6b360992ecec1b Mon Sep 17 00:00:00 2001 From: caseBread Date: Thu, 4 Jun 2026 23:42:26 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=EC=A7=A7=EC=9D=80=20=EC=84=B8?= =?UTF-8?q?=EA=B7=B8=EB=A8=BC=ED=8A=B8=20=EB=B3=91=ED=95=A9=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=BD=94=EC=8A=A4=20polyline=20=EC=83=89=20?= =?UTF-8?q?=EC=A0=84=ED=99=98=20=EB=B9=88=EB=8F=84=20=EA=B0=90=EC=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- app/(tabs)/tracking.tsx | 93 ++++++++++++++++++++++++++++------------- 1 file changed, 63 insertions(+), 30 deletions(-) diff --git a/app/(tabs)/tracking.tsx b/app/(tabs)/tracking.tsx index 3320003..f1519ca 100644 --- a/app/(tabs)/tracking.tsx +++ b/app/(tabs)/tracking.tsx @@ -12,7 +12,6 @@ import { StopConfirmModal } from "@/features/tracking/components/stop-confirm-mo import { SummitSheet } from "@/features/tracking/components/summit-sheet"; import { TrackingCourseCard } from "@/features/tracking/components/tracking-course-card"; import { TrackingSheet } from "@/features/tracking/components/tracking-sheet"; -import { TrailAvatarMarker } from "@/features/tracking/components/trail-avatar-marker"; import { COLLAPSED_PEEK_HEIGHT, Course, @@ -20,15 +19,8 @@ import { FLOATING_CARD_GAP, LOCATION_BUTTON_GAP, SHADOW, - TRACKING_COURSE_CARD_HEIGHT, TRACKING_COURSE_CARD_TOP, TRACKING_SHEET_HEIGHT, - TRAIL_BAR_COLORS, - TRAIL_BAR_GAP, - TRAIL_BAR_LEFT, - TRAIL_BAR_LOCATIONS, - TRAIL_BAR_WIDTH, - TRAIL_MARKER_LEFT, } from "@/features/tracking/constants"; import { useActiveTrackingSession } from "@/features/tracking/hooks/use-active-tracking-session"; import { useCompleteTrackingSession } from "@/features/tracking/hooks/use-complete-tracking-session"; @@ -67,7 +59,6 @@ import { import { useFocusEffect } from "@react-navigation/native"; import * as Sentry from "@sentry/react-native"; import * as ImagePicker from "expo-image-picker"; -import { LinearGradient } from "expo-linear-gradient"; import * as Location from "expo-location"; import { Tabs, useLocalSearchParams } from "expo-router"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -86,8 +77,9 @@ const DIFFICULTY_KO: Record = { HARD: "고급", }; -// eslint-disable-next-line @typescript-eslint/no-var-requires -const { colors } = require("../../tokens.cjs") as { colors: Record> }; +const { colors } = require("../../tokens.cjs") as { + colors: Record>; +}; const COLOR_WHITE = colors.common["100"]; // #ffffff // ── 데모 모드 ───────────────────────────────────────────────────────────────── @@ -104,12 +96,33 @@ const DEMO_SIM_STEP = 5; // 경사 등급별 polyline 색상 (outline은 디자인 토큰 common-100 사용) const SEGMENT_COLORS: Record = { STEEP_DOWN: { color: "#2563EB" }, - MILD_DOWN: { color: "#93C5FD" }, - FLAT: { color: "#FFD40D" }, - MILD_UP: { color: "#FF8C49" }, - STEEP_UP: { color: "#DC2626" }, + MILD_DOWN: { color: "#93C5FD" }, + FLAT: { color: "#FFD40D" }, + MILD_UP: { color: "#FF8C49" }, + STEEP_UP: { color: "#DC2626" }, }; +// 좌표 개수가 MIN_SEGMENT_COORDS 미만인 짧은 세그먼트를 앞 세그먼트에 흡수해 색 전환 빈도를 줄임 +const MIN_SEGMENT_COORDS = 8; + +function mergeShortSegments( + segments: { startIdx: number; endIdx: number; grade: string }[], +): { startIdx: number; endIdx: number; grade: string }[] { + return segments.reduce<{ startIdx: number; endIdx: number; grade: string }[]>( + (acc, seg) => { + const len = seg.endIdx - seg.startIdx + 1; + const last = acc[acc.length - 1]; + if (last && len < MIN_SEGMENT_COORDS) { + last.endIdx = seg.endIdx; + } else { + acc.push({ ...seg }); + } + return acc; + }, + [], + ); +} + export default function TrackingScreen() { const { collapse: collapseParameter, @@ -188,7 +201,11 @@ export default function TrackingScreen() { // DEMO_MODE: 관악산 좌표로 고정 (nearbyMountain API가 관악산 반환) useEffect(() => { if (DEMO_MODE) { - setUserLocation({ latitude: DEMO_LAT, longitude: DEMO_LNG, altitude: null }); + setUserLocation({ + latitude: DEMO_LAT, + longitude: DEMO_LNG, + altitude: null, + }); setMarkerCoord({ latitude: DEMO_LAT, longitude: DEMO_LNG }); return; } @@ -490,10 +507,10 @@ export default function TrackingScreen() { ); const timeToTarget = (() => { - if (remainingDurationMin <= 0) return '-'; + if (remainingDurationMin <= 0) return "-"; const h = Math.floor(remainingDurationMin / 60); const m = remainingDurationMin % 60; - return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`; + return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`; })(); const distanceToTarget = remainingDistanceM >= 1000 @@ -520,10 +537,14 @@ export default function TrackingScreen() { outlineColor={COLOR_WHITE} /> {/* 컬러 segments — 베이스 위에 얹어서 가장자리만 흰색으로 보임 */} - {courseDetail.segments.map((seg, i) => { - const coords = courseCoords.slice(seg.startIdx, seg.endIdx + 1); + {mergeShortSegments(courseDetail.segments).map((seg, i) => { + const coords = courseCoords.slice( + seg.startIdx, + seg.endIdx + 1, + ); if (coords.length < 2) return null; - const { color } = SEGMENT_COLORS[seg.grade] ?? SEGMENT_COLORS.FLAT; + const { color } = + SEGMENT_COLORS[seg.grade] ?? SEGMENT_COLORS.FLAT; return ( ), - [courseCoords, courseDetail?.segments, isFreeMode, recordedCoords, isTracking, nearbyData], + [ + courseCoords, + courseDetail?.segments, + isFreeMode, + recordedCoords, + isTracking, + nearbyData, + ], ); // [DEV] 코스 좌표를 빠르게 publish — 백엔드 마일스톤 트리거 테스트용 @@ -692,7 +720,8 @@ export default function TrackingScreen() { // ── 데모 좌표 시뮬레이션 ──────────────────────────────────────────────────── const simIdxRef = useRef(0); useEffect(() => { - if (!DEMO_MODE || !isTracking || isPaused || courseCoords.length < 2) return; + if (!DEMO_MODE || !isTracking || isPaused || courseCoords.length < 2) + return; simIdxRef.current = 0; // 트래킹 시작 시 처음부터 @@ -827,11 +856,13 @@ export default function TrackingScreen() { }); } } else { - console.warn("[LiveActivity] isLiveActivityEnabled=false — 환경변수 확인 필요"); + console.warn( + "[LiveActivity] isLiveActivityEnabled=false — 환경변수 확인 필요", + ); } return () => {}; - // eslint-disable-next-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps }, [isTracking, isFreeMode]); // 트래킹 중 경과 시간 카운트업 (일시정지 시 멈춤) @@ -976,7 +1007,9 @@ export default function TrackingScreen() { if (action === "pause") { // AppState보다 먼저 발화할 경우 백그라운드 추적 시간을 직접 누적 후 ref 초기화 if (backgroundedAtRef.current != null) { - const diff = Math.floor((Date.now() - backgroundedAtRef.current) / 1000); + const diff = Math.floor( + (Date.now() - backgroundedAtRef.current) / 1000, + ); backgroundedAtRef.current = null; setElapsedSeconds((s) => s + diff); } @@ -1084,9 +1117,10 @@ export default function TrackingScreen() { }, ); } - if (isLiveActivityEnabled) LiveActivity.stop().catch((e: unknown) => { - console.warn("[LiveActivity] stop() 실패:", e); - }); + if (isLiveActivityEnabled) + LiveActivity.stop().catch((e: unknown) => { + console.warn("[LiveActivity] stop() 실패:", e); + }); stopLocationTask().catch(() => {}); disconnectSocket(); setShowDifficultyRating(false); @@ -1213,7 +1247,6 @@ export default function TrackingScreen() { /> )} - {/* 트래킹 중 — 상단 코스 카드 (자유기록 제외) */} From 5d3ec00ab0fda1d04defd4bd661b4ec98313e1f4 Mon Sep 17 00:00:00 2001 From: caseBread Date: Thu, 4 Jun 2026 23:51:42 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=EC=BD=94=EC=8A=A4=20polyline=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99=20=ED=8F=89=EA=B7=A0=20=EC=8A=A4=EB=AC=B4?= =?UTF-8?q?=EB=94=A9=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- app/(tabs)/tracking.tsx | 4 +- .../tracking/utils/parse-course-polyline.ts | 40 ++++++++++++++++--- 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/app/(tabs)/tracking.tsx b/app/(tabs)/tracking.tsx index f1519ca..ac35a36 100644 --- a/app/(tabs)/tracking.tsx +++ b/app/(tabs)/tracking.tsx @@ -43,7 +43,7 @@ import { startLocationTask, stopLocationTask, } from "@/features/tracking/tasks/location-task"; -import { parseCoursePolyline } from "@/features/tracking/utils/parse-course-polyline"; +import { parseCoursePolyline, smoothCourseCoords } from "@/features/tracking/utils/parse-course-polyline"; import { uploadTrackingPhoto } from "@/features/tracking/utils/upload-tracking-photo"; import { useAppState } from "@/hooks/use-app-state"; import { @@ -325,7 +325,7 @@ export default function TrackingScreen() { isFreeMode ? null : selectedCourseId, ); const courseCoords = useMemo( - () => parseCoursePolyline(courseDetail?.polyline), + () => smoothCourseCoords(parseCoursePolyline(courseDetail?.polyline)), [courseDetail?.polyline], ); diff --git a/features/tracking/utils/parse-course-polyline.ts b/features/tracking/utils/parse-course-polyline.ts index 1c9c68d..5b87195 100644 --- a/features/tracking/utils/parse-course-polyline.ts +++ b/features/tracking/utils/parse-course-polyline.ts @@ -1,4 +1,4 @@ -import type { Coord } from '@mj-studio/react-native-naver-map'; +import type { Coord } from "@mj-studio/react-native-naver-map"; /** * API의 polyline 문자열(GeoJSON LineString JSON)을 @@ -9,20 +9,48 @@ import type { Coord } from '@mj-studio/react-native-naver-map'; * * GeoJSON coordinates 순서: [longitude, latitude] */ -export function parseCoursePolyline(polyline: string | object | null | undefined): Coord[] { +export function parseCoursePolyline( + polyline: string | object | null | undefined, +): Coord[] { if (!polyline) return []; try { // API가 문자열로 줄 수도, 이미 파싱된 객체로 줄 수도 있음 - const geojson = typeof polyline === 'string' ? JSON.parse(polyline) : polyline; - if (geojson?.type === 'LineString' && Array.isArray(geojson.coordinates)) { + const geojson = + typeof polyline === "string" ? JSON.parse(polyline) : polyline; + if (geojson?.type === "LineString" && Array.isArray(geojson.coordinates)) { return geojson.coordinates.map(([lng, lat]: [number, number]) => ({ latitude: lat, longitude: lng, })); } - console.warn('[parseCoursePolyline] 예상과 다른 포맷:', JSON.stringify(geojson)?.slice(0, 100)); + console.warn( + "[parseCoursePolyline] 예상과 다른 포맷:", + JSON.stringify(geojson)?.slice(0, 100), + ); } catch { - console.warn('[parseCoursePolyline] polyline 파싱 실패'); + console.warn("[parseCoursePolyline] polyline 파싱 실패"); } return []; } + +/** + * 이동 평균으로 좌표 배열을 부드럽게 만듭니다. + * 시작/종료점은 고정해 출발·도착 마커 위치가 틀어지지 않게 합니다. + * + * @param coords 원본 좌표 배열 + * @param window 평균 낼 이웃 좌표 수 (클수록 더 부드럽고 더 느슨해짐) + */ +export function smoothCourseCoords(coords: Coord[], window = 7): Coord[] { + if (coords.length < 3) return coords; + const half = Math.floor(window / 2); + return coords.map((point, i) => { + if (i === 0 || i === coords.length - 1) return point; + const start = Math.max(0, i - half); + const end = Math.min(coords.length - 1, i + half); + const slice = coords.slice(start, end + 1); + return { + latitude: slice.reduce((s, c) => s + c.latitude, 0) / slice.length, + longitude: slice.reduce((s, c) => s + c.longitude, 0) / slice.length, + }; + }); +} From fda32f5914607e2b506657eaec1516a88b936d96 Mon Sep 17 00:00:00 2001 From: caseBread Date: Thu, 4 Jun 2026 23:56:05 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20smoothCourseCoords=20window=207?= =?UTF-8?q?=E2=86=923=EC=9C=BC=EB=A1=9C=20=EC=A4=84=EC=97=AC=20=EC=BD=94?= =?UTF-8?q?=EB=84=88=20=EA=B0=81=EB=8F=84=20=EB=B3=B4=EC=A1=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- app/(tabs)/tracking.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/(tabs)/tracking.tsx b/app/(tabs)/tracking.tsx index ac35a36..6db278b 100644 --- a/app/(tabs)/tracking.tsx +++ b/app/(tabs)/tracking.tsx @@ -43,7 +43,10 @@ import { startLocationTask, stopLocationTask, } from "@/features/tracking/tasks/location-task"; -import { parseCoursePolyline, smoothCourseCoords } from "@/features/tracking/utils/parse-course-polyline"; +import { + parseCoursePolyline, + smoothCourseCoords, +} from "@/features/tracking/utils/parse-course-polyline"; import { uploadTrackingPhoto } from "@/features/tracking/utils/upload-tracking-photo"; import { useAppState } from "@/hooks/use-app-state"; import { @@ -452,7 +455,7 @@ export default function TrackingScreen() { courseProgressState; // 줌 레벨에 따른 폴리라인 두께 — 줌아웃 시 얇게, 줌인 시 두껍게 - const polylineWidth = { colored: 7, base: 11 }; + const polylineWidth = { colored: 6, base: 10 }; // altitudes 문자열에서 최고 고도(m) 파싱 const peakAltitudeM = useMemo(() => {