Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package com.semosan.api.domain.mountain.service;

import com.semosan.api.domain.mountain.entity.Course;
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.LineString;
import org.springframework.stereotype.Component;

/**
* 코스 시작점부터 정상 지점까지 polyline 을 따라간 누적 거리(m)를 계산한다.
*
* V37 백필로 채워진 정상 좌표는 polyline 위의 점과 정확히 일치하지만, 관리자가 waypoint 로
* 수동 지정(AdminMountainService#updateSummit)한 좌표는 polyline 밖의 점일 수 있다.
* 두 경우를 모두 커버하려고 정확 일치가 아닌 최근접 점 탐색으로 인덱스를 찾는다.
*
* 트래킹 사진 마일스톤 계산과 Live Activity 코스 응답이 반드시 같은 값을 써야 해서
* (푸시가 오는 지점과 화면에 표시되는 "정상까지 거리" 가 어긋나면 안 된다) 별도 컴포넌트로 분리했다.
*/
@Component
public class CourseSummitDistanceCalculator {

/** redis/tracking-stats-update.lua 가 distanceTotal 을 누적할 때 쓰는 값과 동일해야 두 거리가 비교 가능하다. */
private static final double EARTH_RADIUS_METERS = 6_371_000.0;

/**
* @return 정상까지 누적 거리(m). 계산 불가하면 null — 정상 좌표 없음 / polyline 없음 /
* 점 2개 미만 / 정상이 코스 시작점인 경우.
* 정상이 시작점(idx 0)이면 4등분해도 마일스톤이 전부 0 이라 의미가 없어 호출자가 fallback 하도록 null 을 준다.
*/
public Double calculate(Course course) {
if (course == null) {
return null;
}
Double summitLat = course.getSummitLat();
Double summitLng = course.getSummitLng();
LineString polyline = course.getPolyline();
if (summitLat == null || summitLng == null || polyline == null) {
return null;
}
Coordinate[] coords = polyline.getCoordinates();
if (coords.length < 2) {
return null;
}

int nearestIdx = nearestPointIndex(coords, summitLat, summitLng);
if (nearestIdx == 0) {
return null;
}

double cumulative = 0.0;
for (int i = 1; i <= nearestIdx; i++) {
cumulative += haversineMeters(coords[i - 1].y, coords[i - 1].x, coords[i].y, coords[i].x);
}
return cumulative;
}

/** JTS Coordinate 는 x=경도, y=위도 순서다. */
private static int nearestPointIndex(Coordinate[] coords, double summitLat, double summitLng) {
int nearestIdx = 0;
double nearestDistance = Double.MAX_VALUE;
for (int i = 0; i < coords.length; i++) {
double distance = haversineMeters(coords[i].y, coords[i].x, summitLat, summitLng);
if (distance < nearestDistance) {
nearestDistance = distance;
nearestIdx = i;
}
}
return nearestIdx;
}

/** redis/tracking-stats-update.lua 의 누적 공식과 동일 — 두 거리가 같은 기준이어야 마일스톤이 맞는다. */
private static double haversineMeters(double lat1, double lng1, double lat2, double lng2) {
double rad = Math.PI / 180;
double dLat = (lat2 - lat1) * rad;
double dLng = (lng2 - lng1) * rad;
double a = Math.sin(dLat / 2) * Math.sin(dLat / 2)
+ Math.cos(lat1 * rad) * Math.cos(lat2 * rad)
* Math.sin(dLng / 2) * Math.sin(dLng / 2);
double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return EARTH_RADIUS_METERS * c;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ public enum NotificationType {

/**
* 트래킹 중 거리 마일스톤 도달 시 사진 촬영 유도.
* data 에 distance(m), milestoneIndex 를 싣는다 — 클라이언트가 사진 업로드 API 에 그대로 넘긴다.
* iOS 백그라운드/잠금화면/앱 종료 상태에서도 시스템이 즉시 배너를 표시하도록 mixed payload
* (notification 키 + data 키) 로 발송한다. 포그라운드 배너 노출 여부는 클라(앱) 의
* UNUserNotificationCenterDelegate(willPresent) 에서 알림 타입(data.type) 을 식별해
Expand All @@ -47,12 +48,14 @@ public enum NotificationType {
TRACKING_PHOTO_MILESTONE(
"SEMOSAN",
"{distance}m 돌파! 인증 사진을 남겨보세요!",
Set.of("distance"),
Set.of("distance", "milestoneIndex"),
false
),

/**
* 시작점→정상 누적 거리 도달 시 정상 인증 유도.
* data 에 milestoneIndex, milestoneDistanceM 을 싣는다 — 정상 인증 사진 업로드에 필요한 값이다.
* 정상과 일치하는 마일스톤 인덱스는 코스마다 다르다(정상 좌표 있으면 3, fallback 이면 1).
* 정상 좌표(courses.summit_lat/lng)가 없는 코스만 코스 절반 지점을 "정상" 근처로 간주한다.
* iOS 백그라운드/잠금화면/앱 종료 상태에서도 시스템이 즉시 배너를 표시하도록 mixed payload
* (notification 키 + data 키) 로 발송한다. 포그라운드 배너 제어는 TRACKING_PHOTO_MILESTONE
Expand All @@ -61,7 +64,7 @@ public enum NotificationType {
TRACKING_SUMMIT_REACHED(
"SEMOSAN",
"정상에 도착했나요? 정상 인증하기!",
Set.of(),
Set.of("milestoneIndex", "milestoneDistanceM"),
false
);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,11 @@ ResponseEntity<ApiResponse<NearbyMountainResponse>> getNearbyMountain(
@Operation(
summary = "라이브 액티비티용 코스 정보 조회",
description = "코스 기반 트래킹의 Live Activity 초기화에 필요한 전체 코스 좌표 배열, "
+ "전체 거리(m), 예상 소요 시간(분)을 반환합니다. 자유 기록에서는 호출하지 않습니다."
+ "전체 거리(m), 예상 소요 시간(분)을 반환합니다. 자유 기록에서는 호출하지 않습니다.\n\n"
+ "`summitDistance`(시작점→정상 누적 거리, m)와 `summitEstimatedTime`(정상까지 예상 시간, 분)이 "
+ "함께 내려갑니다. 사진 마일스톤 푸시가 이 거리 기준으로 발송되므로 "
+ "\"정상까지 거리/시간\" 표시에는 totalDistance 를 절반으로 나누지 말고 이 값을 쓰세요. "
+ "정상 좌표가 없어 계산이 불가한 코스는 두 필드 모두 null 입니다."
)
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,22 +9,48 @@
import java.util.Arrays;
import java.util.List;

/**
* summitDistance / summitEstimatedTime 은 사진 마일스톤이 정상 기준으로 잡히는 것과 같은 값을 쓴다.
* 화면에 표시되는 "정상까지 거리/시간" 과 실제 푸시가 오는 지점이 어긋나면 안 되기 때문이다.
*/
public record LiveActivityCourseResponse(
Long courseId,
List<CoordinateInfo> coordinates,
Double totalDistance,
Integer estimatedTime
Integer estimatedTime,
/** 시작점→정상 누적 거리(m). 정상 좌표가 없어 계산 불가한 코스는 null. */
Double summitDistance,
/** 정상까지 예상 소요 시간(분). summitDistance 가 null 이면 null. */
Integer summitEstimatedTime
) {

public static LiveActivityCourseResponse from(Course course) {
public static LiveActivityCourseResponse from(Course course, Double summitDistance) {
return new LiveActivityCourseResponse(
course.getId(),
toCoordinates(course.getPolyline()),
course.getDistance(),
course.getDuration()
course.getDuration(),
summitDistance,
estimateSummitTime(course, summitDistance)
);
}

/**
* 정상까지 예상 시간 = 코스 전체 소요 시간 × (정상까지 거리 / 코스 전체 거리).
*
* 오르막이 평지보다 느리다는 점을 반영하지 못하는 근사값이지만, 정상을 코스 중간으로 가정하던
* 것보다는 실제에 가깝다. 비율은 1.0 으로 상한을 둔다 — 정상까지 거리는 polyline 을 Haversine 으로
* 누적한 값이고 course.distance 는 별도 출처라, 정상이 코스 끝인 경우 미세하게 넘길 수 있다.
*/
private static Integer estimateSummitTime(Course course, Double summitDistance) {
Double totalDistance = course.getDistance();
Integer duration = course.getDuration();
if (summitDistance == null || duration == null || totalDistance == null || totalDistance <= 0) {
return null;
}
return (int) Math.round(duration * Math.min(summitDistance / totalDistance, 1.0));
}

private static List<CoordinateInfo> toCoordinates(LineString polyline) {
if (polyline == null || polyline.isEmpty()) {
throw new GeneralException(ErrorStatus.TRACKING_COURSE_POLYLINE_REQUIRED);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
package com.semosan.api.domain.tracking.service;

import com.semosan.api.domain.mountain.entity.Course;
import com.semosan.api.domain.mountain.service.CourseSummitDistanceCalculator;
import com.semosan.api.domain.tracking.entity.TrackingSession;
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.LineString;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

import java.util.ArrayList;
Expand All @@ -15,21 +15,21 @@
* - 코스 + 정상 좌표 있음: 시작점→정상 누적 거리의 1/4, 2/4, 3/4, 4/4 지점 (총 4컷).
* 4/4 가 곧 정상이므로 summitMark 도 같은 값이다. 정상까지 푸시가 4번 오고 하산 구간엔 없다.
* - 코스 + 정상 좌표 없음: course.distance 4등분으로 fallback.
* summitMark 는 종전 정책대로 코스 거리의 절반이다.
* summitMark 는 종전 정책대로 코스 거리의 절반이라 2/4 마일스톤과 같은 지점이 된다.
* - 자유 기록: 500m 간격 4컷 (500/1000/1500/2000m). 정상 개념이 없어 summitMark 는 null.
*
* 단위 정책: 입력/출력 모두 미터(m). distanceTotal(Haversine 누적, m) 과 비교될 값이라 단위 일치 필수.
* 과거: course.distance 를 km 로 가정하고 × 1000 했으나, DB/시드/응답이 모두 m 단위라 마일스톤이 1000배로 박혀 OPEN 이 영영 안 오는 버그가 있었음.
*/
@Component
@RequiredArgsConstructor
public class TrackingMilestoneCalculator {

private static final int COURSE_MILESTONE_COUNT = 4;
private static final double FREE_RECORDING_INTERVAL_METERS = 500.0;
private static final int FREE_RECORDING_MAX_COUNT = 4;

/** redis/tracking-stats-update.lua 가 distanceTotal 을 누적할 때 쓰는 값과 동일해야 두 거리가 비교 가능하다. */
private static final double EARTH_RADIUS_METERS = 6_371_000.0;
private final CourseSummitDistanceCalculator summitDistanceCalculator;

public MilestonePlan calculate(TrackingSession session) {
if (Boolean.TRUE.equals(session.getIsFreeRecording()) || session.getCourse() == null) {
Expand All @@ -47,7 +47,7 @@ public record MilestonePlan(List<Double> milestones, Double summitMark) {
}

private MilestonePlan courseMilestones(Course course) {
Double summitDistance = distanceToSummit(course);
Double summitDistance = summitDistanceCalculator.calculate(course);
if (summitDistance != null && summitDistance > 0) {
return new MilestonePlan(split(summitDistance), summitDistance);
}
Expand All @@ -64,66 +64,11 @@ private static List<Double> split(double totalMeters) {
return result;
}

/**
* 코스 시작점부터 정상 지점까지 polyline 을 따라간 누적 거리(m).
*
* V37 백필로 채워진 정상 좌표는 polyline 위의 점과 정확히 일치하지만, 관리자가 waypoint 로
* 수동 지정(AdminMountainService#updateSummit)한 좌표는 polyline 밖의 점일 수 있다.
* 두 경우를 모두 커버하려고 정확 일치가 아닌 최근접 점 탐색으로 인덱스를 찾는다.
*
* @return 계산 불가하면 null — 정상 좌표 없음 / polyline 없음 / 점 2개 미만 / 정상이 시작점인 경우.
* 정상이 시작점(idx 0)이면 4등분해도 마일스톤이 전부 0 이라 의미가 없어 fallback 시킨다.
*/
private Double distanceToSummit(Course course) {
Double summitLat = course.getSummitLat();
Double summitLng = course.getSummitLng();
LineString polyline = course.getPolyline();
if (summitLat == null || summitLng == null || polyline == null) {
return null;
}
Coordinate[] coords = polyline.getCoordinates();
if (coords.length < 2) {
return null;
}

// JTS Coordinate 는 x=경도, y=위도 순서다.
int nearestIdx = 0;
double nearestDistance = Double.MAX_VALUE;
for (int i = 0; i < coords.length; i++) {
double distance = haversineMeters(coords[i].y, coords[i].x, summitLat, summitLng);
if (distance < nearestDistance) {
nearestDistance = distance;
nearestIdx = i;
}
}
if (nearestIdx == 0) {
return null;
}

double cumulative = 0.0;
for (int i = 1; i <= nearestIdx; i++) {
cumulative += haversineMeters(coords[i - 1].y, coords[i - 1].x, coords[i].y, coords[i].x);
}
return cumulative;
}

private List<Double> freeRecordingMilestones() {
List<Double> result = new ArrayList<>(FREE_RECORDING_MAX_COUNT);
for (int i = 1; i <= FREE_RECORDING_MAX_COUNT; i++) {
result.add(FREE_RECORDING_INTERVAL_METERS * i);
}
return result;
}

/** redis/tracking-stats-update.lua 의 누적 공식과 동일 — 두 거리가 같은 기준이어야 마일스톤이 맞는다. */
private static double haversineMeters(double lat1, double lng1, double lat2, double lng2) {
double rad = Math.PI / 180;
double dLat = (lat2 - lat1) * rad;
double dLng = (lng2 - lng1) * rad;
double a = Math.sin(dLat / 2) * Math.sin(dLat / 2)
+ Math.cos(lat1 * rad) * Math.cos(lat2 * rad)
* Math.sin(dLng / 2) * Math.sin(dLng / 2);
double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return EARTH_RADIUS_METERS * c;
}
}
Loading
Loading