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)를 활용하여 사용자 지정 다중 폴리곤 영역 내 선박을 탐색합니다.
|
인메모리 캐시(D-1~D-7)를 활용하여 사용자 지정 다중 폴리곤 영역 내 선박을 탐색합니다.
|
||||||
|
|
||||||
**검색 모드 (폴리곤 2개 이상 시):**
|
**검색 모드 (폴리곤 2개 이상 시):**
|
||||||
- **ANY**: 합집합 - 어느 한 영역이라도 통과한 선박
|
- **ANY**: 합집합 - 어느 한 영역이라도 통과한 선박 (개별 방문 분리)
|
||||||
- **ALL**: 교집합 - 모든 영역을 통과한 선박
|
- **ALL**: 교집합 - 모든 영역을 통과한 선박 (개별 방문 분리)
|
||||||
- **SEQUENTIAL**: 순차 통과 - 모든 영역을 지정된 순서대로 통과한 선박
|
- **SEQUENTIAL**: 순차 통과 - 모든 영역을 지정된 순서대로 통과한 선박 (유효 체인만 반환)
|
||||||
|
|
||||||
**폴리곤 1개일 때:** mode는 무시되며, 해당 영역 히트 선박 + 전체 트랙 반환
|
**폴리곤 1개일 때:** mode는 무시되며, 해당 영역 히트 선박 + 전체 트랙 반환
|
||||||
|
|
||||||
|
**개별 방문(trip) 분리:**
|
||||||
|
- 동일 구역을 여러 번 진입/진출하면 각각 별도 PolygonHitDetail로 반환
|
||||||
|
- visitIndex: 해당 구역의 방문 순번 (1-based)
|
||||||
|
- hitDetails 배열은 entryTimestamp 오름차순 정렬 (배열 인덱스 = 전체 방문 순서)
|
||||||
|
- SEQUENTIAL 모드: 순서가 성립하는 체인만 반환, visitIndex=1로 재설정
|
||||||
|
|
||||||
|
**경계 보간 타임스탬프:**
|
||||||
|
- entryTimestamp/exitTimestamp는 폴리곤 경계 통과 시각을 거리 비율로 보간
|
||||||
|
- outside↔inside 전환 포인트 사이의 경계 교차점 기준
|
||||||
|
|
||||||
**응답 구조:**
|
**응답 구조:**
|
||||||
- `tracks`: 기존 V2 API와 동일한 CompactVesselTrack 배열 (프론트엔드 렌더링 호환)
|
- `tracks`: 기존 V2 API와 동일한 CompactVesselTrack 배열 (프론트엔드 렌더링 호환)
|
||||||
- `hitDetails`: 선박별 영역 히트 메타데이터 (진입/진출 시간, 히트 포인트 수)
|
- `hitDetails`: 선박별 영역 히트 메타데이터 (방문별 진입/진출 시간, 히트 포인트 수, visitIndex)
|
||||||
- `summary`: 검색 요약 (선박 수, 포인트 수, 처리 시간 등)
|
- `summary`: 검색 요약 (선박 수, 포인트 수, 처리 시간 등)
|
||||||
|
|
||||||
**제약사항:**
|
**제약사항:**
|
||||||
|
|||||||
@ -27,10 +27,10 @@ public class AreaSearchResponse {
|
|||||||
private AreaSearchSummary summary;
|
private AreaSearchSummary summary;
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
@Builder
|
@Builder(toBuilder = true)
|
||||||
@NoArgsConstructor
|
@NoArgsConstructor
|
||||||
@AllArgsConstructor
|
@AllArgsConstructor
|
||||||
@Schema(description = "폴리곤별 히트 상세 정보")
|
@Schema(description = "폴리곤별 히트 상세 정보 (개별 방문 단위)")
|
||||||
public static class PolygonHitDetail {
|
public static class PolygonHitDetail {
|
||||||
|
|
||||||
@Schema(description = "폴리곤 식별자", example = "zone_A")
|
@Schema(description = "폴리곤 식별자", example = "zone_A")
|
||||||
@ -39,14 +39,17 @@ public class AreaSearchResponse {
|
|||||||
@Schema(description = "폴리곤 표시명", example = "대한해협 서수도")
|
@Schema(description = "폴리곤 표시명", example = "대한해협 서수도")
|
||||||
private String polygonName;
|
private String polygonName;
|
||||||
|
|
||||||
@Schema(description = "영역 첫 진입 Unix timestamp (초)", example = "1738368000")
|
@Schema(description = "영역 진입 시각 Unix timestamp (초, 경계 보간 적용)", example = "1738368000")
|
||||||
private Long entryTimestamp;
|
private Long entryTimestamp;
|
||||||
|
|
||||||
@Schema(description = "영역 마지막 진출 Unix timestamp (초)", example = "1738382400")
|
@Schema(description = "영역 진출 시각 Unix timestamp (초, 경계 보간 적용)", example = "1738382400")
|
||||||
private Long exitTimestamp;
|
private Long exitTimestamp;
|
||||||
|
|
||||||
@Schema(description = "영역 내 포인트 수", example = "45")
|
@Schema(description = "영역 내 포인트 수", example = "45")
|
||||||
private Integer hitPointCount;
|
private Integer hitPointCount;
|
||||||
|
|
||||||
|
@Schema(description = "해당 폴리곤의 방문 순번 (1-based, 동일 구역 재방문 시 증가)", example = "1")
|
||||||
|
private Integer visitIndex;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
|
|||||||
@ -57,12 +57,12 @@ public class AreaSearchService {
|
|||||||
// 5. 병합된 트랙으로 STRtree 빌드
|
// 5. 병합된 트랙으로 STRtree 빌드
|
||||||
STRtree spatialIndex = buildSpatialIndex(mergedTracks);
|
STRtree spatialIndex = buildSpatialIndex(mergedTracks);
|
||||||
|
|
||||||
// 6. 각 폴리곤별 히트 선박 + 타임스탬프 수집
|
// 6. 각 폴리곤별 히트 선박 + 개별 방문(trip) 수집
|
||||||
List<Map<String, PolygonHitDetail>> perPolygonHits = new ArrayList<>();
|
List<Map<String, List<PolygonHitDetail>>> perPolygonHits = new ArrayList<>();
|
||||||
for (int i = 0; i < jtsPolygons.size(); i++) {
|
for (int i = 0; i < jtsPolygons.size(); i++) {
|
||||||
Polygon polygon = jtsPolygons.get(i);
|
Polygon polygon = jtsPolygons.get(i);
|
||||||
SearchPolygon searchPolygon = request.getPolygons().get(i);
|
SearchPolygon searchPolygon = request.getPolygons().get(i);
|
||||||
Map<String, PolygonHitDetail> hits = findHitsForPolygon(
|
Map<String, List<PolygonHitDetail>> hits = findHitsForPolygon(
|
||||||
polygon, searchPolygon, mergedTracks, spatialIndex);
|
polygon, searchPolygon, mergedTracks, spatialIndex);
|
||||||
perPolygonHits.add(hits);
|
perPolygonHits.add(hits);
|
||||||
}
|
}
|
||||||
@ -298,10 +298,10 @@ public class AreaSearchService {
|
|||||||
return tree;
|
return tree;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 폴리곤별 히트 검색 ──
|
// ── 폴리곤별 히트 검색 (다중 방문 분리) ──
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
@SuppressWarnings("unchecked")
|
||||||
Map<String, PolygonHitDetail> findHitsForPolygon(
|
Map<String, List<PolygonHitDetail>> findHitsForPolygon(
|
||||||
Polygon polygon, SearchPolygon searchPolygon,
|
Polygon polygon, SearchPolygon searchPolygon,
|
||||||
Map<String, CompactVesselTrack> tracks, STRtree spatialIndex) {
|
Map<String, CompactVesselTrack> tracks, STRtree spatialIndex) {
|
||||||
|
|
||||||
@ -311,14 +311,15 @@ public class AreaSearchService {
|
|||||||
// STRtree 후보 추출
|
// STRtree 후보 추출
|
||||||
List<String> candidates = spatialIndex.query(mbr);
|
List<String> candidates = spatialIndex.query(mbr);
|
||||||
|
|
||||||
Map<String, PolygonHitDetail> hits = new HashMap<>();
|
Map<String, List<PolygonHitDetail>> hits = new HashMap<>();
|
||||||
for (String vesselId : candidates) {
|
for (String vesselId : candidates) {
|
||||||
CompactVesselTrack track = tracks.get(vesselId);
|
CompactVesselTrack track = tracks.get(vesselId);
|
||||||
if (track == null) continue;
|
if (track == null) continue;
|
||||||
|
|
||||||
PolygonHitDetail hit = checkTrackAgainstPolygon(track, prepared, searchPolygon);
|
List<PolygonHitDetail> visits = checkTrackAgainstPolygonMultiVisit(
|
||||||
if (hit != null) {
|
track, prepared, polygon, searchPolygon);
|
||||||
hits.put(vesselId, hit);
|
if (!visits.isEmpty()) {
|
||||||
|
hits.put(vesselId, visits);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -326,44 +327,107 @@ public class AreaSearchService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 정밀 point-in-polygon 검사: 트랙의 각 좌표를 폴리곤과 비교
|
* 정밀 point-in-polygon 검사: 트랙을 순회하며 연속 체류 구간(방문)별로 분리.
|
||||||
|
* 진입/진출 시각은 경계 교차점 거리 비율 보간 적용.
|
||||||
*/
|
*/
|
||||||
private PolygonHitDetail checkTrackAgainstPolygon(
|
private List<PolygonHitDetail> checkTrackAgainstPolygonMultiVisit(
|
||||||
CompactVesselTrack track, PreparedGeometry prepared, SearchPolygon searchPolygon) {
|
CompactVesselTrack track, PreparedGeometry prepared,
|
||||||
|
Polygon polygon, SearchPolygon searchPolygon) {
|
||||||
|
|
||||||
List<double[]> geometry = track.getGeometry();
|
List<double[]> geometry = track.getGeometry();
|
||||||
List<String> timestamps = track.getTimestamps();
|
List<String> timestamps = track.getTimestamps();
|
||||||
if (geometry == null || geometry.isEmpty()) return null;
|
if (geometry == null || geometry.isEmpty()) return Collections.emptyList();
|
||||||
|
|
||||||
Long entryTimestamp = null;
|
List<PolygonHitDetail> visits = new ArrayList<>();
|
||||||
Long exitTimestamp = null;
|
boolean wasInside = false;
|
||||||
int hitCount = 0;
|
long currentEntry = 0;
|
||||||
|
long currentExit = 0;
|
||||||
|
int currentHitCount = 0;
|
||||||
|
int visitIndex = 0;
|
||||||
|
|
||||||
for (int i = 0; i < geometry.size(); i++) {
|
for (int i = 0; i < geometry.size(); i++) {
|
||||||
double[] coord = geometry.get(i);
|
double[] coord = geometry.get(i);
|
||||||
Point point = GEOMETRY_FACTORY.createPoint(new Coordinate(coord[0], coord[1]));
|
Point point = GEOMETRY_FACTORY.createPoint(new Coordinate(coord[0], coord[1]));
|
||||||
|
boolean isInside = prepared.contains(point);
|
||||||
|
|
||||||
if (prepared.contains(point)) {
|
if (isInside) {
|
||||||
hitCount++;
|
|
||||||
long ts = parseTimestamp(timestamps, i);
|
long ts = parseTimestamp(timestamps, i);
|
||||||
if (entryTimestamp == null || ts < entryTimestamp) {
|
if (!wasInside) {
|
||||||
entryTimestamp = ts;
|
// 새 방문 시작 — 보간된 진입 시각
|
||||||
|
if (i > 0) {
|
||||||
|
currentEntry = interpolateBoundaryCrossing(
|
||||||
|
geometry.get(i - 1), parseTimestamp(timestamps, i - 1),
|
||||||
|
coord, ts, polygon);
|
||||||
|
} else {
|
||||||
|
currentEntry = ts;
|
||||||
}
|
}
|
||||||
if (exitTimestamp == null || ts > exitTimestamp) {
|
currentHitCount = 0;
|
||||||
exitTimestamp = ts;
|
|
||||||
}
|
}
|
||||||
}
|
currentExit = ts;
|
||||||
}
|
currentHitCount++;
|
||||||
|
wasInside = true;
|
||||||
if (hitCount == 0) return null;
|
} else {
|
||||||
|
if (wasInside) {
|
||||||
return PolygonHitDetail.builder()
|
// 방문 종료 — 보간된 진출 시각
|
||||||
|
long interpolatedExit = interpolateBoundaryCrossing(
|
||||||
|
geometry.get(i - 1), parseTimestamp(timestamps, i - 1),
|
||||||
|
coord, parseTimestamp(timestamps, i), polygon);
|
||||||
|
visitIndex++;
|
||||||
|
visits.add(PolygonHitDetail.builder()
|
||||||
.polygonId(searchPolygon.getId())
|
.polygonId(searchPolygon.getId())
|
||||||
.polygonName(searchPolygon.getName())
|
.polygonName(searchPolygon.getName())
|
||||||
.entryTimestamp(entryTimestamp)
|
.entryTimestamp(currentEntry)
|
||||||
.exitTimestamp(exitTimestamp)
|
.exitTimestamp(interpolatedExit)
|
||||||
.hitPointCount(hitCount)
|
.hitPointCount(currentHitCount)
|
||||||
.build();
|
.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());
|
||||||
|
}
|
||||||
|
|
||||||
|
return visits;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 경계 통과 시각 보간 ──
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 폴리곤 경계 통과 시각을 거리 비율로 보간 계산.
|
||||||
|
* 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) {
|
private long parseTimestamp(List<String> timestamps, int index) {
|
||||||
@ -375,29 +439,33 @@ public class AreaSearchService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 모드별 결과 처리 ──
|
// ── 모드별 결과 처리 (다중 방문 지원) ──
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ANY 모드: 합집합 (어느 영역이든 통과한 선박)
|
* ANY 모드: 합집합 (어느 영역이든 통과한 선박).
|
||||||
|
* 모든 방문을 entryTimestamp 기준 오름차순 정렬하여 반환.
|
||||||
*/
|
*/
|
||||||
Map<String, List<PolygonHitDetail>> processAnyMode(
|
Map<String, List<PolygonHitDetail>> processAnyMode(
|
||||||
List<Map<String, PolygonHitDetail>> perPolygonHits) {
|
List<Map<String, List<PolygonHitDetail>>> perPolygonHits) {
|
||||||
|
|
||||||
Map<String, List<PolygonHitDetail>> result = new HashMap<>();
|
Map<String, List<PolygonHitDetail>> result = new HashMap<>();
|
||||||
for (Map<String, PolygonHitDetail> polygonHits : perPolygonHits) {
|
for (Map<String, List<PolygonHitDetail>> polygonHits : perPolygonHits) {
|
||||||
for (Map.Entry<String, PolygonHitDetail> entry : polygonHits.entrySet()) {
|
for (Map.Entry<String, List<PolygonHitDetail>> entry : polygonHits.entrySet()) {
|
||||||
result.computeIfAbsent(entry.getKey(), k -> new ArrayList<>())
|
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;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ALL 모드: 교집합 (모든 영역을 통과한 선박)
|
* ALL 모드: 교집합 (모든 영역을 통과한 선박).
|
||||||
|
* 모든 방문을 entryTimestamp 기준 오름차순 정렬하여 반환.
|
||||||
*/
|
*/
|
||||||
Map<String, List<PolygonHitDetail>> processAllMode(
|
Map<String, List<PolygonHitDetail>> processAllMode(
|
||||||
List<Map<String, PolygonHitDetail>> perPolygonHits) {
|
List<Map<String, List<PolygonHitDetail>>> perPolygonHits) {
|
||||||
|
|
||||||
if (perPolygonHits.isEmpty()) return Collections.emptyMap();
|
if (perPolygonHits.isEmpty()) return Collections.emptyMap();
|
||||||
|
|
||||||
@ -409,43 +477,76 @@ public class AreaSearchService {
|
|||||||
|
|
||||||
Map<String, List<PolygonHitDetail>> result = new HashMap<>();
|
Map<String, List<PolygonHitDetail>> result = new HashMap<>();
|
||||||
for (String vesselId : commonVessels) {
|
for (String vesselId : commonVessels) {
|
||||||
List<PolygonHitDetail> hits = new ArrayList<>();
|
List<PolygonHitDetail> allVisits = new ArrayList<>();
|
||||||
for (Map<String, PolygonHitDetail> polygonHits : perPolygonHits) {
|
for (Map<String, List<PolygonHitDetail>> polygonHits : perPolygonHits) {
|
||||||
hits.add(polygonHits.get(vesselId));
|
allVisits.addAll(polygonHits.get(vesselId));
|
||||||
}
|
}
|
||||||
result.put(vesselId, hits);
|
allVisits.sort(Comparator.comparingLong(PolygonHitDetail::getEntryTimestamp));
|
||||||
|
result.put(vesselId, allVisits);
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SEQUENTIAL 모드: 교집합 + entryTimestamp 순서 검증
|
* SEQUENTIAL 모드: 교집합 + 방문 순서 체인 검증.
|
||||||
|
* 각 폴리곤의 방문 중 순서가 성립하는 조합(chain)을 찾고,
|
||||||
|
* 체인에 포함된 방문만 반환 (visitIndex=1로 재설정).
|
||||||
*/
|
*/
|
||||||
Map<String, List<PolygonHitDetail>> processSequentialMode(
|
Map<String, List<PolygonHitDetail>> processSequentialMode(
|
||||||
List<Map<String, PolygonHitDetail>> perPolygonHits) {
|
List<Map<String, List<PolygonHitDetail>>> perPolygonHits) {
|
||||||
|
|
||||||
// 먼저 ALL 모드로 교집합 구함
|
if (perPolygonHits.isEmpty()) return Collections.emptyMap();
|
||||||
Map<String, List<PolygonHitDetail>> allHits = processAllMode(perPolygonHits);
|
|
||||||
|
// 모든 폴리곤에 공통으로 존재하는 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<>();
|
Map<String, List<PolygonHitDetail>> result = new HashMap<>();
|
||||||
for (Map.Entry<String, List<PolygonHitDetail>> entry : allHits.entrySet()) {
|
for (String vesselId : commonVessels) {
|
||||||
List<PolygonHitDetail> hits = entry.getValue();
|
List<List<PolygonHitDetail>> visitsByPolygon = new ArrayList<>();
|
||||||
if (isSequentialOrder(hits)) {
|
boolean valid = true;
|
||||||
result.put(entry.getKey(), hits);
|
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;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean isSequentialOrder(List<PolygonHitDetail> hits) {
|
/**
|
||||||
for (int i = 1; i < hits.size(); i++) {
|
* 폴리곤 순서대로 유효한 순차 체인을 greedy 탐색.
|
||||||
Long prevEntry = hits.get(i - 1).getEntryTimestamp();
|
* 각 단계에서 이전 방문의 exitTimestamp보다 늦은 entryTimestamp를 가진 첫 방문 선택.
|
||||||
Long currEntry = hits.get(i).getEntryTimestamp();
|
* 체인 성립 시 visitIndex=1로 재설정하여 반환.
|
||||||
if (prevEntry == null || currEntry == null) return false;
|
*/
|
||||||
if (currEntry <= prevEntry) return false;
|
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;
|
||||||
}
|
}
|
||||||
return true;
|
}
|
||||||
|
if (matched == null) return null;
|
||||||
|
chain.add(matched.toBuilder().visitIndex(1).build());
|
||||||
|
prevExit = matched.getExitTimestamp();
|
||||||
|
}
|
||||||
|
return chain;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 빈 응답 ──
|
// ── 빈 응답 ──
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user