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
Expand Up @@ -17,9 +17,12 @@
public interface CourseControllerDocs {

@Operation(
summary = "코스 상세 조회 (지도 polyline + 고도 단면 포함)",
description = "선택한 코스의 메타(거리/시간/난이도) + 지도용 PostGIS polyline(GeoJSON LineString) + "
+ "점별 고도 배열을 함께 반환합니다. 산림청 GPX 가 없는 코스는 polyline/altitudes 가 null 일 수 있습니다."
summary = "코스 상세 조회 (지도 polyline + 고도 단면 + 경사 등급 segments 포함)",
description = "선택한 코스의 메타(거리/시간/난이도/누적 상승·하강) + 지도용 PostGIS polyline(GeoJSON LineString) + "
+ "점별 고도 배열 + 경사 등급별로 묶인 segments 배열을 함께 반환합니다. "
+ "segments 는 polyline.coordinates 의 연속된 같은 등급(STEEP_DOWN/MILD_DOWN/FLAT/MILD_UP/STEEP_UP) 구간을 "
+ "startIdx~endIdx(둘 다 inclusive) 로 표시하며, 클라이언트는 해당 인덱스 범위의 좌표를 등급별 색으로 그릴 수 있습니다. "
+ "산림청 GPX 가 없는 코스는 polyline/altitudes 가 null 이고 segments 는 빈 배열일 수 있습니다."
)
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,14 @@
import com.semosan.api.domain.mountain.enums.Difficulty;
import com.semosan.api.domain.mountain.repository.projection.CourseDetailProjection;

import java.util.List;

/**
* 코스 상세 응답 — 지도 polyline + 고도 단면 포함.
* 코스 상세 응답 — 지도 polyline + 고도 단면 + 경사 등급 segments 포함.
* - polyline: GeoJSON LineString. ST_AsGeoJSON 결과를 그대로 JSON 으로 통과시킴.
* - altitudes: 점별 고도 배열(jsonb). polyline.coordinates[i] 와 1:1 대응.
* nullable — 고도 데이터 없는 코스는 null.
* - segments: polyline 의 연속된 같은 경사 등급 구간. 점이 2개 미만이거나 altitudes 가 비면 빈 배열.
*/
public record CourseDetailResponse(
Long id,
Expand All @@ -24,9 +27,14 @@ public record CourseDetailResponse(
Double maxAltitude,
boolean likedByMe,
@JsonRawValue String polyline,
@JsonRawValue String altitudes
@JsonRawValue String altitudes,
List<SlopeSegmentResponse> segments
) {
public static CourseDetailResponse from(CourseDetailProjection p, boolean likedByMe) {
public static CourseDetailResponse from(
CourseDetailProjection p,
boolean likedByMe,
List<SlopeSegmentResponse> segments
) {
return new CourseDetailResponse(
p.getId(),
p.getMountainId(),
Expand All @@ -41,7 +49,8 @@ public static CourseDetailResponse from(CourseDetailProjection p, boolean likedB
p.getMaxAltitude(),
likedByMe,
p.getPolyline(),
p.getAltitudes()
p.getAltitudes(),
segments
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package com.semosan.api.domain.mountain.dto.response;

import com.semosan.api.domain.mountain.enums.SlopeGrade;
import io.swagger.v3.oas.annotations.media.Schema;

/**
* 코스 polyline 의 연속된 같은 등급 구간.
* - startIdx / endIdx: polyline.coordinates 의 점 인덱스. 둘 다 inclusive.
* - grade: 해당 구간의 경사 등급.
*/
@Schema(description = "코스 polyline 의 연속된 같은 경사 등급 구간. 클라이언트는 startIdx~endIdx 범위의 좌표를 grade 별 색으로 그린다.")
public record SlopeSegmentResponse(
@Schema(description = "polyline.coordinates 의 시작 인덱스 (inclusive)", example = "0")
int startIdx,

@Schema(description = "polyline.coordinates 의 끝 인덱스 (inclusive). 다음 segment 의 startIdx 와 같아 끊김 없이 이어진다.",
example = "12")
int endIdx,

@Schema(description = "경사 등급. STEEP_DOWN(심한 내리막) / MILD_DOWN(약한 내리막) / FLAT(평지) / MILD_UP(약한 오르막) / STEEP_UP(심한 오르막).")
SlopeGrade grade
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package com.semosan.api.domain.mountain.enums;

/**
* 코스 polyline 의 segment 별 경사 등급.
* - 임계: ±5%, ±15% (gradePercent = Δaltitude / Δhorizontalmeters × 100)
* - 클라이언트는 등급별 색상으로 polyline sub-구간을 그린다.
*/
public enum SlopeGrade {

STEEP_DOWN,
MILD_DOWN,
FLAT,
MILD_UP,
STEEP_UP;

private static final double MILD_THRESHOLD = 5.0;
private static final double STEEP_THRESHOLD = 15.0;

public static SlopeGrade classify(double gradePercent) {
if (gradePercent <= -STEEP_THRESHOLD) {
return STEEP_DOWN;
}
if (gradePercent <= -MILD_THRESHOLD) {
return MILD_DOWN;
}
if (gradePercent < MILD_THRESHOLD) {
return FLAT;
}
if (gradePercent < STEEP_THRESHOLD) {
return MILD_UP;
}
return STEEP_UP;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,30 @@
import com.semosan.api.common.exception.GeneralException;
import com.semosan.api.common.status.ErrorStatus;
import com.semosan.api.domain.mountain.dto.response.CourseDetailResponse;
import com.semosan.api.domain.mountain.dto.response.SlopeSegmentResponse;
import com.semosan.api.domain.mountain.repository.CourseLikeRepository;
import com.semosan.api.domain.mountain.repository.CourseRepository;
import com.semosan.api.domain.mountain.repository.projection.CourseDetailProjection;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class CourseService {

private final CourseRepository courseRepository;
private final CourseLikeRepository courseLikeRepository;
private final CourseSlopeSegmentCalculator slopeSegmentCalculator;
Comment thread
JangInho marked this conversation as resolved.

public CourseDetailResponse getCourseDetail(Long userId, Long courseId) {
CourseDetailProjection course = courseRepository.findCourseDetailById(courseId)
.orElseThrow(() -> new GeneralException(ErrorStatus.COURSE_NOT_FOUND));
boolean likedByMe = userId != null && courseLikeRepository.existsByUser_IdAndCourse_Id(userId, courseId);
return CourseDetailResponse.from(course, likedByMe);
List<SlopeSegmentResponse> segments = slopeSegmentCalculator.calculate(course.getPolyline(), course.getAltitudes());
return CourseDetailResponse.from(course, likedByMe, segments);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package com.semosan.api.domain.mountain.service;

import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.semosan.api.domain.mountain.dto.response.SlopeSegmentResponse;
import com.semosan.api.domain.mountain.enums.SlopeGrade;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import java.util.ArrayList;
import java.util.List;

/**
* 코스 polyline(GeoJSON LineString) + 점별 altitudes 를 받아 경사 등급 segments 로 변환.
*
* 처리 단계:
* 1) altitudes 이동평균(window={@value #SMOOTHING_WINDOW}) smoothing 으로 GPS 노이즈 완화.
* 2) 인접 점 수평거리(Haversine) 와 고도차로 경사도(%) 산출.
* 3) {@link SlopeGrade} 로 등급 분류.
* 4) 같은 등급 연속 segment 묶기.
*
* 응답 좌표 인덱스(startIdx, endIdx) 는 polyline.coordinates 의 양 끝 포함(inclusive).
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class CourseSlopeSegmentCalculator {

static final int SMOOTHING_WINDOW = 5;
private static final double EARTH_RADIUS_METERS = 6_371_000.0;

private final ObjectMapper objectMapper;

public List<SlopeSegmentResponse> calculate(String polylineJson, String altitudesJson) {
if (polylineJson == null || altitudesJson == null) {
return List.of();
}
try {
JsonNode coords = objectMapper.readTree(polylineJson).get("coordinates");
if (coords == null || !coords.isArray() || coords.size() < 2) {
return List.of();
}
List<Double> altitudes = objectMapper.readValue(altitudesJson, new TypeReference<>() {});
// altitudesJson 이 "null" (문자열) 인 경우 readValue 가 null 을 반환한다. 명시적으로 가드한다.
if (altitudes == null || altitudes.size() != coords.size()) {
return List.of();
Comment thread
JangInho marked this conversation as resolved.
}

List<Double> smoothed = movingAverage(altitudes, SMOOTHING_WINDOW);

List<SlopeSegmentResponse> result = new ArrayList<>();
int n = coords.size();
int currentStart = 0;
SlopeGrade currentGrade = null;

for (int i = 0; i < n - 1; i++) {
double lng1 = coords.get(i).get(0).asDouble();
double lat1 = coords.get(i).get(1).asDouble();
double lng2 = coords.get(i + 1).get(0).asDouble();
double lat2 = coords.get(i + 1).get(1).asDouble();
double distance = haversine(lat1, lng1, lat2, lng2);

double gradePercent;
Double a1 = smoothed.get(i);
Double a2 = smoothed.get(i + 1);
if (distance == 0.0 || a1 == null || a2 == null) {
gradePercent = 0.0;
} else {
gradePercent = (a2 - a1) / distance * 100.0;
}
SlopeGrade grade = SlopeGrade.classify(gradePercent);

if (currentGrade == null) {
currentGrade = grade;
currentStart = i;
} else if (currentGrade != grade) {
result.add(new SlopeSegmentResponse(currentStart, i, currentGrade));
currentGrade = grade;
currentStart = i;
}
}
if (currentGrade != null) {
result.add(new SlopeSegmentResponse(currentStart, n - 1, currentGrade));
}
return result;
} catch (Exception e) {
log.warn("Failed to compute slope segments — returning empty list. polylineLen={} altLen={}",
polylineJson.length(), altitudesJson.length(), e);
return List.of();
}
}

private static List<Double> movingAverage(List<Double> altitudes, int window) {
int n = altitudes.size();
int half = window / 2;
List<Double> out = new ArrayList<>(n);
for (int i = 0; i < n; i++) {
int from = Math.max(0, i - half);
int to = Math.min(n - 1, i + half);
double sum = 0.0;
int cnt = 0;
for (int j = from; j <= to; j++) {
Double v = altitudes.get(j);
if (v != null) {
sum += v;
cnt++;
}
}
out.add(cnt > 0 ? sum / cnt : null);
}
return out;
}

private static double haversine(double lat1, double lng1, double lat2, double lng2) {
double phi1 = Math.toRadians(lat1);
double phi2 = Math.toRadians(lat2);
double dPhi = Math.toRadians(lat2 - lat1);
double dLambda = Math.toRadians(lng2 - lng1);
double a = Math.sin(dPhi / 2) * Math.sin(dPhi / 2)
+ Math.cos(phi1) * Math.cos(phi2) * Math.sin(dLambda / 2) * Math.sin(dLambda / 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 @@ -12,10 +12,12 @@
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;

import java.util.List;
import java.util.Optional;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;

@ExtendWith(MockitoExtension.class)
Expand All @@ -27,6 +29,9 @@ class CourseServiceTest {
@Mock
private CourseLikeRepository courseLikeRepository;

@Mock
private CourseSlopeSegmentCalculator slopeSegmentCalculator;

@Mock
private CourseDetailProjection projection;

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

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