From a3de69772abb4505f6859b3e99d80070f45fc3ee Mon Sep 17 00:00:00 2001 From: LHT Date: Wed, 11 Feb 2026 08:32:32 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20area-search=20=EA=B0=9C=EB=B3=84=20?= =?UTF-8?q?=EB=B0=A9=EB=AC=B8(trip)=20=EB=B6=84=EB=A6=AC=20+=20=EA=B2=BD?= =?UTF-8?q?=EA=B3=84=20=EB=B3=B4=EA=B0=84=20=ED=83=80=EC=9E=84=EC=8A=A4?= =?UTF-8?q?=ED=83=AC=ED=94=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 모든 모드(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 --- .../gis/controller/AreaSearchController.java | 18 +- .../domain/gis/dto/AreaSearchResponse.java | 11 +- .../domain/gis/service/AreaSearchService.java | 217 +++++++++++++----- 3 files changed, 180 insertions(+), 66 deletions(-) diff --git a/src/main/java/gc/mda/signal_batch/domain/gis/controller/AreaSearchController.java b/src/main/java/gc/mda/signal_batch/domain/gis/controller/AreaSearchController.java index 2cd6bcf..13fe75b 100644 --- a/src/main/java/gc/mda/signal_batch/domain/gis/controller/AreaSearchController.java +++ b/src/main/java/gc/mda/signal_batch/domain/gis/controller/AreaSearchController.java @@ -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`: 검색 요약 (선박 수, 포인트 수, 처리 시간 등) **제약사항:** diff --git a/src/main/java/gc/mda/signal_batch/domain/gis/dto/AreaSearchResponse.java b/src/main/java/gc/mda/signal_batch/domain/gis/dto/AreaSearchResponse.java index 1a6b991..ff37d88 100644 --- a/src/main/java/gc/mda/signal_batch/domain/gis/dto/AreaSearchResponse.java +++ b/src/main/java/gc/mda/signal_batch/domain/gis/dto/AreaSearchResponse.java @@ -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 diff --git a/src/main/java/gc/mda/signal_batch/domain/gis/service/AreaSearchService.java b/src/main/java/gc/mda/signal_batch/domain/gis/service/AreaSearchService.java index 87a75e4..50be136 100644 --- a/src/main/java/gc/mda/signal_batch/domain/gis/service/AreaSearchService.java +++ b/src/main/java/gc/mda/signal_batch/domain/gis/service/AreaSearchService.java @@ -57,12 +57,12 @@ public class AreaSearchService { // 5. 병합된 트랙으로 STRtree 빌드 STRtree spatialIndex = buildSpatialIndex(mergedTracks); - // 6. 각 폴리곤별 히트 선박 + 타임스탬프 수집 - List> perPolygonHits = new ArrayList<>(); + // 6. 각 폴리곤별 히트 선박 + 개별 방문(trip) 수집 + List>> perPolygonHits = new ArrayList<>(); for (int i = 0; i < jtsPolygons.size(); i++) { Polygon polygon = jtsPolygons.get(i); SearchPolygon searchPolygon = request.getPolygons().get(i); - Map hits = findHitsForPolygon( + Map> hits = findHitsForPolygon( polygon, searchPolygon, mergedTracks, spatialIndex); perPolygonHits.add(hits); } @@ -298,10 +298,10 @@ public class AreaSearchService { return tree; } - // ── 폴리곤별 히트 검색 ── + // ── 폴리곤별 히트 검색 (다중 방문 분리) ── @SuppressWarnings("unchecked") - Map findHitsForPolygon( + Map> findHitsForPolygon( Polygon polygon, SearchPolygon searchPolygon, Map tracks, STRtree spatialIndex) { @@ -311,14 +311,15 @@ public class AreaSearchService { // STRtree 후보 추출 List candidates = spatialIndex.query(mbr); - Map hits = new HashMap<>(); + Map> 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 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 checkTrackAgainstPolygonMultiVisit( + CompactVesselTrack track, PreparedGeometry prepared, + Polygon polygon, SearchPolygon searchPolygon) { List geometry = track.getGeometry(); List 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 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 timestamps, int index) { @@ -375,29 +439,33 @@ public class AreaSearchService { } } - // ── 모드별 결과 처리 ── + // ── 모드별 결과 처리 (다중 방문 지원) ── /** - * ANY 모드: 합집합 (어느 영역이든 통과한 선박) + * ANY 모드: 합집합 (어느 영역이든 통과한 선박). + * 모든 방문을 entryTimestamp 기준 오름차순 정렬하여 반환. */ Map> processAnyMode( - List> perPolygonHits) { + List>> perPolygonHits) { Map> result = new HashMap<>(); - for (Map polygonHits : perPolygonHits) { - for (Map.Entry entry : polygonHits.entrySet()) { + for (Map> polygonHits : perPolygonHits) { + for (Map.Entry> 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> processAllMode( - List> perPolygonHits) { + List>> perPolygonHits) { if (perPolygonHits.isEmpty()) return Collections.emptyMap(); @@ -409,43 +477,76 @@ public class AreaSearchService { Map> result = new HashMap<>(); for (String vesselId : commonVessels) { - List hits = new ArrayList<>(); - for (Map polygonHits : perPolygonHits) { - hits.add(polygonHits.get(vesselId)); + List allVisits = new ArrayList<>(); + for (Map> 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> processSequentialMode( - List> perPolygonHits) { + List>> perPolygonHits) { - // 먼저 ALL 모드로 교집합 구함 - Map> allHits = processAllMode(perPolygonHits); + if (perPolygonHits.isEmpty()) return Collections.emptyMap(); + + // 모든 폴리곤에 공통으로 존재하는 vesselId 찾기 + Set commonVessels = new HashSet<>(perPolygonHits.get(0).keySet()); + for (int i = 1; i < perPolygonHits.size(); i++) { + commonVessels.retainAll(perPolygonHits.get(i).keySet()); + } - // 순서 검증: 각 선박에 대해 polygon 순서대로 entryTimestamp 증가 확인 Map> result = new HashMap<>(); - for (Map.Entry> entry : allHits.entrySet()) { - List hits = entry.getValue(); - if (isSequentialOrder(hits)) { - result.put(entry.getKey(), hits); + for (String vesselId : commonVessels) { + List> visitsByPolygon = new ArrayList<>(); + boolean valid = true; + for (Map> polygonHits : perPolygonHits) { + List visits = polygonHits.get(vesselId); + if (visits == null || visits.isEmpty()) { + valid = false; + break; + } + visitsByPolygon.add(visits); + } + if (!valid) continue; + + List chain = findSequentialChain(visitsByPolygon); + if (chain != null) { + result.put(vesselId, chain); } } return result; } - private boolean isSequentialOrder(List 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 findSequentialChain( + List> visitsByPolygon) { + List chain = new ArrayList<>(); + Long prevExit = null; + for (List 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; } // ── 빈 응답 ──