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:
부모
1480990f4f
커밋
a3de69772a
@ -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는 폴리곤 경계 통과 시각을 거리 비율로 보간
|
||||
- outside↔inside 전환 포인트 사이의 경계 교차점 기준
|
||||
|
||||
**응답 구조:**
|
||||
- `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();
|
||||
// ── 경계 통과 시각 보간 ──
|
||||
|
||||
/**
|
||||
* 폴리곤 경계 통과 시각을 거리 비율로 보간 계산.
|
||||
* outside↔inside 전환 시 두 인접 포인트 사이의 경계 교차점을 구하고,
|
||||
* 거리 비율에 따라 타임스탬프를 보간한다.
|
||||
* 교차점 미발견 시 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;
|
||||
}
|
||||
|
||||
// ── 빈 응답 ──
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user