feat: area-search 개별 방문(trip) 분리 + 경계 보간 타임스탬프

- 모든 모드(ANY/ALL/SEQUENTIAL)에서 동일 구역 재방문 시 개별 방문 단위로 분리
- PolygonHitDetail에 visitIndex 필드 추가 (구역별 방문 순번, 1-based)
- 진입/진출 시각을 JTS LineSegment.intersection() 기반 거리 비율 보간으로 산출
- SEQUENTIAL 모드: greedy chain 탐색, 유효 체인만 반환 (visitIndex=1 재설정)
- hitDetails 배열을 entryTimestamp 오름차순 정렬 (배열 인덱스 = 전체 방문 순서)
- Swagger 설명에 개별 방문 분리, 경계 보간 관련 내용 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
LHT 2026-02-11 08:32:32 +09:00
부모 1480990f4f
커밋 a3de69772a
3개의 변경된 파일180개의 추가작업 그리고 66개의 파일을 삭제

파일 보기

@ -35,15 +35,25 @@ public class AreaSearchController {
인메모리 캐시(D-1~D-7) 활용하여 사용자 지정 다중 폴리곤 영역 선박을 탐색합니다.
**검색 모드 (폴리곤 2개 이상 ):**
- **ANY**: 합집합 - 어느 영역이라도 통과한 선박
- **ALL**: 교집합 - 모든 영역을 통과한 선박
- **SEQUENTIAL**: 순차 통과 - 모든 영역을 지정된 순서대로 통과한 선박
- **ANY**: 합집합 - 어느 영역이라도 통과한 선박 (개별 방문 분리)
- **ALL**: 교집합 - 모든 영역을 통과한 선박 (개별 방문 분리)
- **SEQUENTIAL**: 순차 통과 - 모든 영역을 지정된 순서대로 통과한 선박 (유효 체인만 반환)
**폴리곤 1개일 :** mode는 무시되며, 해당 영역 히트 선박 + 전체 트랙 반환
**개별 방문(trip) 분리:**
- 동일 구역을 여러 진입/진출하면 각각 별도 PolygonHitDetail로 반환
- visitIndex: 해당 구역의 방문 순번 (1-based)
- hitDetails 배열은 entryTimestamp 오름차순 정렬 (배열 인덱스 = 전체 방문 순서)
- SEQUENTIAL 모드: 순서가 성립하는 체인만 반환, visitIndex=1로 재설정
**경계 보간 타임스탬프:**
- entryTimestamp/exitTimestamp는 폴리곤 경계 통과 시각을 거리 비율로 보간
- outsideinside 전환 포인트 사이의 경계 교차점 기준
**응답 구조:**
- `tracks`: 기존 V2 API와 동일한 CompactVesselTrack 배열 (프론트엔드 렌더링 호환)
- `hitDetails`: 선박별 영역 히트 메타데이터 (진입/진출 시간, 히트 포인트 )
- `hitDetails`: 선박별 영역 히트 메타데이터 (방문별 진입/진출 시간, 히트 포인트 , visitIndex)
- `summary`: 검색 요약 (선박 , 포인트 , 처리 시간 )
**제약사항:**

파일 보기

@ -27,10 +27,10 @@ public class AreaSearchResponse {
private AreaSearchSummary summary;
@Data
@Builder
@Builder(toBuilder = true)
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "폴리곤별 히트 상세 정보")
@Schema(description = "폴리곤별 히트 상세 정보 (개별 방문 단위)")
public static class PolygonHitDetail {
@Schema(description = "폴리곤 식별자", example = "zone_A")
@ -39,14 +39,17 @@ public class AreaSearchResponse {
@Schema(description = "폴리곤 표시명", example = "대한해협 서수도")
private String polygonName;
@Schema(description = "영역 진입 Unix timestamp (초)", example = "1738368000")
@Schema(description = "영역 진입 시각 Unix timestamp (초, 경계 보간 적용)", example = "1738368000")
private Long entryTimestamp;
@Schema(description = "영역 마지막 진출 Unix timestamp (초)", example = "1738382400")
@Schema(description = "영역 진출 시각 Unix timestamp (초, 경계 보간 적용)", example = "1738382400")
private Long exitTimestamp;
@Schema(description = "영역 내 포인트 수", example = "45")
private Integer hitPointCount;
@Schema(description = "해당 폴리곤의 방문 순번 (1-based, 동일 구역 재방문 시 증가)", example = "1")
private Integer visitIndex;
}
@Data

파일 보기

@ -57,12 +57,12 @@ public class AreaSearchService {
// 5. 병합된 트랙으로 STRtree 빌드
STRtree spatialIndex = buildSpatialIndex(mergedTracks);
// 6. 폴리곤별 히트 선박 + 타임스탬프 수집
List<Map<String, PolygonHitDetail>> perPolygonHits = new ArrayList<>();
// 6. 폴리곤별 히트 선박 + 개별 방문(trip) 수집
List<Map<String, List<PolygonHitDetail>>> perPolygonHits = new ArrayList<>();
for (int i = 0; i < jtsPolygons.size(); i++) {
Polygon polygon = jtsPolygons.get(i);
SearchPolygon searchPolygon = request.getPolygons().get(i);
Map<String, PolygonHitDetail> hits = findHitsForPolygon(
Map<String, List<PolygonHitDetail>> hits = findHitsForPolygon(
polygon, searchPolygon, mergedTracks, spatialIndex);
perPolygonHits.add(hits);
}
@ -298,10 +298,10 @@ public class AreaSearchService {
return tree;
}
// 폴리곤별 히트 검색
// 폴리곤별 히트 검색 (다중 방문 분리)
@SuppressWarnings("unchecked")
Map<String, PolygonHitDetail> findHitsForPolygon(
Map<String, List<PolygonHitDetail>> findHitsForPolygon(
Polygon polygon, SearchPolygon searchPolygon,
Map<String, CompactVesselTrack> tracks, STRtree spatialIndex) {
@ -311,14 +311,15 @@ public class AreaSearchService {
// STRtree 후보 추출
List<String> candidates = spatialIndex.query(mbr);
Map<String, PolygonHitDetail> hits = new HashMap<>();
Map<String, List<PolygonHitDetail>> hits = new HashMap<>();
for (String vesselId : candidates) {
CompactVesselTrack track = tracks.get(vesselId);
if (track == null) continue;
PolygonHitDetail hit = checkTrackAgainstPolygon(track, prepared, searchPolygon);
if (hit != null) {
hits.put(vesselId, hit);
List<PolygonHitDetail> visits = checkTrackAgainstPolygonMultiVisit(
track, prepared, polygon, searchPolygon);
if (!visits.isEmpty()) {
hits.put(vesselId, visits);
}
}
@ -326,44 +327,107 @@ public class AreaSearchService {
}
/**
* 정밀 point-in-polygon 검사: 트랙의 좌표를 폴리곤과 비교
* 정밀 point-in-polygon 검사: 트랙을 순회하며 연속 체류 구간(방문)별로 분리.
* 진입/진출 시각은 경계 교차점 거리 비율 보간 적용.
*/
private PolygonHitDetail checkTrackAgainstPolygon(
CompactVesselTrack track, PreparedGeometry prepared, SearchPolygon searchPolygon) {
private List<PolygonHitDetail> checkTrackAgainstPolygonMultiVisit(
CompactVesselTrack track, PreparedGeometry prepared,
Polygon polygon, SearchPolygon searchPolygon) {
List<double[]> geometry = track.getGeometry();
List<String> timestamps = track.getTimestamps();
if (geometry == null || geometry.isEmpty()) return null;
if (geometry == null || geometry.isEmpty()) return Collections.emptyList();
Long entryTimestamp = null;
Long exitTimestamp = null;
int hitCount = 0;
List<PolygonHitDetail> visits = new ArrayList<>();
boolean wasInside = false;
long currentEntry = 0;
long currentExit = 0;
int currentHitCount = 0;
int visitIndex = 0;
for (int i = 0; i < geometry.size(); i++) {
double[] coord = geometry.get(i);
Point point = GEOMETRY_FACTORY.createPoint(new Coordinate(coord[0], coord[1]));
boolean isInside = prepared.contains(point);
if (prepared.contains(point)) {
hitCount++;
if (isInside) {
long ts = parseTimestamp(timestamps, i);
if (entryTimestamp == null || ts < entryTimestamp) {
entryTimestamp = ts;
if (!wasInside) {
// 방문 시작 보간된 진입 시각
if (i > 0) {
currentEntry = interpolateBoundaryCrossing(
geometry.get(i - 1), parseTimestamp(timestamps, i - 1),
coord, ts, polygon);
} else {
currentEntry = ts;
}
currentHitCount = 0;
}
if (exitTimestamp == null || ts > exitTimestamp) {
exitTimestamp = ts;
currentExit = ts;
currentHitCount++;
wasInside = true;
} else {
if (wasInside) {
// 방문 종료 보간된 진출 시각
long interpolatedExit = interpolateBoundaryCrossing(
geometry.get(i - 1), parseTimestamp(timestamps, i - 1),
coord, parseTimestamp(timestamps, i), polygon);
visitIndex++;
visits.add(PolygonHitDetail.builder()
.polygonId(searchPolygon.getId())
.polygonName(searchPolygon.getName())
.entryTimestamp(currentEntry)
.exitTimestamp(interpolatedExit)
.hitPointCount(currentHitCount)
.visitIndex(visitIndex)
.build());
}
wasInside = false;
}
}
// 마지막 포인트가 inside인 경우 마무리 (이후 outside 없어 보간 불가)
if (wasInside && currentHitCount > 0) {
visitIndex++;
visits.add(PolygonHitDetail.builder()
.polygonId(searchPolygon.getId())
.polygonName(searchPolygon.getName())
.entryTimestamp(currentEntry)
.exitTimestamp(currentExit)
.hitPointCount(currentHitCount)
.visitIndex(visitIndex)
.build());
}
if (hitCount == 0) return null;
return visits;
}
return PolygonHitDetail.builder()
.polygonId(searchPolygon.getId())
.polygonName(searchPolygon.getName())
.entryTimestamp(entryTimestamp)
.exitTimestamp(exitTimestamp)
.hitPointCount(hitCount)
.build();
// 경계 통과 시각 보간
/**
* 폴리곤 경계 통과 시각을 거리 비율로 보간 계산.
* outsideinside 전환 인접 포인트 사이의 경계 교차점을 구하고,
* 거리 비율에 따라 타임스탬프를 보간한다.
* 교차점 미발견 fallback으로 p2의 타임스탬프 반환.
*/
private long interpolateBoundaryCrossing(
double[] p1, long ts1, double[] p2, long ts2, Polygon polygon) {
Coordinate c1 = new Coordinate(p1[0], p1[1]);
Coordinate c2 = new Coordinate(p2[0], p2[1]);
LineSegment segment = new LineSegment(c1, c2);
LinearRing ring = polygon.getExteriorRing();
for (int j = 0; j < ring.getNumPoints() - 1; j++) {
LineSegment edge = new LineSegment(
ring.getCoordinateN(j), ring.getCoordinateN(j + 1));
Coordinate intersection = segment.intersection(edge);
if (intersection != null) {
double dTotal = c1.distance(c2);
if (dTotal < 1e-12) return ts1;
double ratio = c1.distance(intersection) / dTotal;
return ts1 + Math.round((ts2 - ts1) * ratio);
}
}
return ts2; // fallback
}
private long parseTimestamp(List<String> timestamps, int index) {
@ -375,29 +439,33 @@ public class AreaSearchService {
}
}
// 모드별 결과 처리
// 모드별 결과 처리 (다중 방문 지원)
/**
* ANY 모드: 합집합 (어느 영역이든 통과한 선박)
* ANY 모드: 합집합 (어느 영역이든 통과한 선박).
* 모든 방문을 entryTimestamp 기준 오름차순 정렬하여 반환.
*/
Map<String, List<PolygonHitDetail>> processAnyMode(
List<Map<String, PolygonHitDetail>> perPolygonHits) {
List<Map<String, List<PolygonHitDetail>>> perPolygonHits) {
Map<String, List<PolygonHitDetail>> result = new HashMap<>();
for (Map<String, PolygonHitDetail> polygonHits : perPolygonHits) {
for (Map.Entry<String, PolygonHitDetail> entry : polygonHits.entrySet()) {
for (Map<String, List<PolygonHitDetail>> polygonHits : perPolygonHits) {
for (Map.Entry<String, List<PolygonHitDetail>> entry : polygonHits.entrySet()) {
result.computeIfAbsent(entry.getKey(), k -> new ArrayList<>())
.add(entry.getValue());
.addAll(entry.getValue());
}
}
result.values().forEach(list ->
list.sort(Comparator.comparingLong(PolygonHitDetail::getEntryTimestamp)));
return result;
}
/**
* ALL 모드: 교집합 (모든 영역을 통과한 선박)
* ALL 모드: 교집합 (모든 영역을 통과한 선박).
* 모든 방문을 entryTimestamp 기준 오름차순 정렬하여 반환.
*/
Map<String, List<PolygonHitDetail>> processAllMode(
List<Map<String, PolygonHitDetail>> perPolygonHits) {
List<Map<String, List<PolygonHitDetail>>> perPolygonHits) {
if (perPolygonHits.isEmpty()) return Collections.emptyMap();
@ -409,43 +477,76 @@ public class AreaSearchService {
Map<String, List<PolygonHitDetail>> result = new HashMap<>();
for (String vesselId : commonVessels) {
List<PolygonHitDetail> hits = new ArrayList<>();
for (Map<String, PolygonHitDetail> polygonHits : perPolygonHits) {
hits.add(polygonHits.get(vesselId));
List<PolygonHitDetail> allVisits = new ArrayList<>();
for (Map<String, List<PolygonHitDetail>> polygonHits : perPolygonHits) {
allVisits.addAll(polygonHits.get(vesselId));
}
result.put(vesselId, hits);
allVisits.sort(Comparator.comparingLong(PolygonHitDetail::getEntryTimestamp));
result.put(vesselId, allVisits);
}
return result;
}
/**
* SEQUENTIAL 모드: 교집합 + entryTimestamp 순서 검증
* SEQUENTIAL 모드: 교집합 + 방문 순서 체인 검증.
* 폴리곤의 방문 순서가 성립하는 조합(chain) 찾고,
* 체인에 포함된 방문만 반환 (visitIndex=1로 재설정).
*/
Map<String, List<PolygonHitDetail>> processSequentialMode(
List<Map<String, PolygonHitDetail>> perPolygonHits) {
List<Map<String, List<PolygonHitDetail>>> perPolygonHits) {
// 먼저 ALL 모드로 교집합 구함
Map<String, List<PolygonHitDetail>> allHits = processAllMode(perPolygonHits);
if (perPolygonHits.isEmpty()) return Collections.emptyMap();
// 모든 폴리곤에 공통으로 존재하는 vesselId 찾기
Set<String> commonVessels = new HashSet<>(perPolygonHits.get(0).keySet());
for (int i = 1; i < perPolygonHits.size(); i++) {
commonVessels.retainAll(perPolygonHits.get(i).keySet());
}
// 순서 검증: 선박에 대해 polygon 순서대로 entryTimestamp 증가 확인
Map<String, List<PolygonHitDetail>> result = new HashMap<>();
for (Map.Entry<String, List<PolygonHitDetail>> entry : allHits.entrySet()) {
List<PolygonHitDetail> hits = entry.getValue();
if (isSequentialOrder(hits)) {
result.put(entry.getKey(), hits);
for (String vesselId : commonVessels) {
List<List<PolygonHitDetail>> visitsByPolygon = new ArrayList<>();
boolean valid = true;
for (Map<String, List<PolygonHitDetail>> polygonHits : perPolygonHits) {
List<PolygonHitDetail> visits = polygonHits.get(vesselId);
if (visits == null || visits.isEmpty()) {
valid = false;
break;
}
visitsByPolygon.add(visits);
}
if (!valid) continue;
List<PolygonHitDetail> chain = findSequentialChain(visitsByPolygon);
if (chain != null) {
result.put(vesselId, chain);
}
}
return result;
}
private boolean isSequentialOrder(List<PolygonHitDetail> hits) {
for (int i = 1; i < hits.size(); i++) {
Long prevEntry = hits.get(i - 1).getEntryTimestamp();
Long currEntry = hits.get(i).getEntryTimestamp();
if (prevEntry == null || currEntry == null) return false;
if (currEntry <= prevEntry) return false;
/**
* 폴리곤 순서대로 유효한 순차 체인을 greedy 탐색.
* 단계에서 이전 방문의 exitTimestamp보다 늦은 entryTimestamp를 가진 방문 선택.
* 체인 성립 visitIndex=1로 재설정하여 반환.
*/
private List<PolygonHitDetail> findSequentialChain(
List<List<PolygonHitDetail>> visitsByPolygon) {
List<PolygonHitDetail> chain = new ArrayList<>();
Long prevExit = null;
for (List<PolygonHitDetail> visits : visitsByPolygon) {
PolygonHitDetail matched = null;
for (PolygonHitDetail visit : visits) {
if (prevExit == null || visit.getEntryTimestamp() > prevExit) {
matched = visit;
break;
}
}
if (matched == null) return null;
chain.add(matched.toBuilder().visitIndex(1).build());
prevExit = matched.getExitTimestamp();
}
return true;
return chain;
}
// 응답