Skip to content

Commit cf0ae1a

Browse files
authored
Merge pull request #175 from SEMOSAN/feat/#114-course-slope-segments
[Feat] 코스 polyline 경사 등급 segments 응답 추가
2 parents 5a49ec0 + 8b891be commit cf0ae1a

7 files changed

Lines changed: 214 additions & 8 deletions

File tree

src/main/java/com/semosan/api/domain/mountain/controller/docs/CourseControllerDocs.java

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,12 @@
1717
public interface CourseControllerDocs {
1818

1919
@Operation(
20-
summary = "코스 상세 조회 (지도 polyline + 고도 단면 포함)",
21-
description = "선택한 코스의 메타(거리/시간/난이도) + 지도용 PostGIS polyline(GeoJSON LineString) + "
22-
+ "점별 고도 배열을 함께 반환합니다. 산림청 GPX 가 없는 코스는 polyline/altitudes 가 null 일 수 있습니다."
20+
summary = "코스 상세 조회 (지도 polyline + 고도 단면 + 경사 등급 segments 포함)",
21+
description = "선택한 코스의 메타(거리/시간/난이도/누적 상승·하강) + 지도용 PostGIS polyline(GeoJSON LineString) + "
22+
+ "점별 고도 배열 + 경사 등급별로 묶인 segments 배열을 함께 반환합니다. "
23+
+ "segments 는 polyline.coordinates 의 연속된 같은 등급(STEEP_DOWN/MILD_DOWN/FLAT/MILD_UP/STEEP_UP) 구간을 "
24+
+ "startIdx~endIdx(둘 다 inclusive) 로 표시하며, 클라이언트는 해당 인덱스 범위의 좌표를 등급별 색으로 그릴 수 있습니다. "
25+
+ "산림청 GPX 가 없는 코스는 polyline/altitudes 가 null 이고 segments 는 빈 배열일 수 있습니다."
2326
)
2427
@ApiResponses({
2528
@io.swagger.v3.oas.annotations.responses.ApiResponse(

src/main/java/com/semosan/api/domain/mountain/dto/response/CourseDetailResponse.java

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,14 @@
44
import com.semosan.api.domain.mountain.enums.Difficulty;
55
import com.semosan.api.domain.mountain.repository.projection.CourseDetailProjection;
66

7+
import java.util.List;
8+
79
/**
8-
* 코스 상세 응답 — 지도 polyline + 고도 단면 포함.
10+
* 코스 상세 응답 — 지도 polyline + 고도 단면 + 경사 등급 segments 포함.
911
* - polyline: GeoJSON LineString. ST_AsGeoJSON 결과를 그대로 JSON 으로 통과시킴.
1012
* - altitudes: 점별 고도 배열(jsonb). polyline.coordinates[i] 와 1:1 대응.
1113
* nullable — 고도 데이터 없는 코스는 null.
14+
* - segments: polyline 의 연속된 같은 경사 등급 구간. 점이 2개 미만이거나 altitudes 가 비면 빈 배열.
1215
*/
1316
public record CourseDetailResponse(
1417
Long id,
@@ -24,9 +27,14 @@ public record CourseDetailResponse(
2427
Double maxAltitude,
2528
boolean likedByMe,
2629
@JsonRawValue String polyline,
27-
@JsonRawValue String altitudes
30+
@JsonRawValue String altitudes,
31+
List<SlopeSegmentResponse> segments
2832
) {
29-
public static CourseDetailResponse from(CourseDetailProjection p, boolean likedByMe) {
33+
public static CourseDetailResponse from(
34+
CourseDetailProjection p,
35+
boolean likedByMe,
36+
List<SlopeSegmentResponse> segments
37+
) {
3038
return new CourseDetailResponse(
3139
p.getId(),
3240
p.getMountainId(),
@@ -41,7 +49,8 @@ public static CourseDetailResponse from(CourseDetailProjection p, boolean likedB
4149
p.getMaxAltitude(),
4250
likedByMe,
4351
p.getPolyline(),
44-
p.getAltitudes()
52+
p.getAltitudes(),
53+
segments
4554
);
4655
}
4756
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package com.semosan.api.domain.mountain.dto.response;
2+
3+
import com.semosan.api.domain.mountain.enums.SlopeGrade;
4+
import io.swagger.v3.oas.annotations.media.Schema;
5+
6+
/**
7+
* 코스 polyline 의 연속된 같은 등급 구간.
8+
* - startIdx / endIdx: polyline.coordinates 의 점 인덱스. 둘 다 inclusive.
9+
* - grade: 해당 구간의 경사 등급.
10+
*/
11+
@Schema(description = "코스 polyline 의 연속된 같은 경사 등급 구간. 클라이언트는 startIdx~endIdx 범위의 좌표를 grade 별 색으로 그린다.")
12+
public record SlopeSegmentResponse(
13+
@Schema(description = "polyline.coordinates 의 시작 인덱스 (inclusive)", example = "0")
14+
int startIdx,
15+
16+
@Schema(description = "polyline.coordinates 의 끝 인덱스 (inclusive). 다음 segment 의 startIdx 와 같아 끊김 없이 이어진다.",
17+
example = "12")
18+
int endIdx,
19+
20+
@Schema(description = "경사 등급. STEEP_DOWN(심한 내리막) / MILD_DOWN(약한 내리막) / FLAT(평지) / MILD_UP(약한 오르막) / STEEP_UP(심한 오르막).")
21+
SlopeGrade grade
22+
) {
23+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package com.semosan.api.domain.mountain.enums;
2+
3+
/**
4+
* 코스 polyline 의 segment 별 경사 등급.
5+
* - 임계: ±5%, ±15% (gradePercent = Δaltitude / Δhorizontalmeters × 100)
6+
* - 클라이언트는 등급별 색상으로 polyline sub-구간을 그린다.
7+
*/
8+
public enum SlopeGrade {
9+
10+
STEEP_DOWN,
11+
MILD_DOWN,
12+
FLAT,
13+
MILD_UP,
14+
STEEP_UP;
15+
16+
private static final double MILD_THRESHOLD = 5.0;
17+
private static final double STEEP_THRESHOLD = 15.0;
18+
19+
public static SlopeGrade classify(double gradePercent) {
20+
if (gradePercent <= -STEEP_THRESHOLD) {
21+
return STEEP_DOWN;
22+
}
23+
if (gradePercent <= -MILD_THRESHOLD) {
24+
return MILD_DOWN;
25+
}
26+
if (gradePercent < MILD_THRESHOLD) {
27+
return FLAT;
28+
}
29+
if (gradePercent < STEEP_THRESHOLD) {
30+
return MILD_UP;
31+
}
32+
return STEEP_UP;
33+
}
34+
}

src/main/java/com/semosan/api/domain/mountain/service/CourseService.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,25 +3,30 @@
33
import com.semosan.api.common.exception.GeneralException;
44
import com.semosan.api.common.status.ErrorStatus;
55
import com.semosan.api.domain.mountain.dto.response.CourseDetailResponse;
6+
import com.semosan.api.domain.mountain.dto.response.SlopeSegmentResponse;
67
import com.semosan.api.domain.mountain.repository.CourseLikeRepository;
78
import com.semosan.api.domain.mountain.repository.CourseRepository;
89
import com.semosan.api.domain.mountain.repository.projection.CourseDetailProjection;
910
import lombok.RequiredArgsConstructor;
1011
import org.springframework.stereotype.Service;
1112
import org.springframework.transaction.annotation.Transactional;
1213

14+
import java.util.List;
15+
1316
@Service
1417
@RequiredArgsConstructor
1518
@Transactional(readOnly = true)
1619
public class CourseService {
1720

1821
private final CourseRepository courseRepository;
1922
private final CourseLikeRepository courseLikeRepository;
23+
private final CourseSlopeSegmentCalculator slopeSegmentCalculator;
2024

2125
public CourseDetailResponse getCourseDetail(Long userId, Long courseId) {
2226
CourseDetailProjection course = courseRepository.findCourseDetailById(courseId)
2327
.orElseThrow(() -> new GeneralException(ErrorStatus.COURSE_NOT_FOUND));
2428
boolean likedByMe = userId != null && courseLikeRepository.existsByUser_IdAndCourse_Id(userId, courseId);
25-
return CourseDetailResponse.from(course, likedByMe);
29+
List<SlopeSegmentResponse> segments = slopeSegmentCalculator.calculate(course.getPolyline(), course.getAltitudes());
30+
return CourseDetailResponse.from(course, likedByMe, segments);
2631
}
2732
}
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
package com.semosan.api.domain.mountain.service;
2+
3+
import com.fasterxml.jackson.core.type.TypeReference;
4+
import com.fasterxml.jackson.databind.JsonNode;
5+
import com.fasterxml.jackson.databind.ObjectMapper;
6+
import com.semosan.api.domain.mountain.dto.response.SlopeSegmentResponse;
7+
import com.semosan.api.domain.mountain.enums.SlopeGrade;
8+
import lombok.RequiredArgsConstructor;
9+
import lombok.extern.slf4j.Slf4j;
10+
import org.springframework.stereotype.Component;
11+
12+
import java.util.ArrayList;
13+
import java.util.List;
14+
15+
/**
16+
* 코스 polyline(GeoJSON LineString) + 점별 altitudes 를 받아 경사 등급 segments 로 변환.
17+
*
18+
* 처리 단계:
19+
* 1) altitudes 이동평균(window={@value #SMOOTHING_WINDOW}) smoothing 으로 GPS 노이즈 완화.
20+
* 2) 인접 점 수평거리(Haversine) 와 고도차로 경사도(%) 산출.
21+
* 3) {@link SlopeGrade} 로 등급 분류.
22+
* 4) 같은 등급 연속 segment 묶기.
23+
*
24+
* 응답 좌표 인덱스(startIdx, endIdx) 는 polyline.coordinates 의 양 끝 포함(inclusive).
25+
*/
26+
@Slf4j
27+
@Component
28+
@RequiredArgsConstructor
29+
public class CourseSlopeSegmentCalculator {
30+
31+
static final int SMOOTHING_WINDOW = 5;
32+
private static final double EARTH_RADIUS_METERS = 6_371_000.0;
33+
34+
private final ObjectMapper objectMapper;
35+
36+
public List<SlopeSegmentResponse> calculate(String polylineJson, String altitudesJson) {
37+
if (polylineJson == null || altitudesJson == null) {
38+
return List.of();
39+
}
40+
try {
41+
JsonNode coords = objectMapper.readTree(polylineJson).get("coordinates");
42+
if (coords == null || !coords.isArray() || coords.size() < 2) {
43+
return List.of();
44+
}
45+
List<Double> altitudes = objectMapper.readValue(altitudesJson, new TypeReference<>() {});
46+
// altitudesJson 이 "null" (문자열) 인 경우 readValue 가 null 을 반환한다. 명시적으로 가드한다.
47+
if (altitudes == null || altitudes.size() != coords.size()) {
48+
return List.of();
49+
}
50+
51+
List<Double> smoothed = movingAverage(altitudes, SMOOTHING_WINDOW);
52+
53+
List<SlopeSegmentResponse> result = new ArrayList<>();
54+
int n = coords.size();
55+
int currentStart = 0;
56+
SlopeGrade currentGrade = null;
57+
58+
for (int i = 0; i < n - 1; i++) {
59+
double lng1 = coords.get(i).get(0).asDouble();
60+
double lat1 = coords.get(i).get(1).asDouble();
61+
double lng2 = coords.get(i + 1).get(0).asDouble();
62+
double lat2 = coords.get(i + 1).get(1).asDouble();
63+
double distance = haversine(lat1, lng1, lat2, lng2);
64+
65+
double gradePercent;
66+
Double a1 = smoothed.get(i);
67+
Double a2 = smoothed.get(i + 1);
68+
if (distance == 0.0 || a1 == null || a2 == null) {
69+
gradePercent = 0.0;
70+
} else {
71+
gradePercent = (a2 - a1) / distance * 100.0;
72+
}
73+
SlopeGrade grade = SlopeGrade.classify(gradePercent);
74+
75+
if (currentGrade == null) {
76+
currentGrade = grade;
77+
currentStart = i;
78+
} else if (currentGrade != grade) {
79+
result.add(new SlopeSegmentResponse(currentStart, i, currentGrade));
80+
currentGrade = grade;
81+
currentStart = i;
82+
}
83+
}
84+
if (currentGrade != null) {
85+
result.add(new SlopeSegmentResponse(currentStart, n - 1, currentGrade));
86+
}
87+
return result;
88+
} catch (Exception e) {
89+
log.warn("Failed to compute slope segments — returning empty list. polylineLen={} altLen={}",
90+
polylineJson.length(), altitudesJson.length(), e);
91+
return List.of();
92+
}
93+
}
94+
95+
private static List<Double> movingAverage(List<Double> altitudes, int window) {
96+
int n = altitudes.size();
97+
int half = window / 2;
98+
List<Double> out = new ArrayList<>(n);
99+
for (int i = 0; i < n; i++) {
100+
int from = Math.max(0, i - half);
101+
int to = Math.min(n - 1, i + half);
102+
double sum = 0.0;
103+
int cnt = 0;
104+
for (int j = from; j <= to; j++) {
105+
Double v = altitudes.get(j);
106+
if (v != null) {
107+
sum += v;
108+
cnt++;
109+
}
110+
}
111+
out.add(cnt > 0 ? sum / cnt : null);
112+
}
113+
return out;
114+
}
115+
116+
private static double haversine(double lat1, double lng1, double lat2, double lng2) {
117+
double phi1 = Math.toRadians(lat1);
118+
double phi2 = Math.toRadians(lat2);
119+
double dPhi = Math.toRadians(lat2 - lat1);
120+
double dLambda = Math.toRadians(lng2 - lng1);
121+
double a = Math.sin(dPhi / 2) * Math.sin(dPhi / 2)
122+
+ Math.cos(phi1) * Math.cos(phi2) * Math.sin(dLambda / 2) * Math.sin(dLambda / 2);
123+
double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
124+
return EARTH_RADIUS_METERS * c;
125+
}
126+
}

src/test/java/com/semosan/api/domain/mountain/service/CourseServiceTest.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,12 @@
1212
import org.mockito.Mock;
1313
import org.mockito.junit.jupiter.MockitoExtension;
1414

15+
import java.util.List;
1516
import java.util.Optional;
1617

1718
import static org.assertj.core.api.Assertions.assertThat;
1819
import static org.assertj.core.api.Assertions.assertThatThrownBy;
20+
import static org.mockito.ArgumentMatchers.any;
1921
import static org.mockito.Mockito.when;
2022

2123
@ExtendWith(MockitoExtension.class)
@@ -27,6 +29,9 @@ class CourseServiceTest {
2729
@Mock
2830
private CourseLikeRepository courseLikeRepository;
2931

32+
@Mock
33+
private CourseSlopeSegmentCalculator slopeSegmentCalculator;
34+
3035
@Mock
3136
private CourseDetailProjection projection;
3237

@@ -37,6 +42,7 @@ class CourseServiceTest {
3742
void getCourseDetailIncludesLikedByMe() {
3843
when(courseRepository.findCourseDetailById(10L)).thenReturn(Optional.of(projection));
3944
when(courseLikeRepository.existsByUser_IdAndCourse_Id(1L, 10L)).thenReturn(true);
45+
when(slopeSegmentCalculator.calculate(any(), any())).thenReturn(List.of());
4046
stubProjection();
4147

4248
CourseDetailResponse response = courseService.getCourseDetail(1L, 10L);

0 commit comments

Comments
 (0)