Skip to content
Merged
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 63 additions & 30 deletions app/(tabs)/tracking.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,23 +12,15 @@ 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,
Difficulty,
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";
Expand Down Expand Up @@ -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";
Expand All @@ -86,8 +77,9 @@ const DIFFICULTY_KO: Record<string, Difficulty> = {
HARD: "고급",
};

// eslint-disable-next-line @typescript-eslint/no-var-requires
const { colors } = require("../../tokens.cjs") as { colors: Record<string, Record<string, string>> };
const { colors } = require("../../tokens.cjs") as {
colors: Record<string, Record<string, string>>;
};
const COLOR_WHITE = colors.common["100"]; // #ffffff

// ── 데모 모드 ─────────────────────────────────────────────────────────────────
Expand All @@ -104,12 +96,33 @@ const DEMO_SIM_STEP = 5;
// 경사 등급별 polyline 색상 (outline은 디자인 토큰 common-100 사용)
const SEGMENT_COLORS: Record<string, { color: string }> = {
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;
},
[],
);
}
Comment on lines +111 to +127

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

mergeShortSegments 함수가 segments 인자로 null 또는 undefined를 받을 수 있도록 안전하게 처리하는 것이 좋습니다. courseDetail이 API로부터 로드되기 전에는 segments가 없을 수 있으므로, 방어적 프로그래밍 관점에서 예외 처리를 추가하면 런타임 에러를 방지할 수 있습니다.

Suggested change
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;
},
[],
);
}
function mergeShortSegments(
segments?: { startIdx: number; endIdx: number; grade: string }[] | null,
): { startIdx: number; endIdx: number; grade: string }[] {
if (!segments) return [];
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,
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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
Expand All @@ -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;
Comment on lines +543 to +550

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

courseDetail이 로딩 중이거나 없을 때 런타임 에러가 발생하지 않도록 courseDetail?.segments와 같이 옵셔널 체이닝을 사용하는 것이 안전합니다.

또한, TrackingScreen 컴포넌트는 타이머(elapsedSeconds)로 인해 매초 리렌더링이 발생합니다. staticMapOverlaysrecordedCoords 등에 의존하여 자주 재평가되므로, mergeShortSegments 연산이 불필요하게 반복 실행될 수 있습니다. 성능 최적화를 위해 컴포넌트 상단에서 useMemo를 사용하여 병합된 세그먼트를 메모이제이션하는 것을 권장합니다.

const mergedSegments = useMemo(() => {
  return mergeShortSegments(courseDetail?.segments);
}, [courseDetail?.segments]);
Suggested change
{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;
{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;

return (
<NaverMapPathOverlay
key={i}
Expand Down Expand Up @@ -626,7 +647,14 @@ export default function TrackingScreen() {
)}
</>
),
[courseCoords, courseDetail?.segments, isFreeMode, recordedCoords, isTracking, nearbyData],
[
courseCoords,
courseDetail?.segments,
isFreeMode,
recordedCoords,
isTracking,
nearbyData,
],
);

// [DEV] 코스 좌표를 빠르게 publish — 백엔드 마일스톤 트리거 테스트용
Expand Down Expand Up @@ -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; // 트래킹 시작 시 처음부터

Expand Down Expand Up @@ -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]);

// 트래킹 중 경과 시간 카운트업 (일시정지 시 멈춤)
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -1213,7 +1247,6 @@ export default function TrackingScreen() {
/>
</View>
)}

</View>

{/* 트래킹 중 — 상단 코스 카드 (자유기록 제외) */}
Expand Down
Loading