diff --git a/docs/implementation-progress.md b/docs/implementation-progress.md index a092157..381e868 100644 --- a/docs/implementation-progress.md +++ b/docs/implementation-progress.md @@ -193,6 +193,37 @@ --- +## Phase 8 — 다중 폴리곤 영역 탐색 REST API + 공간 인덱스 + +- [x] **8.1** DailyTrackCacheManager에 STRtree 공간 인덱스 추가 + - DailyTrackData에 STRtree spatialIndex 필드 추가 (하위 호환 유지) + - loadDay() 완료 후 자동 빌드 → build() 호출로 불변화 (동시성 안전) + - getDailyTrackData(date), getCachedDateList() public 메서드 추가 + - 추가 메모리: 선박당 ~100B × 50K/일 = ~5MB/일, 7일 ~35MB + - 상태: **완료** (2026-02-07) + +- [x] **8.2** AreaSearchRequest / AreaSearchResponse DTO 생성 + - AreaSearchRequest: startTime/endTime + SearchMode(ANY/ALL/SEQUENTIAL) + List + - AreaSearchResponse: tracks(CompactVesselTrack[]) + hitDetails + summary + - Swagger @Schema 상세 기입, 요청/응답 예시 포함 + - 상태: **완료** (2026-02-07) + +- [x] **8.3** AreaSearchService 핵심 비즈니스 로직 구현 + - JTS PreparedGeometry + STRtree 기반 고속 영역 탐색 + - 다일 데이터 병합 → 단일 STRtree → 폴리곤별 후보 추출 → 정밀 PIP + - ANY(합집합)/ALL(교집합)/SEQUENTIAL(순차 통과) 3가지 모드 + - 캐시 미준비 시 CacheNotReadyException → 503 반환 + - 상태: **완료** (2026-02-07) + +- [x] **8.4** AreaSearchController REST 엔드포인트 + - POST /api/v2/tracks/area-search + - @Tag("항적 조회 API V2") → 기존 GisControllerV2와 동일 Swagger 그룹 + - 에러 핸들러: 400 (잘못된 폴리곤), 503 (캐시 미준비) + - Swagger: @Operation, @ExampleObject 상세 문서화 + - 상태: **완료** (2026-02-07) + +--- + ## 커밋 이력 | 날짜 | Phase | 커밋 메시지 | 해시 | 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 new file mode 100644 index 0000000..2cd6bcf --- /dev/null +++ b/src/main/java/gc/mda/signal_batch/domain/gis/controller/AreaSearchController.java @@ -0,0 +1,123 @@ +package gc.mda.signal_batch.domain.gis.controller; + +import gc.mda.signal_batch.domain.gis.dto.AreaSearchRequest; +import gc.mda.signal_batch.domain.gis.dto.AreaSearchResponse; +import gc.mda.signal_batch.domain.gis.service.AreaSearchService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +@Slf4j +@RestController +@RequestMapping("/api/v2/tracks") +@RequiredArgsConstructor +@Tag(name = "항적 조회 API V2", description = "해구/영역별 선박 항적 조회 API (WebSocket 호환 CompactVesselTrack 응답)") +public class AreaSearchController { + + private final AreaSearchService areaSearchService; + + @PostMapping("/area-search") + @Operation( + summary = "다중 폴리곤 영역 내 선박 탐색", + description = """ + 인메모리 캐시(D-1~D-7)를 활용하여 사용자 지정 다중 폴리곤 영역 내 선박을 탐색합니다. + + **검색 모드 (폴리곤 2개 이상 시):** + - **ANY**: 합집합 - 어느 한 영역이라도 통과한 선박 + - **ALL**: 교집합 - 모든 영역을 통과한 선박 + - **SEQUENTIAL**: 순차 통과 - 모든 영역을 지정된 순서대로 통과한 선박 + + **폴리곤 1개일 때:** mode는 무시되며, 해당 영역 히트 선박 + 전체 트랙 반환 + + **응답 구조:** + - `tracks`: 기존 V2 API와 동일한 CompactVesselTrack 배열 (프론트엔드 렌더링 호환) + - `hitDetails`: 선박별 영역 히트 메타데이터 (진입/진출 시간, 히트 포인트 수) + - `summary`: 검색 요약 (선박 수, 포인트 수, 처리 시간 등) + + **제약사항:** + - 캐시된 날짜 범위만 조회 가능 (D-1 ~ D-7, 오늘 제외) + - 폴리곤 최대 10개 + - 폴리곤 좌표는 닫힌 형태 (첫점 == 끝점) + - 캐시 미준비 시 503 반환 + """ + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "탐색 성공", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = AreaSearchResponse.class) + )), + @ApiResponse(responseCode = "400", description = "잘못된 요청 (날짜 범위 초과, 잘못된 폴리곤 등)", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = "{\"error\": \"폴리곤 'zone_A'이 유효하지 않습니다 (자기 교차 등)\"}") + )), + @ApiResponse(responseCode = "503", description = "캐시 미준비 (LOADING 상태)", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject(value = "{\"error\": \"캐시가 아직 준비되지 않았습니다 (상태: LOADING)\"}") + )) + }) + public ResponseEntity searchArea( + @io.swagger.v3.oas.annotations.parameters.RequestBody( + description = "다중 폴리곤 영역 탐색 요청", + required = true, + content = @Content( + schema = @Schema(implementation = AreaSearchRequest.class), + examples = @ExampleObject( + name = "순차 통과 예시", + value = """ + { + "startTime": "2026-02-01T00:00:00", + "endTime": "2026-02-07T23:59:59", + "mode": "SEQUENTIAL", + "polygons": [ + { + "id": "zone_A", "name": "대한해협 서수도", + "coordinates": [[128.5,34.0],[129.5,34.0],[129.5,35.0],[128.5,35.0],[128.5,34.0]] + }, + { + "id": "zone_B", "name": "제주해협", + "coordinates": [[126.0,33.0],[127.0,33.0],[127.0,34.0],[126.0,34.0],[126.0,33.0]] + } + ] + } + """ + ) + ) + ) + @Valid @RequestBody AreaSearchRequest request) { + + log.info("Area search request: mode={}, polygons={}, timeRange={} ~ {}", + request.getMode(), request.getPolygons().size(), + request.getStartTime(), request.getEndTime()); + + return ResponseEntity.ok(areaSearchService.search(request)); + } + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity> handleBadRequest(IllegalArgumentException e) { + log.warn("Area search bad request: {}", e.getMessage()); + return ResponseEntity.badRequest() + .body(Map.of("error", e.getMessage())); + } + + @ExceptionHandler(AreaSearchService.CacheNotReadyException.class) + public ResponseEntity> handleCacheNotReady(AreaSearchService.CacheNotReadyException e) { + log.warn("Area search cache not ready: {}", e.getMessage()); + return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE) + .body(Map.of("error", e.getMessage())); + } +} diff --git a/src/main/java/gc/mda/signal_batch/domain/gis/dto/AreaSearchRequest.java b/src/main/java/gc/mda/signal_batch/domain/gis/dto/AreaSearchRequest.java new file mode 100644 index 0000000..a490fa4 --- /dev/null +++ b/src/main/java/gc/mda/signal_batch/domain/gis/dto/AreaSearchRequest.java @@ -0,0 +1,75 @@ +package gc.mda.signal_batch.domain.gis.dto; + +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import gc.mda.signal_batch.global.config.FlexibleLocalDateTimeDeserializer; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "다중 폴리곤 영역 탐색 요청") +public class AreaSearchRequest { + + @NotNull(message = "시작 시간은 필수입니다") + @JsonDeserialize(using = FlexibleLocalDateTimeDeserializer.class) + @Schema(description = "조회 시작 시간", example = "2026-02-01T00:00:00", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime startTime; + + @NotNull(message = "종료 시간은 필수입니다") + @JsonDeserialize(using = FlexibleLocalDateTimeDeserializer.class) + @Schema(description = "조회 종료 시간", example = "2026-02-07T23:59:59", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime endTime; + + @Schema(description = "검색 모드: ANY(합집합), ALL(교집합), SEQUENTIAL(순차 통과). 폴리곤 1개일 때는 무시됨", + example = "ANY", defaultValue = "ANY") + @Builder.Default + private SearchMode mode = SearchMode.ANY; + + @NotNull(message = "폴리곤 목록은 필수입니다") + @Size(min = 1, max = 10, message = "폴리곤은 1~10개까지 지정 가능합니다") + @Valid + @Schema(description = "탐색 대상 폴리곤 영역 목록 (1~10개)", requiredMode = Schema.RequiredMode.REQUIRED) + private List polygons; + + @Schema(description = "검색 모드 (폴리곤이 2개 이상일 때 적용)") + public enum SearchMode { + @Schema(description = "합집합: 어느 한 영역이라도 통과한 선박") + ANY, + @Schema(description = "교집합: 모든 영역을 통과한 선박") + ALL, + @Schema(description = "순차 통과: 모든 영역을 지정된 순서대로 통과한 선박") + SEQUENTIAL + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "탐색 대상 폴리곤 영역") + public static class SearchPolygon { + + @Schema(description = "클라이언트 지정 영역 식별자", example = "zone_A") + private String id; + + @Schema(description = "영역 표시명", example = "대한해협 서수도") + private String name; + + @NotNull(message = "폴리곤 좌표는 필수입니다") + @Size(min = 4, message = "폴리곤은 최소 4개 좌표 필요 (첫점==끝점)") + @Schema(description = "폴리곤 좌표 배열 [[lon,lat],...] 첫점과 끝점이 동일해야 함", + example = "[[128.5,34.0],[129.5,34.0],[129.5,35.0],[128.5,35.0],[128.5,34.0]]", + requiredMode = Schema.RequiredMode.REQUIRED) + private List coordinates; + } +} 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 new file mode 100644 index 0000000..1a6b991 --- /dev/null +++ b/src/main/java/gc/mda/signal_batch/domain/gis/dto/AreaSearchResponse.java @@ -0,0 +1,80 @@ +package gc.mda.signal_batch.domain.gis.dto; + +import gc.mda.signal_batch.domain.vessel.dto.CompactVesselTrack; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; +import java.util.Map; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "다중 폴리곤 영역 탐색 응답") +public class AreaSearchResponse { + + @Schema(description = "히트된 선박의 전체 기간 항적 (기존 V2 API와 동일한 CompactVesselTrack)") + private List tracks; + + @Schema(description = "선박별 영역 히트 메타데이터 (key: vesselId)") + private Map> hitDetails; + + @Schema(description = "검색 요약 정보") + private AreaSearchSummary summary; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "폴리곤별 히트 상세 정보") + public static class PolygonHitDetail { + + @Schema(description = "폴리곤 식별자", example = "zone_A") + private String polygonId; + + @Schema(description = "폴리곤 표시명", example = "대한해협 서수도") + private String polygonName; + + @Schema(description = "영역 첫 진입 Unix timestamp (초)", example = "1738368000") + private Long entryTimestamp; + + @Schema(description = "영역 마지막 진출 Unix timestamp (초)", example = "1738382400") + private Long exitTimestamp; + + @Schema(description = "영역 내 포인트 수", example = "45") + private Integer hitPointCount; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @Schema(description = "영역 탐색 요약 정보") + public static class AreaSearchSummary { + + @Schema(description = "히트된 선박 수", example = "12") + private Integer totalVessels; + + @Schema(description = "히트된 전체 포인트 수", example = "4560") + private Long totalPoints; + + @Schema(description = "적용된 검색 모드", example = "SEQUENTIAL") + private AreaSearchRequest.SearchMode mode; + + @Schema(description = "검색 대상 폴리곤 ID 목록", example = "[\"zone_A\", \"zone_B\"]") + private List polygonIds; + + @Schema(description = "처리 소요 시간 (ms)", example = "1250") + private Long processingTimeMs; + + @Schema(description = "조회에 사용된 캐시 날짜 목록", example = "[\"2026-02-01\", \"2026-02-02\"]") + private List cachedDates; + + @Schema(description = "캐시된 전체 선박 수", example = "285000") + private Integer totalCachedVessels; + } +} 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 new file mode 100644 index 0000000..87a75e4 --- /dev/null +++ b/src/main/java/gc/mda/signal_batch/domain/gis/service/AreaSearchService.java @@ -0,0 +1,481 @@ +package gc.mda.signal_batch.domain.gis.service; + +import gc.mda.signal_batch.domain.gis.dto.AreaSearchRequest; +import gc.mda.signal_batch.domain.gis.dto.AreaSearchRequest.SearchMode; +import gc.mda.signal_batch.domain.gis.dto.AreaSearchRequest.SearchPolygon; +import gc.mda.signal_batch.domain.gis.dto.AreaSearchResponse; +import gc.mda.signal_batch.domain.gis.dto.AreaSearchResponse.AreaSearchSummary; +import gc.mda.signal_batch.domain.gis.dto.AreaSearchResponse.PolygonHitDetail; +import gc.mda.signal_batch.domain.vessel.dto.CompactVesselTrack; +import gc.mda.signal_batch.global.websocket.service.DailyTrackCacheManager; +import gc.mda.signal_batch.global.websocket.service.DailyTrackCacheManager.DailyTrackData; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.locationtech.jts.geom.*; +import org.locationtech.jts.geom.prep.PreparedGeometry; +import org.locationtech.jts.geom.prep.PreparedGeometryFactory; +import org.locationtech.jts.index.strtree.STRtree; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.*; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AreaSearchService { + + private final DailyTrackCacheManager cacheManager; + private static final GeometryFactory GEOMETRY_FACTORY = new GeometryFactory(); + + /** + * 다중 폴리곤 영역 탐색 메인 엔트리 + */ + public AreaSearchResponse search(AreaSearchRequest request) { + long startMs = System.currentTimeMillis(); + + // 1. 입력 검증 + validateRequest(request); + + // 2. 캐시 데이터 수집 (날짜 범위) + List targetDates = collectTargetDates(request.getStartTime(), request.getEndTime()); + if (targetDates.isEmpty()) { + return buildEmptyResponse(request, startMs); + } + + // 3. 다일 데이터 → 선박별 단일 트랙 병합 + Map mergedTracks = mergeMultipleDays(targetDates); + if (mergedTracks.isEmpty()) { + return buildEmptyResponse(request, startMs); + } + + // 4. 좌표 → JTS Polygon 변환 + List jtsPolygons = convertToJtsPolygons(request.getPolygons()); + + // 5. 병합된 트랙으로 STRtree 빌드 + STRtree spatialIndex = buildSpatialIndex(mergedTracks); + + // 6. 각 폴리곤별 히트 선박 + 타임스탬프 수집 + 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( + polygon, searchPolygon, mergedTracks, spatialIndex); + perPolygonHits.add(hits); + } + + // 7. 모드별 결과 합산 + SearchMode mode = request.getPolygons().size() == 1 ? SearchMode.ANY : request.getMode(); + Map> resultHits; + switch (mode) { + case ALL: + resultHits = processAllMode(perPolygonHits); + break; + case SEQUENTIAL: + resultHits = processSequentialMode(perPolygonHits); + break; + default: + resultHits = processAnyMode(perPolygonHits); + break; + } + + // 8. 결과 선박의 전체 기간 트랙 + 히트 메타 반환 + List resultTracks = resultHits.keySet().stream() + .map(mergedTracks::get) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + + long totalPoints = resultHits.values().stream() + .flatMap(Collection::stream) + .mapToLong(h -> h.getHitPointCount() != null ? h.getHitPointCount() : 0) + .sum(); + + int totalCachedVessels = targetDates.stream() + .mapToInt(d -> { + DailyTrackData data = cacheManager.getDailyTrackData(d); + return data != null ? data.getVesselCount() : 0; + }) + .sum(); + + long elapsedMs = System.currentTimeMillis() - startMs; + log.info("Area search completed: mode={}, polygons={}, hitVessels={}, totalPoints={}, elapsed={}ms", + mode, request.getPolygons().size(), resultHits.size(), totalPoints, elapsedMs); + + return AreaSearchResponse.builder() + .tracks(resultTracks) + .hitDetails(resultHits) + .summary(AreaSearchSummary.builder() + .totalVessels(resultHits.size()) + .totalPoints(totalPoints) + .mode(mode) + .polygonIds(request.getPolygons().stream() + .map(SearchPolygon::getId) + .collect(Collectors.toList())) + .processingTimeMs(elapsedMs) + .cachedDates(targetDates.stream() + .map(LocalDate::toString) + .collect(Collectors.toList())) + .totalCachedVessels(totalCachedVessels) + .build()) + .build(); + } + + // ── 입력 검증 ── + + private void validateRequest(AreaSearchRequest request) { + if (request.getStartTime().isAfter(request.getEndTime())) { + throw new IllegalArgumentException("시작 시간이 종료 시간보다 나중입니다"); + } + + DailyTrackCacheManager.CacheStatus cacheStatus = cacheManager.getStatus(); + if (cacheStatus == DailyTrackCacheManager.CacheStatus.LOADING + || cacheStatus == DailyTrackCacheManager.CacheStatus.NOT_STARTED) { + throw new CacheNotReadyException("캐시가 아직 준비되지 않았습니다 (상태: " + cacheStatus + ")"); + } + + for (SearchPolygon polygon : request.getPolygons()) { + validatePolygon(polygon); + } + } + + private void validatePolygon(SearchPolygon polygon) { + List coords = polygon.getCoordinates(); + if (coords == null || coords.size() < 4) { + throw new IllegalArgumentException( + "폴리곤 '" + polygon.getId() + "'은 최소 4개 좌표가 필요합니다 (첫점==끝점)"); + } + + double[] first = coords.get(0); + double[] last = coords.get(coords.size() - 1); + if (first[0] != last[0] || first[1] != last[1]) { + throw new IllegalArgumentException( + "폴리곤 '" + polygon.getId() + "'의 첫점과 끝점이 동일해야 합니다"); + } + + // JTS로 유효성 검사 + try { + Polygon jtsPolygon = toJtsPolygon(coords); + if (!jtsPolygon.isValid()) { + throw new IllegalArgumentException( + "폴리곤 '" + polygon.getId() + "'이 유효하지 않습니다 (자기 교차 등)"); + } + } catch (IllegalArgumentException e) { + throw e; + } catch (Exception e) { + throw new IllegalArgumentException( + "폴리곤 '" + polygon.getId() + "' 변환 실패: " + e.getMessage()); + } + } + + // ── 날짜 수집 ── + + private List collectTargetDates(LocalDateTime startTime, LocalDateTime endTime) { + LocalDate today = LocalDate.now(); + LocalDate startDate = startTime.toLocalDate(); + LocalDate endDate = endTime.toLocalDate(); + List dates = new ArrayList<>(); + + for (LocalDate d = startDate; !d.isAfter(endDate); d = d.plusDays(1)) { + if (d.isAfter(today) || d.equals(today)) { + // 오늘/미래 데이터는 캐시에 없음 (경고 로그) + if (d.equals(today)) { + log.warn("Area search: 오늘({}) 데이터는 캐시에 없어 제외됩니다", today); + } + continue; + } + if (cacheManager.isCached(d)) { + dates.add(d); + } else { + log.debug("Area search: 날짜 {}가 캐시에 없어 제외됩니다", d); + } + } + + return dates; + } + + // ── JTS 변환 ── + + List convertToJtsPolygons(List searchPolygons) { + return searchPolygons.stream() + .map(sp -> toJtsPolygon(sp.getCoordinates())) + .collect(Collectors.toList()); + } + + private Polygon toJtsPolygon(List coordinates) { + Coordinate[] coords = new Coordinate[coordinates.size()]; + for (int i = 0; i < coordinates.size(); i++) { + double[] c = coordinates.get(i); + coords[i] = new Coordinate(c[0], c[1]); + } + return GEOMETRY_FACTORY.createPolygon(coords); + } + + // ── 다일 데이터 병합 ── + + Map mergeMultipleDays(List dates) { + Map> byVessel = new HashMap<>(); + + for (LocalDate date : dates) { + DailyTrackData data = cacheManager.getDailyTrackData(date); + if (data == null) continue; + + for (Map.Entry entry : data.getTracks().entrySet()) { + byVessel.computeIfAbsent(entry.getKey(), k -> new ArrayList<>()) + .add(entry.getValue()); + } + } + + Map merged = new HashMap<>(byVessel.size()); + for (Map.Entry> entry : byVessel.entrySet()) { + List trackList = entry.getValue(); + if (trackList.size() == 1) { + merged.put(entry.getKey(), trackList.get(0)); + continue; + } + + // 여러 날짜 병합 + CompactVesselTrack first = trackList.get(0); + List geo = new ArrayList<>(); + List ts = new ArrayList<>(); + List sp = new ArrayList<>(); + double totalDist = 0; + double maxSpeed = 0; + int pointCount = 0; + + for (CompactVesselTrack t : trackList) { + if (t.getGeometry() != null) geo.addAll(t.getGeometry()); + if (t.getTimestamps() != null) ts.addAll(t.getTimestamps()); + if (t.getSpeeds() != null) sp.addAll(t.getSpeeds()); + if (t.getTotalDistance() != null) totalDist += t.getTotalDistance(); + if (t.getMaxSpeed() != null) maxSpeed = Math.max(maxSpeed, t.getMaxSpeed()); + if (t.getPointCount() != null) pointCount += t.getPointCount(); + } + + merged.put(entry.getKey(), CompactVesselTrack.builder() + .vesselId(first.getVesselId()) + .sigSrcCd(first.getSigSrcCd()) + .targetId(first.getTargetId()) + .nationalCode(first.getNationalCode()) + .shipName(first.getShipName()) + .shipType(first.getShipType()) + .shipKindCode(first.getShipKindCode()) + .integrationTargetId(first.getIntegrationTargetId()) + .geometry(geo) + .timestamps(ts) + .speeds(sp) + .totalDistance(totalDist) + .avgSpeed(pointCount > 0 ? totalDist / Math.max(1, pointCount) * 60 : 0) + .maxSpeed(maxSpeed) + .pointCount(pointCount) + .build()); + } + + return merged; + } + + // ── STRtree 빌드 ── + + private STRtree buildSpatialIndex(Map tracks) { + STRtree tree = new STRtree(); + for (Map.Entry entry : tracks.entrySet()) { + CompactVesselTrack track = entry.getValue(); + if (track.getGeometry() == null || track.getGeometry().isEmpty()) continue; + + double minLon = Double.MAX_VALUE, maxLon = -Double.MAX_VALUE; + double minLat = Double.MAX_VALUE, maxLat = -Double.MAX_VALUE; + for (double[] coord : track.getGeometry()) { + if (coord[0] < minLon) minLon = coord[0]; + if (coord[0] > maxLon) maxLon = coord[0]; + if (coord[1] < minLat) minLat = coord[1]; + if (coord[1] > maxLat) maxLat = coord[1]; + } + tree.insert(new Envelope(minLon, maxLon, minLat, maxLat), entry.getKey()); + } + tree.build(); + return tree; + } + + // ── 폴리곤별 히트 검색 ── + + @SuppressWarnings("unchecked") + Map findHitsForPolygon( + Polygon polygon, SearchPolygon searchPolygon, + Map tracks, STRtree spatialIndex) { + + PreparedGeometry prepared = PreparedGeometryFactory.prepare(polygon); + Envelope mbr = polygon.getEnvelopeInternal(); + + // STRtree 후보 추출 + List candidates = spatialIndex.query(mbr); + + 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); + } + } + + return hits; + } + + /** + * 정밀 point-in-polygon 검사: 트랙의 각 좌표를 폴리곤과 비교 + */ + private PolygonHitDetail checkTrackAgainstPolygon( + CompactVesselTrack track, PreparedGeometry prepared, SearchPolygon searchPolygon) { + + List geometry = track.getGeometry(); + List timestamps = track.getTimestamps(); + if (geometry == null || geometry.isEmpty()) return null; + + Long entryTimestamp = null; + Long exitTimestamp = null; + int hitCount = 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])); + + if (prepared.contains(point)) { + hitCount++; + long ts = parseTimestamp(timestamps, i); + if (entryTimestamp == null || ts < entryTimestamp) { + entryTimestamp = ts; + } + if (exitTimestamp == null || ts > exitTimestamp) { + exitTimestamp = ts; + } + } + } + + if (hitCount == 0) return null; + + return PolygonHitDetail.builder() + .polygonId(searchPolygon.getId()) + .polygonName(searchPolygon.getName()) + .entryTimestamp(entryTimestamp) + .exitTimestamp(exitTimestamp) + .hitPointCount(hitCount) + .build(); + } + + private long parseTimestamp(List timestamps, int index) { + if (timestamps == null || index >= timestamps.size()) return 0L; + try { + return Long.parseLong(timestamps.get(index)); + } catch (NumberFormatException e) { + return 0L; + } + } + + // ── 모드별 결과 처리 ── + + /** + * ANY 모드: 합집합 (어느 영역이든 통과한 선박) + */ + Map> processAnyMode( + List> perPolygonHits) { + + Map> result = new HashMap<>(); + for (Map polygonHits : perPolygonHits) { + for (Map.Entry entry : polygonHits.entrySet()) { + result.computeIfAbsent(entry.getKey(), k -> new ArrayList<>()) + .add(entry.getValue()); + } + } + return result; + } + + /** + * ALL 모드: 교집합 (모든 영역을 통과한 선박) + */ + Map> processAllMode( + List> 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()); + } + + Map> result = new HashMap<>(); + for (String vesselId : commonVessels) { + List hits = new ArrayList<>(); + for (Map polygonHits : perPolygonHits) { + hits.add(polygonHits.get(vesselId)); + } + result.put(vesselId, hits); + } + return result; + } + + /** + * SEQUENTIAL 모드: 교집합 + entryTimestamp 순서 검증 + */ + Map> processSequentialMode( + List> perPolygonHits) { + + // 먼저 ALL 모드로 교집합 구함 + Map> allHits = processAllMode(perPolygonHits); + + // 순서 검증: 각 선박에 대해 polygon 순서대로 entryTimestamp 증가 확인 + Map> result = new HashMap<>(); + for (Map.Entry> entry : allHits.entrySet()) { + List hits = entry.getValue(); + if (isSequentialOrder(hits)) { + result.put(entry.getKey(), hits); + } + } + 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; + } + return true; + } + + // ── 빈 응답 ── + + private AreaSearchResponse buildEmptyResponse(AreaSearchRequest request, long startMs) { + long elapsedMs = System.currentTimeMillis() - startMs; + SearchMode mode = request.getPolygons().size() == 1 ? SearchMode.ANY : request.getMode(); + + return AreaSearchResponse.builder() + .tracks(Collections.emptyList()) + .hitDetails(Collections.emptyMap()) + .summary(AreaSearchSummary.builder() + .totalVessels(0) + .totalPoints(0L) + .mode(mode) + .polygonIds(request.getPolygons().stream() + .map(SearchPolygon::getId) + .collect(Collectors.toList())) + .processingTimeMs(elapsedMs) + .cachedDates(Collections.emptyList()) + .totalCachedVessels(0) + .build()) + .build(); + } + + // ── 예외 클래스 ── + + public static class CacheNotReadyException extends RuntimeException { + public CacheNotReadyException(String message) { + super(message); + } + } +} diff --git a/src/main/java/gc/mda/signal_batch/global/websocket/service/DailyTrackCacheManager.java b/src/main/java/gc/mda/signal_batch/global/websocket/service/DailyTrackCacheManager.java index e4ef65f..34295de 100644 --- a/src/main/java/gc/mda/signal_batch/global/websocket/service/DailyTrackCacheManager.java +++ b/src/main/java/gc/mda/signal_batch/global/websocket/service/DailyTrackCacheManager.java @@ -8,7 +8,9 @@ import gc.mda.signal_batch.domain.vessel.dto.IntegrationVessel; import gc.mda.signal_batch.global.util.IntegrationSignalConstants; import lombok.extern.slf4j.Slf4j; import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.Envelope; import org.locationtech.jts.geom.LineString; +import org.locationtech.jts.index.strtree.STRtree; import org.locationtech.jts.io.ParseException; import org.locationtech.jts.io.WKTReader; import org.springframework.beans.factory.annotation.Qualifier; @@ -75,13 +77,19 @@ public class DailyTrackCacheManager { private final long loadedAtMillis; private final int vesselCount; private final long memorySizeBytes; + private final STRtree spatialIndex; public DailyTrackData(LocalDate date, Map tracks, long memorySizeBytes) { + this(date, tracks, memorySizeBytes, null); + } + + public DailyTrackData(LocalDate date, Map tracks, long memorySizeBytes, STRtree spatialIndex) { this.date = date; this.tracks = tracks; this.loadedAtMillis = System.currentTimeMillis(); this.vesselCount = tracks.size(); this.memorySizeBytes = memorySizeBytes; + this.spatialIndex = spatialIndex; } public LocalDate getDate() { return date; } @@ -89,6 +97,7 @@ public class DailyTrackCacheManager { public long getLoadedAtMillis() { return loadedAtMillis; } public int getVesselCount() { return vesselCount; } public long getMemorySizeBytes() { return memorySizeBytes; } + public STRtree getSpatialIndex() { return spatialIndex; } } /** @@ -341,7 +350,12 @@ public class DailyTrackCacheManager { } estimatedMemory += tracks.size() * 200L; // 객체 오버헤드 - return new DailyTrackData(date, tracks, estimatedMemory); + + // STRtree 공간 인덱스 빌드 + STRtree spatialIndex = buildSpatialIndex(tracks); + estimatedMemory += tracks.size() * 100L; // 인덱스 오버헤드 + + return new DailyTrackData(date, tracks, estimatedMemory, spatialIndex); } // ── 캐시 조회 API ── @@ -590,6 +604,22 @@ public class DailyTrackCacheManager { return cacheProperties.isEnabled(); } + /** + * 특정 날짜의 DailyTrackData 직접 접근 (STRtree 포함) + */ + public DailyTrackData getDailyTrackData(LocalDate date) { + return cache.get(date); + } + + /** + * 현재 캐시된 날짜 목록 반환 + */ + public List getCachedDateList() { + List dates = new ArrayList<>(cache.keySet()); + Collections.sort(dates); + return dates; + } + // ── 내부 유틸 ── private boolean isInViewport(CompactVesselTrack track, double minLon, double minLat, double maxLon, double maxLat) { @@ -602,6 +632,29 @@ public class DailyTrackCacheManager { return false; } + /** + * 항적 맵에서 STRtree 공간 인덱스 빌드 + */ + private STRtree buildSpatialIndex(Map tracks) { + STRtree tree = new STRtree(); + for (Map.Entry entry : tracks.entrySet()) { + CompactVesselTrack track = entry.getValue(); + if (track.getGeometry() == null || track.getGeometry().isEmpty()) continue; + + double minLon = Double.MAX_VALUE, maxLon = -Double.MAX_VALUE; + double minLat = Double.MAX_VALUE, maxLat = -Double.MAX_VALUE; + for (double[] coord : track.getGeometry()) { + if (coord[0] < minLon) minLon = coord[0]; + if (coord[0] > maxLon) maxLon = coord[0]; + if (coord[1] < minLat) minLat = coord[1]; + if (coord[1] > maxLat) maxLat = coord[1]; + } + tree.insert(new Envelope(minLon, maxLon, minLat, maxLat), entry.getKey()); + } + tree.build(); + return tree; + } + /** * 선박 데이터 누적용 내부 클래스 */