feat: 다중 폴리곤 영역 탐색 REST API + STRtree 공간 인덱스 (Phase 8)
캐시 기반 인메모리 다중 폴리곤 영역 내 선박 탐색 API 구현. JTS STRtree + PreparedGeometry로 고속 공간 검색, ANY/ALL/SEQUENTIAL 3가지 모드 지원. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
부모
e729316edf
커밋
b4221c36fd
@ -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<SearchPolygon>
|
||||||
|
- 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 | 커밋 메시지 | 해시 |
|
| 날짜 | Phase | 커밋 메시지 | 해시 |
|
||||||
|
|||||||
@ -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<AreaSearchResponse> 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<Map<String, String>> 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<Map<String, String>> 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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<SearchPolygon> 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<double[]> coordinates;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<CompactVesselTrack> tracks;
|
||||||
|
|
||||||
|
@Schema(description = "선박별 영역 히트 메타데이터 (key: vesselId)")
|
||||||
|
private Map<String, List<PolygonHitDetail>> 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<String> polygonIds;
|
||||||
|
|
||||||
|
@Schema(description = "처리 소요 시간 (ms)", example = "1250")
|
||||||
|
private Long processingTimeMs;
|
||||||
|
|
||||||
|
@Schema(description = "조회에 사용된 캐시 날짜 목록", example = "[\"2026-02-01\", \"2026-02-02\"]")
|
||||||
|
private List<String> cachedDates;
|
||||||
|
|
||||||
|
@Schema(description = "캐시된 전체 선박 수", example = "285000")
|
||||||
|
private Integer totalCachedVessels;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<LocalDate> targetDates = collectTargetDates(request.getStartTime(), request.getEndTime());
|
||||||
|
if (targetDates.isEmpty()) {
|
||||||
|
return buildEmptyResponse(request, startMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 다일 데이터 → 선박별 단일 트랙 병합
|
||||||
|
Map<String, CompactVesselTrack> mergedTracks = mergeMultipleDays(targetDates);
|
||||||
|
if (mergedTracks.isEmpty()) {
|
||||||
|
return buildEmptyResponse(request, startMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 좌표 → JTS Polygon 변환
|
||||||
|
List<Polygon> jtsPolygons = convertToJtsPolygons(request.getPolygons());
|
||||||
|
|
||||||
|
// 5. 병합된 트랙으로 STRtree 빌드
|
||||||
|
STRtree spatialIndex = buildSpatialIndex(mergedTracks);
|
||||||
|
|
||||||
|
// 6. 각 폴리곤별 히트 선박 + 타임스탬프 수집
|
||||||
|
List<Map<String, 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(
|
||||||
|
polygon, searchPolygon, mergedTracks, spatialIndex);
|
||||||
|
perPolygonHits.add(hits);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. 모드별 결과 합산
|
||||||
|
SearchMode mode = request.getPolygons().size() == 1 ? SearchMode.ANY : request.getMode();
|
||||||
|
Map<String, List<PolygonHitDetail>> resultHits;
|
||||||
|
switch (mode) {
|
||||||
|
case ALL:
|
||||||
|
resultHits = processAllMode(perPolygonHits);
|
||||||
|
break;
|
||||||
|
case SEQUENTIAL:
|
||||||
|
resultHits = processSequentialMode(perPolygonHits);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
resultHits = processAnyMode(perPolygonHits);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. 결과 선박의 전체 기간 트랙 + 히트 메타 반환
|
||||||
|
List<CompactVesselTrack> 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<double[]> 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<LocalDate> collectTargetDates(LocalDateTime startTime, LocalDateTime endTime) {
|
||||||
|
LocalDate today = LocalDate.now();
|
||||||
|
LocalDate startDate = startTime.toLocalDate();
|
||||||
|
LocalDate endDate = endTime.toLocalDate();
|
||||||
|
List<LocalDate> 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<Polygon> convertToJtsPolygons(List<SearchPolygon> searchPolygons) {
|
||||||
|
return searchPolygons.stream()
|
||||||
|
.map(sp -> toJtsPolygon(sp.getCoordinates()))
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
private Polygon toJtsPolygon(List<double[]> 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<String, CompactVesselTrack> mergeMultipleDays(List<LocalDate> dates) {
|
||||||
|
Map<String, List<CompactVesselTrack>> byVessel = new HashMap<>();
|
||||||
|
|
||||||
|
for (LocalDate date : dates) {
|
||||||
|
DailyTrackData data = cacheManager.getDailyTrackData(date);
|
||||||
|
if (data == null) continue;
|
||||||
|
|
||||||
|
for (Map.Entry<String, CompactVesselTrack> entry : data.getTracks().entrySet()) {
|
||||||
|
byVessel.computeIfAbsent(entry.getKey(), k -> new ArrayList<>())
|
||||||
|
.add(entry.getValue());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, CompactVesselTrack> merged = new HashMap<>(byVessel.size());
|
||||||
|
for (Map.Entry<String, List<CompactVesselTrack>> entry : byVessel.entrySet()) {
|
||||||
|
List<CompactVesselTrack> trackList = entry.getValue();
|
||||||
|
if (trackList.size() == 1) {
|
||||||
|
merged.put(entry.getKey(), trackList.get(0));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 여러 날짜 병합
|
||||||
|
CompactVesselTrack first = trackList.get(0);
|
||||||
|
List<double[]> geo = new ArrayList<>();
|
||||||
|
List<String> ts = new ArrayList<>();
|
||||||
|
List<Double> 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<String, CompactVesselTrack> tracks) {
|
||||||
|
STRtree tree = new STRtree();
|
||||||
|
for (Map.Entry<String, CompactVesselTrack> 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<String, PolygonHitDetail> findHitsForPolygon(
|
||||||
|
Polygon polygon, SearchPolygon searchPolygon,
|
||||||
|
Map<String, CompactVesselTrack> tracks, STRtree spatialIndex) {
|
||||||
|
|
||||||
|
PreparedGeometry prepared = PreparedGeometryFactory.prepare(polygon);
|
||||||
|
Envelope mbr = polygon.getEnvelopeInternal();
|
||||||
|
|
||||||
|
// STRtree 후보 추출
|
||||||
|
List<String> candidates = spatialIndex.query(mbr);
|
||||||
|
|
||||||
|
Map<String, 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return hits;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 정밀 point-in-polygon 검사: 트랙의 각 좌표를 폴리곤과 비교
|
||||||
|
*/
|
||||||
|
private PolygonHitDetail checkTrackAgainstPolygon(
|
||||||
|
CompactVesselTrack track, PreparedGeometry prepared, SearchPolygon searchPolygon) {
|
||||||
|
|
||||||
|
List<double[]> geometry = track.getGeometry();
|
||||||
|
List<String> 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<String> 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<String, List<PolygonHitDetail>> processAnyMode(
|
||||||
|
List<Map<String, PolygonHitDetail>> perPolygonHits) {
|
||||||
|
|
||||||
|
Map<String, List<PolygonHitDetail>> result = new HashMap<>();
|
||||||
|
for (Map<String, PolygonHitDetail> polygonHits : perPolygonHits) {
|
||||||
|
for (Map.Entry<String, PolygonHitDetail> entry : polygonHits.entrySet()) {
|
||||||
|
result.computeIfAbsent(entry.getKey(), k -> new ArrayList<>())
|
||||||
|
.add(entry.getValue());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ALL 모드: 교집합 (모든 영역을 통과한 선박)
|
||||||
|
*/
|
||||||
|
Map<String, List<PolygonHitDetail>> processAllMode(
|
||||||
|
List<Map<String, PolygonHitDetail>> 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());
|
||||||
|
}
|
||||||
|
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
result.put(vesselId, hits);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SEQUENTIAL 모드: 교집합 + entryTimestamp 순서 검증
|
||||||
|
*/
|
||||||
|
Map<String, List<PolygonHitDetail>> processSequentialMode(
|
||||||
|
List<Map<String, PolygonHitDetail>> perPolygonHits) {
|
||||||
|
|
||||||
|
// 먼저 ALL 모드로 교집합 구함
|
||||||
|
Map<String, List<PolygonHitDetail>> allHits = processAllMode(perPolygonHits);
|
||||||
|
|
||||||
|
// 순서 검증: 각 선박에 대해 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -8,7 +8,9 @@ import gc.mda.signal_batch.domain.vessel.dto.IntegrationVessel;
|
|||||||
import gc.mda.signal_batch.global.util.IntegrationSignalConstants;
|
import gc.mda.signal_batch.global.util.IntegrationSignalConstants;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.locationtech.jts.geom.Coordinate;
|
import org.locationtech.jts.geom.Coordinate;
|
||||||
|
import org.locationtech.jts.geom.Envelope;
|
||||||
import org.locationtech.jts.geom.LineString;
|
import org.locationtech.jts.geom.LineString;
|
||||||
|
import org.locationtech.jts.index.strtree.STRtree;
|
||||||
import org.locationtech.jts.io.ParseException;
|
import org.locationtech.jts.io.ParseException;
|
||||||
import org.locationtech.jts.io.WKTReader;
|
import org.locationtech.jts.io.WKTReader;
|
||||||
import org.springframework.beans.factory.annotation.Qualifier;
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
@ -75,13 +77,19 @@ public class DailyTrackCacheManager {
|
|||||||
private final long loadedAtMillis;
|
private final long loadedAtMillis;
|
||||||
private final int vesselCount;
|
private final int vesselCount;
|
||||||
private final long memorySizeBytes;
|
private final long memorySizeBytes;
|
||||||
|
private final STRtree spatialIndex;
|
||||||
|
|
||||||
public DailyTrackData(LocalDate date, Map<String, CompactVesselTrack> tracks, long memorySizeBytes) {
|
public DailyTrackData(LocalDate date, Map<String, CompactVesselTrack> tracks, long memorySizeBytes) {
|
||||||
|
this(date, tracks, memorySizeBytes, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public DailyTrackData(LocalDate date, Map<String, CompactVesselTrack> tracks, long memorySizeBytes, STRtree spatialIndex) {
|
||||||
this.date = date;
|
this.date = date;
|
||||||
this.tracks = tracks;
|
this.tracks = tracks;
|
||||||
this.loadedAtMillis = System.currentTimeMillis();
|
this.loadedAtMillis = System.currentTimeMillis();
|
||||||
this.vesselCount = tracks.size();
|
this.vesselCount = tracks.size();
|
||||||
this.memorySizeBytes = memorySizeBytes;
|
this.memorySizeBytes = memorySizeBytes;
|
||||||
|
this.spatialIndex = spatialIndex;
|
||||||
}
|
}
|
||||||
|
|
||||||
public LocalDate getDate() { return date; }
|
public LocalDate getDate() { return date; }
|
||||||
@ -89,6 +97,7 @@ public class DailyTrackCacheManager {
|
|||||||
public long getLoadedAtMillis() { return loadedAtMillis; }
|
public long getLoadedAtMillis() { return loadedAtMillis; }
|
||||||
public int getVesselCount() { return vesselCount; }
|
public int getVesselCount() { return vesselCount; }
|
||||||
public long getMemorySizeBytes() { return memorySizeBytes; }
|
public long getMemorySizeBytes() { return memorySizeBytes; }
|
||||||
|
public STRtree getSpatialIndex() { return spatialIndex; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -341,7 +350,12 @@ public class DailyTrackCacheManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
estimatedMemory += tracks.size() * 200L; // 객체 오버헤드
|
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 ──
|
// ── 캐시 조회 API ──
|
||||||
@ -590,6 +604,22 @@ public class DailyTrackCacheManager {
|
|||||||
return cacheProperties.isEnabled();
|
return cacheProperties.isEnabled();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 날짜의 DailyTrackData 직접 접근 (STRtree 포함)
|
||||||
|
*/
|
||||||
|
public DailyTrackData getDailyTrackData(LocalDate date) {
|
||||||
|
return cache.get(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 현재 캐시된 날짜 목록 반환
|
||||||
|
*/
|
||||||
|
public List<LocalDate> getCachedDateList() {
|
||||||
|
List<LocalDate> dates = new ArrayList<>(cache.keySet());
|
||||||
|
Collections.sort(dates);
|
||||||
|
return dates;
|
||||||
|
}
|
||||||
|
|
||||||
// ── 내부 유틸 ──
|
// ── 내부 유틸 ──
|
||||||
|
|
||||||
private boolean isInViewport(CompactVesselTrack track, double minLon, double minLat, double maxLon, double maxLat) {
|
private boolean isInViewport(CompactVesselTrack track, double minLon, double minLat, double maxLon, double maxLat) {
|
||||||
@ -602,6 +632,29 @@ public class DailyTrackCacheManager {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 항적 맵에서 STRtree 공간 인덱스 빌드
|
||||||
|
*/
|
||||||
|
private STRtree buildSpatialIndex(Map<String, CompactVesselTrack> tracks) {
|
||||||
|
STRtree tree = new STRtree();
|
||||||
|
for (Map.Entry<String, CompactVesselTrack> 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;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 선박 데이터 누적용 내부 클래스
|
* 선박 데이터 누적용 내부 클래스
|
||||||
*/
|
*/
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user