Compare commits
104 커밋
release/20
...
main
| 작성자 | SHA1 | 날짜 | |
|---|---|---|---|
| 6303831df3 | |||
| dd9de6739c | |||
| ed618f6dd0 | |||
| d37653c1be | |||
| 17922bf74c | |||
| 32f9aa897b | |||
| de11a162b4 | |||
| b14b6c241e | |||
| 7ea5b4719a | |||
| d57f993960 | |||
| 0b74831b87 | |||
| 83db0f8149 | |||
| e7ed536be5 | |||
| 1033654c82 | |||
| 15f5f680fd | |||
| 2ca6371d87 | |||
| e11caf2767 | |||
| 23828c742e | |||
| 5432e1f282 | |||
| 973b419287 | |||
| 8362bc5b6c | |||
| 7dd46f2078 | |||
| 2534c9dbca | |||
| 67523b475d | |||
| b8b60bf314 | |||
| d15039ce18 | |||
| e9ae058017 | |||
| 5c85afea22 | |||
| b04e96c457 | |||
| ae70eceb96 | |||
| b320aeb3fd | |||
| e9cbeaa0d8 | |||
| acef08fca9 | |||
| d44837e64a | |||
| fc6f696d1f | |||
| 98d173701e | |||
| a7eb706839 | |||
| 650c027013 | |||
| 31f557e54d | |||
| 71a2188273 | |||
| 93ce2092d2 | |||
| 8048eb533c | |||
| f0094c21d3 | |||
| f1f965fcd4 | |||
| ebde2dd4cf | |||
| a556e5f434 | |||
| 2fc8b1d785 | |||
| e30dcb74ad | |||
| 07d47c999e | |||
| 1029e07432 | |||
| 89786f1ec3 | |||
| 03747d3c63 | |||
| 5384092b21 | |||
| a404d81173 | |||
| 90d1fc249d | |||
| a3a933f096 | |||
| ed77005619 | |||
| bc355ff521 | |||
| a1ba74697a | |||
| a1c917108c | |||
| b0dfa7f6a7 | |||
| f36e1b297b | |||
| 9f0f60159f | |||
| f98eca0aec | |||
| db352946ae | |||
| cc32ba6290 | |||
| a6de14ecef | |||
| 3a31b90a96 | |||
| 9cf2dbe58c | |||
| 56b92e408f | |||
| d35cafb6c5 | |||
| 93ddb7d1b6 | |||
| fcf1ff5363 | |||
| 15b68bb634 | |||
| 7b31f93d86 | |||
| 318cfa94ad | |||
| d6aac611d0 | |||
| b24d43e4a1 | |||
| be38983cc5 | |||
| 6e12883768 | |||
| d09b8de765 | |||
| e0f9b5cf64 | |||
| d99e356a5d | |||
| 7d27d5fc83 | |||
| fb15b4c89b | |||
| 4cf54a0b4e | |||
| 4b33d1792b | |||
| 51a0ff933a | |||
| 635753f636 | |||
| d9d5a9483e | |||
| 8035692dfc | |||
| 3967d77d65 | |||
| 4fb16678f8 | |||
| 962f2df683 | |||
| e052795ef5 | |||
| a96103e639 | |||
| 5ff400f982 | |||
| f735a3ce7f | |||
| 0604887c75 | |||
| 9c091d1052 | |||
| 5e85e80142 | |||
| 5ce172eb82 | |||
| 278c20968e | |||
| d0c8b3d1bd |
4
.gitignore
vendored
4
.gitignore
vendored
@ -29,6 +29,10 @@ coverage/
|
||||
.prettiercache
|
||||
*.tsbuildinfo
|
||||
|
||||
# === Codex CLI ===
|
||||
AGENTS.md
|
||||
.codex/
|
||||
|
||||
# === Claude Code ===
|
||||
# 글로벌 gitignore에서 .claude/ 전체를 무시하므로 팀 파일을 명시적으로 포함
|
||||
!.claude/
|
||||
|
||||
@ -175,6 +175,11 @@ deploy/ # systemd + nginx 배포 설정
|
||||
3. **API 응답 검증**: MR 생성, 봇 승인, 머지 API 호출 후 반드시 상태를 확인.
|
||||
실패 시 사용자에게 알리고 중단.
|
||||
|
||||
**MR/머지는 사용자 명시적 요청 없이 절대 진행 금지:**
|
||||
- `git push` 완료 후에는 "푸시 완료" 보고만 하고 **중단**
|
||||
- MR 생성은 사용자가 `/mr`, `/release` 호출 또는 "MR 해줘" 등 명시적 요청 시에만
|
||||
- **스킬 없이 Gitea API를 직접 호출하여 MR/머지를 진행하지 말 것** — 스킬 절차(사용자 확인 단계)를 우회하는 것과 동일
|
||||
|
||||
### 스킬 목록
|
||||
- `/push` — 커밋 + 푸시 (해시 비교 → 커밋 메시지 확인 → 푸시)
|
||||
- `/mr` — 커밋 + 푸시 + MR 생성 (릴리즈 노트 갱신 포함)
|
||||
|
||||
@ -17,7 +17,7 @@ import lombok.NoArgsConstructor;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "login_history", schema = "kcg")
|
||||
@Table(name = "login_history")
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
|
||||
@ -15,7 +15,7 @@ import lombok.Setter;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Entity
|
||||
@Table(name = "users", schema = "kcg")
|
||||
@Table(name = "users")
|
||||
@Getter
|
||||
@Setter
|
||||
@Builder
|
||||
|
||||
@ -14,7 +14,7 @@ import java.util.List;
|
||||
@Configuration
|
||||
public class WebConfig {
|
||||
|
||||
@Value("${app.cors.allowed-origins:http://localhost:5173}")
|
||||
@Value("${app.cors.allowed-origins:http://localhost:5174,http://localhost:5173}")
|
||||
private List<String> allowedOrigins;
|
||||
|
||||
@Bean
|
||||
|
||||
@ -7,7 +7,7 @@ import org.locationtech.jts.geom.Point;
|
||||
import java.time.Instant;
|
||||
|
||||
@Entity
|
||||
@Table(name = "aircraft_positions", schema = "kcg")
|
||||
@Table(name = "aircraft_positions")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
|
||||
@ -9,7 +9,7 @@ import java.time.Instant;
|
||||
import java.util.Map;
|
||||
|
||||
@Entity
|
||||
@Table(name = "vessel_analysis_results", schema = "kcg")
|
||||
@Table(name = "vessel_analysis_results")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
|
||||
@ -9,7 +9,7 @@ import java.time.Instant;
|
||||
import java.util.Map;
|
||||
|
||||
@Entity
|
||||
@Table(name = "events", schema = "kcg")
|
||||
@Table(name = "events")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package gc.mda.kcg.domain.fleet;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
@ -17,10 +18,14 @@ public class FleetCompanyController {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
@Value("${DB_SCHEMA:kcg}")
|
||||
private String dbSchema;
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<List<Map<String, Object>>> getFleetCompanies() {
|
||||
List<Map<String, Object>> results = jdbcTemplate.queryForList(
|
||||
"SELECT id, name_cn AS \"nameCn\", name_en AS \"nameEn\" FROM kcg.fleet_companies ORDER BY id"
|
||||
"SELECT id, name_cn AS \"nameCn\", name_en AS \"nameEn\" FROM "
|
||||
+ dbSchema + ".fleet_companies ORDER BY id"
|
||||
);
|
||||
return ResponseEntity.ok(results);
|
||||
}
|
||||
|
||||
@ -0,0 +1,12 @@
|
||||
package gc.mda.kcg.domain.fleet;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public class GlobalParentCandidateExclusionRequest {
|
||||
private String candidateMmsi;
|
||||
private String actor;
|
||||
private String comment;
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
package gc.mda.kcg.domain.fleet;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public class GroupParentCandidateExclusionRequest {
|
||||
private String candidateMmsi;
|
||||
private Integer durationDays;
|
||||
private String actor;
|
||||
private String comment;
|
||||
}
|
||||
@ -0,0 +1,26 @@
|
||||
package gc.mda.kcg.domain.fleet;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Getter
|
||||
@Builder
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class GroupParentInferenceDto {
|
||||
private String groupType;
|
||||
private String groupKey;
|
||||
private String groupLabel;
|
||||
private int subClusterId;
|
||||
private String snapshotTime;
|
||||
private String zoneName;
|
||||
private Integer memberCount;
|
||||
private String resolution;
|
||||
private Integer candidateCount;
|
||||
private ParentInferenceSummaryDto parentInference;
|
||||
private List<ParentInferenceCandidateDto> candidates;
|
||||
private Map<String, Object> evidenceSummary;
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
package gc.mda.kcg.domain.fleet;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public class GroupParentInferenceReviewRequest {
|
||||
private String action;
|
||||
private String selectedParentMmsi;
|
||||
private String actor;
|
||||
private String comment;
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
package gc.mda.kcg.domain.fleet;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public class GroupParentLabelSessionRequest {
|
||||
private String selectedParentMmsi;
|
||||
private Integer durationDays;
|
||||
private String actor;
|
||||
private String comment;
|
||||
}
|
||||
@ -63,4 +63,61 @@ public class GroupPolygonController {
|
||||
"items", correlations
|
||||
));
|
||||
}
|
||||
|
||||
@GetMapping("/parent-inference/review")
|
||||
public ResponseEntity<Map<String, Object>> getParentInferenceReview(
|
||||
@RequestParam(defaultValue = "REVIEW_REQUIRED") String status,
|
||||
@RequestParam(defaultValue = "100") int limit) {
|
||||
List<GroupParentInferenceDto> items = groupPolygonService.getParentInferenceReview(status, limit);
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"count", items.size(),
|
||||
"items", items
|
||||
));
|
||||
}
|
||||
|
||||
@GetMapping("/{groupKey}/parent-inference")
|
||||
public ResponseEntity<Map<String, Object>> getGroupParentInference(@PathVariable String groupKey) {
|
||||
List<GroupParentInferenceDto> items = groupPolygonService.getGroupParentInference(groupKey);
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"groupKey", groupKey,
|
||||
"count", items.size(),
|
||||
"items", items
|
||||
));
|
||||
}
|
||||
|
||||
@PostMapping("/{groupKey}/parent-inference/{subClusterId}/review")
|
||||
public ResponseEntity<?> reviewGroupParentInference(
|
||||
@PathVariable String groupKey,
|
||||
@PathVariable int subClusterId,
|
||||
@RequestBody GroupParentInferenceReviewRequest request) {
|
||||
try {
|
||||
return ResponseEntity.ok(groupPolygonService.reviewParentInference(groupKey, subClusterId, request));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/{groupKey}/parent-inference/{subClusterId}/label-sessions")
|
||||
public ResponseEntity<?> createGroupParentLabelSession(
|
||||
@PathVariable String groupKey,
|
||||
@PathVariable int subClusterId,
|
||||
@RequestBody GroupParentLabelSessionRequest request) {
|
||||
try {
|
||||
return ResponseEntity.ok(groupPolygonService.createGroupParentLabelSession(groupKey, subClusterId, request));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/{groupKey}/parent-inference/{subClusterId}/candidate-exclusions")
|
||||
public ResponseEntity<?> createGroupCandidateExclusion(
|
||||
@PathVariable String groupKey,
|
||||
@PathVariable int subClusterId,
|
||||
@RequestBody GroupParentCandidateExclusionRequest request) {
|
||||
try {
|
||||
return ResponseEntity.ok(groupPolygonService.createGroupCandidateExclusion(groupKey, subClusterId, request));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -26,4 +26,6 @@ public class GroupPolygonDto {
|
||||
private List<Map<String, Object>> members;
|
||||
private String color;
|
||||
private String resolution;
|
||||
private Integer candidateCount;
|
||||
private ParentInferenceSummaryDto parentInference;
|
||||
}
|
||||
|
||||
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
@ -0,0 +1,28 @@
|
||||
package gc.mda.kcg.domain.fleet;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@Getter
|
||||
@Builder
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class ParentCandidateExclusionDto {
|
||||
private Long id;
|
||||
private String scopeType;
|
||||
private String groupKey;
|
||||
private Integer subClusterId;
|
||||
private String candidateMmsi;
|
||||
private String reasonType;
|
||||
private Integer durationDays;
|
||||
private String activeFrom;
|
||||
private String activeUntil;
|
||||
private String releasedAt;
|
||||
private String releasedBy;
|
||||
private String actor;
|
||||
private String comment;
|
||||
private Boolean active;
|
||||
private Map<String, Object> metadata;
|
||||
}
|
||||
@ -0,0 +1,30 @@
|
||||
package gc.mda.kcg.domain.fleet;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@Getter
|
||||
@Builder
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class ParentInferenceCandidateDto {
|
||||
private String candidateMmsi;
|
||||
private String candidateName;
|
||||
private Integer candidateVesselId;
|
||||
private Integer rank;
|
||||
private String candidateSource;
|
||||
private Double finalScore;
|
||||
private Double baseCorrScore;
|
||||
private Double nameMatchScore;
|
||||
private Double trackSimilarityScore;
|
||||
private Double visitScore6h;
|
||||
private Double proximityScore6h;
|
||||
private Double activitySyncScore6h;
|
||||
private Double stabilityScore;
|
||||
private Double registryBonus;
|
||||
private Double marginFromTop;
|
||||
private Boolean trackAvailable;
|
||||
private Map<String, Object> evidence;
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
package gc.mda.kcg.domain.fleet;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
@Getter
|
||||
@Builder
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class ParentInferenceSummaryDto {
|
||||
private String status;
|
||||
private String normalizedParentName;
|
||||
private String selectedParentMmsi;
|
||||
private String selectedParentName;
|
||||
private Double confidence;
|
||||
private String decisionSource;
|
||||
private Double topScore;
|
||||
private Double scoreMargin;
|
||||
private Integer stableCycles;
|
||||
private String skipReason;
|
||||
private String statusReason;
|
||||
}
|
||||
@ -0,0 +1,95 @@
|
||||
package gc.mda.kcg.domain.fleet;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/vessel-analysis/parent-inference")
|
||||
@RequiredArgsConstructor
|
||||
public class ParentInferenceWorkflowController {
|
||||
|
||||
private final GroupPolygonService groupPolygonService;
|
||||
|
||||
@GetMapping("/candidate-exclusions")
|
||||
public ResponseEntity<Map<String, Object>> getCandidateExclusions(
|
||||
@RequestParam(required = false) String scopeType,
|
||||
@RequestParam(required = false) String groupKey,
|
||||
@RequestParam(required = false) Integer subClusterId,
|
||||
@RequestParam(required = false) String candidateMmsi,
|
||||
@RequestParam(defaultValue = "true") boolean activeOnly,
|
||||
@RequestParam(defaultValue = "100") int limit) {
|
||||
List<ParentCandidateExclusionDto> items = groupPolygonService.getCandidateExclusions(
|
||||
scopeType,
|
||||
groupKey,
|
||||
subClusterId,
|
||||
candidateMmsi,
|
||||
activeOnly,
|
||||
limit
|
||||
);
|
||||
return ResponseEntity.ok(Map.of("count", items.size(), "items", items));
|
||||
}
|
||||
|
||||
@PostMapping("/candidate-exclusions/global")
|
||||
public ResponseEntity<?> createGlobalCandidateExclusion(@RequestBody GlobalParentCandidateExclusionRequest request) {
|
||||
try {
|
||||
return ResponseEntity.ok(groupPolygonService.createGlobalCandidateExclusion(request));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping("/candidate-exclusions/{exclusionId}/release")
|
||||
public ResponseEntity<?> releaseCandidateExclusion(
|
||||
@PathVariable long exclusionId,
|
||||
@RequestBody ParentWorkflowActionRequest request) {
|
||||
try {
|
||||
return ResponseEntity.ok(groupPolygonService.releaseCandidateExclusion(exclusionId, request));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/label-sessions")
|
||||
public ResponseEntity<Map<String, Object>> getLabelSessions(
|
||||
@RequestParam(required = false) String groupKey,
|
||||
@RequestParam(required = false) Integer subClusterId,
|
||||
@RequestParam(required = false) String status,
|
||||
@RequestParam(defaultValue = "true") boolean activeOnly,
|
||||
@RequestParam(defaultValue = "100") int limit) {
|
||||
List<ParentLabelSessionDto> items = groupPolygonService.getLabelSessions(
|
||||
groupKey,
|
||||
subClusterId,
|
||||
status,
|
||||
activeOnly,
|
||||
limit
|
||||
);
|
||||
return ResponseEntity.ok(Map.of("count", items.size(), "items", items));
|
||||
}
|
||||
|
||||
@PostMapping("/label-sessions/{labelSessionId}/cancel")
|
||||
public ResponseEntity<?> cancelLabelSession(
|
||||
@PathVariable long labelSessionId,
|
||||
@RequestBody ParentWorkflowActionRequest request) {
|
||||
try {
|
||||
return ResponseEntity.ok(groupPolygonService.cancelLabelSession(labelSessionId, request));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/label-sessions/{labelSessionId}/tracking")
|
||||
public ResponseEntity<Map<String, Object>> getLabelSessionTracking(
|
||||
@PathVariable long labelSessionId,
|
||||
@RequestParam(defaultValue = "200") int limit) {
|
||||
List<ParentLabelTrackingCycleDto> items = groupPolygonService.getLabelSessionTracking(labelSessionId, limit);
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"labelSessionId", labelSessionId,
|
||||
"count", items.size(),
|
||||
"items", items
|
||||
));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,31 @@
|
||||
package gc.mda.kcg.domain.fleet;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@Getter
|
||||
@Builder
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class ParentLabelSessionDto {
|
||||
private Long id;
|
||||
private String groupKey;
|
||||
private Integer subClusterId;
|
||||
private String labelParentMmsi;
|
||||
private String labelParentName;
|
||||
private Integer labelParentVesselId;
|
||||
private Integer durationDays;
|
||||
private String status;
|
||||
private String activeFrom;
|
||||
private String activeUntil;
|
||||
private String actor;
|
||||
private String comment;
|
||||
private String anchorSnapshotTime;
|
||||
private Double anchorCenterLat;
|
||||
private Double anchorCenterLon;
|
||||
private Integer anchorMemberCount;
|
||||
private Boolean active;
|
||||
private Map<String, Object> metadata;
|
||||
}
|
||||
@ -0,0 +1,31 @@
|
||||
package gc.mda.kcg.domain.fleet;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@Getter
|
||||
@Builder
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class ParentLabelTrackingCycleDto {
|
||||
private Long id;
|
||||
private Long labelSessionId;
|
||||
private String observedAt;
|
||||
private String candidateSnapshotObservedAt;
|
||||
private String autoStatus;
|
||||
private String topCandidateMmsi;
|
||||
private String topCandidateName;
|
||||
private Double topCandidateScore;
|
||||
private Double topCandidateMargin;
|
||||
private Integer candidateCount;
|
||||
private Boolean labeledCandidatePresent;
|
||||
private Integer labeledCandidateRank;
|
||||
private Double labeledCandidateScore;
|
||||
private Double labeledCandidatePreBonusScore;
|
||||
private Double labeledCandidateMarginFromTop;
|
||||
private Boolean matchedTop1;
|
||||
private Boolean matchedTop3;
|
||||
private Map<String, Object> evidenceSummary;
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
package gc.mda.kcg.domain.fleet;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
public class ParentWorkflowActionRequest {
|
||||
private String actor;
|
||||
private String comment;
|
||||
}
|
||||
@ -9,7 +9,6 @@ import java.time.Instant;
|
||||
@Entity
|
||||
@Table(
|
||||
name = "osint_feeds",
|
||||
schema = "kcg",
|
||||
uniqueConstraints = @UniqueConstraint(columnNames = {"source", "source_url"})
|
||||
)
|
||||
@Getter
|
||||
|
||||
@ -6,7 +6,7 @@ import lombok.*;
|
||||
import java.time.Instant;
|
||||
|
||||
@Entity
|
||||
@Table(name = "satellite_tle", schema = "kcg")
|
||||
@Table(name = "satellite_tle")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
|
||||
@ -8,7 +8,6 @@ import java.time.Instant;
|
||||
@Entity
|
||||
@Table(
|
||||
name = "pressure_readings",
|
||||
schema = "kcg",
|
||||
uniqueConstraints = @UniqueConstraint(columnNames = {"station", "reading_time"})
|
||||
)
|
||||
@Getter
|
||||
|
||||
@ -6,7 +6,7 @@ import lombok.*;
|
||||
import java.time.Instant;
|
||||
|
||||
@Entity
|
||||
@Table(name = "seismic_events", schema = "kcg")
|
||||
@Table(name = "seismic_events")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
|
||||
@ -1,16 +1,19 @@
|
||||
spring:
|
||||
datasource:
|
||||
url: jdbc:postgresql://localhost:5432/kcgdb?currentSchema=kcg
|
||||
username: kcg_user
|
||||
password: kcg_pass
|
||||
url: ${DB_URL:jdbc:postgresql://localhost:5432/kcgdb?currentSchema=kcg,public}
|
||||
username: ${DB_USERNAME:kcg_user}
|
||||
password: ${DB_PASSWORD:kcg_pass}
|
||||
app:
|
||||
jwt:
|
||||
secret: local-dev-secret-key-32chars-minimum!!
|
||||
expiration-ms: 86400000
|
||||
secret: ${JWT_SECRET:local-dev-secret-key-32chars-minimum!!}
|
||||
expiration-ms: ${JWT_EXPIRATION_MS:86400000}
|
||||
google:
|
||||
client-id: YOUR_GOOGLE_CLIENT_ID
|
||||
client-id: ${GOOGLE_CLIENT_ID:YOUR_GOOGLE_CLIENT_ID}
|
||||
auth:
|
||||
allowed-domain: gcsc.co.kr
|
||||
allowed-domain: ${AUTH_ALLOWED_DOMAIN:gcsc.co.kr}
|
||||
collector:
|
||||
open-sky-client-id: YOUR_OPENSKY_CLIENT_ID
|
||||
open-sky-client-secret: YOUR_OPENSKY_CLIENT_SECRET
|
||||
open-sky-client-id: ${OPENSKY_CLIENT_ID:YOUR_OPENSKY_CLIENT_ID}
|
||||
open-sky-client-secret: ${OPENSKY_CLIENT_SECRET:YOUR_OPENSKY_CLIENT_SECRET}
|
||||
prediction-base-url: ${PREDICTION_BASE_URL:http://localhost:8001}
|
||||
cors:
|
||||
allowed-origins: ${APP_CORS_ALLOWED_ORIGINS:http://localhost:5174,http://localhost:5173}
|
||||
|
||||
@ -16,4 +16,4 @@ app:
|
||||
open-sky-client-secret: ${OPENSKY_CLIENT_SECRET:}
|
||||
prediction-base-url: ${PREDICTION_BASE_URL:http://192.168.1.18:8001}
|
||||
cors:
|
||||
allowed-origins: http://localhost:5173,https://kcg.gc-si.dev
|
||||
allowed-origins: ${APP_CORS_ALLOWED_ORIGINS:http://localhost:5174,http://localhost:5173,https://kcg.gc-si.dev}
|
||||
|
||||
@ -6,6 +6,6 @@ spring:
|
||||
ddl-auto: none
|
||||
properties:
|
||||
hibernate:
|
||||
default_schema: kcg
|
||||
default_schema: ${DB_SCHEMA:kcg}
|
||||
server:
|
||||
port: 8080
|
||||
port: ${SERVER_PORT:8080}
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
-- 기존 데이터는 DEFAULT '6h'로 취급
|
||||
|
||||
ALTER TABLE kcg.group_polygon_snapshots
|
||||
ADD COLUMN IF NOT EXISTS resolution VARCHAR(4) DEFAULT '6h';
|
||||
ADD COLUMN IF NOT EXISTS resolution VARCHAR(8) DEFAULT '6h';
|
||||
|
||||
-- 기존 인덱스 교체: resolution 포함
|
||||
DROP INDEX IF EXISTS kcg.idx_gps_type_time;
|
||||
|
||||
176
database/migration/012_gear_parent_inference.sql
Normal file
176
database/migration/012_gear_parent_inference.sql
Normal file
@ -0,0 +1,176 @@
|
||||
-- 012: 어구 그룹 모선 추론 저장소 + sub_cluster/resolution 스키마 정합성
|
||||
|
||||
SET search_path TO kcg, public;
|
||||
|
||||
-- ── live lab과 repo 마이그레이션 정합성 맞추기 ─────────────────────
|
||||
|
||||
ALTER TABLE kcg.group_polygon_snapshots
|
||||
ADD COLUMN IF NOT EXISTS sub_cluster_id SMALLINT NOT NULL DEFAULT 0;
|
||||
|
||||
ALTER TABLE kcg.group_polygon_snapshots
|
||||
ADD COLUMN IF NOT EXISTS resolution VARCHAR(20) NOT NULL DEFAULT '6h';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_gps_type_res_time
|
||||
ON kcg.group_polygon_snapshots(group_type, resolution, snapshot_time DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_gps_key_res_time
|
||||
ON kcg.group_polygon_snapshots(group_key, resolution, snapshot_time DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_gps_key_sub_time
|
||||
ON kcg.group_polygon_snapshots(group_key, sub_cluster_id, snapshot_time DESC);
|
||||
|
||||
ALTER TABLE kcg.gear_correlation_raw_metrics
|
||||
ADD COLUMN IF NOT EXISTS sub_cluster_id SMALLINT NOT NULL DEFAULT 0;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_raw_metrics_group_sub_time
|
||||
ON kcg.gear_correlation_raw_metrics(group_key, sub_cluster_id, observed_at DESC);
|
||||
|
||||
ALTER TABLE kcg.gear_correlation_scores
|
||||
ADD COLUMN IF NOT EXISTS sub_cluster_id SMALLINT NOT NULL DEFAULT 0;
|
||||
|
||||
ALTER TABLE kcg.gear_correlation_scores
|
||||
DROP CONSTRAINT IF EXISTS gear_correlation_scores_model_id_group_key_target_mmsi_key;
|
||||
|
||||
DROP INDEX IF EXISTS kcg.gear_correlation_scores_model_id_group_key_target_mmsi_key;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_constraint
|
||||
WHERE connamespace = 'kcg'::regnamespace
|
||||
AND conrelid = 'kcg.gear_correlation_scores'::regclass
|
||||
AND conname = 'gear_correlation_scores_unique'
|
||||
) THEN
|
||||
ALTER TABLE kcg.gear_correlation_scores
|
||||
ADD CONSTRAINT gear_correlation_scores_unique
|
||||
UNIQUE (model_id, group_key, sub_cluster_id, target_mmsi);
|
||||
END IF;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_gc_model_group_sub
|
||||
ON kcg.gear_correlation_scores(model_id, group_key, sub_cluster_id, current_score DESC);
|
||||
|
||||
-- ── 그룹 단위 모선 추론 저장소 ─────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS kcg.gear_group_parent_candidate_snapshots (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
observed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
group_key VARCHAR(100) NOT NULL,
|
||||
sub_cluster_id SMALLINT NOT NULL DEFAULT 0,
|
||||
parent_name TEXT NOT NULL,
|
||||
candidate_mmsi VARCHAR(20) NOT NULL,
|
||||
candidate_name VARCHAR(200),
|
||||
candidate_vessel_id INT REFERENCES kcg.fleet_vessels(id) ON DELETE SET NULL,
|
||||
rank INT NOT NULL,
|
||||
candidate_source VARCHAR(100) NOT NULL,
|
||||
model_id INT REFERENCES kcg.correlation_param_models(id) ON DELETE SET NULL,
|
||||
model_name VARCHAR(100),
|
||||
base_corr_score DOUBLE PRECISION DEFAULT 0,
|
||||
name_match_score DOUBLE PRECISION DEFAULT 0,
|
||||
track_similarity_score DOUBLE PRECISION DEFAULT 0,
|
||||
visit_score_6h DOUBLE PRECISION DEFAULT 0,
|
||||
proximity_score_6h DOUBLE PRECISION DEFAULT 0,
|
||||
activity_sync_score_6h DOUBLE PRECISION DEFAULT 0,
|
||||
stability_score DOUBLE PRECISION DEFAULT 0,
|
||||
registry_bonus DOUBLE PRECISION DEFAULT 0,
|
||||
final_score DOUBLE PRECISION DEFAULT 0,
|
||||
margin_from_top DOUBLE PRECISION DEFAULT 0,
|
||||
evidence JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (observed_at, group_key, sub_cluster_id, candidate_mmsi)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ggpcs_group_time
|
||||
ON kcg.gear_group_parent_candidate_snapshots(group_key, sub_cluster_id, observed_at DESC, rank ASC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ggpcs_candidate
|
||||
ON kcg.gear_group_parent_candidate_snapshots(candidate_mmsi, observed_at DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS kcg.gear_group_parent_resolution (
|
||||
group_key VARCHAR(100) NOT NULL,
|
||||
sub_cluster_id SMALLINT NOT NULL DEFAULT 0,
|
||||
parent_name TEXT NOT NULL,
|
||||
normalized_parent_name VARCHAR(200),
|
||||
status VARCHAR(40) NOT NULL,
|
||||
selected_parent_mmsi VARCHAR(20),
|
||||
selected_parent_name VARCHAR(200),
|
||||
selected_vessel_id INT REFERENCES kcg.fleet_vessels(id) ON DELETE SET NULL,
|
||||
confidence DOUBLE PRECISION,
|
||||
decision_source VARCHAR(40),
|
||||
top_score DOUBLE PRECISION DEFAULT 0,
|
||||
second_score DOUBLE PRECISION DEFAULT 0,
|
||||
score_margin DOUBLE PRECISION DEFAULT 0,
|
||||
stable_cycles INT DEFAULT 0,
|
||||
last_evaluated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
last_promoted_at TIMESTAMPTZ,
|
||||
approved_by VARCHAR(100),
|
||||
approved_at TIMESTAMPTZ,
|
||||
manual_comment TEXT,
|
||||
rejected_candidate_mmsi VARCHAR(20),
|
||||
rejected_at TIMESTAMPTZ,
|
||||
evidence_summary JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
PRIMARY KEY (group_key, sub_cluster_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ggpr_status
|
||||
ON kcg.gear_group_parent_resolution(status, last_evaluated_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ggpr_parent
|
||||
ON kcg.gear_group_parent_resolution(selected_parent_mmsi);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS kcg.gear_group_parent_review_log (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
group_key VARCHAR(100) NOT NULL,
|
||||
sub_cluster_id SMALLINT NOT NULL DEFAULT 0,
|
||||
action VARCHAR(20) NOT NULL,
|
||||
selected_parent_mmsi VARCHAR(20),
|
||||
actor VARCHAR(100) NOT NULL,
|
||||
comment TEXT,
|
||||
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ggprl_group_time
|
||||
ON kcg.gear_group_parent_review_log(group_key, sub_cluster_id, created_at DESC);
|
||||
|
||||
-- ── copied schema 환경의 sequence 정렬 ─────────────────────────────
|
||||
|
||||
SELECT setval(
|
||||
pg_get_serial_sequence('kcg.fleet_companies', 'id'),
|
||||
COALESCE((SELECT MAX(id) FROM kcg.fleet_companies), 1),
|
||||
TRUE
|
||||
);
|
||||
|
||||
SELECT setval(
|
||||
pg_get_serial_sequence('kcg.fleet_vessels', 'id'),
|
||||
COALESCE((SELECT MAX(id) FROM kcg.fleet_vessels), 1),
|
||||
TRUE
|
||||
);
|
||||
|
||||
SELECT setval(
|
||||
pg_get_serial_sequence('kcg.gear_identity_log', 'id'),
|
||||
COALESCE((SELECT MAX(id) FROM kcg.gear_identity_log), 1),
|
||||
TRUE
|
||||
);
|
||||
|
||||
SELECT setval(
|
||||
pg_get_serial_sequence('kcg.fleet_tracking_snapshot', 'id'),
|
||||
COALESCE((SELECT MAX(id) FROM kcg.fleet_tracking_snapshot), 1),
|
||||
TRUE
|
||||
);
|
||||
|
||||
SELECT setval(
|
||||
pg_get_serial_sequence('kcg.group_polygon_snapshots', 'id'),
|
||||
COALESCE((SELECT MAX(id) FROM kcg.group_polygon_snapshots), 1),
|
||||
TRUE
|
||||
);
|
||||
|
||||
SELECT setval(
|
||||
pg_get_serial_sequence('kcg.gear_correlation_scores', 'id'),
|
||||
COALESCE((SELECT MAX(id) FROM kcg.gear_correlation_scores), 1),
|
||||
TRUE
|
||||
);
|
||||
23
database/migration/013_short_parent_name_cleanup.sql
Normal file
23
database/migration/013_short_parent_name_cleanup.sql
Normal file
@ -0,0 +1,23 @@
|
||||
SET search_path TO kcg, public;
|
||||
|
||||
DELETE FROM kcg.gear_group_parent_candidate_snapshots
|
||||
WHERE LENGTH(REGEXP_REPLACE(UPPER(group_key), '[ _%\\-]', '', 'g')) < 4;
|
||||
|
||||
DELETE FROM kcg.gear_group_parent_review_log
|
||||
WHERE LENGTH(REGEXP_REPLACE(UPPER(group_key), '[ _%\\-]', '', 'g')) < 4;
|
||||
|
||||
DELETE FROM kcg.gear_group_parent_resolution
|
||||
WHERE LENGTH(REGEXP_REPLACE(UPPER(group_key), '[ _%\\-]', '', 'g')) < 4;
|
||||
|
||||
DELETE FROM kcg.gear_correlation_raw_metrics
|
||||
WHERE LENGTH(REGEXP_REPLACE(UPPER(group_key), '[ _%\\-]', '', 'g')) < 4;
|
||||
|
||||
DELETE FROM kcg.gear_correlation_scores
|
||||
WHERE LENGTH(REGEXP_REPLACE(UPPER(group_key), '[ _%\\-]', '', 'g')) < 4;
|
||||
|
||||
DELETE FROM kcg.group_polygon_snapshots
|
||||
WHERE group_type IN ('GEAR_IN_ZONE', 'GEAR_OUT_ZONE')
|
||||
AND LENGTH(REGEXP_REPLACE(UPPER(group_key), '[ _%\\-]', '', 'g')) < 4;
|
||||
|
||||
DELETE FROM kcg.gear_identity_log
|
||||
WHERE LENGTH(REGEXP_REPLACE(UPPER(COALESCE(parent_name, name)), '[ _%\\-]', '', 'g')) < 4;
|
||||
125
database/migration/014_gear_parent_workflow_v2_phase1.sql
Normal file
125
database/migration/014_gear_parent_workflow_v2_phase1.sql
Normal file
@ -0,0 +1,125 @@
|
||||
-- 014: 어구 모선 검토 워크플로우 v2 phase 1
|
||||
|
||||
SET search_path TO kcg, public;
|
||||
|
||||
-- ── 그룹/전역 후보 제외 ───────────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS kcg.gear_parent_candidate_exclusions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
scope_type VARCHAR(16) NOT NULL,
|
||||
group_key VARCHAR(100),
|
||||
sub_cluster_id SMALLINT,
|
||||
candidate_mmsi VARCHAR(20) NOT NULL,
|
||||
reason_type VARCHAR(32) NOT NULL,
|
||||
duration_days INT,
|
||||
active_from TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
active_until TIMESTAMPTZ,
|
||||
released_at TIMESTAMPTZ,
|
||||
released_by VARCHAR(100),
|
||||
actor VARCHAR(100) NOT NULL,
|
||||
comment TEXT,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT chk_gpce_scope CHECK (scope_type IN ('GROUP', 'GLOBAL')),
|
||||
CONSTRAINT chk_gpce_reason CHECK (reason_type IN ('GROUP_WRONG_PARENT', 'GLOBAL_NOT_PARENT_TARGET')),
|
||||
CONSTRAINT chk_gpce_group_scope CHECK (
|
||||
(scope_type = 'GROUP' AND group_key IS NOT NULL AND sub_cluster_id IS NOT NULL AND duration_days IN (1, 3, 5) AND active_until IS NOT NULL)
|
||||
OR
|
||||
(scope_type = 'GLOBAL' AND duration_days IS NULL)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_gpce_scope_mmsi_active
|
||||
ON kcg.gear_parent_candidate_exclusions(scope_type, candidate_mmsi, active_from DESC)
|
||||
WHERE released_at IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_gpce_group_active
|
||||
ON kcg.gear_parent_candidate_exclusions(group_key, sub_cluster_id, active_from DESC)
|
||||
WHERE released_at IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_gpce_active_until
|
||||
ON kcg.gear_parent_candidate_exclusions(active_until);
|
||||
|
||||
-- ── 기간형 정답 라벨 세션 ────────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS kcg.gear_parent_label_sessions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
group_key VARCHAR(100) NOT NULL,
|
||||
sub_cluster_id SMALLINT NOT NULL,
|
||||
label_parent_mmsi VARCHAR(20) NOT NULL,
|
||||
label_parent_name VARCHAR(200),
|
||||
label_parent_vessel_id INT REFERENCES kcg.fleet_vessels(id) ON DELETE SET NULL,
|
||||
duration_days INT NOT NULL,
|
||||
active_from TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
active_until TIMESTAMPTZ NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
|
||||
actor VARCHAR(100) NOT NULL,
|
||||
comment TEXT,
|
||||
anchor_snapshot_time TIMESTAMPTZ,
|
||||
anchor_center_point geometry(Point, 4326),
|
||||
anchor_member_mmsis JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT chk_gpls_duration CHECK (duration_days IN (1, 3, 5)),
|
||||
CONSTRAINT chk_gpls_status CHECK (status IN ('ACTIVE', 'EXPIRED', 'CANCELLED'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_gpls_group_active
|
||||
ON kcg.gear_parent_label_sessions(group_key, sub_cluster_id, active_from DESC)
|
||||
WHERE status = 'ACTIVE';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_gpls_mmsi_active
|
||||
ON kcg.gear_parent_label_sessions(label_parent_mmsi, active_from DESC)
|
||||
WHERE status = 'ACTIVE';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_gpls_active_until
|
||||
ON kcg.gear_parent_label_sessions(active_until);
|
||||
|
||||
-- ── 라벨 세션 기간 중 cycle별 자동 추론 기록 ─────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS kcg.gear_parent_label_tracking_cycles (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
label_session_id BIGINT NOT NULL REFERENCES kcg.gear_parent_label_sessions(id) ON DELETE CASCADE,
|
||||
observed_at TIMESTAMPTZ NOT NULL,
|
||||
candidate_snapshot_observed_at TIMESTAMPTZ,
|
||||
auto_status VARCHAR(40),
|
||||
top_candidate_mmsi VARCHAR(20),
|
||||
top_candidate_name VARCHAR(200),
|
||||
top_candidate_score DOUBLE PRECISION,
|
||||
top_candidate_margin DOUBLE PRECISION,
|
||||
candidate_count INT NOT NULL DEFAULT 0,
|
||||
labeled_candidate_present BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
labeled_candidate_rank INT,
|
||||
labeled_candidate_score DOUBLE PRECISION,
|
||||
labeled_candidate_pre_bonus_score DOUBLE PRECISION,
|
||||
labeled_candidate_margin_from_top DOUBLE PRECISION,
|
||||
matched_top1 BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
matched_top3 BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
evidence_summary JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT uq_gpltc_session_observed UNIQUE (label_session_id, observed_at)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_gpltc_session_observed
|
||||
ON kcg.gear_parent_label_tracking_cycles(label_session_id, observed_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_gpltc_top_candidate
|
||||
ON kcg.gear_parent_label_tracking_cycles(top_candidate_mmsi);
|
||||
|
||||
-- ── active view ────────────────────────────────────────────────
|
||||
|
||||
CREATE OR REPLACE VIEW kcg.vw_active_gear_parent_candidate_exclusions AS
|
||||
SELECT *
|
||||
FROM kcg.gear_parent_candidate_exclusions
|
||||
WHERE released_at IS NULL
|
||||
AND active_from <= NOW()
|
||||
AND (active_until IS NULL OR active_until > NOW());
|
||||
|
||||
CREATE OR REPLACE VIEW kcg.vw_active_gear_parent_label_sessions AS
|
||||
SELECT *
|
||||
FROM kcg.gear_parent_label_sessions
|
||||
WHERE status = 'ACTIVE'
|
||||
AND active_from <= NOW()
|
||||
AND active_until > NOW();
|
||||
111
database/migration/015_gear_parent_episode_tracking.sql
Normal file
111
database/migration/015_gear_parent_episode_tracking.sql
Normal file
@ -0,0 +1,111 @@
|
||||
-- 015: 어구 모선 추론 episode continuity + prior bonus
|
||||
|
||||
SET search_path TO kcg, public;
|
||||
|
||||
ALTER TABLE kcg.gear_group_parent_candidate_snapshots
|
||||
ADD COLUMN IF NOT EXISTS normalized_parent_name VARCHAR(200);
|
||||
|
||||
ALTER TABLE kcg.gear_group_parent_candidate_snapshots
|
||||
ADD COLUMN IF NOT EXISTS episode_id VARCHAR(64);
|
||||
|
||||
ALTER TABLE kcg.gear_group_parent_candidate_snapshots
|
||||
ADD COLUMN IF NOT EXISTS episode_prior_bonus DOUBLE PRECISION NOT NULL DEFAULT 0;
|
||||
|
||||
ALTER TABLE kcg.gear_group_parent_candidate_snapshots
|
||||
ADD COLUMN IF NOT EXISTS lineage_prior_bonus DOUBLE PRECISION NOT NULL DEFAULT 0;
|
||||
|
||||
ALTER TABLE kcg.gear_group_parent_candidate_snapshots
|
||||
ADD COLUMN IF NOT EXISTS label_prior_bonus DOUBLE PRECISION NOT NULL DEFAULT 0;
|
||||
|
||||
UPDATE kcg.gear_group_parent_candidate_snapshots
|
||||
SET normalized_parent_name = regexp_replace(upper(COALESCE(parent_name, '')), '[[:space:]_%-]+', '', 'g')
|
||||
WHERE normalized_parent_name IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ggpcs_episode_time
|
||||
ON kcg.gear_group_parent_candidate_snapshots(episode_id, observed_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ggpcs_lineage_time
|
||||
ON kcg.gear_group_parent_candidate_snapshots(normalized_parent_name, observed_at DESC);
|
||||
|
||||
ALTER TABLE kcg.gear_group_parent_resolution
|
||||
ADD COLUMN IF NOT EXISTS episode_id VARCHAR(64);
|
||||
|
||||
ALTER TABLE kcg.gear_group_parent_resolution
|
||||
ADD COLUMN IF NOT EXISTS continuity_source VARCHAR(32);
|
||||
|
||||
ALTER TABLE kcg.gear_group_parent_resolution
|
||||
ADD COLUMN IF NOT EXISTS continuity_score DOUBLE PRECISION;
|
||||
|
||||
ALTER TABLE kcg.gear_group_parent_resolution
|
||||
ADD COLUMN IF NOT EXISTS prior_bonus_total DOUBLE PRECISION NOT NULL DEFAULT 0;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ggpr_episode
|
||||
ON kcg.gear_group_parent_resolution(episode_id);
|
||||
|
||||
ALTER TABLE kcg.gear_parent_label_sessions
|
||||
ADD COLUMN IF NOT EXISTS normalized_parent_name VARCHAR(200);
|
||||
|
||||
UPDATE kcg.gear_parent_label_sessions
|
||||
SET normalized_parent_name = regexp_replace(upper(COALESCE(group_key, '')), '[[:space:]_%-]+', '', 'g')
|
||||
WHERE normalized_parent_name IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_gpls_lineage_active
|
||||
ON kcg.gear_parent_label_sessions(normalized_parent_name, active_from DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS kcg.gear_group_episodes (
|
||||
episode_id VARCHAR(64) PRIMARY KEY,
|
||||
lineage_key VARCHAR(200) NOT NULL,
|
||||
group_key VARCHAR(100) NOT NULL,
|
||||
normalized_parent_name VARCHAR(200) NOT NULL,
|
||||
current_sub_cluster_id SMALLINT NOT NULL DEFAULT 0,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
|
||||
continuity_source VARCHAR(32) NOT NULL DEFAULT 'NEW',
|
||||
continuity_score DOUBLE PRECISION,
|
||||
first_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
last_snapshot_time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
current_member_count INT NOT NULL DEFAULT 0,
|
||||
current_member_mmsis JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
current_center_point geometry(Point, 4326),
|
||||
split_from_episode_id VARCHAR(64),
|
||||
merged_from_episode_ids JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
merged_into_episode_id VARCHAR(64),
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT chk_gge_status CHECK (status IN ('ACTIVE', 'MERGED', 'EXPIRED')),
|
||||
CONSTRAINT chk_gge_continuity CHECK (continuity_source IN ('NEW', 'CONTINUED', 'SPLIT_CONTINUE', 'SPLIT_NEW', 'MERGE_NEW', 'DIRECT_PARENT_MATCH'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_gge_lineage_status_time
|
||||
ON kcg.gear_group_episodes(lineage_key, status, last_snapshot_time DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_gge_group_time
|
||||
ON kcg.gear_group_episodes(group_key, current_sub_cluster_id, last_snapshot_time DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS kcg.gear_group_episode_snapshots (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
episode_id VARCHAR(64) NOT NULL REFERENCES kcg.gear_group_episodes(episode_id) ON DELETE CASCADE,
|
||||
lineage_key VARCHAR(200) NOT NULL,
|
||||
group_key VARCHAR(100) NOT NULL,
|
||||
normalized_parent_name VARCHAR(200) NOT NULL,
|
||||
sub_cluster_id SMALLINT NOT NULL DEFAULT 0,
|
||||
observed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
member_count INT NOT NULL DEFAULT 0,
|
||||
member_mmsis JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
center_point geometry(Point, 4326),
|
||||
continuity_source VARCHAR(32) NOT NULL,
|
||||
continuity_score DOUBLE PRECISION,
|
||||
parent_episode_ids JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
top_candidate_mmsi VARCHAR(20),
|
||||
top_candidate_score DOUBLE PRECISION,
|
||||
resolution_status VARCHAR(40),
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT uq_gges_episode_observed UNIQUE (episode_id, observed_at)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_gges_lineage_observed
|
||||
ON kcg.gear_group_episode_snapshots(lineage_key, observed_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_gges_group_observed
|
||||
ON kcg.gear_group_episode_snapshots(group_key, sub_cluster_id, observed_at DESC);
|
||||
514
docs/GEAR-PARENT-INFERENCE-ALGORITHM-SPEC.md
Normal file
514
docs/GEAR-PARENT-INFERENCE-ALGORITHM-SPEC.md
Normal file
@ -0,0 +1,514 @@
|
||||
# Gear Parent Inference Algorithm Spec
|
||||
|
||||
## 문서 목적
|
||||
|
||||
이 문서는 현재 구현된 어구 모선 추적 알고리즘을 모듈, 메서드, 파라미터, 판단 기준, 저장소, 엔드포인트, 영향 관계 기준으로 정리한 구현 명세다. `GEAR-PARENT-INFERENCE-DATAFLOW-PAPER.md`가 서술형 통합 문서라면, 이 문서는 구현과 후속 변경 작업에 바로 연결할 수 있는 참조 스펙이다.
|
||||
|
||||
## 1. 시스템 요약
|
||||
|
||||
### 1.1 현재 목적
|
||||
|
||||
- 최근 24시간 한국 수역 AIS를 캐시에 유지한다.
|
||||
- 어구 이름 패턴과 위치를 기준으로 어구 그룹을 만든다.
|
||||
- 주변 선박/오분류 어구를 correlation 후보로 평가한다.
|
||||
- 후보 중 대표 모선 가능성이 높은 선박을 추론한다.
|
||||
- 사람의 라벨/제외를 별도 저장소에 남겨 향후 모델 평가와 자동화 전환에 활용한다.
|
||||
|
||||
### 1.2 현재 점수 구조의 역할 구분
|
||||
|
||||
- `gear_correlation_scores.current_score`
|
||||
- 후보 스크리닝용 correlation score
|
||||
- EMA 기반 단기 메모리
|
||||
- `gear_group_parent_candidate_snapshots.final_score`
|
||||
- 모선 추론용 최종 후보 점수
|
||||
- coverage-aware 보정과 이름/안정성/episode/lineage/label prior 반영
|
||||
- `gear_group_parent_resolution`
|
||||
- 그룹별 현재 추론 상태
|
||||
- `gear_group_episodes`, `gear_group_episode_snapshots`
|
||||
- `sub_cluster_id`와 분리된 continuity memory
|
||||
- `gear_parent_label_tracking_cycles`
|
||||
- 라벨 세션 동안의 자동 추론 성능 추적
|
||||
|
||||
## 2. 현재 DB 저장소와 유지 기간
|
||||
|
||||
| 저장소 | 역할 | 현재 유지 규칙 |
|
||||
| --- | --- | --- |
|
||||
| `group_polygon_snapshots` | `1h/1h-fb/6h` 그룹 스냅샷 | `7일` cleanup |
|
||||
| `gear_correlation_raw_metrics` | correlation raw metric 시계열 | `7일` retention partition |
|
||||
| `gear_correlation_scores` | correlation EMA score 현재 상태 | `30일` 미관측 시 cleanup |
|
||||
| `gear_group_parent_candidate_snapshots` | cycle별 parent candidate snapshot | 현재 자동 cleanup 없음 |
|
||||
| `gear_group_parent_resolution` | 그룹별 현재 추론 상태 1행 | 현재 자동 cleanup 없음 |
|
||||
| `gear_group_episodes` | active/merged/expired episode 현재 상태 | 현재 자동 cleanup 없음 |
|
||||
| `gear_group_episode_snapshots` | cycle별 episode continuity 스냅샷 | 현재 자동 cleanup 없음 |
|
||||
| `gear_parent_candidate_exclusions` | 그룹/전역 후보 제외 | 기간 종료 또는 수동 해제까지 |
|
||||
| `gear_parent_label_sessions` | 정답 라벨 세션 | 만료 시 `EXPIRED`, row는 유지 |
|
||||
| `gear_parent_label_tracking_cycles` | 라벨 세션 cycle별 추적 | 현재 자동 cleanup 없음 |
|
||||
|
||||
## 3. 모듈 인덱스
|
||||
|
||||
### 3.1 시간/원천 적재
|
||||
|
||||
| 모듈 | 메서드 | 역할 |
|
||||
| --- | --- | --- |
|
||||
| `prediction/time_bucket.py` | `compute_safe_bucket()` | DB 적재 완료 전 bucket 차단 |
|
||||
| `prediction/time_bucket.py` | `compute_initial_window_start()` | 초기 24h window 시작점 |
|
||||
| `prediction/time_bucket.py` | `compute_incremental_window_start()` | overlap backfill 시작점 |
|
||||
| `prediction/db/snpdb.py` | `fetch_all_tracks()` | safe bucket까지 초기 bulk 적재 |
|
||||
| `prediction/db/snpdb.py` | `fetch_incremental()` | backfill 포함 증분 적재 |
|
||||
| `prediction/cache/vessel_store.py` | `load_initial()` | 초기 메모리 캐시 구성 |
|
||||
| `prediction/cache/vessel_store.py` | `merge_incremental()` | 증분 merge + dedupe |
|
||||
| `prediction/cache/vessel_store.py` | `evict_stale()` | 24h sliding window 유지 |
|
||||
|
||||
### 3.2 어구 identity / 그룹
|
||||
|
||||
| 모듈 | 메서드 | 역할 |
|
||||
| --- | --- | --- |
|
||||
| `prediction/fleet_tracker.py` | `track_gear_identity()` | 어구 이름 파싱, identity log 관리 |
|
||||
| `prediction/algorithms/gear_name_rules.py` | `normalize_parent_name()` | 모선명 정규화 |
|
||||
| `prediction/algorithms/gear_name_rules.py` | `is_trackable_parent_name()` | 짧은 이름 제외 |
|
||||
| `prediction/algorithms/polygon_builder.py` | `detect_gear_groups()` | 어구 그룹 및 서브클러스터 생성 |
|
||||
| `prediction/algorithms/polygon_builder.py` | `build_all_group_snapshots()` | `1h/1h-fb/6h` 스냅샷 저장용 생성 |
|
||||
|
||||
### 3.3 correlation / parent inference
|
||||
|
||||
| 모듈 | 메서드 | 역할 |
|
||||
| --- | --- | --- |
|
||||
| `prediction/algorithms/gear_correlation.py` | `run_gear_correlation()` | raw metric + EMA score 계산 |
|
||||
| `prediction/algorithms/gear_correlation.py` | `_compute_gear_vessel_metrics()` | proximity/visit/activity 계산 |
|
||||
| `prediction/algorithms/gear_correlation.py` | `update_score()` | EMA + freeze/decay 상태 전이 |
|
||||
| `prediction/algorithms/gear_parent_episode.py` | `build_episode_plan()` | continuity source와 episode assignment 계산 |
|
||||
| `prediction/algorithms/gear_parent_episode.py` | `compute_prior_bonus_components()` | episode/lineage/label prior bonus 계산 |
|
||||
| `prediction/algorithms/gear_parent_episode.py` | `sync_episode_states()` | `gear_group_episodes` upsert |
|
||||
| `prediction/algorithms/gear_parent_episode.py` | `insert_episode_snapshots()` | episode snapshot append |
|
||||
| `prediction/algorithms/gear_parent_inference.py` | `run_gear_parent_inference()` | 최종 모선 추론 실행 |
|
||||
| `prediction/algorithms/gear_parent_inference.py` | `_build_candidate_scores()` | 후보별 상세 점수 계산 |
|
||||
| `prediction/algorithms/gear_parent_inference.py` | `_name_match_score()` | 이름 점수 규칙 |
|
||||
| `prediction/algorithms/gear_parent_inference.py` | `_build_track_coverage_metrics()` | coverage-aware evidence 계산 |
|
||||
| `prediction/algorithms/gear_parent_inference.py` | `_select_status()` | 상태 전이 규칙 |
|
||||
|
||||
### 3.4 backend read model / workflow
|
||||
|
||||
| 모듈 | 메서드 | 역할 |
|
||||
| --- | --- | --- |
|
||||
| `GroupPolygonService.java` | group list/review/detail SQL | 최신 `1h` live + stale suppression read model |
|
||||
| `ParentInferenceWorkflowController.java` | exclusion/label API | 사람 판단 저장소 API |
|
||||
|
||||
## 4. 메서드 상세
|
||||
|
||||
## 4.1 `prediction/time_bucket.py`
|
||||
|
||||
### `compute_safe_bucket(now: datetime | None = None) -> datetime`
|
||||
|
||||
- 입력:
|
||||
- 현재 시각
|
||||
- 출력:
|
||||
- `safe_delay`를 뺀 뒤 5분 단위로 내림한 KST naive bucket
|
||||
- 기준:
|
||||
- `SNPDB_SAFE_DELAY_MIN`
|
||||
- 영향:
|
||||
- 초기 적재, 증분 적재, eviction 기준점
|
||||
|
||||
### `compute_incremental_window_start(last_bucket: datetime) -> datetime`
|
||||
|
||||
- 입력:
|
||||
- 현재 캐시의 마지막 처리 bucket
|
||||
- 출력:
|
||||
- `last_bucket - SNPDB_BACKFILL_BUCKETS * 5m`
|
||||
- 의미:
|
||||
- 늦게 들어온 같은 bucket row 재흡수
|
||||
|
||||
## 4.2 `prediction/db/snpdb.py`
|
||||
|
||||
### `fetch_all_tracks(hours: int = 24) -> pd.DataFrame`
|
||||
|
||||
- 역할:
|
||||
- safe bucket까지 최근 `N`시간 full load
|
||||
- 핵심 쿼리 조건:
|
||||
- bbox: `122,31,132,39`
|
||||
- `time_bucket > window_start`
|
||||
- `time_bucket <= safe_bucket`
|
||||
- 출력 컬럼:
|
||||
- `mmsi`, `timestamp`, `time_bucket`, `lat`, `lon`, `raw_sog`
|
||||
|
||||
### `fetch_incremental(last_bucket: datetime) -> pd.DataFrame`
|
||||
|
||||
- 역할:
|
||||
- overlap backfill 포함 증분 load
|
||||
- 핵심 쿼리 조건:
|
||||
- `time_bucket > from_bucket`
|
||||
- `time_bucket <= safe_bucket`
|
||||
- 주의:
|
||||
- 이미 본 bucket도 일부 다시 읽는 구조다
|
||||
|
||||
## 4.3 `prediction/cache/vessel_store.py`
|
||||
|
||||
### `load_initial(hours: int = 24) -> None`
|
||||
|
||||
- 역할:
|
||||
- 초기 bulk DataFrame을 MMSI별 track cache로 구성
|
||||
- 파생 효과:
|
||||
- `_last_bucket` 갱신
|
||||
- static info refresh
|
||||
- permit registry refresh
|
||||
|
||||
### `merge_incremental(df_new: pd.DataFrame) -> None`
|
||||
|
||||
- 역할:
|
||||
- 증분 batch merge
|
||||
- 기준:
|
||||
- `timestamp`, `time_bucket` 정렬
|
||||
- `timestamp` 기준 dedupe
|
||||
- 영향:
|
||||
- 같은 bucket overlap backfill에서도 최종 row만 유지
|
||||
|
||||
### `evict_stale(hours: int = 24) -> None`
|
||||
|
||||
- 역할:
|
||||
- sliding 24h 유지
|
||||
- 기준:
|
||||
- `time_bucket` 있으면 bucket 기준
|
||||
- 없으면 timestamp fallback
|
||||
|
||||
## 4.4 `prediction/fleet_tracker.py`
|
||||
|
||||
### `track_gear_identity(gear_signals, conn) -> None`
|
||||
|
||||
- 역할:
|
||||
- 어구 이름 패턴에서 `parent_name`, `gear_index_1`, `gear_index_2` 추출
|
||||
- `gear_identity_log` insert/update
|
||||
- 입력:
|
||||
- gear signal list
|
||||
- 주요 기준:
|
||||
- 정규화 길이 `< 4`면 건너뜀
|
||||
- 같은 이름, 다른 MMSI는 identity migration 처리
|
||||
- 영향:
|
||||
- `gear_correlation_scores.target_mmsi`를 새 MMSI로 이전 가능
|
||||
|
||||
## 4.5 `prediction/algorithms/polygon_builder.py`
|
||||
|
||||
### `detect_gear_groups(vessel_store) -> list[dict]`
|
||||
|
||||
- 역할:
|
||||
- 어구 이름 기반 raw group 생성
|
||||
- 거리 기반 서브클러스터 분리
|
||||
- 근접 병합
|
||||
- 입력:
|
||||
- `all_positions`
|
||||
- 주요 기준:
|
||||
- 어구 패턴 매칭
|
||||
- `440/441` 제외
|
||||
- `is_trackable_parent_name()`
|
||||
- `MAX_DIST_DEG = 0.15`
|
||||
- 출력:
|
||||
- `parent_name`, `parent_mmsi`, `sub_cluster_id`, `members`
|
||||
|
||||
### `build_all_group_snapshots(vessel_store, company_vessels, companies) -> list[dict]`
|
||||
|
||||
- 역할:
|
||||
- `FLEET`, `GEAR_IN_ZONE`, `GEAR_OUT_ZONE`의 `1h/1h-fb/6h` snapshot 생성
|
||||
- 주요 기준:
|
||||
- 같은 `parent_name` 전체 기준 1h active member 수
|
||||
- `GEAR_OUT_ZONE` 최소 멤버 수
|
||||
- parent nearby 시 `isParent=true`
|
||||
|
||||
## 4.6 `prediction/algorithms/gear_correlation.py`
|
||||
|
||||
### `run_gear_correlation(vessel_store, gear_groups, conn) -> dict`
|
||||
|
||||
- 역할:
|
||||
- 그룹당 후보 탐색
|
||||
- raw metric 저장
|
||||
- EMA score 갱신
|
||||
- 입력:
|
||||
- `gear_groups`
|
||||
- 출력:
|
||||
- `updated`, `models`, `raw_inserted`
|
||||
|
||||
### `_compute_gear_vessel_metrics(gear_center_lat, gear_center_lon, gear_radius_nm, vessel_track, params) -> dict`
|
||||
|
||||
- 출력 metric:
|
||||
- `proximity_ratio`
|
||||
- `visit_score`
|
||||
- `activity_sync`
|
||||
- `composite`
|
||||
- 한계:
|
||||
- raw metric은 짧은 항적에 과대 우호적일 수 있음
|
||||
- 이 문제는 parent inference 단계의 coverage-aware 보정에서 완화
|
||||
|
||||
### `update_score(prev_score, raw_score, streak, last_observed, now, gear_group_active_ratio, shadow_bonus, params) -> tuple`
|
||||
|
||||
- 상태:
|
||||
- `ACTIVE`
|
||||
- `PATTERN_DIVERGE`
|
||||
- `GROUP_QUIET`
|
||||
- `NORMAL_GAP`
|
||||
- `SIGNAL_LOSS`
|
||||
- 의미:
|
||||
- correlation score는 장기 기억보다 short-memory EMA에 가깝다
|
||||
|
||||
## 4.7 `prediction/algorithms/gear_parent_inference.py`
|
||||
|
||||
### `run_gear_parent_inference(vessel_store, gear_groups, conn) -> dict[str, int]`
|
||||
|
||||
- 역할:
|
||||
- direct parent 보강
|
||||
- active exclusion/label 적용
|
||||
- 후보 점수 계산
|
||||
- 상태 전이
|
||||
- snapshot/resolution/tracking 저장
|
||||
|
||||
### `_load_existing_resolution(conn, group_keys) -> dict`
|
||||
|
||||
- 역할:
|
||||
- 현재 그룹의 이전 resolution 상태 로드
|
||||
- 현재 쓰임:
|
||||
- `PREVIOUS_SELECTION` 후보 seed
|
||||
- `stable_cycles`
|
||||
- `MANUAL_CONFIRMED` 유지
|
||||
- reject cooldown
|
||||
|
||||
### `_build_candidate_scores(...) -> list[CandidateScore]`
|
||||
|
||||
- 후보 집합 원천:
|
||||
- 상위 correlation 후보
|
||||
- registry name exact bucket
|
||||
- previous selection
|
||||
- 제거 규칙:
|
||||
- global exclusion
|
||||
- group exclusion
|
||||
- reject cooldown
|
||||
- 점수 항목:
|
||||
- `base_corr_score`
|
||||
- `name_match_score`
|
||||
- `track_similarity_score`
|
||||
- `visit_score_6h`
|
||||
- `proximity_score_6h`
|
||||
- `activity_sync_score_6h`
|
||||
- `stability_score`
|
||||
- `registry_bonus`
|
||||
- `china_mmsi_bonus` 후가산
|
||||
|
||||
### `_name_match_score(parent_name, candidate_name, registry) -> float`
|
||||
|
||||
- 규칙:
|
||||
- 원문 동일 `1.0`
|
||||
- 정규화 동일 `0.8`
|
||||
- prefix/contains `0.5`
|
||||
- 숫자 제거 후 문자 부분 동일 `0.3`
|
||||
- else `0.0`
|
||||
|
||||
### `_build_track_coverage_metrics(center_track, vessel_track, gear_center_lat, gear_center_lon) -> dict`
|
||||
|
||||
- 역할:
|
||||
- short-track 과대평가 방지용 증거 강도 계산
|
||||
- 핵심 출력:
|
||||
- `trackCoverageFactor`
|
||||
- `visitCoverageFactor`
|
||||
- `activityCoverageFactor`
|
||||
- `coverageFactor`
|
||||
- downstream:
|
||||
- `track`, `visit`, `proximity`, `activity` raw score에 곱해 effective score 생성
|
||||
|
||||
## 4.8 `prediction/algorithms/gear_parent_episode.py`
|
||||
|
||||
### `build_episode_plan(groups, previous_by_lineage) -> EpisodePlan`
|
||||
|
||||
- 역할:
|
||||
- 현재 cycle group을 이전 active episode와 매칭
|
||||
- `NEW`, `CONTINUED`, `SPLIT_CONTINUE`, `SPLIT_NEW`, `MERGE_NEW` 결정
|
||||
- 입력:
|
||||
- `GroupEpisodeInput[]`
|
||||
- 최근 `6h` active `EpisodeState[]`
|
||||
- continuity score:
|
||||
- `0.75 * member_jaccard + 0.25 * center_support`
|
||||
- 기준:
|
||||
- `member_jaccard`
|
||||
- 중심점 거리 `12nm`
|
||||
- continuity score threshold `0.45`
|
||||
- merge score threshold `0.35`
|
||||
- 출력:
|
||||
- assignment map
|
||||
- expired episode set
|
||||
- merged target map
|
||||
|
||||
### `compute_prior_bonus_components(...) -> dict[str, float]`
|
||||
|
||||
- 역할:
|
||||
- 동일 candidate에 대한 episode/lineage/label prior bonus 계산
|
||||
- 입력 집계 범위:
|
||||
- episode prior: `24h`
|
||||
- lineage prior: `7d`
|
||||
- label prior: `30d`
|
||||
- cap:
|
||||
- `episode <= 0.10`
|
||||
- `lineage <= 0.05`
|
||||
- `label <= 0.10`
|
||||
- `total <= 0.20`
|
||||
- 출력:
|
||||
- `episodePriorBonus`
|
||||
- `lineagePriorBonus`
|
||||
- `labelPriorBonus`
|
||||
- `priorBonusTotal`
|
||||
|
||||
### `sync_episode_states(conn, observed_at, plan) -> None`
|
||||
|
||||
- 역할:
|
||||
- active/merged/expired episode 상태를 `gear_group_episodes`에 반영
|
||||
- 기준:
|
||||
- merge 대상은 `MERGED`
|
||||
- continuity 없는 old episode는 `EXPIRED`
|
||||
|
||||
### `insert_episode_snapshots(conn, observed_at, plan, payloads) -> int`
|
||||
|
||||
- 역할:
|
||||
- cycle별 continuity 결과와 top candidate/result를 `gear_group_episode_snapshots`에 저장
|
||||
- 기록:
|
||||
- `episode_id`
|
||||
- `parent_episode_ids`
|
||||
- `top_candidate_mmsi`
|
||||
- `top_candidate_score`
|
||||
- `resolution_status`
|
||||
|
||||
### `_select_status(top_candidate, margin, stable_cycles) -> tuple[str, str]`
|
||||
|
||||
- 상태:
|
||||
- `NO_CANDIDATE`
|
||||
- `AUTO_PROMOTED`
|
||||
- `REVIEW_REQUIRED`
|
||||
- `UNRESOLVED`
|
||||
- auto promotion 조건:
|
||||
- `target_type == VESSEL`
|
||||
- `CORRELATION` source 포함
|
||||
- `final_score >= 0.72`
|
||||
- `margin >= 0.15`
|
||||
- `stable_cycles >= 3`
|
||||
- review 조건:
|
||||
- `final_score >= 0.60`
|
||||
|
||||
## 5. 현재 엔드포인트 스펙
|
||||
|
||||
## 5.1 조회 계열
|
||||
|
||||
### `/api/kcg/vessel-analysis/groups/parent-inference/review`
|
||||
|
||||
- 역할:
|
||||
- 최신 전역 `1h` 기준 검토 대기 목록
|
||||
- 조건:
|
||||
- stale resolution 숨김
|
||||
- candidate count는 latest candidate snapshot 기준
|
||||
|
||||
### `/api/kcg/vessel-analysis/groups/{groupKey}/parent-inference`
|
||||
|
||||
- 역할:
|
||||
- 특정 그룹의 현재 live sub-cluster 상세
|
||||
- 주의:
|
||||
- “현재 최신 전역 `1h`에 실제 존재하는 sub-cluster만” 반환
|
||||
|
||||
### `/api/kcg/vessel-analysis/parent-inference/candidate-exclusions`
|
||||
|
||||
- 역할:
|
||||
- 그룹/전역 제외 목록 조회
|
||||
|
||||
### `/api/kcg/vessel-analysis/parent-inference/label-sessions`
|
||||
|
||||
- 역할:
|
||||
- active 또는 전체 라벨 세션 조회
|
||||
|
||||
## 5.2 액션 계열
|
||||
|
||||
### `POST /candidate-exclusions/global`
|
||||
|
||||
- 역할:
|
||||
- 전역 후보 제외 생성
|
||||
- 영향:
|
||||
- 다음 cycle부터 모든 그룹에서 해당 MMSI 제거
|
||||
|
||||
### `POST /groups/{groupKey}/parent-inference/{subClusterId}/exclude`
|
||||
|
||||
- 역할:
|
||||
- 그룹 단위 후보 제외 생성
|
||||
- 영향:
|
||||
- 다음 cycle부터 해당 그룹에서만 제거
|
||||
|
||||
### `POST /groups/{groupKey}/parent-inference/{subClusterId}/label`
|
||||
|
||||
- 역할:
|
||||
- 기간형 정답 라벨 세션 생성
|
||||
- 영향:
|
||||
- 다음 cycle부터 tracking row 누적
|
||||
|
||||
## 6. 현재 기억 구조와 prior bonus
|
||||
|
||||
### 6.1 short-memory와 long-memory의 분리
|
||||
|
||||
- `gear_correlation_scores`
|
||||
- EMA short-memory
|
||||
- 미관측 시 decay
|
||||
- 현재 후보 seed 역할
|
||||
- `gear_group_parent_resolution`
|
||||
- 현재 상태 1행
|
||||
- same-episode가 아니면 `PREVIOUS_SELECTION` carry를 직접 사용하지 않음
|
||||
- `gear_group_episodes`
|
||||
- continuity memory
|
||||
- `candidate_snapshots`
|
||||
- bonus 집계 원천
|
||||
|
||||
### 6.2 현재 final score의 장기 기억 반영
|
||||
|
||||
현재는 과거 점수를 직접 carry하지 않고, 약한 prior bonus만 후가산한다.
|
||||
|
||||
```text
|
||||
final_score =
|
||||
current_signal_score
|
||||
+ china_mmsi_bonus
|
||||
+ prior_bonus_total
|
||||
```
|
||||
|
||||
여기서 `prior_bonus_total`은:
|
||||
|
||||
- `episode_prior_bonus`
|
||||
- `lineage_prior_bonus`
|
||||
- `label_prior_bonus`
|
||||
|
||||
의 합이며 총합 cap은 `0.20`이다.
|
||||
|
||||
### 6.3 왜 weak prior인가
|
||||
|
||||
과거 점수를 그대로 넘기면:
|
||||
|
||||
- 다른 episode로 잘못 관성이 전이될 수 있다
|
||||
- split/merge 이후 잘못된 top1이 고착될 수 있다
|
||||
- 오래된 오답이 장기 drift로 남을 수 있다
|
||||
|
||||
그래서 현재 구현은 과거 점수를 “현재 score 자체”가 아니라 “작은 bonus”로만 쓴다.
|
||||
|
||||
## 7. 현재 continuity / prior 동작
|
||||
|
||||
### 7.1 episode continuity
|
||||
|
||||
- 같은 lineage 안에서 최근 `6h` active episode를 불러온다.
|
||||
- continuity score가 높은 이전 episode가 있으면 `CONTINUED`
|
||||
- 1개 parent episode가 여러 current cluster로 이어지면 `SPLIT_CONTINUE` + `SPLIT_NEW`
|
||||
- 여러 previous episode가 하나 current cluster로 모이면 `MERGE_NEW`
|
||||
- 어떤 current와도 연결되지 못한 old episode는 `EXPIRED`
|
||||
|
||||
### 7.2 prior 집계
|
||||
|
||||
| prior | 참조 범위 | 현재 집계 값 |
|
||||
| --- | --- | --- |
|
||||
| episode prior | 최근 동일 episode `24h` | seen_count, top1_count, avg_score, last_seen_at |
|
||||
| lineage prior | 동일 이름 lineage `7d` | seen_count, top1_count, top3_count, avg_score, last_seen_at |
|
||||
| label prior | 라벨 세션 `30d` | session_count, last_labeled_at |
|
||||
|
||||
### 7.3 구현 시 주의
|
||||
|
||||
- 과거 점수를 직접 누적하지 말 것
|
||||
- prior는 bonus로만 사용하고 cap을 둘 것
|
||||
- split/merge 이후 parent 후보 관성은 약하게만 상속할 것
|
||||
- stale live sub-cluster와 vanished old sub-cluster를 혼동하지 않도록, aggregation도 최신 episode anchor를 기준으로 할 것
|
||||
|
||||
## 8. 참조 문서
|
||||
|
||||
- [GEAR-PARENT-INFERENCE-DATAFLOW-PAPER.md](/Users/lht/work/devProjects/iran-airstrike-replay-codex/docs/GEAR-PARENT-INFERENCE-DATAFLOW-PAPER.md)
|
||||
- [GEAR-PARENT-INFERENCE-WORKFLOW-V2.md](/Users/lht/work/devProjects/iran-airstrike-replay-codex/docs/GEAR-PARENT-INFERENCE-WORKFLOW-V2.md)
|
||||
- [GEAR-PARENT-INFERENCE-WORKFLOW-V2-PHASE1.md](/Users/lht/work/devProjects/iran-airstrike-replay-codex/docs/GEAR-PARENT-INFERENCE-WORKFLOW-V2-PHASE1.md)
|
||||
677
docs/GEAR-PARENT-INFERENCE-DATAFLOW-PAPER.md
Normal file
677
docs/GEAR-PARENT-INFERENCE-DATAFLOW-PAPER.md
Normal file
@ -0,0 +1,677 @@
|
||||
# Gear Parent Inference Dataflow Paper
|
||||
|
||||
## 초록
|
||||
|
||||
이 문서는 `iran-airstrike-replay-codex`의 한국 수역 어구 모선 추적 체계를 코드 기준으로 복원하는 통합 기술 문서다. 범위는 `snpdb` 5분 궤적 적재, 인메모리 캐시 유지, 어구 그룹 검출, 서브클러스터 생성, `1h/1h-fb/6h` 폴리곤 스냅샷 저장, correlation 기반 후보 점수화, coverage-aware parent inference, `episode_id` 기반 연속성 계층, backend read model, review/exclusion/label v2까지 포함한다. 문서의 목적은 “현재 무엇이 구현되어 있고, 각 경우의 수에서 어떤 분기 규칙이 적용되는가”를 한 문서에서 복원 가능하게 만드는 것이다.
|
||||
|
||||
## 1. 범위와 전제
|
||||
|
||||
### 1.1 구현 기준
|
||||
|
||||
- frontend: `frontend/`
|
||||
- backend: `backend/`
|
||||
- prediction: `prediction/`
|
||||
- schema migration: `database/migration/012_gear_parent_inference.sql`, `database/migration/014_gear_parent_workflow_v2_phase1.sql`, `database/migration/015_gear_parent_episode_tracking.sql`
|
||||
|
||||
### 1.2 실행 환경
|
||||
|
||||
- lab backend: `rocky-211:18083`
|
||||
- lab prediction: `redis-211:18091`
|
||||
- lab schema: `kcg_lab`
|
||||
- 로컬 프론트 진입점: `yarn dev:lab`, `yarn dev:lab:ssh`
|
||||
|
||||
### 1.3 문서의 구분
|
||||
|
||||
- 구현됨:
|
||||
- 현재 repo 코드와 lab 배포에 이미 반영된 규칙
|
||||
- 후속 확장 후보:
|
||||
- episode continuity 위에서 추가로 올릴 `focus mode`, richer episode lineage API, calibration report
|
||||
|
||||
## 2. 문제 정의
|
||||
|
||||
이 시스템은 한국 수역에서 AIS 신호를 이용해 아래 문제를 단계적으로 푼다.
|
||||
|
||||
1. 최근 24시간의 선박/어구 궤적을 메모리 캐시에 유지한다.
|
||||
2. 동일한 어구 이름 계열을 공간적으로 묶어 어구 그룹을 만든다.
|
||||
3. 각 그룹에 대해 `1h`, `1h-fb`, `6h` 스냅샷을 생성한다.
|
||||
4. 주변 선박 또는 잘못 분류된 어구 AIS를 후보로 수집하고 correlation 점수를 만든다.
|
||||
5. 후보를 모선 추론 점수로 다시 환산한다.
|
||||
6. 사람이 라벨/제외를 누적해 모델 정확도 고도화용 데이터셋을 만든다.
|
||||
|
||||
핵심 난점은 아래 세 가지다.
|
||||
|
||||
- DB 적재 지연 때문에 live incremental cache와 fresh reload가 다를 수 있다.
|
||||
- 같은 `parent_name` 아래에서도 실제로는 여러 공간 덩어리로 갈라질 수 있다.
|
||||
- 짧은 항적이 `track/proximity/activity`에서 과대평가될 수 있다.
|
||||
|
||||
## 3. 전체 아키텍처 흐름
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["signal.t_vessel_tracks_5min<br/>5분 bucket linestringM"] --> B["prediction/db/snpdb.py<br/>safe bucket + overlap backfill"]
|
||||
B --> C["prediction/cache/vessel_store.py<br/>24h in-memory cache"]
|
||||
C --> D["prediction/fleet_tracker.py<br/>gear_identity_log / snapshot"]
|
||||
C --> E["prediction/algorithms/polygon_builder.py<br/>gear group detect + sub-cluster + snapshots"]
|
||||
E --> F["kcg_lab.group_polygon_snapshots"]
|
||||
C --> G["prediction/algorithms/gear_correlation.py<br/>raw metrics + EMA score"]
|
||||
G --> H["kcg_lab.gear_correlation_raw_metrics"]
|
||||
G --> I["kcg_lab.gear_correlation_scores"]
|
||||
F --> J["prediction/algorithms/gear_parent_inference.py<br/>candidate build + scoring + status"]
|
||||
H --> J
|
||||
I --> J
|
||||
K["v2 exclusions / labels"] --> J
|
||||
J --> L["kcg_lab.gear_group_parent_candidate_snapshots"]
|
||||
J --> M["kcg_lab.gear_group_parent_resolution"]
|
||||
J --> N["kcg_lab.gear_parent_label_tracking_cycles"]
|
||||
F --> O["backend GroupPolygonService"]
|
||||
L --> O
|
||||
M --> O
|
||||
N --> O
|
||||
O --> P["frontend ParentReviewPanel"]
|
||||
```
|
||||
|
||||
## 4. 원천 데이터와 시간 모델
|
||||
|
||||
### 4.1 원천 데이터 형식
|
||||
|
||||
원천은 `signal.t_vessel_tracks_5min`이며, `1 row = 1 MMSI = 5분 구간의 궤적 전체`를 `LineStringM`으로 보관한다. 실제 위치 포인트는 `ST_DumpPoints(track_geom)`로 분해하고, 각 점의 timestamp는 `ST_M((dp).geom)`에서 꺼낸다. 구현 위치는 `prediction/db/snpdb.py`다.
|
||||
|
||||
### 4.2 safe watermark
|
||||
|
||||
현재 구현은 “DB 적재가 완료된 bucket만 읽는다”는 원칙을 따른다.
|
||||
|
||||
- `prediction/time_bucket.py`
|
||||
- `compute_safe_bucket()`
|
||||
- `compute_initial_window_start()`
|
||||
- `compute_incremental_window_start()`
|
||||
- 기본값:
|
||||
- `SNPDB_SAFE_DELAY_MIN`
|
||||
- `SNPDB_BACKFILL_BUCKETS`
|
||||
|
||||
핵심 규칙:
|
||||
|
||||
1. 초기 적재는 `now - safe_delay`를 5분 내림한 `safe_bucket`까지만 읽는다.
|
||||
2. 증분 적재는 `last_bucket - backfill_window`부터 `safe_bucket`까지 다시 읽는다.
|
||||
3. live cache는 `timestamp`가 아니라 `time_bucket` 기준으로 24시간 cutoff를 맞춘다.
|
||||
|
||||
### 4.3 왜 safe watermark가 필요한가
|
||||
|
||||
`time_bucket > last_bucket`만 사용하면, 늦게 들어온 같은 bucket row를 영구히 놓칠 수 있다. 현재 구현은 overlap backfill과 dedupe로 이 drift를 줄인다.
|
||||
|
||||
- 조회: `prediction/db/snpdb.py`
|
||||
- 병합 dedupe: `prediction/cache/vessel_store.py`
|
||||
|
||||
## 5. Stage 1: 캐시 적재와 유지
|
||||
|
||||
### 5.1 초기 적재
|
||||
|
||||
`prediction/main.py`는 시작 시 `vessel_store.load_initial(24)`를 호출한다.
|
||||
|
||||
`prediction/cache/vessel_store.py`의 규칙:
|
||||
|
||||
1. `snpdb.fetch_all_tracks(hours)`로 최근 24시간을 safe bucket까지 읽는다.
|
||||
2. MMSI별 DataFrame으로 `_tracks`를 구성한다.
|
||||
3. 최대 `time_bucket`을 `_last_bucket`으로 저장한다.
|
||||
4. static info와 permit registry를 함께 refresh한다.
|
||||
|
||||
### 5.2 증분 병합
|
||||
|
||||
스케줄러는 `snpdb.fetch_incremental(vessel_store.last_bucket)`로 overlap backfill 구간을 다시 읽는다.
|
||||
|
||||
`merge_incremental()` 규칙:
|
||||
|
||||
1. 기존 DataFrame과 새 batch를 합친다.
|
||||
2. `timestamp`, `time_bucket`으로 정렬한다.
|
||||
3. `timestamp` 기준 중복은 `keep='last'`로 제거한다.
|
||||
4. batch의 최대 `time_bucket`이 더 크면 `_last_bucket`을 갱신한다.
|
||||
|
||||
### 5.3 stale eviction
|
||||
|
||||
`evict_stale()`는 safe bucket 기준 24시간 이전 포인트를 제거한다. `time_bucket`이 있으면 bucket 기준, 없으면 timestamp 기준으로 fallback한다.
|
||||
|
||||
## 6. Stage 2: 어구 identity 추출
|
||||
|
||||
`prediction/fleet_tracker.py`는 어구 이름 패턴에서 `parent_name`, `gear_index_1`, `gear_index_2`를 파싱하고 `gear_identity_log`를 관리한다.
|
||||
|
||||
### 6.1 이름 기반 필터
|
||||
|
||||
공통 규칙은 `prediction/algorithms/gear_name_rules.py`에 있다.
|
||||
|
||||
- 정규화:
|
||||
- 대문자화
|
||||
- 공백, `_`, `-`, `%` 제거
|
||||
- 추적 가능 최소 길이:
|
||||
- 정규화 길이 `>= 4`
|
||||
|
||||
`fleet_tracker.py`와 `polygon_builder.py`는 모두 `is_trackable_parent_name()`을 사용한다. 즉 짧은 이름은 추론 이전, 그룹 생성 이전 단계부터 제외된다.
|
||||
|
||||
### 6.2 identity log 동작
|
||||
|
||||
`fleet_tracker.py`의 핵심 분기:
|
||||
|
||||
1. 같은 MMSI + 같은 이름:
|
||||
- 기존 활성 row의 `last_seen_at`, 위치만 갱신
|
||||
2. 같은 MMSI + 다른 이름:
|
||||
- 기존 row 비활성화
|
||||
- 새 row insert
|
||||
3. 다른 MMSI + 같은 이름:
|
||||
- 기존 row 비활성화
|
||||
- 새 MMSI로 row insert
|
||||
- 기존 `gear_correlation_scores.target_mmsi`를 새 MMSI로 이전
|
||||
|
||||
## 7. Stage 3: 어구 그룹 생성과 서브클러스터
|
||||
|
||||
실제 어구 그룹은 `prediction/algorithms/polygon_builder.py`의 `detect_gear_groups()`가 만든다.
|
||||
|
||||
### 7.1 1차 그룹화
|
||||
|
||||
규칙:
|
||||
|
||||
1. 최신 position 이름이 어구 패턴에 맞아야 한다.
|
||||
2. `STALE_SEC`를 넘는 오래된 신호는 제외한다.
|
||||
3. `440`, `441` MMSI는 어구 AIS 미사용으로 간주해 제외한다.
|
||||
4. `is_trackable_parent_name(parent_raw)`를 만족해야 한다.
|
||||
5. 같은 `parent_name`은 공백 제거 버전으로 묶는다.
|
||||
|
||||
### 7.2 서브클러스터 생성
|
||||
|
||||
같은 이름 아래에서도 거리 기반 연결성으로 덩어리를 나눈다.
|
||||
|
||||
- 거리 임계치: `MAX_DIST_DEG = 0.15`
|
||||
- 연결 규칙:
|
||||
- 각 어구가 클러스터 내 최소 1개와 `MAX_DIST_DEG` 이내면 같은 연결 요소
|
||||
- 구현:
|
||||
- Union-Find
|
||||
|
||||
모선이 이미 있으면, 모선과 가장 가까운 클러스터를 seed cluster로 간주한다.
|
||||
|
||||
### 7.3 `sub_cluster_id` 부여 규칙
|
||||
|
||||
현재 구현은 아래와 같다.
|
||||
|
||||
1. 클러스터가 1개면 `sub_cluster_id = 0`
|
||||
2. 클러스터가 여러 개면 `1..N`
|
||||
3. 이후 동일 `parent_key`의 두 서브그룹이 다시 근접 병합되면 `sub_cluster_id = 0`
|
||||
|
||||
즉 `sub_cluster_id`는 영구 식별자가 아니라 “그 시점의 공간 분리 라벨”이다.
|
||||
|
||||
### 7.4 병합 규칙
|
||||
|
||||
동일 `parent_key`의 두 그룹이 다시 가까워지면:
|
||||
|
||||
1. 멤버를 합친다.
|
||||
2. 부모 MMSI가 없는 큰 그룹에 작은 그룹의 `parent_mmsi`를 승계할 수 있다.
|
||||
3. `sub_cluster_id = 0`으로 재설정한다.
|
||||
|
||||
### 7.5 스냅샷 생성 규칙
|
||||
|
||||
`build_all_group_snapshots()`는 각 그룹에 대해 `1h`, `1h-fb`, `6h` 스냅샷을 만든다.
|
||||
|
||||
- `1h`
|
||||
- 같은 `parent_name` 전체 기준 1시간 활성 멤버 수 `>= 2`
|
||||
- `1h-fb`
|
||||
- 같은 `parent_name` 전체 기준 1시간 활성 멤버 수 `< 2`
|
||||
- 리플레이/일치율 추적용
|
||||
- 라이브 현황에서 제외
|
||||
- `6h`
|
||||
- 6시간 내 stale이 아니어야 함
|
||||
|
||||
추가 규칙:
|
||||
|
||||
1. 서브클러스터 내 1h 활성 멤버가 2개 미만이면 최신 2개로 fallback display를 만든다.
|
||||
2. 수역 외(`GEAR_OUT_ZONE`)인데 멤버 수가 `MIN_GEAR_GROUP_SIZE` 미만이면 스킵한다.
|
||||
3. 모선이 있고, 멤버와 충분히 근접하면 `members[].isParent = true`로 같이 넣는다.
|
||||
|
||||
## 8. Stage 4: correlation 모델
|
||||
|
||||
`prediction/algorithms/gear_correlation.py`는 어구 그룹별 raw metric과 EMA score를 만든다.
|
||||
|
||||
### 8.1 후보 생성
|
||||
|
||||
입력:
|
||||
|
||||
- group center
|
||||
- group radius
|
||||
- active ratio
|
||||
- group member MMSI set
|
||||
|
||||
출력 후보:
|
||||
|
||||
- 선박 후보(`VESSEL`)
|
||||
- 잘못 분류된 어구 후보(`GEAR_BUOY`)
|
||||
|
||||
후보 수는 그룹당 최대 `30`개로 제한된다.
|
||||
|
||||
### 8.2 raw metric
|
||||
|
||||
선박 후보는 최근 6시간 항적 기반으로 아래 값을 만든다.
|
||||
|
||||
- `proximity_ratio`
|
||||
- `visit_score`
|
||||
- `activity_sync`
|
||||
- `dtw_similarity`
|
||||
|
||||
어구 후보는 단순 거리 기반 `proximity_ratio`만 사용한다.
|
||||
|
||||
### 8.3 EMA score
|
||||
|
||||
모델 파라미터(`gear_correlation_param_models`)별로 아래를 수행한다.
|
||||
|
||||
1. composite score 계산
|
||||
2. 이전 score와 streak를 읽는다
|
||||
3. `update_score()`로 EMA 갱신
|
||||
4. threshold 이상이거나 기존 row가 있으면 upsert
|
||||
|
||||
반대로 이번 사이클 후보군에서 빠진 기존 항목은 `OUT_OF_RANGE`로 fast decay된다.
|
||||
|
||||
### 8.4 correlation 산출물
|
||||
|
||||
- `gear_correlation_raw_metrics`
|
||||
- `gear_correlation_scores`
|
||||
|
||||
여기까지는 “잠재적 모선/근접 대상”의 score이고, 최종 parent inference는 아직 아니다.
|
||||
|
||||
## 9. Stage 5: parent inference
|
||||
|
||||
`prediction/algorithms/gear_parent_inference.py`가 최종 모선 추론을 수행한다.
|
||||
|
||||
전체 진입점은 `run_gear_parent_inference(vessel_store, gear_groups, conn)`이다.
|
||||
|
||||
### 9.1 전체 분기 개요
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["active gear group"] --> B{"direct parent member<br/>exists?"}
|
||||
B -- yes --> C["DIRECT_PARENT_MATCH<br/>fresh resolution upsert"]
|
||||
B -- no --> D{"trackable parent name?"}
|
||||
D -- no --> E["SKIPPED_SHORT_NAME"]
|
||||
D -- yes --> F["build candidate set"]
|
||||
F --> G{"candidate exists?"}
|
||||
G -- no --> H["NO_CANDIDATE"]
|
||||
G -- yes --> I["score + rank + margin + stable cycles"]
|
||||
I --> J{"auto promotion rule?"}
|
||||
J -- yes --> K["AUTO_PROMOTED"]
|
||||
J -- no --> L{"top score >= 0.60?"}
|
||||
L -- yes --> M["REVIEW_REQUIRED"]
|
||||
L -- no --> N["UNRESOLVED"]
|
||||
```
|
||||
|
||||
### 9.1.1 episode continuity 선행 단계
|
||||
|
||||
현재 구현에서 `run_gear_parent_inference()`는 후보 점수를 만들기 전에 먼저 `prediction/algorithms/gear_parent_episode.py`를 호출해 active 그룹의 continuity를 계산한다.
|
||||
|
||||
입력:
|
||||
|
||||
- 현재 cycle `gear_groups`
|
||||
- 정규화된 `parent_name`
|
||||
- 최근 `6h` active `gear_group_episodes`
|
||||
- 최근 `24h` episode prior, `7d` lineage prior, `30d` label prior 집계
|
||||
|
||||
핵심 규칙:
|
||||
|
||||
1. continuity score는 `0.75 * member_jaccard + 0.25 * center_support`다.
|
||||
2. 중심점 지원값은 `12nm` 이내일수록 커진다.
|
||||
3. continuity score가 충분하거나, overlap member가 있고 거리 조건을 만족하면 연결 후보로 본다.
|
||||
4. 두 개 이상 active episode가 하나의 현재 cluster로 들어오면 `MERGE_NEW`다.
|
||||
5. 하나의 episode가 여러 현재 cluster로 갈라지면 하나는 `SPLIT_CONTINUE`, 나머지는 `SPLIT_NEW`다.
|
||||
6. 아무 previous episode와도 연결되지 않으면 `NEW`다.
|
||||
7. 현재 cycle과 연결되지 못한 active episode는 `EXPIRED` 또는 `MERGED`로 종료한다.
|
||||
|
||||
현재 저장되는 continuity 메타데이터:
|
||||
|
||||
- `gear_group_parent_candidate_snapshots.episode_id`
|
||||
- `gear_group_parent_resolution.episode_id`
|
||||
- `gear_group_parent_resolution.continuity_source`
|
||||
- `gear_group_parent_resolution.continuity_score`
|
||||
- `gear_group_parent_resolution.prior_bonus_total`
|
||||
- `gear_group_episodes`
|
||||
- `gear_group_episode_snapshots`
|
||||
|
||||
### 9.2 direct parent 보강
|
||||
|
||||
최신 어구 그룹에 아래 중 하나가 있으면 후보 추론 대신 직접 모선 매칭으로 처리한다.
|
||||
|
||||
1. `members[].isParent = true`
|
||||
2. `group.parent_mmsi` 존재
|
||||
|
||||
이 경우:
|
||||
|
||||
- `status = DIRECT_PARENT_MATCH`
|
||||
- `decision_source = DIRECT_PARENT_MATCH`
|
||||
- `confidence = 1.0`
|
||||
- `candidateCount = 0`
|
||||
|
||||
단, 기존 상태가 `MANUAL_CONFIRMED`면 그 수동 상태를 유지한다.
|
||||
|
||||
### 9.3 짧은 이름 스킵
|
||||
|
||||
정규화 이름 길이 `< 4`면:
|
||||
|
||||
- 후보 생성 자체를 수행하지 않는다.
|
||||
- `status = SKIPPED_SHORT_NAME`
|
||||
- `decision_source = AUTO_SKIP`
|
||||
|
||||
### 9.4 후보 집합
|
||||
|
||||
후보 집합은 아래의 합집합이다.
|
||||
|
||||
1. default correlation model 상위 후보
|
||||
2. registry name exact bucket
|
||||
3. 기존 resolution의 `selected_parent_mmsi` 또는 이전 top candidate
|
||||
|
||||
여기에 아래를 적용한다.
|
||||
|
||||
- active global exclusion 제거
|
||||
- active group exclusion 제거
|
||||
- 최근 reject cooldown 후보 제거
|
||||
|
||||
### 9.5 이름 점수
|
||||
|
||||
현재 구현 규칙:
|
||||
|
||||
1. 원문 완전일치: `1.0`
|
||||
2. 정규화 완전일치: `0.8`
|
||||
3. prefix/contains: `0.5`
|
||||
4. 숫자를 제거한 순수 문자 부분만 동일: `0.3`
|
||||
5. 그 외: `0.0`
|
||||
|
||||
비교 대상:
|
||||
|
||||
- `parent_name`
|
||||
- 후보 AIS 이름
|
||||
- registry `name_cn`
|
||||
- registry `name_en`
|
||||
|
||||
### 9.6 coverage-aware evidence
|
||||
|
||||
짧은 항적 과대평가를 막기 위해 raw score와 effective score를 분리한다.
|
||||
|
||||
evidence에 남는 값:
|
||||
|
||||
- `trackPointCount`
|
||||
- `trackSpanMinutes`
|
||||
- `overlapPointCount`
|
||||
- `overlapSpanMinutes`
|
||||
- `inZonePointCount`
|
||||
- `inZoneSpanMinutes`
|
||||
- `trackCoverageFactor`
|
||||
- `visitCoverageFactor`
|
||||
- `activityCoverageFactor`
|
||||
- `coverageFactor`
|
||||
|
||||
현재 최종 점수에는 raw가 아니라 adjusted score가 들어간다.
|
||||
|
||||
### 9.7 점수 식
|
||||
|
||||
가중치 합은 아래다.
|
||||
|
||||
- `0.40 * base_corr`
|
||||
- `0.15 * name_match`
|
||||
- `0.15 * track_similarity_effective`
|
||||
- `0.10 * visit_effective`
|
||||
- `0.05 * proximity_effective`
|
||||
- `0.05 * activity_effective`
|
||||
- `0.10 * stability`
|
||||
- `+ registry_bonus(0.05)`
|
||||
|
||||
그 다음 별도 후가산:
|
||||
|
||||
- `412/413` MMSI 보너스 `+0.15`
|
||||
- 단, `preBonusScore >= 0.30`일 때만 적용
|
||||
- `episode/lineage/label prior bonus`
|
||||
- 최근 동일 episode `24h`
|
||||
- 동일 lineage `7d`
|
||||
- 라벨 세션 `30d`
|
||||
- 총합 cap `0.20`
|
||||
|
||||
### 9.8 상태 전이
|
||||
|
||||
분기 조건:
|
||||
|
||||
- `NO_CANDIDATE`
|
||||
- 후보가 하나도 없을 때
|
||||
- `AUTO_PROMOTED`
|
||||
- `target_type == VESSEL`
|
||||
- candidate source에 `CORRELATION` 포함
|
||||
- `final_score >= auto_promotion_threshold`
|
||||
- `margin >= auto_promotion_margin`
|
||||
- `stable_cycles >= auto_promotion_stable_cycles`
|
||||
- `REVIEW_REQUIRED`
|
||||
- `final_score >= 0.60`
|
||||
- `UNRESOLVED`
|
||||
- 나머지
|
||||
|
||||
추가 예외:
|
||||
|
||||
- 기존 상태가 `MANUAL_CONFIRMED`면 수동 상태를 유지한다.
|
||||
- active label session이 있으면 tracking row를 별도로 적재한다.
|
||||
|
||||
### 9.9 산출물
|
||||
|
||||
- `gear_group_parent_candidate_snapshots`
|
||||
- `gear_group_parent_resolution`
|
||||
- `gear_parent_label_tracking_cycles`
|
||||
- `gear_group_episodes`
|
||||
- `gear_group_episode_snapshots`
|
||||
|
||||
## 10. Stage 6: backend read model
|
||||
|
||||
backend의 중심은 `backend/.../GroupPolygonService.java`다.
|
||||
|
||||
### 10.1 최신 1h만 라이브로 간주
|
||||
|
||||
group list, review queue, detail API는 모두 최신 전역 `1h` 스냅샷만 기준으로 삼는다.
|
||||
|
||||
핵심 효과:
|
||||
|
||||
1. `1h-fb`는 라이브 현황에서 기본 제외된다.
|
||||
2. 이미 사라진 과거 sub-cluster는 detail API에서 다시 보이지 않는다.
|
||||
|
||||
### 10.2 stale inference 차단
|
||||
|
||||
`resolution.last_evaluated_at >= group.snapshot_time`인 경우만 join한다.
|
||||
|
||||
즉 최신 group snapshot보다 오래된 candidate/resolution은 detail/review/list에서 숨긴다. 이 규칙이 `ZHEDAIYU02433`, `ZHEDAIYU02394` 유형 stale 표시를 막는다.
|
||||
|
||||
### 10.3 detail API 의미
|
||||
|
||||
`/api/kcg/vessel-analysis/groups/{groupKey}/parent-inference`
|
||||
|
||||
현재 의미:
|
||||
|
||||
- 해당 그룹의 최신 전역 `1h` live sub-cluster 집합
|
||||
- 각 sub-cluster의 fresh resolution
|
||||
- 각 sub-cluster의 latest candidate snapshot
|
||||
|
||||
## 11. Stage 7: review / exclusion / label v2
|
||||
|
||||
v2 Phase 1은 “자동 추론 결과”와 “사람 판단 데이터”를 분리하는 구조다.
|
||||
|
||||
### 11.1 사람 판단 저장소
|
||||
|
||||
- `gear_parent_candidate_exclusions`
|
||||
- `gear_parent_label_sessions`
|
||||
- `gear_parent_label_tracking_cycles`
|
||||
|
||||
### 11.2 액션 의미
|
||||
|
||||
- 그룹 제외:
|
||||
- 특정 `group_key + sub_cluster_id`에서 특정 후보 MMSI를 일정 기간 제거
|
||||
- 전체 후보 제외:
|
||||
- 특정 MMSI를 모든 그룹 후보군에서 제거
|
||||
- 정답 라벨:
|
||||
- 특정 그룹에 대해 정답 parent MMSI를 `1/3/5일` 세션으로 지정
|
||||
- prediction은 이후 cycle마다 top1/top3 여부를 추적
|
||||
|
||||
### 11.3 why v2
|
||||
|
||||
기존 `MANUAL_CONFIRMED`/`REJECT`는 운영 override 성격이 강했고, “모델 정확도 평가용 백데이터”와 섞였다. v2는 이 둘을 분리해 라벨을 평가 데이터로 쓰도록 한다.
|
||||
|
||||
## 12. 실제 경우의 수 분기표
|
||||
|
||||
| 경우 | 구현 위치 | 현재 동작 |
|
||||
| --- | --- | --- |
|
||||
| 이름 길이 `< 4` | `gear_name_rules.py`, `fleet_tracker.py`, `polygon_builder.py`, `gear_parent_inference.py` | identity/grouping/inference 단계에서 제외 또는 `SKIPPED_SHORT_NAME` |
|
||||
| 직접 모선 포함 | `polygon_builder.py`, `gear_parent_inference.py` | `DIRECT_PARENT_MATCH` fresh resolution |
|
||||
| 같은 이름, 멀리 떨어진 어구 | `polygon_builder.py` | 별도 sub-cluster 생성 |
|
||||
| 두 서브클러스터가 다시 근접 | `polygon_builder.py` | 하나로 병합, `sub_cluster_id = 0` |
|
||||
| group 전체 1h 활성 멤버 `< 2` | `polygon_builder.py` | `1h-fb` 생성, live 현황 제외 |
|
||||
| 후보가 하나도 없음 | `gear_parent_inference.py` | `NO_CANDIDATE` |
|
||||
| 짧은 항적이 우연히 근접 | `gear_parent_inference.py` | coverage-aware 보정으로 effective score 감소 |
|
||||
| stale old inference가 남아 있음 | `GroupPolygonService.java` | 최신 group snapshot보다 오래되면 숨김 |
|
||||
| 직접 parent가 이미 있음 | `gear_parent_inference.py` | 후보 계산 대신 direct parent resolution |
|
||||
|
||||
## 13. `sub_cluster_id`의 한계
|
||||
|
||||
현재 코드에서 `sub_cluster_id`는 영구 identity가 아니다.
|
||||
|
||||
이유:
|
||||
|
||||
1. 같은 이름 그룹의 공간 분리 수가 cycle마다 달라질 수 있다.
|
||||
2. 병합되면 `0`으로 재설정된다.
|
||||
3. 멤버가 추가/이탈해도 기존 번호 의미가 유지된다고 보장할 수 없다.
|
||||
|
||||
따라서 `group_key + sub_cluster_id`는 “현재 cycle의 공간 덩어리”를 가리키는 키로는 유효하지만, 장기 연속 추적 키로는 부적합하다.
|
||||
|
||||
## 14. Stage 8: `episode_id` continuity + prior bonus
|
||||
|
||||
### 14.1 목적
|
||||
|
||||
현재 구현의 `episode_id`는 “같은 어구 덩어리의 시간적 연속성”을 추적하는 별도 식별자다. `sub_cluster_id`를 대체하지 않고, 그 위에 얹는 계층이다.
|
||||
|
||||
핵심 목적:
|
||||
|
||||
- 작은 멤버 변화는 같은 episode로 이어 붙인다.
|
||||
- 구조적 split/merge는 continuity source로 기록한다.
|
||||
- long-memory는 `stable_cycles` 직접 승계가 아니라 약한 prior bonus로만 전달한다.
|
||||
|
||||
### 14.2 현재 저장소
|
||||
|
||||
- `gear_group_episodes`
|
||||
- active/merged/expired episode 현재 상태
|
||||
- `gear_group_episode_snapshots`
|
||||
- cycle별 episode 스냅샷
|
||||
- `gear_group_parent_candidate_snapshots`
|
||||
- `episode_id`, `normalized_parent_name`,
|
||||
`episode_prior_bonus`, `lineage_prior_bonus`, `label_prior_bonus`
|
||||
- `gear_group_parent_resolution`
|
||||
- `episode_id`, `continuity_source`, `continuity_score`, `prior_bonus_total`
|
||||
|
||||
### 14.3 continuity score
|
||||
|
||||
현재 continuity score는 아래다.
|
||||
|
||||
```text
|
||||
continuity_score =
|
||||
0.75 * member_jaccard
|
||||
+ 0.25 * center_support
|
||||
```
|
||||
|
||||
- `member_jaccard`
|
||||
- 현재/이전 episode 멤버 MMSI Jaccard
|
||||
- `center_support`
|
||||
- 중심점 거리 `12nm` 이내일수록 높아지는 값
|
||||
|
||||
연결 후보 판단:
|
||||
|
||||
- continuity score `>= 0.45`
|
||||
- 또는 overlap member가 있고 거리 조건을 만족하면 연결 후보로 인정
|
||||
|
||||
### 14.4 continuity source 규칙
|
||||
|
||||
- `NEW`
|
||||
- 어떤 이전 episode와도 연결되지 않음
|
||||
- `CONTINUED`
|
||||
- 1:1 continuity
|
||||
- `SPLIT_CONTINUE`
|
||||
- 하나의 이전 episode가 여러 현재 cluster로 갈라졌고, 그중 주 가지
|
||||
- `SPLIT_NEW`
|
||||
- split로 새로 생성된 가지
|
||||
- `MERGE_NEW`
|
||||
- 2개 이상 active episode가 의미 있게 하나의 현재 cluster로 합쳐짐
|
||||
- `DIRECT_PARENT_MATCH`
|
||||
- 직접 모선 포함 그룹이 fresh resolution으로 정리되는 경우의 최종 resolution source
|
||||
|
||||
### 14.5 merge / split / expire
|
||||
|
||||
현재 구현 규칙:
|
||||
|
||||
1. split
|
||||
- 가장 유사한 현재 cluster 1개는 기존 episode 유지
|
||||
- 나머지는 새 episode 생성
|
||||
- 새 episode에는 `split_from_episode_id` 저장
|
||||
2. merge
|
||||
- 2개 이상 previous episode가 같은 현재 cluster로 의미 있게 들어오면 새 episode 생성
|
||||
- 이전 episode들은 `MERGED`, `merged_into_episode_id = 새 episode`
|
||||
3. expire
|
||||
- 최근 `6h` active episode가 현재 cycle 어떤 cluster와도 연결되지 않으면 `EXPIRED`
|
||||
|
||||
### 14.6 prior bonus 계층
|
||||
|
||||
현재 final score에는 signal score 뒤에 아래 prior bonus가 후가산된다.
|
||||
|
||||
- `episode_prior_bonus`
|
||||
- 최근 동일 episode `24h`
|
||||
- cap `0.10`
|
||||
- `lineage_prior_bonus`
|
||||
- 동일 정규화 이름 lineage `7d`
|
||||
- cap `0.05`
|
||||
- `label_prior_bonus`
|
||||
- 동일 lineage 라벨 세션 `30d`
|
||||
- cap `0.10`
|
||||
- 총합 cap
|
||||
- `0.20`
|
||||
|
||||
현재 후보가 이미 candidate set에 들어온 경우에만 적용하며, 과거 점수를 직접 carry하는 대신 약한 보너스로만 사용한다.
|
||||
|
||||
### 14.7 병합 후 후보 관성
|
||||
|
||||
질문 사례처럼 `A` episode 후보 `a`, `B` episode 후보 `b`가 있다가 병합 후 `b`가 더 적합해질 수 있다. 현재 구현은 병합 시 무조건 `A`를 유지하지 않고 새 episode를 생성해 `A/B` 둘 다의 history를 prior bonus 풀에서 재평가한다. 따라서 `b`는 완전 신규 후보처럼 0에서 시작하지 않지만, `A`의 과거 `stable_cycles`가 그대로 지배하지도 않는다.
|
||||
|
||||
## 15. 현재 episode 상태 흐름
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Active
|
||||
Active --> Active: "CONTINUED / 소규모 멤버 변동"
|
||||
Active --> Active: "SPLIT_CONTINUE"
|
||||
Active --> Active: "MERGE_NEW로 새 episode 생성 후 연결"
|
||||
Active --> Merged: "merged_into_episode_id 기록"
|
||||
Active --> Expired: "최근 6h continuity 없음"
|
||||
Merged --> [*]
|
||||
Expired --> [*]
|
||||
```
|
||||
|
||||
## 16. 결론
|
||||
|
||||
현재 구현은 아래를 모두 포함한다.
|
||||
|
||||
- safe watermark + overlap backfill 기반 incremental 안정화
|
||||
- 짧은 이름 그룹 제거
|
||||
- 거리 기반 sub-cluster와 `1h/1h-fb/6h` 스냅샷
|
||||
- correlation + parent inference 분리
|
||||
- coverage-aware score 보정
|
||||
- stale inference 차단
|
||||
- direct parent supplement
|
||||
- v2 exclusion/label/tracking 저장소
|
||||
- `episode_id` continuity와 prior bonus
|
||||
|
||||
남은 과제는 `episode` 자체보다, 이 continuity 계층을 read model과 시각화에서 더 설명력 있게 노출하는 것이다. 즉 다음 단계의 핵심은 episode 도입이 아니라, `episode lineage API`, calibration report, richer review analytics를 얹는 일이다.
|
||||
|
||||
## 17. 참고 코드
|
||||
|
||||
- `prediction/main.py`
|
||||
- `prediction/time_bucket.py`
|
||||
- `prediction/db/snpdb.py`
|
||||
- `prediction/cache/vessel_store.py`
|
||||
- `prediction/fleet_tracker.py`
|
||||
- `prediction/algorithms/gear_name_rules.py`
|
||||
- `prediction/algorithms/polygon_builder.py`
|
||||
- `prediction/algorithms/gear_correlation.py`
|
||||
- `prediction/algorithms/gear_parent_episode.py`
|
||||
- `prediction/algorithms/gear_parent_inference.py`
|
||||
- `backend/src/main/java/gc/mda/kcg/domain/fleet/GroupPolygonService.java`
|
||||
- `backend/src/main/java/gc/mda/kcg/domain/fleet/ParentInferenceWorkflowController.java`
|
||||
- `database/migration/012_gear_parent_inference.sql`
|
||||
- `database/migration/014_gear_parent_workflow_v2_phase1.sql`
|
||||
- `database/migration/015_gear_parent_episode_tracking.sql`
|
||||
706
docs/GEAR-PARENT-INFERENCE-WORKFLOW-V2-PHASE1.md
Normal file
706
docs/GEAR-PARENT-INFERENCE-WORKFLOW-V2-PHASE1.md
Normal file
@ -0,0 +1,706 @@
|
||||
# Gear Parent Inference Workflow V2 Phase 1 Spec
|
||||
|
||||
## 목적
|
||||
|
||||
이 문서는 `GEAR-PARENT-INFERENCE-WORKFLOW-V2.md`의 첫 구현 단계를 바로 개발할 수 있는 수준으로 구체화한 명세다.
|
||||
|
||||
Phase 1 범위는 아래로 제한한다.
|
||||
|
||||
- DB 마이그레이션
|
||||
- backend API 계약
|
||||
- prediction exclusion/label read-write 지점
|
||||
- 프론트의 최소 계약 변화
|
||||
|
||||
이번 단계에서는 실제 자동화/LLM 연결은 다루지 않는다.
|
||||
|
||||
## 범위 요약
|
||||
|
||||
### 포함
|
||||
|
||||
- 그룹 단위 후보 제외 `1/3/5일`
|
||||
- 전역 후보 제외
|
||||
- 정답 라벨 세션 `1/3/5일`
|
||||
- 라벨 세션 기간 동안 cycle별 tracking 기록
|
||||
- active exclusion을 parent inference 후보 생성에 반영
|
||||
- exclusion/label 관리 API
|
||||
|
||||
### 제외
|
||||
|
||||
- 운영 `kcg` 스키마 반영
|
||||
- 기존 `gear_correlation_scores` 산식 변경
|
||||
- LLM reviewer
|
||||
- label session의 anchor 기반 재매칭 보강
|
||||
- UI 고도화 화면 전부
|
||||
|
||||
## 구현 원칙
|
||||
|
||||
1. 기존 자동 추론 저장소는 유지한다.
|
||||
2. 새 사람 판단 데이터는 별도 테이블에 저장한다.
|
||||
3. Phase 1에서는 `group_key + sub_cluster_id`를 세션 식별 기준으로 고정한다.
|
||||
4. 기존 `CONFIRM/REJECT/RESET` API는 삭제하지 않지만, 새 UI에서는 사용하지 않는다.
|
||||
5. 새 API와 prediction 로직은 `kcg_lab` 기준으로만 먼저 구현한다.
|
||||
|
||||
## DB 명세
|
||||
|
||||
## 1. `gear_parent_candidate_exclusions`
|
||||
|
||||
### 목적
|
||||
|
||||
- 그룹 단위 후보 제외와 전역 후보 제외를 단일 저장소에서 관리
|
||||
|
||||
### DDL 초안
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS kcg.gear_parent_candidate_exclusions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
scope_type VARCHAR(16) NOT NULL,
|
||||
group_key VARCHAR(100),
|
||||
sub_cluster_id SMALLINT,
|
||||
candidate_mmsi VARCHAR(20) NOT NULL,
|
||||
reason_type VARCHAR(32) NOT NULL,
|
||||
duration_days INT,
|
||||
active_from TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
active_until TIMESTAMPTZ,
|
||||
released_at TIMESTAMPTZ,
|
||||
released_by VARCHAR(100),
|
||||
actor VARCHAR(100) NOT NULL,
|
||||
comment TEXT,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT chk_gpce_scope CHECK (scope_type IN ('GROUP', 'GLOBAL')),
|
||||
CONSTRAINT chk_gpce_reason CHECK (reason_type IN ('GROUP_WRONG_PARENT', 'GLOBAL_NOT_PARENT_TARGET')),
|
||||
CONSTRAINT chk_gpce_group_scope CHECK (
|
||||
(scope_type = 'GROUP' AND group_key IS NOT NULL AND sub_cluster_id IS NOT NULL AND duration_days IN (1, 3, 5) AND active_until IS NOT NULL)
|
||||
OR
|
||||
(scope_type = 'GLOBAL' AND duration_days IS NULL)
|
||||
)
|
||||
);
|
||||
```
|
||||
|
||||
### 인덱스
|
||||
|
||||
```sql
|
||||
CREATE INDEX IF NOT EXISTS idx_gpce_scope_mmsi_active
|
||||
ON kcg.gear_parent_candidate_exclusions(scope_type, candidate_mmsi, active_from DESC)
|
||||
WHERE released_at IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_gpce_group_active
|
||||
ON kcg.gear_parent_candidate_exclusions(group_key, sub_cluster_id, active_from DESC)
|
||||
WHERE released_at IS NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_gpce_active_until
|
||||
ON kcg.gear_parent_candidate_exclusions(active_until);
|
||||
```
|
||||
|
||||
### active 판정 규칙
|
||||
|
||||
active exclusion은 아래를 만족해야 한다.
|
||||
|
||||
```sql
|
||||
released_at IS NULL
|
||||
AND active_from <= NOW()
|
||||
AND (active_until IS NULL OR active_until > NOW())
|
||||
```
|
||||
|
||||
### 해석 규칙
|
||||
|
||||
- `GROUP`
|
||||
- 특정 그룹에서만 해당 후보 제거
|
||||
- `GLOBAL`
|
||||
- 모든 그룹에서 해당 후보 제거
|
||||
|
||||
## 2. `gear_parent_label_sessions`
|
||||
|
||||
### 목적
|
||||
|
||||
- 정답 라벨 세션 저장
|
||||
|
||||
### DDL 초안
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS kcg.gear_parent_label_sessions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
group_key VARCHAR(100) NOT NULL,
|
||||
sub_cluster_id SMALLINT NOT NULL,
|
||||
label_parent_mmsi VARCHAR(20) NOT NULL,
|
||||
label_parent_name VARCHAR(200),
|
||||
label_parent_vessel_id INT REFERENCES kcg.fleet_vessels(id) ON DELETE SET NULL,
|
||||
duration_days INT NOT NULL,
|
||||
active_from TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
active_until TIMESTAMPTZ NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE',
|
||||
actor VARCHAR(100) NOT NULL,
|
||||
comment TEXT,
|
||||
anchor_snapshot_time TIMESTAMPTZ,
|
||||
anchor_center_point geometry(Point, 4326),
|
||||
anchor_member_mmsis JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT chk_gpls_duration CHECK (duration_days IN (1, 3, 5)),
|
||||
CONSTRAINT chk_gpls_status CHECK (status IN ('ACTIVE', 'EXPIRED', 'CANCELLED'))
|
||||
);
|
||||
```
|
||||
|
||||
### 인덱스
|
||||
|
||||
```sql
|
||||
CREATE INDEX IF NOT EXISTS idx_gpls_group_active
|
||||
ON kcg.gear_parent_label_sessions(group_key, sub_cluster_id, active_from DESC)
|
||||
WHERE status = 'ACTIVE';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_gpls_mmsi_active
|
||||
ON kcg.gear_parent_label_sessions(label_parent_mmsi, active_from DESC)
|
||||
WHERE status = 'ACTIVE';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_gpls_active_until
|
||||
ON kcg.gear_parent_label_sessions(active_until);
|
||||
```
|
||||
|
||||
### active 판정 규칙
|
||||
|
||||
```sql
|
||||
status = 'ACTIVE'
|
||||
AND active_from <= NOW()
|
||||
AND active_until > NOW()
|
||||
```
|
||||
|
||||
### 만료 처리 규칙
|
||||
|
||||
prediction 또는 backend batch에서 아래를 주기적으로 실행한다.
|
||||
|
||||
```sql
|
||||
UPDATE kcg.gear_parent_label_sessions
|
||||
SET status = 'EXPIRED', updated_at = NOW()
|
||||
WHERE status = 'ACTIVE'
|
||||
AND active_until <= NOW();
|
||||
```
|
||||
|
||||
## 3. `gear_parent_label_tracking_cycles`
|
||||
|
||||
### 목적
|
||||
|
||||
- 활성 정답 라벨 세션 동안 cycle별 자동 추론 결과 저장
|
||||
|
||||
### DDL 초안
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS kcg.gear_parent_label_tracking_cycles (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
label_session_id BIGINT NOT NULL REFERENCES kcg.gear_parent_label_sessions(id) ON DELETE CASCADE,
|
||||
observed_at TIMESTAMPTZ NOT NULL,
|
||||
candidate_snapshot_observed_at TIMESTAMPTZ,
|
||||
auto_status VARCHAR(40),
|
||||
top_candidate_mmsi VARCHAR(20),
|
||||
top_candidate_name VARCHAR(200),
|
||||
top_candidate_score DOUBLE PRECISION,
|
||||
top_candidate_margin DOUBLE PRECISION,
|
||||
candidate_count INT NOT NULL DEFAULT 0,
|
||||
labeled_candidate_present BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
labeled_candidate_rank INT,
|
||||
labeled_candidate_score DOUBLE PRECISION,
|
||||
labeled_candidate_pre_bonus_score DOUBLE PRECISION,
|
||||
labeled_candidate_margin_from_top DOUBLE PRECISION,
|
||||
matched_top1 BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
matched_top3 BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
evidence_summary JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT uq_gpltc_session_observed UNIQUE (label_session_id, observed_at)
|
||||
);
|
||||
```
|
||||
|
||||
### 인덱스
|
||||
|
||||
```sql
|
||||
CREATE INDEX IF NOT EXISTS idx_gpltc_session_observed
|
||||
ON kcg.gear_parent_label_tracking_cycles(label_session_id, observed_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_gpltc_top_candidate
|
||||
ON kcg.gear_parent_label_tracking_cycles(top_candidate_mmsi);
|
||||
```
|
||||
|
||||
## 4. 기존 `gear_group_parent_review_log` action 확장
|
||||
|
||||
### 새 action 목록
|
||||
|
||||
- `LABEL_PARENT`
|
||||
- `EXCLUDE_GROUP`
|
||||
- `EXCLUDE_GLOBAL`
|
||||
- `RELEASE_EXCLUSION`
|
||||
- `CANCEL_LABEL`
|
||||
|
||||
기존 action과 공존한다.
|
||||
|
||||
## migration 파일 제안
|
||||
|
||||
- `014_gear_parent_workflow_v2_phase1.sql`
|
||||
|
||||
구성 순서:
|
||||
|
||||
1. 새 테이블 3개 생성
|
||||
2. 인덱스 생성
|
||||
3. review log action 확장은 schema 변경 불필요
|
||||
4. optional helper view 추가
|
||||
|
||||
## optional view 제안
|
||||
|
||||
### `vw_active_gear_parent_candidate_exclusions`
|
||||
|
||||
```sql
|
||||
CREATE OR REPLACE VIEW kcg.vw_active_gear_parent_candidate_exclusions AS
|
||||
SELECT *
|
||||
FROM kcg.gear_parent_candidate_exclusions
|
||||
WHERE released_at IS NULL
|
||||
AND active_from <= NOW()
|
||||
AND (active_until IS NULL OR active_until > NOW());
|
||||
```
|
||||
|
||||
### `vw_active_gear_parent_label_sessions`
|
||||
|
||||
```sql
|
||||
CREATE OR REPLACE VIEW kcg.vw_active_gear_parent_label_sessions AS
|
||||
SELECT *
|
||||
FROM kcg.gear_parent_label_sessions
|
||||
WHERE status = 'ACTIVE'
|
||||
AND active_from <= NOW()
|
||||
AND active_until > NOW();
|
||||
```
|
||||
|
||||
## backend API 명세
|
||||
|
||||
## 공통 정책
|
||||
|
||||
- 모든 write API는 `actor` 필수
|
||||
- `group_key`, `sub_cluster_id`, `candidate_mmsi`, `selected_parent_mmsi`는 trim 후 저장
|
||||
- 잘못된 기간은 `400 Bad Request`
|
||||
- 중복 active session/exclusion 생성 시 `409 Conflict` 대신 동일 active row를 반환해도 됨
|
||||
- Phase 1에서는 멱등성을 우선한다
|
||||
|
||||
## 1. 정답 라벨 세션 생성
|
||||
|
||||
### endpoint
|
||||
|
||||
`POST /api/vessel-analysis/groups/{groupKey}/parent-inference/{subClusterId}/label-sessions`
|
||||
|
||||
### request
|
||||
|
||||
```json
|
||||
{
|
||||
"selectedParentMmsi": "412333326",
|
||||
"durationDays": 3,
|
||||
"actor": "analyst-01",
|
||||
"comment": "수동 검토 확정"
|
||||
}
|
||||
```
|
||||
|
||||
### validation
|
||||
|
||||
- `selectedParentMmsi` 필수
|
||||
- `durationDays in (1,3,5)`
|
||||
- 동일 `groupKey + subClusterId`에 active label session이 이미 있으면 새 row 생성 금지
|
||||
|
||||
### response
|
||||
|
||||
```json
|
||||
{
|
||||
"groupKey": "58399",
|
||||
"subClusterId": 0,
|
||||
"action": "LABEL_PARENT",
|
||||
"labelSession": {
|
||||
"id": 12,
|
||||
"status": "ACTIVE",
|
||||
"labelParentMmsi": "412333326",
|
||||
"labelParentName": "UWEIJINGYU51015",
|
||||
"durationDays": 3,
|
||||
"activeFrom": "2026-04-03T10:00:00+09:00",
|
||||
"activeUntil": "2026-04-06T10:00:00+09:00",
|
||||
"actor": "analyst-01",
|
||||
"comment": "수동 검토 확정"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 2. 그룹 후보 제외 생성
|
||||
|
||||
### endpoint
|
||||
|
||||
`POST /api/vessel-analysis/groups/{groupKey}/parent-inference/{subClusterId}/candidate-exclusions`
|
||||
|
||||
### request
|
||||
|
||||
```json
|
||||
{
|
||||
"candidateMmsi": "412333326",
|
||||
"durationDays": 3,
|
||||
"actor": "analyst-01",
|
||||
"comment": "이 그룹에서는 오답"
|
||||
}
|
||||
```
|
||||
|
||||
### 생성 규칙
|
||||
|
||||
- 내부적으로 `scopeType='GROUP'`
|
||||
- `reasonType='GROUP_WRONG_PARENT'`
|
||||
- 동일 `groupKey + subClusterId + candidateMmsi` active row가 있으면 재사용
|
||||
|
||||
### response
|
||||
|
||||
```json
|
||||
{
|
||||
"groupKey": "58399",
|
||||
"subClusterId": 0,
|
||||
"action": "EXCLUDE_GROUP",
|
||||
"exclusion": {
|
||||
"id": 33,
|
||||
"scopeType": "GROUP",
|
||||
"candidateMmsi": "412333326",
|
||||
"durationDays": 3,
|
||||
"activeFrom": "2026-04-03T10:00:00+09:00",
|
||||
"activeUntil": "2026-04-06T10:00:00+09:00"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 3. 전역 후보 제외 생성
|
||||
|
||||
### endpoint
|
||||
|
||||
`POST /api/vessel-analysis/parent-inference/candidate-exclusions/global`
|
||||
|
||||
### request
|
||||
|
||||
```json
|
||||
{
|
||||
"candidateMmsi": "412333326",
|
||||
"actor": "analyst-01",
|
||||
"comment": "모든 어구에서 후보 제외"
|
||||
}
|
||||
```
|
||||
|
||||
### 생성 규칙
|
||||
|
||||
- `scopeType='GLOBAL'`
|
||||
- `reasonType='GLOBAL_NOT_PARENT_TARGET'`
|
||||
- `activeUntil = NULL`
|
||||
- 동일 candidate active global exclusion이 있으면 재사용
|
||||
|
||||
## 4. exclusion 해제
|
||||
|
||||
### endpoint
|
||||
|
||||
`POST /api/vessel-analysis/parent-inference/candidate-exclusions/{id}/release`
|
||||
|
||||
### request
|
||||
|
||||
```json
|
||||
{
|
||||
"actor": "analyst-01",
|
||||
"comment": "해제"
|
||||
}
|
||||
```
|
||||
|
||||
### 동작
|
||||
|
||||
- `released_at = NOW()`
|
||||
- `released_by = actor`
|
||||
- `updated_at = NOW()`
|
||||
|
||||
## 5. label session 종료
|
||||
|
||||
### endpoint
|
||||
|
||||
`POST /api/vessel-analysis/parent-inference/label-sessions/{id}/cancel`
|
||||
|
||||
### request
|
||||
|
||||
```json
|
||||
{
|
||||
"actor": "analyst-01",
|
||||
"comment": "조기 종료"
|
||||
}
|
||||
```
|
||||
|
||||
### 동작
|
||||
|
||||
- `status='CANCELLED'`
|
||||
- `updated_at = NOW()`
|
||||
|
||||
## 6. active exclusion 조회
|
||||
|
||||
### endpoint
|
||||
|
||||
`GET /api/vessel-analysis/parent-inference/candidate-exclusions?status=ACTIVE&scopeType=GROUP|GLOBAL&candidateMmsi=...&groupKey=...`
|
||||
|
||||
### response 필드
|
||||
|
||||
- `id`
|
||||
- `scopeType`
|
||||
- `groupKey`
|
||||
- `subClusterId`
|
||||
- `candidateMmsi`
|
||||
- `reasonType`
|
||||
- `durationDays`
|
||||
- `activeFrom`
|
||||
- `activeUntil`
|
||||
- `releasedAt`
|
||||
- `actor`
|
||||
- `comment`
|
||||
- `isActive`
|
||||
|
||||
## 7. label session 목록
|
||||
|
||||
### endpoint
|
||||
|
||||
`GET /api/vessel-analysis/parent-inference/label-sessions?status=ACTIVE|EXPIRED|CANCELLED&groupKey=...`
|
||||
|
||||
### response 필드
|
||||
|
||||
- `id`
|
||||
- `groupKey`
|
||||
- `subClusterId`
|
||||
- `labelParentMmsi`
|
||||
- `labelParentName`
|
||||
- `durationDays`
|
||||
- `activeFrom`
|
||||
- `activeUntil`
|
||||
- `status`
|
||||
- `actor`
|
||||
- `comment`
|
||||
- `latestTrackingSummary`
|
||||
|
||||
## 8. label tracking 상세
|
||||
|
||||
### endpoint
|
||||
|
||||
`GET /api/vessel-analysis/parent-inference/label-sessions/{id}/tracking`
|
||||
|
||||
### response 필드
|
||||
|
||||
- `session`
|
||||
- `count`
|
||||
- `items[]`
|
||||
- `observedAt`
|
||||
- `autoStatus`
|
||||
- `topCandidateMmsi`
|
||||
- `topCandidateScore`
|
||||
- `topCandidateMargin`
|
||||
- `candidateCount`
|
||||
- `labeledCandidatePresent`
|
||||
- `labeledCandidateRank`
|
||||
- `labeledCandidateScore`
|
||||
- `labeledCandidatePreBonusScore`
|
||||
- `matchedTop1`
|
||||
- `matchedTop3`
|
||||
|
||||
## backend 구현 위치
|
||||
|
||||
### 새 DTO/Request 제안
|
||||
|
||||
- `GroupParentLabelSessionRequest`
|
||||
- `GroupParentCandidateExclusionRequest`
|
||||
- `ReleaseParentCandidateExclusionRequest`
|
||||
- `CancelParentLabelSessionRequest`
|
||||
- `ParentCandidateExclusionDto`
|
||||
- `ParentLabelSessionDto`
|
||||
- `ParentLabelTrackingCycleDto`
|
||||
|
||||
### service 추가 메서드 제안
|
||||
|
||||
- `createGroupCandidateExclusion(...)`
|
||||
- `createGlobalCandidateExclusion(...)`
|
||||
- `releaseCandidateExclusion(...)`
|
||||
- `createLabelSession(...)`
|
||||
- `cancelLabelSession(...)`
|
||||
- `listCandidateExclusions(...)`
|
||||
- `listLabelSessions(...)`
|
||||
- `getLabelSessionTracking(...)`
|
||||
|
||||
## prediction 명세
|
||||
|
||||
## 적용 함수
|
||||
|
||||
중심 파일은 [prediction/algorithms/gear_parent_inference.py](/Users/lht/work/devProjects/iran-airstrike-replay-codex/prediction/algorithms/gear_parent_inference.py)다.
|
||||
|
||||
### 새 load 함수
|
||||
|
||||
- `_load_active_candidate_exclusions(conn, group_keys)`
|
||||
- `_load_active_label_sessions(conn, group_keys)`
|
||||
|
||||
### 반환 구조
|
||||
|
||||
`_load_active_candidate_exclusions`
|
||||
|
||||
```python
|
||||
{
|
||||
"global": {"412333326", "413000111"},
|
||||
"group": {("58399", 0): {"412333326"}}
|
||||
}
|
||||
```
|
||||
|
||||
`_load_active_label_sessions`
|
||||
|
||||
```python
|
||||
{
|
||||
("58399", 0): {
|
||||
"id": 12,
|
||||
"label_parent_mmsi": "412333326",
|
||||
"active_until": ...,
|
||||
...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 후보 pruning 순서
|
||||
|
||||
1. 기존 candidate union 생성
|
||||
2. `GLOBAL` exclusion 제거
|
||||
3. 해당 그룹의 `GROUP` exclusion 제거
|
||||
4. 남은 후보만 scoring
|
||||
|
||||
### tracking row write 규칙
|
||||
|
||||
각 그룹 처리 후:
|
||||
|
||||
- active label session이 없으면 skip
|
||||
- 있으면 현재 cycle 결과를 `gear_parent_label_tracking_cycles`에 upsert-like insert
|
||||
|
||||
필수 기록값:
|
||||
|
||||
- `label_session_id`
|
||||
- `observed_at`
|
||||
- `candidate_snapshot_observed_at`
|
||||
- `auto_status`
|
||||
- `top_candidate_mmsi`
|
||||
- `top_candidate_score`
|
||||
- `top_candidate_margin`
|
||||
- `candidate_count`
|
||||
- `labeled_candidate_present`
|
||||
- `labeled_candidate_rank`
|
||||
- `labeled_candidate_score`
|
||||
- `labeled_candidate_pre_bonus_score`
|
||||
- `matched_top1`
|
||||
- `matched_top3`
|
||||
|
||||
### pre-bonus score 취득
|
||||
|
||||
현재 candidate evidence에 이미 아래가 있다.
|
||||
|
||||
- `evidence.scoreBreakdown.preBonusScore`
|
||||
|
||||
tracking row에서는 이 값을 직접 읽어 저장한다.
|
||||
|
||||
### resolution 처리 원칙
|
||||
|
||||
Phase 1에서는 다음을 적용한다.
|
||||
|
||||
- `LABEL_PARENT`, `EXCLUDE_GROUP`, `EXCLUDE_GLOBAL`은 `gear_group_parent_resolution` 상태를 바꾸지 않는다.
|
||||
- 자동 추론은 기존 상태 전이를 그대로 사용한다.
|
||||
- legacy `MANUAL_CONFIRMED` 로직은 남겨두되, 새 UI에서는 호출하지 않는다.
|
||||
|
||||
## 프론트 최소 계약
|
||||
|
||||
## 기존 패널 액션 치환
|
||||
|
||||
현재:
|
||||
|
||||
- `확정`
|
||||
- `24시간 제외`
|
||||
|
||||
Phase 1 새 기본 액션:
|
||||
|
||||
- `정답 라벨`
|
||||
- `이 그룹에서 제외`
|
||||
- `전체 후보 제외`
|
||||
|
||||
### 기간 선택 UI
|
||||
|
||||
- `정답 라벨`: `1일`, `3일`, `5일`
|
||||
- `이 그룹에서 제외`: `1일`, `3일`, `5일`
|
||||
- `전체 후보 제외`: 기간 없음
|
||||
|
||||
### 표시 정보
|
||||
|
||||
후보 card badge:
|
||||
|
||||
- `이 그룹 제외 중`
|
||||
- `전체 후보 제외 중`
|
||||
- `정답 라벨 대상`
|
||||
|
||||
그룹 summary box:
|
||||
|
||||
- active label session 여부
|
||||
- active group exclusion count
|
||||
|
||||
## API 에러 규약
|
||||
|
||||
### 400
|
||||
|
||||
- 잘못된 duration
|
||||
- 필수 필드 누락
|
||||
- groupKey/subClusterId 없음
|
||||
|
||||
### 404
|
||||
|
||||
- 대상 group 없음
|
||||
- exclusion/session id 없음
|
||||
|
||||
### 409
|
||||
|
||||
- active label session 중복 생성
|
||||
|
||||
단, Phase 1에서는 backend에서 충돌 시 기존 active row를 그대로 반환하는 방식도 허용한다.
|
||||
|
||||
## 테스트 기준
|
||||
|
||||
## DB
|
||||
|
||||
- GROUP exclusion active query가 정확히 동작
|
||||
- GLOBAL exclusion active query가 정확히 동작
|
||||
- label session 만료 시 `EXPIRED` 전환
|
||||
|
||||
## backend
|
||||
|
||||
- create/release exclusion API
|
||||
- create/cancel label session API
|
||||
- list APIs 필터 조건
|
||||
|
||||
## prediction
|
||||
|
||||
- active exclusion candidate pruning
|
||||
- global/group exclusion 우선 적용
|
||||
- label session tracking row 생성
|
||||
- labeled candidate absent/present/top1/top3 케이스
|
||||
|
||||
## 수용 기준
|
||||
|
||||
1. 특정 그룹에서 후보 제외를 걸면 다음 cycle부터 그 그룹 후보 목록에서만 빠진다.
|
||||
2. 전역 후보 제외를 걸면 모든 그룹 후보 목록에서 빠진다.
|
||||
3. 정답 라벨 세션 생성 후 다음 cycle부터 tracking row가 쌓인다.
|
||||
4. 자동 resolution은 계속 자동 상태를 유지한다.
|
||||
5. 기존 manual override API를 쓰지 않아도 review/label/exclusion 흐름이 독립적으로 동작한다.
|
||||
|
||||
## Phase 1 이후 바로 이어질 일
|
||||
|
||||
### Phase 2
|
||||
|
||||
- 라벨 추적 대시보드
|
||||
- exclusion 관리 화면
|
||||
- 지표 요약 endpoint
|
||||
- episode continuity read model 노출
|
||||
- prior bonus calibration report
|
||||
|
||||
### Phase 3
|
||||
|
||||
- label session anchor 기반 재매칭
|
||||
- group case/episode lineage API 확장
|
||||
- calibration report
|
||||
|
||||
## 권장 구현 순서
|
||||
|
||||
1. `014_gear_parent_workflow_v2_phase1.sql`
|
||||
2. backend DTO + controller/service
|
||||
3. prediction active exclusion/load + tracking write
|
||||
4. frontend 버튼 교체와 최소 조회 화면
|
||||
|
||||
이 순서가 현재 코드 충돌과 운영 영향이 가장 적다.
|
||||
693
docs/GEAR-PARENT-INFERENCE-WORKFLOW-V2.md
Normal file
693
docs/GEAR-PARENT-INFERENCE-WORKFLOW-V2.md
Normal file
@ -0,0 +1,693 @@
|
||||
# Gear Parent Inference Workflow V2
|
||||
|
||||
## 문서 목적
|
||||
|
||||
이 문서는 lab 환경의 어구 모선 추적 워크플로우를 v1 운영 override 중심 구조에서,
|
||||
`평가 데이터 축적 + 후보 제외 관리 + 기간형 정답 라벨 추적` 중심 구조로 재정의하는 설계서다.
|
||||
|
||||
대상 범위는 아래와 같다.
|
||||
|
||||
- `kcg_lab` 스키마
|
||||
- `backend-lab` (`192.168.1.20:18083`)
|
||||
- `prediction-lab` (`192.168.1.18:18091`)
|
||||
- 로컬 프론트 `yarn dev:lab`
|
||||
|
||||
운영 `kcg` 스키마와 기존 데모 동작은 이번 설계 단계에서 변경하지 않는다.
|
||||
|
||||
현재 구현 기준으로는 v2 Phase 1 저장소/API가 이미 lab에 반영되어 있고, 그 위에 `015_gear_parent_episode_tracking.sql`과 `prediction/algorithms/gear_parent_episode.py`를 통해 `episode continuity + prior bonus` 계층이 추가되었다. 이 문서는 여전히 워크플로우 설계서지만, 사람 판단 저장소와 자동 추론 저장소 분리 원칙은 현재 코드의 실제 기준이기도 하다.
|
||||
|
||||
## 배경
|
||||
|
||||
현재 v1은 자동 추론 결과와 사람 판단이 같은 저장소에 섞여 있다.
|
||||
|
||||
- `확정`은 `gear_group_parent_resolution`을 `MANUAL_CONFIRMED`로 덮어쓴다.
|
||||
- `24시간 제외`는 특정 그룹에서 후보 1개를 24시간 숨긴다.
|
||||
- 자동 추론은 계속 돌지만, 수동 판단이 최종 상태를 override한다.
|
||||
|
||||
이 구조는 단기 운용에는 편하지만, 아래 목적에는 맞지 않는다.
|
||||
|
||||
- 사람이 보면서 모델 가중치와 후보 생성 품질을 평가
|
||||
- 정답/오답 사례를 데이터셋으로 축적
|
||||
- 충분한 정확도 확보 후 자동화 또는 LLM 연결
|
||||
|
||||
따라서 v2에서는 `자동 추론`, `사람 라벨`, `후보 제외`를 분리한다.
|
||||
|
||||
## 핵심 목표
|
||||
|
||||
1. 자동 추론 상태는 계속 독립적으로 유지한다.
|
||||
2. 사람 판단은 override가 아니라 별도 라벨/제외 데이터로 저장한다.
|
||||
3. 그룹 단위 오답 라벨은 `1일 / 3일 / 5일` 기간형 후보 제외로 관리한다.
|
||||
4. 전역 후보 제외는 모든 어구 그룹에서 동일 MMSI를 후보군에서 제거한다.
|
||||
5. 정답 라벨은 `1일 / 3일 / 5일` 세션으로 만들고, 활성 기간 동안 자동 추론 결과를 별도 추적 로그로 남긴다.
|
||||
6. 알고리즘은 DB exclusion/label 정보를 읽어 다음 cycle부터 바로 반영한다.
|
||||
7. 향후 threshold 튜닝, 가산점 실험, LLM 연결 평가에 쓰일 수 있는 정량 지표를 만든다.
|
||||
|
||||
## 용어
|
||||
|
||||
- 자동 추론
|
||||
- `gear_parent_inference`가 계산한 현재 cycle의 후보 점수와 추천 결과
|
||||
- 그룹 제외
|
||||
- 특정 `group_key + sub_cluster_id`에서 특정 후보 MMSI를 일정 기간 후보군에서 제거
|
||||
- 전역 후보 제외
|
||||
- 특정 MMSI를 모든 어구 그룹의 모선 후보군에서 제거
|
||||
- 정답 라벨 세션
|
||||
- 특정 어구 그룹에 대해 “이 MMSI가 정답 모선”이라고 사람이 지정하고, 일정 기간 자동 추론 결과를 추적하는 세션
|
||||
- 라벨 추적
|
||||
- 정답 라벨 세션 활성 기간 동안 자동 추론이 정답 후보를 어떻게 rank/score하는지 누적 저장하는 기록
|
||||
|
||||
## 현재 v1의 한계
|
||||
|
||||
### 1. `확정`이 평가 라벨이 아니라 운영 override다
|
||||
|
||||
- 현재 `CONFIRM`은 resolution을 `MANUAL_CONFIRMED`로 덮어쓴다.
|
||||
- 이 경우 자동 추론의 실제 성능과 사람 판단이 섞여, 나중에 모델 정확도를 평가하기 어렵다.
|
||||
|
||||
### 2. `24시간 제외`는 기간과 범위가 너무 좁다
|
||||
|
||||
- 현재는 그룹 단위 24시간 mute만 가능하다.
|
||||
- `1/3/5일`처럼 길이를 다르게 두고 비교할 수 없다.
|
||||
- “이 MMSI는 아예 모선 후보 대상이 아니다”라는 전역 규칙을 넣을 수 없다.
|
||||
|
||||
### 3. 백데이터 축적 구조가 없다
|
||||
|
||||
- 현재는 review log는 남지만, “정답 후보가 cycle별로 몇 위였는지”, “점수가 어떻게 변했는지”, “후보군에 들어왔는지”를 체계적으로 저장하지 않는다.
|
||||
|
||||
### 4. 장기 세션에 대한 그룹 스코프가 약하다
|
||||
|
||||
- 현재 그룹 기준은 `group_key + sub_cluster_id`다.
|
||||
- 기간형 라벨/제외를 도입하면 subcluster 재편성 리스크를 고려해야 한다.
|
||||
|
||||
## v2 설계 원칙
|
||||
|
||||
### 1. 자동 추론 저장소는 그대로 유지한다
|
||||
|
||||
아래 기존 저장소는 계속 자동 추론 전용으로 유지한다.
|
||||
|
||||
- `gear_group_parent_candidate_snapshots`
|
||||
- `gear_group_parent_resolution`
|
||||
- `gear_group_parent_review_log`
|
||||
|
||||
단, `review_log`의 의미는 “UI action audit”로 바꾸고, 더 이상 최종 라벨 저장소로 보지 않는다.
|
||||
|
||||
### 2. 사람 판단은 새 저장소로 분리한다
|
||||
|
||||
사람이 내린 판단은 아래 두 축으로 분리한다.
|
||||
|
||||
- 제외 축
|
||||
- 이 그룹에서 제외
|
||||
- 전체 후보 제외
|
||||
- 정답 축
|
||||
- 기간형 정답 라벨 세션
|
||||
|
||||
### 3. 제외는 후보 생성 이후의 gating layer로 둔다
|
||||
|
||||
전역 후보 제외는 raw correlation이나 원시 선박 분류를 지우지 않는다.
|
||||
|
||||
- `gear_correlation_scores`는 계속 쌓는다.
|
||||
- exclusion은 parent inference candidate set에서만 hard filter로 적용한다.
|
||||
|
||||
이렇게 해야 원시 모델 출력과 사람 개입의 차이를 비교할 수 있다.
|
||||
|
||||
### 4. 라벨 세션 동안 자동 추론은 계속 돈다
|
||||
|
||||
정답 라벨 세션이 활성화되어도 자동 추론은 그대로 수행한다.
|
||||
|
||||
- UI의 기본 검토 대기에서는 숨길 수 있다.
|
||||
- 하지만 prediction은 계속 candidate snapshot과 tracking record를 남긴다.
|
||||
|
||||
### 5. lab에서는 override보다 평가를 우선한다
|
||||
|
||||
v2 이후 lab에서 사람 버튼은 기본적으로 자동 resolution을 덮어쓰지 않는다.
|
||||
|
||||
- 운영 override가 필요해지면 추후 별도 action으로 분리한다.
|
||||
- lab의 기본 목적은 평가 데이터 생성이다.
|
||||
|
||||
## 사용자 액션 재정의
|
||||
|
||||
### `정답 라벨`
|
||||
|
||||
의미:
|
||||
|
||||
- 해당 어구 그룹의 정답 모선으로 특정 MMSI를 지정
|
||||
- `1일 / 3일 / 5일` 중 하나의 기간 동안 자동 추론 결과를 추적
|
||||
|
||||
동작:
|
||||
|
||||
1. `gear_parent_label_sessions`에 active session 생성
|
||||
2. 다음 cycle부터 prediction이 이 그룹에 대한 추적 로그를 `gear_parent_label_tracking_cycles`에 누적
|
||||
3. 기본 review queue에서는 해당 그룹을 숨기고, 별도 `라벨 추적` 목록으로 이동
|
||||
4. 세션 종료 후에는 completed label dataset으로 남음
|
||||
|
||||
중요:
|
||||
|
||||
- 자동 resolution은 계속 자동 상태를 유지
|
||||
- 점수에 수동 가산점/감점은 넣지 않음
|
||||
|
||||
### `이 그룹에서 제외`
|
||||
|
||||
의미:
|
||||
|
||||
- 해당 어구 그룹에서만 특정 후보 MMSI를 일정 기간 후보군에서 제외
|
||||
|
||||
기간:
|
||||
|
||||
- `1일`
|
||||
- `3일`
|
||||
- `5일`
|
||||
|
||||
동작:
|
||||
|
||||
1. `gear_parent_candidate_exclusions`에 `scope_type='GROUP'` row 생성
|
||||
2. 다음 cycle부터 해당 그룹의 candidate set에서 제거
|
||||
3. 다른 그룹에는 영향 없음
|
||||
4. 기간이 끝나면 자동으로 inactive 처리
|
||||
|
||||
용도:
|
||||
|
||||
- 이 후보는 이 어구 그룹의 모선이 아니라고 사람이 판단한 경우
|
||||
- 단기/중기 관찰을 위해 일정 기간만 빼고 싶을 때
|
||||
|
||||
### `전체 후보 제외`
|
||||
|
||||
의미:
|
||||
|
||||
- 특정 MMSI는 모든 어구 그룹에서 모선 후보 대상이 아님
|
||||
|
||||
동작:
|
||||
|
||||
1. `gear_parent_candidate_exclusions`에 `scope_type='GLOBAL'` row 생성
|
||||
2. prediction candidate generation에서 모든 그룹에 대해 hard filter
|
||||
3. 해제 전까지 계속 적용
|
||||
|
||||
초기 정책:
|
||||
|
||||
- 전역 후보 제외는 기본적으로 기간 없이 active 상태 유지
|
||||
- 수동 `해제` 전까지 유지
|
||||
|
||||
용도:
|
||||
|
||||
- 패턴 분류상 선박으로 들어왔지만 실제 모선 후보가 아니라고 판단한 AIS
|
||||
- 잘못된 유형의 신호가 반복적으로 후보군에 유입되는 경우
|
||||
|
||||
### `해제`
|
||||
|
||||
의미:
|
||||
|
||||
- 활성 그룹 제외, 전역 제외, 정답 라벨 세션을 조기 종료
|
||||
|
||||
동작:
|
||||
|
||||
- exclusion/session row에 `released_at`, `released_by` 또는 `status='CANCELLED'`를 기록
|
||||
- 다음 cycle부터 알고리즘 적용 대상에서 빠짐
|
||||
|
||||
## DB 설계
|
||||
|
||||
### 1. `gear_parent_candidate_exclusions`
|
||||
|
||||
역할:
|
||||
|
||||
- 그룹 단위 제외와 전역 후보 제외를 모두 저장
|
||||
- active list의 단일 진실원
|
||||
|
||||
권장 컬럼:
|
||||
|
||||
```sql
|
||||
CREATE TABLE kcg_lab.gear_parent_candidate_exclusions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
scope_type VARCHAR(16) NOT NULL, -- GROUP | GLOBAL
|
||||
group_key VARCHAR(100), -- GROUP scope에서만 사용
|
||||
sub_cluster_id SMALLINT,
|
||||
candidate_mmsi VARCHAR(20) NOT NULL,
|
||||
reason_type VARCHAR(32) NOT NULL, -- GROUP_WRONG_PARENT | GLOBAL_NOT_PARENT_TARGET
|
||||
duration_days INT, -- GROUP scope는 1|3|5, GLOBAL은 NULL 허용
|
||||
active_from TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
active_until TIMESTAMPTZ, -- GROUP scope는 필수, GLOBAL은 NULL 가능
|
||||
released_at TIMESTAMPTZ,
|
||||
released_by VARCHAR(100),
|
||||
actor VARCHAR(100) NOT NULL,
|
||||
comment TEXT,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
권장 인덱스:
|
||||
|
||||
- `(scope_type, candidate_mmsi)`
|
||||
- `(group_key, sub_cluster_id, active_from DESC)`
|
||||
- `(released_at, active_until)`
|
||||
|
||||
조회 규칙:
|
||||
|
||||
active exclusion은 아래 조건으로 판단한다.
|
||||
|
||||
```sql
|
||||
released_at IS NULL
|
||||
AND active_from <= NOW()
|
||||
AND (active_until IS NULL OR active_until > NOW())
|
||||
```
|
||||
|
||||
### 2. `gear_parent_label_sessions`
|
||||
|
||||
역할:
|
||||
|
||||
- 특정 그룹에 대한 정답 라벨 세션 저장
|
||||
|
||||
권장 컬럼:
|
||||
|
||||
```sql
|
||||
CREATE TABLE kcg_lab.gear_parent_label_sessions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
group_key VARCHAR(100) NOT NULL,
|
||||
sub_cluster_id SMALLINT NOT NULL,
|
||||
label_parent_mmsi VARCHAR(20) NOT NULL,
|
||||
label_parent_name VARCHAR(200),
|
||||
label_parent_vessel_id INT REFERENCES kcg_lab.fleet_vessels(id) ON DELETE SET NULL,
|
||||
duration_days INT NOT NULL, -- 1 | 3 | 5
|
||||
active_from TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
active_until TIMESTAMPTZ NOT NULL,
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', -- ACTIVE | EXPIRED | CANCELLED
|
||||
actor VARCHAR(100) NOT NULL,
|
||||
comment TEXT,
|
||||
anchor_snapshot_time TIMESTAMPTZ,
|
||||
anchor_center_point geometry(Point, 4326),
|
||||
anchor_member_mmsis JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
설명:
|
||||
|
||||
- `anchor_*` 컬럼은 기간형 라벨 동안 subcluster가 재편성될 가능성에 대비한 보조 식별자다.
|
||||
- phase 1에서는 실제 매칭은 `group_key + sub_cluster_id`를 기본으로 쓰고, anchor 정보는 저장만 한다.
|
||||
|
||||
### 3. `gear_parent_label_tracking_cycles`
|
||||
|
||||
역할:
|
||||
|
||||
- 활성 정답 라벨 세션 동안 cycle별 자동 추론 결과 저장
|
||||
- 향후 정확도 지표의 기준 데이터
|
||||
|
||||
권장 컬럼:
|
||||
|
||||
```sql
|
||||
CREATE TABLE kcg_lab.gear_parent_label_tracking_cycles (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
label_session_id BIGINT NOT NULL REFERENCES kcg_lab.gear_parent_label_sessions(id) ON DELETE CASCADE,
|
||||
observed_at TIMESTAMPTZ NOT NULL,
|
||||
candidate_snapshot_observed_at TIMESTAMPTZ,
|
||||
auto_status VARCHAR(40),
|
||||
top_candidate_mmsi VARCHAR(20),
|
||||
top_candidate_name VARCHAR(200),
|
||||
top_candidate_score DOUBLE PRECISION,
|
||||
top_candidate_margin DOUBLE PRECISION,
|
||||
candidate_count INT NOT NULL DEFAULT 0,
|
||||
labeled_candidate_present BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
labeled_candidate_rank INT,
|
||||
labeled_candidate_score DOUBLE PRECISION,
|
||||
labeled_candidate_pre_bonus_score DOUBLE PRECISION,
|
||||
labeled_candidate_margin_from_top DOUBLE PRECISION,
|
||||
matched_top1 BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
matched_top3 BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
evidence_summary JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
설명:
|
||||
|
||||
- 전체 후보 상세는 기존 `gear_group_parent_candidate_snapshots`를 그대로 사용한다.
|
||||
- 여기에는 지표 계산에 직접 필요한 값만 요약 저장한다.
|
||||
|
||||
### 4. 기존 `gear_group_parent_review_log` 재사용
|
||||
|
||||
새 action 이름 예시:
|
||||
|
||||
- `LABEL_PARENT`
|
||||
- `EXCLUDE_GROUP`
|
||||
- `EXCLUDE_GLOBAL`
|
||||
- `RELEASE_EXCLUSION`
|
||||
- `CANCEL_LABEL`
|
||||
|
||||
즉, 별도 audit table를 또 만들기보다 기존 review log를 action log로 재사용한다.
|
||||
|
||||
## prediction 변경 설계
|
||||
|
||||
### 적용 지점
|
||||
|
||||
핵심 변경 지점은 [gear_parent_inference.py](/Users/lht/work/devProjects/iran-airstrike-replay-codex/prediction/algorithms/gear_parent_inference.py), [fleet_tracker.py](/Users/lht/work/devProjects/iran-airstrike-replay-codex/prediction/fleet_tracker.py), [polygon_builder.py](/Users/lht/work/devProjects/iran-airstrike-replay-codex/prediction/algorithms/polygon_builder.py) 중 `gear_parent_inference.py`가 중심이다.
|
||||
|
||||
### 1. active exclusion load
|
||||
|
||||
cycle 시작 시 아래 두 집합을 읽는다.
|
||||
|
||||
- `global_excluded_mmsis`
|
||||
- `group_excluded_mmsis[(group_key, sub_cluster_id)]`
|
||||
|
||||
적용 위치:
|
||||
|
||||
- `_build_candidate_scores()`에서 candidate union 이후, 실제 scoring 전에 hard filter
|
||||
|
||||
규칙:
|
||||
|
||||
- GLOBAL exclusion은 모든 그룹에 적용
|
||||
- GROUP exclusion은 해당 그룹에만 적용
|
||||
- exclusion된 후보는 candidate snapshot에도 남기지 않음
|
||||
|
||||
중요:
|
||||
|
||||
- raw correlation score는 그대로 계산/저장
|
||||
- exclusion은 parent inference candidate set에서만 적용
|
||||
|
||||
### 2. active label session load
|
||||
|
||||
cycle 시작 시 현재 unresolved/active gear group에 매칭되는 active label session을 읽는다.
|
||||
|
||||
phase 1 매칭 기준:
|
||||
|
||||
- `group_key`
|
||||
- `sub_cluster_id`
|
||||
|
||||
phase 2 보강 기준:
|
||||
|
||||
- member overlap
|
||||
- center distance
|
||||
- anchor snapshot similarity
|
||||
|
||||
### 3. tracking cycle write
|
||||
|
||||
각 그룹의 자동 추론이 끝난 뒤, active label session이 있으면 `gear_parent_label_tracking_cycles`에 1 row를 쓴다.
|
||||
|
||||
기록 항목:
|
||||
|
||||
- 현재 auto top-1 후보
|
||||
- auto top-1 점수/격차
|
||||
- 후보 수
|
||||
- 라벨 대상 MMSI가 현재 후보군에 존재하는지
|
||||
- 존재한다면 rank/score/pre-bonus score
|
||||
- top1/top3 일치 여부
|
||||
|
||||
### 4. resolution 저장 원칙 변경
|
||||
|
||||
v2 이후 lab에서는 아래를 원칙으로 한다.
|
||||
|
||||
- 자동 resolution은 자동 추론만 반영
|
||||
- 사람 라벨은 resolution을 덮어쓰지 않음
|
||||
|
||||
즉 아래 legacy 상태는 새로 만들지 않는다.
|
||||
|
||||
- `MANUAL_CONFIRMED`
|
||||
- `MANUAL_REJECT`
|
||||
|
||||
기존 row는 읽기 전용으로 남겨둘 수 있지만, v2 새 액션은 이 상태를 만들지 않는다.
|
||||
|
||||
### 5. exclusion이 적용된 경우의 상태 전이
|
||||
|
||||
후보 pruning 이후:
|
||||
|
||||
- 후보가 남으면 기존 자동 상태 전이 사용
|
||||
- top1이 제외되어 후보가 비면 `NO_CANDIDATE`
|
||||
- top1이 제외되어 top2가 승격되면 새 top1 기준으로 `AUTO_PROMOTED / REVIEW_REQUIRED / UNRESOLVED` 재판정
|
||||
|
||||
## backend API 설계
|
||||
|
||||
### 1. 정답 라벨 세션 생성
|
||||
|
||||
`POST /api/vessel-analysis/groups/{groupKey}/parent-inference/{subClusterId}/label-session`
|
||||
|
||||
request:
|
||||
|
||||
```json
|
||||
{
|
||||
"selectedParentMmsi": "412333326",
|
||||
"durationDays": 3,
|
||||
"actor": "analyst-01",
|
||||
"comment": "수동 확인"
|
||||
}
|
||||
```
|
||||
|
||||
response:
|
||||
|
||||
- 생성된 label session
|
||||
- 현재 active label summary
|
||||
|
||||
### 2. 그룹 후보 제외 생성
|
||||
|
||||
`POST /api/vessel-analysis/groups/{groupKey}/parent-inference/{subClusterId}/candidate-exclusions`
|
||||
|
||||
request:
|
||||
|
||||
```json
|
||||
{
|
||||
"candidateMmsi": "412333326",
|
||||
"scopeType": "GROUP",
|
||||
"durationDays": 3,
|
||||
"actor": "analyst-01",
|
||||
"comment": "이 그룹에서는 오답"
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 전역 후보 제외 생성
|
||||
|
||||
`POST /api/vessel-analysis/parent-inference/candidate-exclusions`
|
||||
|
||||
request:
|
||||
|
||||
```json
|
||||
{
|
||||
"candidateMmsi": "412333326",
|
||||
"scopeType": "GLOBAL",
|
||||
"actor": "analyst-01",
|
||||
"comment": "모든 어구에서 모선 후보 대상 제외"
|
||||
}
|
||||
```
|
||||
|
||||
### 4. exclusion 해제
|
||||
|
||||
`POST /api/vessel-analysis/parent-inference/candidate-exclusions/{id}/release`
|
||||
|
||||
### 5. label session 종료
|
||||
|
||||
`POST /api/vessel-analysis/parent-inference/label-sessions/{id}/cancel`
|
||||
|
||||
### 6. active exclusion 조회
|
||||
|
||||
`GET /api/vessel-analysis/parent-inference/candidate-exclusions?status=ACTIVE&scopeType=GLOBAL`
|
||||
|
||||
용도:
|
||||
|
||||
- “대상 선박이 어느 어구에서 제외중인지” 목록 관리
|
||||
- 운영자 관리 화면
|
||||
|
||||
### 7. active label tracking 조회
|
||||
|
||||
`GET /api/vessel-analysis/parent-inference/label-sessions?status=ACTIVE`
|
||||
|
||||
`GET /api/vessel-analysis/parent-inference/label-sessions/{id}/tracking`
|
||||
|
||||
### 8. 기존 review/detail API 확장
|
||||
|
||||
기존 `GroupParentInferenceDto`에 아래 요약을 추가한다.
|
||||
|
||||
- `activeLabelSession`
|
||||
- `groupExclusionCount`
|
||||
- `hasGlobalExclusionCandidate`
|
||||
- `availableActions`
|
||||
|
||||
`ParentInferenceCandidateDto`에는 아래를 추가한다.
|
||||
|
||||
- `isExcludedInGroup`
|
||||
- `isExcludedGlobally`
|
||||
- `activeExclusionIds`
|
||||
|
||||
## 프론트엔드 설계
|
||||
|
||||
### 버튼 재구성
|
||||
|
||||
현재:
|
||||
|
||||
- `확정`
|
||||
- `24시간 제외`
|
||||
|
||||
v2:
|
||||
|
||||
- `정답 라벨`
|
||||
- `이 그룹에서 제외`
|
||||
- `전체 후보 제외`
|
||||
- `해제`
|
||||
|
||||
### 기간 선택
|
||||
|
||||
`정답 라벨`과 `이 그룹에서 제외`는 버튼 클릭 후 아래 중 하나를 고르게 한다.
|
||||
|
||||
- `1일`
|
||||
- `3일`
|
||||
- `5일`
|
||||
|
||||
### 우측 모선 검토 패널 변화
|
||||
|
||||
- 후보 카드 상단 action area를 아래처럼 재구성
|
||||
- `정답 라벨`
|
||||
- `이 그룹에서 제외`
|
||||
- `전체 후보 제외`
|
||||
- 현재 후보에 active exclusion이 있으면 badge 표시
|
||||
- `이 그룹 제외 중`
|
||||
- `전체 후보 제외 중`
|
||||
- 현재 그룹에 active label session이 있으면 summary box 표시
|
||||
- 라벨 MMSI
|
||||
- 남은 기간
|
||||
- 최근 top1 일치율
|
||||
|
||||
### 새 목록
|
||||
|
||||
- `검토 대기`
|
||||
- active label session이 없는 그룹만 기본 표시
|
||||
- `라벨 추적`
|
||||
- active label session이 있는 그룹
|
||||
- `제외 대상 관리`
|
||||
- active group/global exclusions
|
||||
|
||||
### 지도 표시 원칙
|
||||
|
||||
- active label session 그룹은 기본 review 색과 다른 badge 색을 사용
|
||||
- globally excluded candidate는 raw correlation 패널에서는 참고로 보일 수 있지만, parent-review actionable candidate 목록에서는 숨김
|
||||
|
||||
## 지표 설계
|
||||
|
||||
정답 라벨 세션을 기반으로 최소 아래 지표를 계산한다.
|
||||
|
||||
### 핵심 지표
|
||||
|
||||
- top1 exact match rate
|
||||
- top3 hit rate
|
||||
- labeled candidate mean rank
|
||||
- labeled candidate mean score
|
||||
- time-to-first-top1
|
||||
- session duration 동안 top1 일치 지속률
|
||||
|
||||
### 보정/실험 지표
|
||||
|
||||
- `412/413` 가산점 적용 전후 top1/top3 uplift
|
||||
- pre-bonus score 대비 final score uplift
|
||||
- global exclusion 적용 전후 오탐 감소량
|
||||
- group exclusion 이후 대체 top1 품질 변화
|
||||
|
||||
### 운영 준비 지표
|
||||
|
||||
- auto-promoted 후보 중 라벨과 일치하는 비율
|
||||
- high-confidence (`>= 0.72`) 구간 calibration
|
||||
- label session 종료 시점 기준 `실무 참고 가능` threshold
|
||||
|
||||
## 단계별 구현 순서
|
||||
|
||||
### Phase 1. DB/Backend 계약
|
||||
|
||||
- 마이그레이션 추가
|
||||
- `gear_parent_candidate_exclusions`
|
||||
- `gear_parent_label_sessions`
|
||||
- `gear_parent_label_tracking_cycles`
|
||||
- backend DTO/API 추가
|
||||
- 기존 `CONFIRM/REJECT/RESET`는 lab UI에서 숨기고 legacy로만 남김
|
||||
|
||||
### Phase 2. prediction 연동
|
||||
|
||||
- active exclusion load
|
||||
- candidate pruning
|
||||
- active label session load
|
||||
- tracking cycle write
|
||||
|
||||
### Phase 3. 프론트 UI 전환
|
||||
|
||||
- 버튼 재구성
|
||||
- 기간 선택 UI
|
||||
- 라벨 추적 목록
|
||||
- 제외 대상 관리 화면
|
||||
|
||||
### Phase 4. 지표와 리포트
|
||||
|
||||
- label session summary endpoint
|
||||
- exclusion usage summary endpoint
|
||||
- 실험 리포트 화면 또는 문서 산출
|
||||
|
||||
## 마이그레이션 전략
|
||||
|
||||
### 기존 v1 상태 처리
|
||||
|
||||
- `MANUAL_CONFIRMED`, `MANUAL_REJECT`는 새로 생성하지 않는다.
|
||||
- 기존 row는 history로 남긴다.
|
||||
- 필요하면 one-time migration으로 legacy `MANUAL_CONFIRMED`를 `expired label session`으로 변환할 수 있다.
|
||||
|
||||
### 운영 영향 제한
|
||||
|
||||
- v2는 우선 `kcg_lab`에만 적용
|
||||
- 운영 `kcg` 반영 전에는 사람이 직접 누르는 흐름과 tracking 지표가 충분히 쌓여야 함
|
||||
|
||||
## 수용 기준
|
||||
|
||||
### 기능 기준
|
||||
|
||||
- 그룹 제외가 다음 cycle부터 해당 그룹에서만 적용된다.
|
||||
- 전역 후보 제외가 다음 cycle부터 모든 그룹에 적용된다.
|
||||
- active exclusion list가 DB/API/UI에서 동일하게 보인다.
|
||||
- 정답 라벨 세션 동안 cycle별 tracking row가 누락 없이 쌓인다.
|
||||
|
||||
### 데이터 기준
|
||||
|
||||
- label session당 최소 아래 값이 저장된다.
|
||||
- top1 후보
|
||||
- labeled candidate rank
|
||||
- labeled candidate score
|
||||
- candidate count
|
||||
- observed_at
|
||||
- exclusion row에는 scope, duration, actor, comment, active 기간이 남는다.
|
||||
|
||||
### 평가 기준
|
||||
|
||||
- `412/413` 가산점, threshold, exclusion 정책 변경 전후를 label session 데이터로 비교 가능해야 한다.
|
||||
- 일정 기간 후 “자동 top1을 운영 참고값으로 써도 되는지”를 정량으로 판단할 수 있어야 한다.
|
||||
|
||||
## 열린 이슈
|
||||
|
||||
### 1. 그룹 스코프 안정성
|
||||
|
||||
`group_key + sub_cluster_id`가 며칠 동안 완전히 안정적인지 추가 확인이 필요하다.
|
||||
|
||||
현재 권장:
|
||||
|
||||
- phase 1은 기존 키를 그대로 사용
|
||||
- 대신 `anchor_snapshot_time`, `anchor_center_point`, `anchor_member_mmsis`를 저장
|
||||
|
||||
### 2. 전역 후보 제외의 기간 정책
|
||||
|
||||
현재 제안은 “수동 해제 전까지 유지”다.
|
||||
|
||||
이유:
|
||||
|
||||
- 전역 제외는 단기 오답보다 “이 AIS는 parent candidate class가 아님”에 가깝다.
|
||||
|
||||
필요 시 추후 `1/3/5일` 옵션을 추가할 수 있다.
|
||||
|
||||
### 3. raw correlation UI 노출
|
||||
|
||||
전역 제외된 후보를 모델 패널에서 완전히 숨길지, `참고 제외` badge만 붙여 남길지는 사용성 확인이 필요하다.
|
||||
|
||||
현재 권장은 아래다.
|
||||
|
||||
- parent-review actionable 후보 목록에서는 숨김
|
||||
- raw model/correlation 참고 패널에서는 badge와 함께 유지
|
||||
|
||||
## 권장 결론
|
||||
|
||||
v2의 핵심은 `사람 판단을 자동 추론의 override가 아니라 평가 데이터로 축적하는 것`이다.
|
||||
|
||||
따라서 다음 구현 우선순위는 아래가 맞다.
|
||||
|
||||
1. exclusion/label DB 추가
|
||||
2. prediction candidate gating + tracking write
|
||||
3. UI 액션 재정의
|
||||
4. 지표 산출
|
||||
|
||||
그 다음 단계에서만 threshold 자동화, 가산점 조정, LLM 연결을 검토하는 것이 안전하다.
|
||||
@ -4,9 +4,35 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2026-04-04]
|
||||
|
||||
### 추가
|
||||
- 어구 모선 추론(Gear Parent Inference) 시스템 — 다층 점수 모델 + Episode 연속성 + 자동 승격/검토 워크플로우
|
||||
- Python: gear_parent_inference(1,428줄), gear_parent_episode(631줄), gear_name_rules
|
||||
- Backend: ParentInferenceWorkflowController + GroupPolygonService 15개 API
|
||||
- Frontend: ParentReviewPanel (모선 검토 대시보드) + React Flow 흐름도 시각화
|
||||
- DB: migration 012~015 (후보 스냅샷, resolution, episode, 라벨 세션, 제외 관리)
|
||||
- LoginPage DEV_LOGIN 환경변수 지원 (VITE_ENABLE_DEV_LOGIN)
|
||||
|
||||
### 수정
|
||||
- 모선 검토 대기 목록을 폴리곤 5분 폴링 데이터에서 파생하여 동기화 문제 해소
|
||||
- 후보 소스 배지 축약 (CORRELATION→CORR, PREVIOUS_SELECTION→PREV 등)
|
||||
- 1h 활성 판정을 parent_name 전체 합산 기준으로 변경
|
||||
- vessel_store의 _last_bucket 타임존 오류 수정 (tz-naive KST 유지)
|
||||
- time_bucket 수집 안전 윈도우 도입 — safe_bucket(12분 지연) + 3 bucket 백필
|
||||
- 모선 추론 점수 가중치 조정 — 100%는 DIRECT_PARENT_MATCH 전용
|
||||
- prediction proxy target을 nginx 경유로 변경
|
||||
|
||||
### 변경
|
||||
- fleet_tracker: SQL 테이블명 qualified_table() 동적화 + is_trackable_parent_name 필터
|
||||
- gear_correlation: 후보 track에 timestamp 필드 추가
|
||||
- kcgdb: SQL 스키마 하드코딩 → qualified_table() 패턴 전환
|
||||
|
||||
## [2026-04-01]
|
||||
|
||||
### 추가
|
||||
- 한국 현황 위성지도/ENC 토글 (gcnautical 벡터 타일 연동)
|
||||
- ENC 스타일 설정 패널 (12개 심볼 토글 + 8개 색상 수정 + 초기화)
|
||||
- 어구 그룹 1h/6h 듀얼 폴리곤 (Python 듀얼 스냅샷 + DB resolution 컬럼 + Backend/Frontend 독립 렌더)
|
||||
- 리플레이 컨트롤러 A-B 구간 반복 기능
|
||||
- 리플레이 프로그레스바 통합 (1h/6h 스냅샷 막대 + 호버 툴팁 + 클릭 고정)
|
||||
@ -31,6 +57,9 @@
|
||||
- 모델 패널: 헤더→푸터 구조, 개별 확장/축소, 우클릭 툴팁 고정
|
||||
|
||||
### 수정
|
||||
- 라이브 어구 현황에서 fallback 그룹 제외 (1h-fb resolution 분리)
|
||||
- FLEET 타입 resolution='1h' 누락 수정
|
||||
- DB resolution 컬럼 VARCHAR(4)→VARCHAR(8) 확장
|
||||
- 어구 group_key 변동 → 이력 불연속 문제 해결 (sub_cluster_id 구조 전환)
|
||||
- 한국 국적 선박(440/441) 어구 오탐 제외
|
||||
- Backend correlation API 서브클러스터 중복 제거 (DISTINCT ON CTE)
|
||||
@ -86,6 +115,9 @@
|
||||
- 현장분석 항적 미니맵: 선박 클릭 시 72시간 항적 + 현재 위치 표시
|
||||
- 현장분석 위험도 점수 기준 섹션
|
||||
- Python 경량 분석: 파이프라인 미통과 412* 선박 간이 위험도
|
||||
- 폴리곤 히스토리 애니메이션: 12시간 타임라인 기반 재생 (중심 궤적 + 어구별 궤적 + 가상 아이콘)
|
||||
- 재생 컨트롤러: 재생/일시정지 + 프로그레스 바 (드래그/클릭) + 신호없음 구간 표시
|
||||
- nginx /api/gtts 프록시 (Google TTS CORS 우회)
|
||||
|
||||
### 변경
|
||||
- 위험도 용어 통일: HIGH→WATCH, MEDIUM→MONITOR, LOW→NORMAL (전체)
|
||||
@ -93,15 +125,6 @@
|
||||
- 보고서: Python riskCounts 실데이터 기반 위험 평가
|
||||
- 현장분석: AI 파이프라인 ON/OFF 실상태 + BD-09 실측 탐지 수
|
||||
- 보고서 버튼: 현장분석 내부로 이동, 수역별 허가업종 동적 참조
|
||||
|
||||
## [2026-03-25]
|
||||
|
||||
### 추가
|
||||
- 폴리곤 히스토리 애니메이션: 12시간 타임라인 기반 재생 (중심 궤적 + 어구별 궤적 + 가상 아이콘)
|
||||
- 재생 컨트롤러: 재생/일시정지 + 프로그레스 바 (드래그/클릭) + 신호없음 구간 표시
|
||||
- nginx /api/gtts 프록시 (Google TTS CORS 우회)
|
||||
|
||||
### 변경
|
||||
- 분석 파이프라인: MIN_TRAJ_POINTS 100→20 (16척→684척 분석 대상 확대)
|
||||
- risk.py: SOG 급변 count 위험도 점수 반영
|
||||
- spoofing.py: BD09 오프셋 중국 MMSI(412*) 예외 처리
|
||||
|
||||
13
frontend/gear-parent-flow.html
Normal file
13
frontend/gear-parent-flow.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/kcg.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>gear-parent-flow-viewer</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/gearParentFlowMain.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
169
frontend/package-lock.json
generated
169
frontend/package-lock.json
generated
@ -19,6 +19,7 @@
|
||||
"@turf/boolean-point-in-polygon": "^7.3.4",
|
||||
"@turf/helpers": "^7.3.4",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@xyflow/react": "^12.10.2",
|
||||
"date-fns": "^4.1.0",
|
||||
"hls.js": "^1.6.15",
|
||||
"i18next": "^25.8.18",
|
||||
@ -383,6 +384,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@deck.gl/layers/-/layers-9.2.11.tgz",
|
||||
"integrity": "sha512-2FSb0Qa6YR+Rg6GWhYOGTUug3vtZ4uKcFdnrdiJoVXGyibKJMScKZIsivY0r/yQQZsaBjYqty5QuVJvdtEHxSA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@loaders.gl/images": "~4.3.4",
|
||||
"@loaders.gl/schema": "~4.3.4",
|
||||
@ -2512,6 +2514,15 @@
|
||||
"version": "3.1.3",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-drag": {
|
||||
"version": "3.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
|
||||
"integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-ease": {
|
||||
"version": "3.0.2",
|
||||
"license": "MIT"
|
||||
@ -2534,6 +2545,12 @@
|
||||
"@types/d3-time": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-selection": {
|
||||
"version": "3.0.11",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
|
||||
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-shape": {
|
||||
"version": "3.1.8",
|
||||
"license": "MIT",
|
||||
@ -2549,6 +2566,25 @@
|
||||
"version": "3.0.2",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-transition": {
|
||||
"version": "3.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
|
||||
"integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-zoom": {
|
||||
"version": "3.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
|
||||
"integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-interpolate": "*",
|
||||
"@types/d3-selection": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"license": "MIT"
|
||||
@ -2951,6 +2987,66 @@
|
||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@xyflow/react": {
|
||||
"version": "12.10.2",
|
||||
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.2.tgz",
|
||||
"integrity": "sha512-CgIi6HwlcHXwlkTpr0fxLv/0sRVNZ8IdwKLzzeCscaYBwpvfcH1QFOCeaTCuEn1FQEs/B8CjnTSjhs8udgmBgQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@xyflow/system": "0.0.76",
|
||||
"classcat": "^5.0.3",
|
||||
"zustand": "^4.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=17",
|
||||
"react-dom": ">=17"
|
||||
}
|
||||
},
|
||||
"node_modules/@xyflow/react/node_modules/zustand": {
|
||||
"version": "4.5.7",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
|
||||
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"use-sync-external-store": "^1.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.7.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=16.8",
|
||||
"immer": ">=9.0.6",
|
||||
"react": ">=16.8"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"immer": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@xyflow/system": {
|
||||
"version": "0.0.76",
|
||||
"resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.76.tgz",
|
||||
"integrity": "sha512-hvwvnRS1B3REwVDlWexsq7YQaPZeG3/mKo1jv38UmnpWmxihp14bW6VtEOuHEwJX2FvzFw8k77LyKSk/wiZVNA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-drag": "^3.0.7",
|
||||
"@types/d3-interpolate": "^3.0.4",
|
||||
"@types/d3-selection": "^3.0.10",
|
||||
"@types/d3-transition": "^3.0.8",
|
||||
"@types/d3-zoom": "^3.0.8",
|
||||
"d3-drag": "^3.0.0",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"d3-selection": "^3.0.0",
|
||||
"d3-zoom": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/a5-js": {
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmjs.org/a5-js/-/a5-js-0.5.0.tgz",
|
||||
@ -3192,6 +3288,12 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/classcat": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
|
||||
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"license": "MIT",
|
||||
@ -3288,6 +3390,28 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-dispatch": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
|
||||
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-drag": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
|
||||
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-selection": "3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-ease": {
|
||||
"version": "3.0.1",
|
||||
"license": "BSD-3-Clause",
|
||||
@ -3333,6 +3457,16 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-selection": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-shape": {
|
||||
"version": "3.2.0",
|
||||
"license": "ISC",
|
||||
@ -3370,6 +3504,41 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-transition": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
|
||||
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3",
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-ease": "1 - 3",
|
||||
"d3-interpolate": "1 - 3",
|
||||
"d3-timer": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"d3-selection": "2 - 3"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-zoom": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
|
||||
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-dispatch": "1 - 3",
|
||||
"d3-drag": "2 - 3",
|
||||
"d3-interpolate": "1 - 3",
|
||||
"d3-selection": "2 - 3",
|
||||
"d3-transition": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/date-fns": {
|
||||
"version": "4.1.0",
|
||||
"license": "MIT",
|
||||
|
||||
@ -21,6 +21,7 @@
|
||||
"@turf/boolean-point-in-polygon": "^7.3.4",
|
||||
"@turf/helpers": "^7.3.4",
|
||||
"@types/leaflet": "^1.9.21",
|
||||
"@xyflow/react": "^12.10.2",
|
||||
"date-fns": "^4.1.0",
|
||||
"hls.js": "^1.6.15",
|
||||
"i18next": "^25.8.18",
|
||||
|
||||
@ -106,6 +106,15 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
|
||||
>
|
||||
MON
|
||||
</button>
|
||||
<a
|
||||
className="header-toggle-btn"
|
||||
href="/gear-parent-flow.html"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title="어구 모선 추적 흐름도"
|
||||
>
|
||||
FLOW
|
||||
</a>
|
||||
<button type="button" className="header-toggle-btn" onClick={toggleLang} title="Language">
|
||||
{i18n.language === 'ko' ? 'KO' : 'EN'}
|
||||
</button>
|
||||
|
||||
@ -8,6 +8,7 @@ interface LoginPageProps {
|
||||
|
||||
const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID;
|
||||
const IS_DEV = import.meta.env.DEV;
|
||||
const DEV_LOGIN_ENABLED = IS_DEV || import.meta.env.VITE_ENABLE_DEV_LOGIN === 'true';
|
||||
|
||||
function useGoogleIdentity(onCredential: (credential: string) => void) {
|
||||
const btnRef = useRef<HTMLDivElement>(null);
|
||||
@ -136,7 +137,7 @@ const LoginPage = ({ onGoogleLogin, onDevLogin }: LoginPageProps) => {
|
||||
)}
|
||||
|
||||
{/* Dev Login */}
|
||||
{IS_DEV && (
|
||||
{DEV_LOGIN_ENABLED && (
|
||||
<>
|
||||
<div className="w-full border-t border-kcg-border pt-4 text-center">
|
||||
<span className="text-xs font-mono tracking-wider text-kcg-dim">
|
||||
|
||||
@ -6,6 +6,8 @@ import type { UseGroupPolygonsResult } from '../../hooks/useGroupPolygons';
|
||||
import { FONT_MONO } from '../../styles/fonts';
|
||||
import { MODEL_ORDER, MODEL_COLORS, MODEL_DESC } from './fleetClusterConstants';
|
||||
import { useGearReplayStore } from '../../stores/gearReplayStore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useReplayCenterPanelLayout } from './useReplayCenterPanelLayout';
|
||||
|
||||
interface CorrelationPanelProps {
|
||||
selectedGearGroup: string;
|
||||
@ -17,6 +19,8 @@ interface CorrelationPanelProps {
|
||||
enabledVessels: Set<string>;
|
||||
correlationLoading: boolean;
|
||||
hoveredTarget: { mmsi: string; model: string } | null;
|
||||
hasRightReviewPanel?: boolean;
|
||||
reviewDriven?: boolean;
|
||||
onEnabledModelsChange: (updater: (prev: Set<string>) => Set<string>) => void;
|
||||
onEnabledVesselsChange: (updater: (prev: Set<string>) => Set<string>) => void;
|
||||
onHoveredTargetChange: (target: { mmsi: string; model: string } | null) => void;
|
||||
@ -35,11 +39,19 @@ const CorrelationPanel = ({
|
||||
enabledVessels,
|
||||
correlationLoading,
|
||||
hoveredTarget,
|
||||
hasRightReviewPanel = false,
|
||||
reviewDriven = false,
|
||||
onEnabledModelsChange,
|
||||
onEnabledVesselsChange,
|
||||
onHoveredTargetChange,
|
||||
}: CorrelationPanelProps) => {
|
||||
const { t } = useTranslation();
|
||||
const historyActive = useGearReplayStore(s => s.historyFrames.length > 0);
|
||||
const layout = useReplayCenterPanelLayout({
|
||||
minWidth: 252,
|
||||
maxWidth: 966,
|
||||
hasRightReviewPanel,
|
||||
});
|
||||
|
||||
// Local tooltip state
|
||||
const [hoveredModelTip, setHoveredModelTip] = useState<string | null>(null);
|
||||
@ -193,16 +205,30 @@ const CorrelationPanel = ({
|
||||
key={`${modelName}-${c.targetMmsi}`}
|
||||
style={{
|
||||
fontSize: 9, marginBottom: 1, display: 'flex', alignItems: 'center', gap: 3,
|
||||
padding: '1px 2px', borderRadius: 2, cursor: 'pointer',
|
||||
padding: '1px 2px', borderRadius: 2, cursor: reviewDriven ? 'default' : 'pointer',
|
||||
background: isHovered ? `${color}22` : 'transparent',
|
||||
opacity: isEnabled ? 1 : 0.5,
|
||||
opacity: reviewDriven ? 1 : isEnabled ? 1 : 0.5,
|
||||
}}
|
||||
onClick={() => toggleVessel(c.targetMmsi)}
|
||||
onMouseEnter={() => onHoveredTargetChange({ mmsi: c.targetMmsi, model: modelName })}
|
||||
onMouseLeave={() => onHoveredTargetChange(null)}
|
||||
onClick={reviewDriven ? undefined : () => toggleVessel(c.targetMmsi)}
|
||||
onMouseEnter={reviewDriven ? undefined : () => onHoveredTargetChange({ mmsi: c.targetMmsi, model: modelName })}
|
||||
onMouseLeave={reviewDriven ? undefined : () => onHoveredTargetChange(null)}
|
||||
>
|
||||
<input type="checkbox" checked={isEnabled} readOnly title="맵 표시"
|
||||
style={{ accentColor: color, width: 9, height: 9, flexShrink: 0, pointerEvents: 'none' }} />
|
||||
{reviewDriven ? (
|
||||
<span
|
||||
title={t('parentInference.reference.reviewDriven')}
|
||||
style={{
|
||||
width: 9,
|
||||
height: 9,
|
||||
borderRadius: 999,
|
||||
background: color,
|
||||
flexShrink: 0,
|
||||
opacity: 0.9,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<input type="checkbox" checked={isEnabled} readOnly title="맵 표시"
|
||||
style={{ accentColor: color, width: 9, height: 9, flexShrink: 0, pointerEvents: 'none' }} />
|
||||
)}
|
||||
<span style={{ color: isVessel ? '#60a5fa' : '#f97316', width: 10, textAlign: 'center', flexShrink: 0 }}>
|
||||
{isVessel ? '⛴' : '◆'}
|
||||
</span>
|
||||
@ -219,6 +245,15 @@ const CorrelationPanel = ({
|
||||
);
|
||||
};
|
||||
|
||||
const visibleModelNames = useMemo(() => {
|
||||
if (reviewDriven) {
|
||||
return availableModels
|
||||
.filter(model => (correlationByModel.get(model.name) ?? []).length > 0)
|
||||
.map(model => model.name);
|
||||
}
|
||||
return availableModels.filter(model => enabledModels.has(model.name)).map(model => model.name);
|
||||
}, [availableModels, correlationByModel, enabledModels, reviewDriven]);
|
||||
|
||||
// Member row renderer (identity model — no score, independent hover)
|
||||
const renderMemberRow = (m: { mmsi: string; name: string }, icon: string, iconColor: string, keyPrefix = 'id') => {
|
||||
const isHovered = hoveredTarget?.mmsi === m.mmsi && hoveredTarget?.model === 'identity';
|
||||
@ -251,10 +286,8 @@ const CorrelationPanel = ({
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: historyActive ? 120 : 20,
|
||||
left: 'calc(50% + 100px)',
|
||||
transform: 'translateX(-50%)',
|
||||
width: 'calc(100vw - 880px)',
|
||||
maxWidth: 1320,
|
||||
left: `${layout.left}px`,
|
||||
width: `${layout.width}px`,
|
||||
display: 'flex',
|
||||
gap: 6,
|
||||
alignItems: 'flex-end',
|
||||
@ -270,6 +303,7 @@ const CorrelationPanel = ({
|
||||
border: '1px solid rgba(249,115,22,0.3)',
|
||||
borderRadius: 8,
|
||||
padding: '8px 10px',
|
||||
width: 165,
|
||||
minWidth: 165,
|
||||
flexShrink: 0,
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.5)',
|
||||
@ -278,6 +312,22 @@ const CorrelationPanel = ({
|
||||
<span style={{ fontWeight: 700, color: '#f97316', fontSize: 11 }}>{selectedGearGroup}</span>
|
||||
<span style={{ color: '#64748b', fontSize: 9 }}>{memberCount}개</span>
|
||||
</div>
|
||||
<div style={{
|
||||
marginBottom: 7,
|
||||
padding: '6px 7px',
|
||||
borderRadius: 6,
|
||||
background: 'rgba(15,23,42,0.72)',
|
||||
border: '1px solid rgba(249,115,22,0.14)',
|
||||
color: '#cbd5e1',
|
||||
fontSize: 8,
|
||||
lineHeight: 1.45,
|
||||
whiteSpace: 'normal',
|
||||
wordBreak: 'keep-all',
|
||||
}}>
|
||||
{reviewDriven
|
||||
? t('parentInference.reference.reviewDriven')
|
||||
: t('parentInference.reference.shipOnly')}
|
||||
</div>
|
||||
<div style={{ fontSize: 9, fontWeight: 700, color: '#93c5fd', marginBottom: 4 }}>폴리곤 오버레이</div>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 9, cursor: 'pointer', marginBottom: 3 }}>
|
||||
<input
|
||||
@ -300,15 +350,19 @@ const CorrelationPanel = ({
|
||||
const gc = modelItems.filter(c => c.targetType !== 'VESSEL').length;
|
||||
const am = availableModels.find(m => m.name === mn);
|
||||
return (
|
||||
<label key={mn} style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 9, cursor: hasData ? 'pointer' : 'default', marginBottom: 3, opacity: hasData ? 1 : 0.4 }}>
|
||||
<input type="checkbox" checked={enabledModels.has(mn)}
|
||||
disabled={!hasData}
|
||||
onChange={() => onEnabledModelsChange(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(mn)) next.delete(mn); else next.add(mn);
|
||||
return next;
|
||||
})}
|
||||
style={{ accentColor: color, width: 11, height: 11 }} title={mn} />
|
||||
<label key={mn} style={{ display: 'flex', alignItems: 'center', gap: 4, fontSize: 9, cursor: reviewDriven ? 'default' : hasData ? 'pointer' : 'default', marginBottom: 3, opacity: hasData ? 1 : 0.4 }}>
|
||||
{reviewDriven ? (
|
||||
<span style={{ width: 11, height: 11, borderRadius: 999, background: hasData ? color : 'rgba(148,163,184,0.2)', flexShrink: 0 }} />
|
||||
) : (
|
||||
<input type="checkbox" checked={enabledModels.has(mn)}
|
||||
disabled={!hasData}
|
||||
onChange={() => onEnabledModelsChange(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(mn)) next.delete(mn); else next.add(mn);
|
||||
return next;
|
||||
})}
|
||||
style={{ accentColor: color, width: 11, height: 11 }} title={mn} />
|
||||
)}
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: color, flexShrink: 0, opacity: hasData ? 1 : 0.3 }} />
|
||||
<span style={{ color: hasData ? '#e2e8f0' : '#64748b', flex: 1 }}>{mn}{am?.isDefault ? '*' : ''}</span>
|
||||
<span style={{ color: '#64748b', fontSize: 8 }}>{hasData ? `${vc}⛴${gc}◆` : '—'}</span>
|
||||
@ -324,7 +378,7 @@ const CorrelationPanel = ({
|
||||
}}>
|
||||
|
||||
{/* 이름 기반 카드 (체크 시) */}
|
||||
{enabledModels.has('identity') && (identityVessels.length > 0 || identityGear.length > 0) && (
|
||||
{(reviewDriven || enabledModels.has('identity')) && (identityVessels.length > 0 || identityGear.length > 0) && (
|
||||
<div ref={(el) => setCardRef('identity', el)} style={{ ...cardStyle, borderColor: 'rgba(249,115,22,0.25)', position: 'relative' }}>
|
||||
<div style={getCardBodyStyle('identity')}>
|
||||
{identityVessels.length > 0 && (
|
||||
@ -335,7 +389,9 @@ const CorrelationPanel = ({
|
||||
)}
|
||||
{identityGear.length > 0 && (
|
||||
<>
|
||||
<div style={{ fontSize: 8, color: '#64748b', marginBottom: 2, marginTop: 3 }}>연관 어구 ({identityGear.length})</div>
|
||||
<div style={{ fontSize: 8, color: '#94a3b8', marginBottom: 2, marginTop: 3 }}>
|
||||
{t('parentInference.reference.referenceGear')} ({identityGear.length})
|
||||
</div>
|
||||
{identityGear.map(m => renderMemberRow(m, '◆', '#f97316'))}
|
||||
</>
|
||||
)}
|
||||
@ -355,7 +411,9 @@ const CorrelationPanel = ({
|
||||
)}
|
||||
|
||||
{/* 각 Correlation 모델 카드 (체크 시 우측에 추가) */}
|
||||
{availableModels.filter(m => enabledModels.has(m.name)).map(m => {
|
||||
{visibleModelNames.map(modelName => {
|
||||
const m = availableModels.find(model => model.name === modelName);
|
||||
if (!m) return null;
|
||||
const color = MODEL_COLORS[m.name] ?? '#94a3b8';
|
||||
const items = correlationByModel.get(m.name) ?? [];
|
||||
const vessels = items.filter(c => c.targetType === 'VESSEL');
|
||||
@ -372,7 +430,9 @@ const CorrelationPanel = ({
|
||||
)}
|
||||
{gears.length > 0 && (
|
||||
<>
|
||||
<div style={{ fontSize: 8, color: '#64748b', marginBottom: 2, marginTop: 3 }}>연관 어구 ({gears.length})</div>
|
||||
<div style={{ fontSize: 8, color: '#94a3b8', marginBottom: 2, marginTop: 3 }}>
|
||||
{t('parentInference.reference.referenceGear')} ({gears.length})
|
||||
</div>
|
||||
{gears.map(c => renderRow(c, color, m.name))}
|
||||
</>
|
||||
)}
|
||||
|
||||
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
@ -4,6 +4,7 @@ import type { UseGroupPolygonsResult } from '../../hooks/useGroupPolygons';
|
||||
import type { FleetListItem } from './fleetClusterTypes';
|
||||
import { panelStyle, headerStyle, toggleButtonStyle } from './fleetClusterConstants';
|
||||
import GearGroupSection from './GearGroupSection';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface FleetGearListPanelProps {
|
||||
fleetList: FleetListItem[];
|
||||
@ -42,14 +43,15 @@ const FleetGearListPanel = ({
|
||||
onExpandGearGroup,
|
||||
onShipSelect,
|
||||
}: FleetGearListPanelProps) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div style={panelStyle}>
|
||||
{/* ── 선단 현황 섹션 ── */}
|
||||
<div style={{ ...headerStyle, cursor: 'pointer' }} onClick={() => onToggleSection('fleet')}>
|
||||
<span style={{ fontWeight: 700, color: '#63b3ed', letterSpacing: 0.5 }}>
|
||||
선단 현황 ({fleetList.length}개)
|
||||
{t('fleetGear.fleetSection', { count: fleetList.length })}
|
||||
</span>
|
||||
<button type="button" style={toggleButtonStyle} aria-label="선단 현황 접기/펴기">
|
||||
<button type="button" style={toggleButtonStyle} aria-label={t('fleetGear.toggleFleetSection')}>
|
||||
{activeSection === 'fleet' ? '▲' : '▼'}
|
||||
</button>
|
||||
</div>
|
||||
@ -57,12 +59,12 @@ const FleetGearListPanel = ({
|
||||
<div style={{ padding: '4px 0', overflowY: 'auto', flex: 1 }}>
|
||||
{fleetList.length === 0 ? (
|
||||
<div style={{ color: '#64748b', textAlign: 'center', padding: '8px 10px' }}>
|
||||
선단 데이터 없음
|
||||
{t('fleetGear.emptyFleet')}
|
||||
</div>
|
||||
) : (
|
||||
fleetList.map(({ id, mmsiList, label, color, members }) => {
|
||||
const company = companies.get(id);
|
||||
const companyName = company?.nameCn ?? label ?? `선단 #${id}`;
|
||||
const companyName = company?.nameCn ?? label ?? t('fleetGear.fleetFallback', { id });
|
||||
const isOpen = expandedFleet === id;
|
||||
const isHovered = hoveredFleetId === id;
|
||||
|
||||
@ -95,17 +97,19 @@ const FleetGearListPanel = ({
|
||||
title={company ? `${company.nameCn} / ${company.nameEn}` : companyName}>
|
||||
{companyName}
|
||||
</span>
|
||||
<span style={{ color: '#64748b', fontSize: 10, flexShrink: 0 }}>({mmsiList.length}척)</span>
|
||||
<span style={{ color: '#64748b', fontSize: 10, flexShrink: 0 }}>
|
||||
{t('fleetGear.vesselCountCompact', { count: mmsiList.length })}
|
||||
</span>
|
||||
<button type="button" onClick={e => { e.stopPropagation(); onFleetZoom(id); }}
|
||||
style={{ background: 'none', border: '1px solid rgba(99,179,237,0.3)', borderRadius: 3, color: '#63b3ed', fontSize: 9, cursor: 'pointer', padding: '1px 4px', flexShrink: 0 }}
|
||||
title="이 선단으로 지도 이동">
|
||||
zoom
|
||||
title={t('fleetGear.moveToFleet')}>
|
||||
{t('fleetGear.zoom')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isOpen && (
|
||||
<div style={{ paddingLeft: 22, paddingRight: 10, paddingBottom: 6, fontSize: 10, color: '#94a3b8', borderLeft: `2px solid ${color}33`, marginLeft: 10 }}>
|
||||
<div style={{ color: '#64748b', fontSize: 9, marginBottom: 3 }}>선박:</div>
|
||||
<div style={{ color: '#64748b', fontSize: 9, marginBottom: 3 }}>{t('fleetGear.shipList')}:</div>
|
||||
{displayMembers.map(m => {
|
||||
const dto = analysisMap.get(m.mmsi);
|
||||
const role = dto?.algorithms.fleetRole.role ?? m.role;
|
||||
@ -116,11 +120,11 @@ const FleetGearListPanel = ({
|
||||
{displayName}
|
||||
</span>
|
||||
<span style={{ color: role === 'LEADER' ? '#fbbf24' : '#64748b', fontSize: 9, flexShrink: 0 }}>
|
||||
({role === 'LEADER' ? 'MAIN' : 'SUB'})
|
||||
({role === 'LEADER' ? t('fleetGear.roleMain') : t('fleetGear.roleSub')})
|
||||
</span>
|
||||
<button type="button" onClick={() => onShipSelect(m.mmsi)}
|
||||
style={{ background: 'none', border: 'none', color: '#63b3ed', fontSize: 10, cursor: 'pointer', padding: '0 2px', flexShrink: 0 }}
|
||||
title="선박으로 이동" aria-label={`${displayName} 선박으로 이동`}>
|
||||
title={t('fleetGear.moveToShip')} aria-label={t('fleetGear.moveToShipItem', { name: displayName })}>
|
||||
▶
|
||||
</button>
|
||||
</div>
|
||||
@ -139,7 +143,7 @@ const FleetGearListPanel = ({
|
||||
<GearGroupSection
|
||||
groups={inZoneGearGroups}
|
||||
sectionKey="inZone"
|
||||
sectionLabel={`조업구역내 어구 (${inZoneGearGroups.length}개)`}
|
||||
sectionLabel={t('fleetGear.inZoneSection', { count: inZoneGearGroups.length })}
|
||||
accentColor="#dc2626"
|
||||
hoverBgColor="rgba(220,38,38,0.06)"
|
||||
isActive={activeSection === 'inZone'}
|
||||
@ -154,7 +158,7 @@ const FleetGearListPanel = ({
|
||||
<GearGroupSection
|
||||
groups={outZoneGearGroups}
|
||||
sectionKey="outZone"
|
||||
sectionLabel={`비허가 어구 (${outZoneGearGroups.length}개)`}
|
||||
sectionLabel={t('fleetGear.outZoneSection', { count: outZoneGearGroups.length })}
|
||||
accentColor="#f97316"
|
||||
hoverBgColor="rgba(255,255,255,0.04)"
|
||||
isActive={activeSection === 'outZone'}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import type { GroupPolygonDto } from '../../services/vesselAnalysis';
|
||||
import { FONT_MONO } from '../../styles/fonts';
|
||||
import { headerStyle, toggleButtonStyle } from './fleetClusterConstants';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface GearGroupSectionProps {
|
||||
groups: GroupPolygonDto[];
|
||||
@ -29,8 +30,47 @@ const GearGroupSection = ({
|
||||
onGroupZoom,
|
||||
onShipSelect,
|
||||
}: GearGroupSectionProps) => {
|
||||
const { t } = useTranslation();
|
||||
const isInZoneSection = sectionKey === 'inZone';
|
||||
|
||||
const getInferenceBadge = (status: string | null | undefined) => {
|
||||
switch (status) {
|
||||
case 'AUTO_PROMOTED':
|
||||
return { label: t('parentInference.badges.AUTO_PROMOTED'), color: '#22c55e' };
|
||||
case 'MANUAL_CONFIRMED':
|
||||
return { label: t('parentInference.badges.MANUAL_CONFIRMED'), color: '#38bdf8' };
|
||||
case 'DIRECT_PARENT_MATCH':
|
||||
return { label: t('parentInference.badges.DIRECT_PARENT_MATCH'), color: '#2dd4bf' };
|
||||
case 'REVIEW_REQUIRED':
|
||||
return { label: t('parentInference.badges.REVIEW_REQUIRED'), color: '#f59e0b' };
|
||||
case 'SKIPPED_SHORT_NAME':
|
||||
return { label: t('parentInference.badges.SKIPPED_SHORT_NAME'), color: '#94a3b8' };
|
||||
case 'NO_CANDIDATE':
|
||||
return { label: t('parentInference.badges.NO_CANDIDATE'), color: '#c084fc' };
|
||||
case 'UNRESOLVED':
|
||||
return { label: t('parentInference.badges.UNRESOLVED'), color: '#64748b' };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getInferenceStatusLabel = (status: string | null | undefined) => {
|
||||
if (!status) return '';
|
||||
return t(`parentInference.status.${status}`, { defaultValue: status });
|
||||
};
|
||||
|
||||
const getInferenceReason = (inference: GroupPolygonDto['parentInference']) => {
|
||||
if (!inference) return '';
|
||||
switch (inference.status) {
|
||||
case 'SKIPPED_SHORT_NAME':
|
||||
return t('parentInference.reasons.shortName');
|
||||
case 'NO_CANDIDATE':
|
||||
return t('parentInference.reasons.noCandidate');
|
||||
default:
|
||||
return inference.statusReason || inference.skipReason || '';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
@ -42,7 +82,7 @@ const GearGroupSection = ({
|
||||
onClick={onToggleSection}
|
||||
>
|
||||
<span style={{ fontWeight: 700, color: accentColor, letterSpacing: 0.3, fontFamily: FONT_MONO }}>
|
||||
{sectionLabel} ({groups.length}개)
|
||||
{sectionLabel}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
@ -61,6 +101,8 @@ const GearGroupSection = ({
|
||||
const parentMember = g.members.find(m => m.isParent);
|
||||
const gearMembers = g.members.filter(m => !m.isParent);
|
||||
const zoneName = g.zoneName ?? '';
|
||||
const inference = g.parentInference ?? null;
|
||||
const badge = getInferenceBadge(inference?.status);
|
||||
|
||||
return (
|
||||
<div key={name} id={`gear-row-${name}`}>
|
||||
@ -117,6 +159,25 @@ const GearGroupSection = ({
|
||||
⚓
|
||||
</span>
|
||||
)}
|
||||
{badge && (
|
||||
<span
|
||||
style={{
|
||||
color: badge.color,
|
||||
border: `1px solid ${badge.color}55`,
|
||||
borderRadius: 3,
|
||||
padding: '0 4px',
|
||||
fontSize: 8,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
title={
|
||||
inference?.selectedParentName
|
||||
? `${getInferenceStatusLabel(inference.status)}: ${inference.selectedParentName}`
|
||||
: getInferenceReason(inference) || getInferenceStatusLabel(inference?.status) || ''
|
||||
}
|
||||
>
|
||||
{badge.label}
|
||||
</span>
|
||||
)}
|
||||
{isInZoneSection && zoneName && (
|
||||
<span style={{ color: '#ef4444', fontSize: 9, flexShrink: 0 }}>{zoneName}</span>
|
||||
)}
|
||||
@ -139,9 +200,9 @@ const GearGroupSection = ({
|
||||
padding: '1px 4px',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
title="이 어구 그룹으로 지도 이동"
|
||||
title={t('fleetGear.moveToGroup')}
|
||||
>
|
||||
zoom
|
||||
{t('fleetGear.zoom')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -158,10 +219,17 @@ const GearGroupSection = ({
|
||||
}}>
|
||||
{parentMember && (
|
||||
<div style={{ color: '#fbbf24', marginBottom: 2 }}>
|
||||
모선: {parentMember.name || parentMember.mmsi}
|
||||
{t('parentInference.summary.recommendedParent')}: {parentMember.name || parentMember.mmsi}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ color: '#64748b', marginBottom: 2 }}>어구 목록:</div>
|
||||
{inference && (
|
||||
<div style={{ marginBottom: 4, color: inference.status === 'AUTO_PROMOTED' ? '#22c55e' : '#94a3b8' }}>
|
||||
{t('parentInference.summary.label')}: {getInferenceStatusLabel(inference.status)}
|
||||
{inference.selectedParentName ? ` / ${inference.selectedParentName}` : ''}
|
||||
{getInferenceReason(inference) ? ` / ${getInferenceReason(inference)}` : ''}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ color: '#64748b', marginBottom: 2 }}>{t('fleetGear.gearList')}:</div>
|
||||
{gearMembers.map(m => (
|
||||
<div key={m.mmsi} style={{
|
||||
display: 'flex',
|
||||
@ -190,8 +258,8 @@ const GearGroupSection = ({
|
||||
padding: '0 2px',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
title="어구 위치로 이동"
|
||||
aria-label={`${m.name || m.mmsi} 위치로 이동`}
|
||||
title={t('fleetGear.moveToGear')}
|
||||
aria-label={t('fleetGear.moveToGearItem', { name: m.name || m.mmsi })}
|
||||
>
|
||||
▶
|
||||
</button>
|
||||
|
||||
@ -1,16 +1,40 @@
|
||||
import { useRef, useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import { FONT_MONO } from '../../styles/fonts';
|
||||
import { useGearReplayStore } from '../../stores/gearReplayStore';
|
||||
import { useLocalStorage } from '../../hooks/useLocalStorage';
|
||||
import { MODEL_COLORS } from './fleetClusterConstants';
|
||||
import type { HistoryFrame } from './fleetClusterTypes';
|
||||
import type { GearCorrelationItem } from '../../services/vesselAnalysis';
|
||||
import { useReplayCenterPanelLayout } from './useReplayCenterPanelLayout';
|
||||
|
||||
interface HistoryReplayControllerProps {
|
||||
onClose: () => void;
|
||||
onFilterByScore: (minPct: number | null) => void;
|
||||
hasRightReviewPanel?: boolean;
|
||||
}
|
||||
|
||||
const MIN_AB_GAP_MS = 2 * 3600_000;
|
||||
const BASE_PLAYBACK_SPEED = 0.5;
|
||||
const SPEED_MULTIPLIERS = [1, 2, 5, 10] as const;
|
||||
|
||||
interface ReplayUiPrefs {
|
||||
showTrails: boolean;
|
||||
showLabels: boolean;
|
||||
focusMode: boolean;
|
||||
show1hPolygon: boolean;
|
||||
show6hPolygon: boolean;
|
||||
abLoop: boolean;
|
||||
speedMultiplier: 1 | 2 | 5 | 10;
|
||||
}
|
||||
|
||||
const DEFAULT_REPLAY_UI_PREFS: ReplayUiPrefs = {
|
||||
showTrails: true,
|
||||
showLabels: true,
|
||||
focusMode: false,
|
||||
show1hPolygon: true,
|
||||
show6hPolygon: false,
|
||||
abLoop: false,
|
||||
speedMultiplier: 1,
|
||||
};
|
||||
|
||||
// 멤버 정보 + 소속 모델 매핑
|
||||
interface TooltipMember {
|
||||
@ -70,7 +94,7 @@ function buildTooltipMembers(
|
||||
return [...map.values()];
|
||||
}
|
||||
|
||||
const HistoryReplayController = ({ onClose, onFilterByScore }: HistoryReplayControllerProps) => {
|
||||
const HistoryReplayController = ({ onClose, hasRightReviewPanel = false }: HistoryReplayControllerProps) => {
|
||||
const isPlaying = useGearReplayStore(s => s.isPlaying);
|
||||
const snapshotRanges = useGearReplayStore(s => s.snapshotRanges);
|
||||
const snapshotRanges6h = useGearReplayStore(s => s.snapshotRanges6h);
|
||||
@ -78,6 +102,9 @@ const HistoryReplayController = ({ onClose, onFilterByScore }: HistoryReplayCont
|
||||
const historyFrames6h = useGearReplayStore(s => s.historyFrames6h);
|
||||
const frameCount = historyFrames.length;
|
||||
const frameCount6h = historyFrames6h.length;
|
||||
const dataStartTime = useGearReplayStore(s => s.dataStartTime);
|
||||
const dataEndTime = useGearReplayStore(s => s.dataEndTime);
|
||||
const playbackSpeed = useGearReplayStore(s => s.playbackSpeed);
|
||||
const showTrails = useGearReplayStore(s => s.showTrails);
|
||||
const showLabels = useGearReplayStore(s => s.showLabels);
|
||||
const focusMode = useGearReplayStore(s => s.focusMode);
|
||||
@ -95,11 +122,15 @@ const HistoryReplayController = ({ onClose, onFilterByScore }: HistoryReplayCont
|
||||
const [hoveredTooltip, setHoveredTooltip] = useState<{ pos: number; time: number; frame1h: HistoryFrame | null; frame6h: HistoryFrame | null } | null>(null);
|
||||
const [pinnedTooltip, setPinnedTooltip] = useState<{ pos: number; time: number; frame1h: HistoryFrame | null; frame6h: HistoryFrame | null } | null>(null);
|
||||
const [dragging, setDragging] = useState<'A' | 'B' | null>(null);
|
||||
const [replayUiPrefs, setReplayUiPrefs] = useLocalStorage<ReplayUiPrefs>('gearReplayUiPrefs', DEFAULT_REPLAY_UI_PREFS);
|
||||
const trackRef = useRef<HTMLDivElement>(null);
|
||||
const progressIndicatorRef = useRef<HTMLDivElement>(null);
|
||||
const timeDisplayRef = useRef<HTMLSpanElement>(null);
|
||||
|
||||
const store = useGearReplayStore;
|
||||
const speedMultiplier = SPEED_MULTIPLIERS.includes(replayUiPrefs.speedMultiplier)
|
||||
? replayUiPrefs.speedMultiplier
|
||||
: 1;
|
||||
|
||||
// currentTime → 진행 인디케이터
|
||||
useEffect(() => {
|
||||
@ -123,6 +154,34 @@ const HistoryReplayController = ({ onClose, onFilterByScore }: HistoryReplayCont
|
||||
if (isPlaying) setPinnedTooltip(null);
|
||||
}, [isPlaying]);
|
||||
|
||||
useEffect(() => {
|
||||
const replayStore = store.getState();
|
||||
replayStore.setShowTrails(replayUiPrefs.showTrails);
|
||||
replayStore.setShowLabels(replayUiPrefs.showLabels);
|
||||
replayStore.setFocusMode(replayUiPrefs.focusMode);
|
||||
replayStore.setShow1hPolygon(replayUiPrefs.show1hPolygon);
|
||||
replayStore.setShow6hPolygon(has6hData ? replayUiPrefs.show6hPolygon : false);
|
||||
}, [
|
||||
has6hData,
|
||||
replayUiPrefs.focusMode,
|
||||
replayUiPrefs.show1hPolygon,
|
||||
replayUiPrefs.show6hPolygon,
|
||||
replayUiPrefs.showLabels,
|
||||
replayUiPrefs.showTrails,
|
||||
store,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
store.getState().setAbLoop(replayUiPrefs.abLoop);
|
||||
}, [dataEndTime, dataStartTime, replayUiPrefs.abLoop, store]);
|
||||
|
||||
useEffect(() => {
|
||||
const nextSpeed = BASE_PLAYBACK_SPEED * speedMultiplier;
|
||||
if (Math.abs(playbackSpeed - nextSpeed) > 1e-9) {
|
||||
store.getState().setPlaybackSpeed(nextSpeed);
|
||||
}
|
||||
}, [playbackSpeed, speedMultiplier, store]);
|
||||
|
||||
const posToProgress = useCallback((clientX: number) => {
|
||||
const rect = trackRef.current?.getBoundingClientRect();
|
||||
if (!rect) return 0;
|
||||
@ -258,13 +317,17 @@ const HistoryReplayController = ({ onClose, onFilterByScore }: HistoryReplayCont
|
||||
const btnActiveStyle: React.CSSProperties = {
|
||||
...btnStyle, background: 'rgba(99,179,237,0.15)', color: '#93c5fd',
|
||||
};
|
||||
const layout = useReplayCenterPanelLayout({
|
||||
minWidth: 266,
|
||||
maxWidth: 966,
|
||||
hasRightReviewPanel,
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
position: 'absolute', bottom: 20,
|
||||
left: 'calc(50% + 100px)', transform: 'translateX(-50%)',
|
||||
width: 'calc(100vw - 880px)',
|
||||
minWidth: 380, maxWidth: 1320,
|
||||
left: `${layout.left}px`,
|
||||
width: `${layout.width}px`,
|
||||
background: 'rgba(12,24,37,0.95)', border: '1px solid rgba(99,179,237,0.25)',
|
||||
borderRadius: 8, padding: '8px 14px', display: 'flex', flexDirection: 'column', gap: 4,
|
||||
zIndex: 50, fontFamily: FONT_MONO, fontSize: 10, color: '#e2e8f0',
|
||||
@ -452,38 +515,44 @@ const HistoryReplayController = ({ onClose, onFilterByScore }: HistoryReplayCont
|
||||
style={{ ...btnStyle, fontSize: 12 }}>{isPlaying ? '⏸' : '▶'}</button>
|
||||
<span ref={timeDisplayRef} style={{ color: '#fbbf24', minWidth: 36, textAlign: 'center' }}>--:--</span>
|
||||
<span style={{ color: '#475569' }}>|</span>
|
||||
<button type="button" onClick={() => store.getState().setShowTrails(!showTrails)}
|
||||
<button type="button" onClick={() => setReplayUiPrefs(prev => ({ ...prev, showTrails: !prev.showTrails }))}
|
||||
style={showTrails ? btnActiveStyle : btnStyle} title="항적">항적</button>
|
||||
<button type="button" onClick={() => store.getState().setShowLabels(!showLabels)}
|
||||
<button type="button" onClick={() => setReplayUiPrefs(prev => ({ ...prev, showLabels: !prev.showLabels }))}
|
||||
style={showLabels ? btnActiveStyle : btnStyle} title="이름">이름</button>
|
||||
<button type="button" onClick={() => store.getState().setFocusMode(!focusMode)}
|
||||
<button type="button" onClick={() => setReplayUiPrefs(prev => ({ ...prev, focusMode: !prev.focusMode }))}
|
||||
style={focusMode ? { ...btnStyle, background: 'rgba(239,68,68,0.15)', color: '#f87171' } : btnStyle}
|
||||
title="집중 모드">집중</button>
|
||||
<span style={{ color: '#475569' }}>|</span>
|
||||
<button type="button" onClick={() => store.getState().setShow1hPolygon(!show1hPolygon)}
|
||||
<button type="button" onClick={() => setReplayUiPrefs(prev => ({ ...prev, show1hPolygon: !prev.show1hPolygon }))}
|
||||
style={show1hPolygon ? { ...btnActiveStyle, background: 'rgba(251,191,36,0.15)', color: '#fbbf24', border: '1px solid rgba(251,191,36,0.4)' } : btnStyle}
|
||||
title="1h 폴리곤">1h</button>
|
||||
<button type="button" onClick={() => store.getState().setShow6hPolygon(!show6hPolygon)}
|
||||
<button type="button" onClick={() => has6hData && setReplayUiPrefs(prev => ({ ...prev, show6hPolygon: !prev.show6hPolygon }))}
|
||||
style={!has6hData ? { ...btnStyle, opacity: 0.3, cursor: 'not-allowed' }
|
||||
: show6hPolygon ? { ...btnActiveStyle, background: 'rgba(147,197,253,0.15)', color: '#93c5fd', border: '1px solid rgba(147,197,253,0.4)' } : btnStyle}
|
||||
disabled={!has6hData} title="6h 폴리곤">6h</button>
|
||||
<span style={{ color: '#475569' }}>|</span>
|
||||
<button type="button" onClick={() => store.getState().setAbLoop(!abLoop)}
|
||||
<button type="button" onClick={() => setReplayUiPrefs(prev => ({ ...prev, abLoop: !prev.abLoop }))}
|
||||
style={abLoop ? { ...btnStyle, background: 'rgba(34,197,94,0.15)', color: '#22c55e', border: '1px solid rgba(34,197,94,0.4)' } : btnStyle}
|
||||
title="A-B 구간 반복">A-B</button>
|
||||
<span style={{ color: '#475569' }}>|</span>
|
||||
<span style={{ color: '#64748b', fontSize: 9 }}>일치율</span>
|
||||
<select defaultValue="70"
|
||||
onChange={e => { onFilterByScore(e.target.value === '' ? null : Number(e.target.value)); }}
|
||||
style={{ background: 'rgba(15,23,42,0.9)', border: '1px solid rgba(99,179,237,0.3)', borderRadius: 4, color: '#e2e8f0', fontSize: 9, fontFamily: FONT_MONO, padding: '1px 4px', cursor: 'pointer' }}
|
||||
title="일치율 필터" aria-label="일치율 필터">
|
||||
<option value="">전체 (30%+)</option>
|
||||
<option value="50">50%+</option>
|
||||
<option value="60">60%+</option>
|
||||
<option value="70">70%+</option>
|
||||
<option value="80">80%+</option>
|
||||
<option value="90">90%+</option>
|
||||
</select>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
{SPEED_MULTIPLIERS.map(multiplier => {
|
||||
const active = speedMultiplier === multiplier;
|
||||
return (
|
||||
<button
|
||||
key={multiplier}
|
||||
type="button"
|
||||
onClick={() => setReplayUiPrefs(prev => ({ ...prev, speedMultiplier: multiplier }))}
|
||||
style={active
|
||||
? { ...btnActiveStyle, background: 'rgba(250,204,21,0.16)', color: '#fde68a', border: '1px solid rgba(250,204,21,0.32)' }
|
||||
: btnStyle}
|
||||
title={`재생 속도 x${multiplier}`}
|
||||
>
|
||||
x{multiplier}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<span style={{ flex: 1 }} />
|
||||
<span style={{ color: '#64748b', fontSize: 9 }}>
|
||||
<span style={{ color: '#fbbf24' }}>{frameCount}</span>
|
||||
|
||||
@ -2,6 +2,9 @@ import { useState, useMemo, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLocalStorage, useLocalStorageSet } from '../../hooks/useLocalStorage';
|
||||
import { DEFAULT_ENC_MAP_SETTINGS } from '../../features/encMap/types';
|
||||
import type { EncMapSettings } from '../../features/encMap/types';
|
||||
import { EncMapSettingsPanel } from '../../features/encMap/EncMapSettingsPanel';
|
||||
import { KoreaMap } from './KoreaMap';
|
||||
import { FieldAnalysisModal } from './FieldAnalysisModal';
|
||||
import { ReportModal } from './ReportModal';
|
||||
@ -88,6 +91,9 @@ export const KoreaDashboard = ({
|
||||
const [externalFlyTo, setExternalFlyTo] = useState<{ lat: number; lng: number; zoom: number } | null>(null);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [mapMode, setMapMode] = useLocalStorage<'satellite' | 'enc'>('koreaMapMode', 'satellite');
|
||||
const [encSettings, setEncSettings] = useLocalStorage<EncMapSettings>('encMapSettings', DEFAULT_ENC_MAP_SETTINGS);
|
||||
|
||||
const { hiddenAcCategories, hiddenShipCategories, toggleAcCategory, toggleShipCategory } =
|
||||
useSharedFilters();
|
||||
|
||||
@ -274,7 +280,25 @@ export const KoreaDashboard = ({
|
||||
return (
|
||||
<>
|
||||
{headerSlot && createPortal(
|
||||
<div className="mode-toggle">
|
||||
<>
|
||||
<div className="map-mode-toggle" style={{ display: 'flex', alignItems: 'center', gap: 2, marginRight: 8, position: 'relative' }}>
|
||||
<button type="button"
|
||||
className={`mode-btn${mapMode === 'satellite' ? ' active' : ''}`}
|
||||
onClick={() => setMapMode('satellite')}
|
||||
title="위성지도">
|
||||
🛰 위성
|
||||
</button>
|
||||
<button type="button"
|
||||
className={`mode-btn${mapMode === 'enc' ? ' active' : ''}`}
|
||||
onClick={() => setMapMode('enc')}
|
||||
title="전자해도 (ENC)">
|
||||
🗺 ENC
|
||||
</button>
|
||||
{mapMode === 'enc' && (
|
||||
<EncMapSettingsPanel value={encSettings} onChange={setEncSettings} />
|
||||
)}
|
||||
</div>
|
||||
<div className="mode-toggle">
|
||||
<button type="button" className={`mode-btn ${koreaFiltersResult.filters.illegalFishing ? 'active live' : ''}`}
|
||||
onClick={() => koreaFiltersResult.setFilter('illegalFishing', !koreaFiltersResult.filters.illegalFishing)} title={t('filters.illegalFishing')}>
|
||||
<span className="text-[11px]">🚫🐟</span>{t('filters.illegalFishing')}
|
||||
@ -311,7 +335,8 @@ export const KoreaDashboard = ({
|
||||
onClick={() => setShowOpsGuide(v => !v)} title="경비함정 작전 가이드">
|
||||
<span className="text-[11px]">⚓</span>작전가이드
|
||||
</button>
|
||||
</div>,
|
||||
</div>
|
||||
</>,
|
||||
headerSlot,
|
||||
)}
|
||||
{countsSlot && createPortal(
|
||||
@ -365,6 +390,8 @@ export const KoreaDashboard = ({
|
||||
externalFlyTo={externalFlyTo}
|
||||
onExternalFlyToDone={() => setExternalFlyTo(null)}
|
||||
opsRoute={opsRoute}
|
||||
mapMode={mapMode}
|
||||
encSettings={encSettings}
|
||||
/>
|
||||
<div className="map-overlay-left">
|
||||
<LayerPanel
|
||||
|
||||
@ -2,6 +2,10 @@ import { useRef, useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Map, NavigationControl, Marker, Source, Layer } from 'react-map-gl/maplibre';
|
||||
import type { MapRef } from 'react-map-gl/maplibre';
|
||||
import type { StyleSpecification } from 'maplibre-gl';
|
||||
import { fetchEncStyle } from '../../features/encMap/encStyle';
|
||||
import { useEncMapSettings } from '../../features/encMap/useEncMapSettings';
|
||||
import type { EncMapSettings } from '../../features/encMap/types';
|
||||
import { ScatterplotLayer, TextLayer } from '@deck.gl/layers';
|
||||
import { useFontScale } from '../../hooks/useFontScale';
|
||||
import { FONT_MONO } from '../../styles/fonts';
|
||||
@ -78,6 +82,8 @@ interface Props {
|
||||
externalFlyTo?: { lat: number; lng: number; zoom: number } | null;
|
||||
onExternalFlyToDone?: () => void;
|
||||
opsRoute?: { from: { lat: number; lng: number; name: string }; to: { lat: number; lng: number; name: string }; distanceNM: number; riskLevel: string } | null;
|
||||
mapMode: 'satellite' | 'enc';
|
||||
encSettings: EncMapSettings;
|
||||
}
|
||||
|
||||
// MarineTraffic-style: satellite + dark ocean + nautical overlay
|
||||
@ -213,17 +219,41 @@ const DebugTools = import.meta.env.DEV
|
||||
? lazy(() => import('./debug'))
|
||||
: null;
|
||||
|
||||
export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintFeed, currentTime, koreaFilters, transshipSuspects, cableWatchSuspects, dokdoWatchSuspects, dokdoAlerts, vesselAnalysis, groupPolygons, hiddenShipCategories, hiddenNationalities, externalFlyTo, onExternalFlyToDone, opsRoute }: Props) {
|
||||
export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintFeed, currentTime, koreaFilters, transshipSuspects, cableWatchSuspects, dokdoWatchSuspects, dokdoAlerts, vesselAnalysis, groupPolygons, hiddenShipCategories, hiddenNationalities, externalFlyTo, onExternalFlyToDone, opsRoute, mapMode, encSettings }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const mapRef = useRef<MapRef>(null);
|
||||
const maplibreRef = useRef<import('maplibre-gl').Map | null>(null);
|
||||
const overlayRef = useRef<MapboxOverlay | null>(null);
|
||||
|
||||
// ENC 스타일 사전 로드
|
||||
const [encStyle, setEncStyle] = useState<StyleSpecification | null>(null);
|
||||
useEffect(() => {
|
||||
const ctrl = new AbortController();
|
||||
fetchEncStyle(ctrl.signal).then(setEncStyle).catch(() => {});
|
||||
return () => ctrl.abort();
|
||||
}, []);
|
||||
const activeMapStyle = mapMode === 'enc' && encStyle ? encStyle : MAP_STYLE;
|
||||
|
||||
// ENC 설정 적용을 트리거하는 epoch — 맵 로드/스타일 전환 시 증가
|
||||
const [encSyncEpoch, setEncSyncEpoch] = useState(0);
|
||||
|
||||
// ENC 설정 런타임 적용
|
||||
useEncMapSettings(maplibreRef, mapMode, encSettings, encSyncEpoch);
|
||||
const replayLayerRef = useRef<DeckLayer[]>([]);
|
||||
const fleetClusterLayerRef = useRef<DeckLayer[]>([]);
|
||||
const fleetMapClickHandlerRef = useRef<((payload: { coordinate: [number, number]; screen: [number, number] }) => void) | null>(null);
|
||||
const fleetMapMoveHandlerRef = useRef<((payload: { coordinate: [number, number]; screen: [number, number] }) => void) | null>(null);
|
||||
const requestRenderRef = useRef<(() => void) | null>(null);
|
||||
const handleFleetDeckLayers = useCallback((layers: DeckLayer[]) => {
|
||||
fleetClusterLayerRef.current = layers;
|
||||
requestRenderRef.current?.();
|
||||
}, []);
|
||||
const registerFleetMapClickHandler = useCallback((handler: ((payload: { coordinate: [number, number]; screen: [number, number] }) => void) | null) => {
|
||||
fleetMapClickHandlerRef.current = handler;
|
||||
}, []);
|
||||
const registerFleetMapMoveHandler = useCallback((handler: ((payload: { coordinate: [number, number]; screen: [number, number] }) => void) | null) => {
|
||||
fleetMapMoveHandlerRef.current = handler;
|
||||
}, []);
|
||||
const [infra, setInfra] = useState<PowerFacility[]>([]);
|
||||
const [flyToTarget, setFlyToTarget] = useState<{ lng: number; lat: number; zoom: number } | null>(null);
|
||||
const [selectedAnalysisMmsi, setSelectedAnalysisMmsi] = useState<string | null>(null);
|
||||
@ -276,7 +306,10 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
||||
}, []);
|
||||
|
||||
// MapLibre 맵 로드 완료 콜백 (ship-triangle/gear-diamond → deck.gl 전환 완료로 삭제)
|
||||
const handleMapLoad = useCallback(() => {}, []);
|
||||
const handleMapLoad = useCallback(() => {
|
||||
maplibreRef.current = mapRef.current?.getMap() ?? null;
|
||||
setEncSyncEpoch(v => v + 1);
|
||||
}, []);
|
||||
|
||||
// ── shipDeckStore 동기화 ──
|
||||
useEffect(() => {
|
||||
@ -656,9 +689,27 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
||||
ref={mapRef}
|
||||
initialViewState={{ ...KOREA_MAP_CENTER, zoom: KOREA_MAP_ZOOM }}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
mapStyle={MAP_STYLE}
|
||||
mapStyle={activeMapStyle}
|
||||
onZoom={handleZoom}
|
||||
onLoad={handleMapLoad}
|
||||
onClick={event => {
|
||||
const handler = fleetMapClickHandlerRef.current;
|
||||
if (handler) {
|
||||
handler({
|
||||
coordinate: [event.lngLat.lng, event.lngLat.lat],
|
||||
screen: [event.point.x, event.point.y],
|
||||
});
|
||||
}
|
||||
}}
|
||||
onMouseMove={event => {
|
||||
const handler = fleetMapMoveHandlerRef.current;
|
||||
if (handler) {
|
||||
handler({
|
||||
coordinate: [event.lngLat.lng, event.lngLat.lat],
|
||||
screen: [event.point.x, event.point.y],
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<NavigationControl position="top-right" />
|
||||
|
||||
@ -800,10 +851,13 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
||||
groupPolygons={groupPolygons}
|
||||
zoomScale={zoomScale}
|
||||
onDeckLayersChange={handleFleetDeckLayers}
|
||||
registerMapClickHandler={registerFleetMapClickHandler}
|
||||
registerMapMoveHandler={registerFleetMapMoveHandler}
|
||||
onShipSelect={handleAnalysisShipSelect}
|
||||
onFleetZoom={handleFleetZoom}
|
||||
onSelectedGearChange={setSelectedGearData}
|
||||
onSelectedFleetChange={setSelectedFleetData}
|
||||
autoOpenReviewPanel={koreaFilters.cnFishing}
|
||||
/>
|
||||
)}
|
||||
{vesselAnalysis && vesselAnalysis.analysisMap.size > 0 && !replayFocusMode && (
|
||||
@ -1057,6 +1111,8 @@ export function KoreaMap({ ships, allShips, aircraft, satellites, layers, osintF
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* ENC 설정 패널은 KoreaDashboard 헤더에서 렌더 */}
|
||||
</Map>
|
||||
);
|
||||
}
|
||||
|
||||
1404
frontend/src/components/korea/ParentReviewPanel.tsx
Normal file
1404
frontend/src/components/korea/ParentReviewPanel.tsx
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
@ -36,6 +36,9 @@ export interface HoverTooltipState {
|
||||
lat: number;
|
||||
type: 'fleet' | 'gear';
|
||||
id: number | string;
|
||||
groupKey?: string;
|
||||
subClusterId?: number;
|
||||
compositeKey?: string;
|
||||
}
|
||||
|
||||
export interface PickerCandidate {
|
||||
|
||||
15
frontend/src/components/korea/parentInferenceConstants.ts
Normal file
15
frontend/src/components/korea/parentInferenceConstants.ts
Normal file
@ -0,0 +1,15 @@
|
||||
export const MIN_PARENT_REVIEW_SCORE = 0.3;
|
||||
export const MIN_PARENT_REVIEW_SCORE_PCT = 30;
|
||||
export const MIN_PARENT_REVIEW_MEMBER_COUNT = 2;
|
||||
export const REPLAY_COMPARE_PANEL_WIDTH_RATIO = 0.7;
|
||||
export const KOREA_SIDE_PANEL_WIDTH = 300;
|
||||
export const FLEET_LIST_PANEL_MAX_WIDTH = 300;
|
||||
export const FLEET_LIST_PANEL_LEFT_OFFSET = 10;
|
||||
export const ANALYSIS_PANEL_MAX_WIDTH = 280;
|
||||
export const ANALYSIS_PANEL_RIGHT_OFFSET = 50;
|
||||
export const REVIEW_PANEL_MAX_WIDTH = 560;
|
||||
export const REVIEW_PANEL_RIGHT_OFFSET = 16;
|
||||
export const REPLAY_CENTER_SAFE_GAP = 8;
|
||||
export const REPLAY_LEFT_RESERVED_WIDTH = FLEET_LIST_PANEL_LEFT_OFFSET + FLEET_LIST_PANEL_MAX_WIDTH + REPLAY_CENTER_SAFE_GAP;
|
||||
export const REPLAY_ANALYSIS_RESERVED_WIDTH = ANALYSIS_PANEL_MAX_WIDTH + ANALYSIS_PANEL_RIGHT_OFFSET + REPLAY_CENTER_SAFE_GAP;
|
||||
export const REPLAY_REVIEW_RESERVED_WIDTH = REVIEW_PANEL_MAX_WIDTH + REVIEW_PANEL_RIGHT_OFFSET + REPLAY_CENTER_SAFE_GAP;
|
||||
13
frontend/src/components/korea/parentReviewCandidateColors.ts
Normal file
13
frontend/src/components/korea/parentReviewCandidateColors.ts
Normal file
@ -0,0 +1,13 @@
|
||||
const PARENT_REVIEW_CANDIDATE_COLORS = [
|
||||
'#22d3ee',
|
||||
'#f59e0b',
|
||||
'#a78bfa',
|
||||
'#34d399',
|
||||
'#fb7185',
|
||||
'#60a5fa',
|
||||
] as const;
|
||||
|
||||
export function getParentReviewCandidateColor(rank: number): string {
|
||||
const index = Math.max(0, (rank || 1) - 1) % PARENT_REVIEW_CANDIDATE_COLORS.length;
|
||||
return PARENT_REVIEW_CANDIDATE_COLORS[index];
|
||||
}
|
||||
@ -13,7 +13,10 @@ export interface UseFleetClusterGeoJsonParams {
|
||||
groupPolygons: UseGroupPolygonsResult | undefined;
|
||||
analysisMap: Map<string, VesselAnalysisDto>;
|
||||
hoveredFleetId: number | null;
|
||||
hoveredGearCompositeKey?: string | null;
|
||||
visibleGearCompositeKeys?: Set<string> | null;
|
||||
selectedGearGroup: string | null;
|
||||
selectedGearCompositeKey?: string | null;
|
||||
pickerHoveredGroup: string | null;
|
||||
historyActive: boolean;
|
||||
correlationData: GearCorrelationItem[];
|
||||
@ -32,6 +35,7 @@ export interface FleetClusterGeoJsonResult {
|
||||
memberMarkersGeoJson: GeoJSON;
|
||||
pickerHighlightGeoJson: GeoJSON;
|
||||
selectedGearHighlightGeoJson: GeoJSON.FeatureCollection | null;
|
||||
hoveredGearHighlightGeoJson: GeoJSON.FeatureCollection | null;
|
||||
// correlation GeoJSON
|
||||
correlationVesselGeoJson: GeoJSON;
|
||||
correlationTrailGeoJson: GeoJSON;
|
||||
@ -74,7 +78,10 @@ export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): Fl
|
||||
shipMap,
|
||||
groupPolygons,
|
||||
hoveredFleetId,
|
||||
hoveredGearCompositeKey = null,
|
||||
visibleGearCompositeKeys = null,
|
||||
selectedGearGroup,
|
||||
selectedGearCompositeKey = null,
|
||||
pickerHoveredGroup,
|
||||
historyActive,
|
||||
correlationData,
|
||||
@ -195,10 +202,15 @@ export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): Fl
|
||||
if (!groupPolygons) return { type: 'FeatureCollection', features };
|
||||
for (const g of groupPolygons.allGroups.filter(x => x.groupType !== 'FLEET')) {
|
||||
if (!g.polygon) continue;
|
||||
const compositeKey = `${g.groupKey}:${g.subClusterId ?? 0}`;
|
||||
if (visibleGearCompositeKeys && !visibleGearCompositeKeys.has(compositeKey)) continue;
|
||||
features.push({
|
||||
type: 'Feature',
|
||||
properties: {
|
||||
name: g.groupKey,
|
||||
groupKey: g.groupKey,
|
||||
subClusterId: g.subClusterId ?? 0,
|
||||
compositeKey,
|
||||
gearCount: g.memberCount,
|
||||
inZone: g.groupType === 'GEAR_IN_ZONE' ? 1 : 0,
|
||||
},
|
||||
@ -206,7 +218,7 @@ export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): Fl
|
||||
});
|
||||
}
|
||||
return { type: 'FeatureCollection', features };
|
||||
}, [groupPolygons]);
|
||||
}, [groupPolygons, visibleGearCompositeKeys]);
|
||||
|
||||
// 가상 선박 마커 GeoJSON (API members + shipMap heading 보정)
|
||||
const memberMarkersGeoJson = useMemo((): GeoJSON => {
|
||||
@ -248,10 +260,12 @@ export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): Fl
|
||||
}
|
||||
for (const g of [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]) {
|
||||
const color = g.groupType === 'GEAR_IN_ZONE' ? '#dc2626' : '#f97316';
|
||||
const compositeKey = `${g.groupKey}:${g.subClusterId ?? 0}`;
|
||||
if (visibleGearCompositeKeys && !visibleGearCompositeKeys.has(compositeKey)) continue;
|
||||
for (const m of g.members) addMember(m, g.groupKey, g.groupType, color);
|
||||
}
|
||||
return { type: 'FeatureCollection', features };
|
||||
}, [groupPolygons, shipMap]);
|
||||
}, [groupPolygons, shipMap, visibleGearCompositeKeys]);
|
||||
|
||||
// picker 호버 하이라이트 (선단 + 어구 통합)
|
||||
const pickerHighlightGeoJson = useMemo((): GeoJSON => {
|
||||
@ -270,17 +284,47 @@ export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): Fl
|
||||
const allGroups = groupPolygons
|
||||
? [...groupPolygons.gearInZoneGroups, ...groupPolygons.gearOutZoneGroups]
|
||||
: [];
|
||||
const matches = allGroups.filter(g => g.groupKey === selectedGearGroup && g.polygon);
|
||||
const matches = allGroups.filter(g => {
|
||||
if (!g.polygon || g.groupKey !== selectedGearGroup) return false;
|
||||
const compositeKey = `${g.groupKey}:${g.subClusterId ?? 0}`;
|
||||
if (selectedGearCompositeKey && compositeKey !== selectedGearCompositeKey) return false;
|
||||
if (visibleGearCompositeKeys && !visibleGearCompositeKeys.has(compositeKey)) return false;
|
||||
return true;
|
||||
});
|
||||
if (matches.length === 0) return null;
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: matches.map(g => ({
|
||||
type: 'Feature' as const,
|
||||
properties: { subClusterId: g.subClusterId },
|
||||
properties: {
|
||||
subClusterId: g.subClusterId,
|
||||
compositeKey: `${g.groupKey}:${g.subClusterId ?? 0}`,
|
||||
},
|
||||
geometry: g.polygon!,
|
||||
})),
|
||||
};
|
||||
}, [selectedGearGroup, enabledModels, historyActive, groupPolygons]);
|
||||
}, [selectedGearGroup, selectedGearCompositeKey, enabledModels, historyActive, groupPolygons, visibleGearCompositeKeys]);
|
||||
|
||||
const hoveredGearHighlightGeoJson = useMemo((): GeoJSON.FeatureCollection | null => {
|
||||
if (!hoveredGearCompositeKey || !groupPolygons) return null;
|
||||
const group = groupPolygons.allGroups.find(
|
||||
item => item.groupType !== 'FLEET' && `${item.groupKey}:${item.subClusterId ?? 0}` === hoveredGearCompositeKey && item.polygon,
|
||||
);
|
||||
if (!group?.polygon) return null;
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: [{
|
||||
type: 'Feature',
|
||||
properties: {
|
||||
groupKey: group.groupKey,
|
||||
subClusterId: group.subClusterId ?? 0,
|
||||
compositeKey: hoveredGearCompositeKey,
|
||||
inZone: group.groupType === 'GEAR_IN_ZONE' ? 1 : 0,
|
||||
},
|
||||
geometry: group.polygon,
|
||||
}],
|
||||
};
|
||||
}, [groupPolygons, hoveredGearCompositeKey]);
|
||||
|
||||
// ── 연관 대상 마커 (ships[] fallback) ──
|
||||
const correlationVesselGeoJson = useMemo((): GeoJSON => {
|
||||
@ -416,6 +460,7 @@ export function useFleetClusterGeoJson(params: UseFleetClusterGeoJsonParams): Fl
|
||||
memberMarkersGeoJson,
|
||||
pickerHighlightGeoJson,
|
||||
selectedGearHighlightGeoJson,
|
||||
hoveredGearHighlightGeoJson,
|
||||
correlationVesselGeoJson,
|
||||
correlationTrailGeoJson,
|
||||
modelBadgesGeoJson,
|
||||
|
||||
69
frontend/src/components/korea/useReplayCenterPanelLayout.ts
Normal file
69
frontend/src/components/korea/useReplayCenterPanelLayout.ts
Normal file
@ -0,0 +1,69 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
KOREA_SIDE_PANEL_WIDTH,
|
||||
REPLAY_ANALYSIS_RESERVED_WIDTH,
|
||||
REPLAY_COMPARE_PANEL_WIDTH_RATIO,
|
||||
REPLAY_LEFT_RESERVED_WIDTH,
|
||||
REPLAY_REVIEW_RESERVED_WIDTH,
|
||||
} from './parentInferenceConstants';
|
||||
|
||||
interface ReplayCenterPanelLayoutOptions {
|
||||
minWidth: number;
|
||||
maxWidth: number;
|
||||
hasRightReviewPanel?: boolean;
|
||||
}
|
||||
|
||||
interface ReplayCenterPanelLayout {
|
||||
left: number;
|
||||
width: number;
|
||||
}
|
||||
|
||||
const FALLBACK_VIEWPORT_WIDTH = 1920;
|
||||
const ABSOLUTE_MIN_WIDTH = 180;
|
||||
|
||||
export function useReplayCenterPanelLayout({
|
||||
minWidth,
|
||||
maxWidth,
|
||||
hasRightReviewPanel = false,
|
||||
}: ReplayCenterPanelLayoutOptions): ReplayCenterPanelLayout {
|
||||
const [viewportWidth, setViewportWidth] = useState(
|
||||
() => (typeof window === 'undefined' ? FALLBACK_VIEWPORT_WIDTH : window.innerWidth),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const handleResize = () => {
|
||||
setViewportWidth(window.innerWidth);
|
||||
};
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return useMemo(() => {
|
||||
const mapPanelWidth = Math.max(ABSOLUTE_MIN_WIDTH, viewportWidth - KOREA_SIDE_PANEL_WIDTH);
|
||||
const leftReserved = REPLAY_LEFT_RESERVED_WIDTH;
|
||||
const rightReserved = Math.max(
|
||||
REPLAY_ANALYSIS_RESERVED_WIDTH,
|
||||
hasRightReviewPanel ? REPLAY_REVIEW_RESERVED_WIDTH : 0,
|
||||
);
|
||||
const availableWidth = Math.max(ABSOLUTE_MIN_WIDTH, mapPanelWidth - leftReserved - rightReserved);
|
||||
|
||||
let width: number;
|
||||
if (availableWidth >= maxWidth) {
|
||||
width = maxWidth;
|
||||
} else if (availableWidth <= minWidth) {
|
||||
width = Math.max(ABSOLUTE_MIN_WIDTH, availableWidth);
|
||||
} else {
|
||||
width = Math.min(maxWidth, Math.max(minWidth, availableWidth * REPLAY_COMPARE_PANEL_WIDTH_RATIO));
|
||||
}
|
||||
|
||||
const left = leftReserved + Math.max(0, (availableWidth - width) / 2);
|
||||
|
||||
return {
|
||||
left,
|
||||
width,
|
||||
};
|
||||
}, [hasRightReviewPanel, maxWidth, minWidth, viewportWidth]);
|
||||
}
|
||||
129
frontend/src/features/encMap/EncMapSettingsPanel.tsx
Normal file
129
frontend/src/features/encMap/EncMapSettingsPanel.tsx
Normal file
@ -0,0 +1,129 @@
|
||||
import { useState } from 'react';
|
||||
import type { EncMapSettings } from './types';
|
||||
import { DEFAULT_ENC_MAP_SETTINGS, ENC_DEPTH_COLOR_TARGETS } from './types';
|
||||
import { FONT_MONO } from '../../styles/fonts';
|
||||
|
||||
interface EncMapSettingsPanelProps {
|
||||
value: EncMapSettings;
|
||||
onChange: (next: EncMapSettings) => void;
|
||||
}
|
||||
|
||||
const SYMBOL_TOGGLES: { key: keyof EncMapSettings; label: string }[] = [
|
||||
{ key: 'showBuoys', label: '부표' },
|
||||
{ key: 'showBeacons', label: '비콘' },
|
||||
{ key: 'showLights', label: '등대' },
|
||||
{ key: 'showDangers', label: '위험물' },
|
||||
{ key: 'showLandmarks', label: '랜드마크' },
|
||||
{ key: 'showSoundings', label: '수심' },
|
||||
{ key: 'showPilot', label: '도선소' },
|
||||
{ key: 'showAnchorage', label: '정박지' },
|
||||
{ key: 'showRestricted', label: '제한구역' },
|
||||
{ key: 'showDredged', label: '준설구역' },
|
||||
{ key: 'showTSS', label: '통항분리대' },
|
||||
{ key: 'showContours', label: '등심선' },
|
||||
];
|
||||
|
||||
const AREA_COLOR_INPUTS: { key: keyof EncMapSettings; label: string }[] = [
|
||||
{ key: 'backgroundColor', label: '바다 배경' },
|
||||
{ key: 'landColor', label: '육지' },
|
||||
{ key: 'coastlineColor', label: '해안선' },
|
||||
];
|
||||
|
||||
|
||||
export function EncMapSettingsPanel({ value, onChange }: EncMapSettingsPanelProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const update = <K extends keyof EncMapSettings>(key: K, val: EncMapSettings[K]) => {
|
||||
onChange({ ...value, [key]: val });
|
||||
};
|
||||
|
||||
const isDefault = JSON.stringify(value) === JSON.stringify(DEFAULT_ENC_MAP_SETTINGS);
|
||||
const allChecked = SYMBOL_TOGGLES.every(({ key }) => value[key] as boolean);
|
||||
|
||||
const toggleAll = (checked: boolean) => {
|
||||
const next = { ...value };
|
||||
for (const { key } of SYMBOL_TOGGLES) {
|
||||
(next as Record<string, unknown>)[key] = checked;
|
||||
}
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(p => !p)}
|
||||
title="ENC 스타일 설정"
|
||||
className={`mode-btn${open ? ' active' : ''}`}
|
||||
>
|
||||
⚙
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div style={{
|
||||
position: 'absolute', top: '100%', left: 0, marginTop: 4, width: 240,
|
||||
background: 'rgba(12,24,37,0.95)', border: '1px solid rgba(99,179,237,0.25)',
|
||||
borderRadius: 8, padding: '8px 10px', zIndex: 100,
|
||||
fontFamily: FONT_MONO, fontSize: 10, color: '#e2e8f0',
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,0.5)', maxHeight: 'calc(100vh - 80px)', overflowY: 'auto',
|
||||
}}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }}>
|
||||
<span style={{ fontWeight: 600, fontSize: 11 }}>ENC 설정</span>
|
||||
{!isDefault && (
|
||||
<button type="button" onClick={() => onChange(DEFAULT_ENC_MAP_SETTINGS)}
|
||||
style={{ background: 'none', border: '1px solid rgba(239,68,68,0.3)', borderRadius: 3, color: '#f87171', cursor: 'pointer', padding: '1px 6px', fontSize: 9, fontFamily: FONT_MONO }}>
|
||||
초기화
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 레이어 토글 */}
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 3, color: '#94a3b8', fontSize: 9 }}>
|
||||
<span>레이어 표시</span>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 3, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={allChecked} onChange={e => toggleAll(e.target.checked)} />
|
||||
<span>전체</span>
|
||||
</label>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '2px 8px' }}>
|
||||
{SYMBOL_TOGGLES.map(({ key, label }) => (
|
||||
<label key={key} style={{ display: 'flex', alignItems: 'center', gap: 3, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={value[key] as boolean}
|
||||
onChange={e => update(key, e.target.checked as never)} />
|
||||
<span>{label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 영역 색상 */}
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<div style={{ color: '#94a3b8', fontSize: 9, marginBottom: 3 }}>영역 색상</div>
|
||||
{AREA_COLOR_INPUTS.map(({ key, label }) => (
|
||||
<div key={key} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '1px 0' }}>
|
||||
<span>{label}</span>
|
||||
<input type="color" value={value[key] as string} title={label}
|
||||
onChange={e => update(key, e.target.value as never)}
|
||||
style={{ width: 24, height: 16, border: 'none', cursor: 'pointer', background: 'transparent' }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 수심 색상 */}
|
||||
<div>
|
||||
<div style={{ color: '#94a3b8', fontSize: 9, marginBottom: 3 }}>수심 색상</div>
|
||||
{ENC_DEPTH_COLOR_TARGETS.map(({ key, label }) => (
|
||||
<div key={key} style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '1px 0' }}>
|
||||
<span>{label}</span>
|
||||
<input type="color" value={value[key] as string} title={label}
|
||||
onChange={e => update(key, e.target.value as never)}
|
||||
style={{ width: 24, height: 16, border: 'none', cursor: 'pointer', background: 'transparent' }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
44
frontend/src/features/encMap/encSettings.ts
Normal file
44
frontend/src/features/encMap/encSettings.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import type maplibregl from 'maplibre-gl';
|
||||
import type { EncMapSettings } from './types';
|
||||
import { ENC_LAYER_CATEGORIES, ENC_COLOR_TARGETS, ENC_DEPTH_COLOR_TARGETS } from './types';
|
||||
|
||||
export function applyEncVisibility(map: maplibregl.Map, settings: EncMapSettings): void {
|
||||
for (const [key, layerIds] of Object.entries(ENC_LAYER_CATEGORIES)) {
|
||||
const visible = settings[key as keyof EncMapSettings] as boolean;
|
||||
const vis = visible ? 'visible' : 'none';
|
||||
for (const layerId of layerIds) {
|
||||
try {
|
||||
if (map.getLayer(layerId)) {
|
||||
map.setLayoutProperty(layerId, 'visibility', vis);
|
||||
}
|
||||
} catch { /* layer may not exist */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function applyEncColors(map: maplibregl.Map, settings: EncMapSettings): void {
|
||||
for (const [layerId, prop, key] of ENC_COLOR_TARGETS) {
|
||||
try {
|
||||
if (map.getLayer(layerId)) {
|
||||
map.setPaintProperty(layerId, prop, settings[key] as string);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
try {
|
||||
if (map.getLayer('background')) {
|
||||
map.setPaintProperty('background', 'background-color', settings.backgroundColor);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
for (const { key, layerIds } of ENC_DEPTH_COLOR_TARGETS) {
|
||||
const color = settings[key] as string;
|
||||
for (const layerId of layerIds) {
|
||||
try {
|
||||
if (map.getLayer(layerId)) {
|
||||
map.setPaintProperty(layerId, 'fill-color', color);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
22
frontend/src/features/encMap/encStyle.ts
Normal file
22
frontend/src/features/encMap/encStyle.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import type { StyleSpecification } from 'maplibre-gl';
|
||||
|
||||
const NAUTICAL_STYLE_URL = 'https://tiles.gcnautical.com/styles/nautical.json';
|
||||
|
||||
const SERVER_FONTS = ['Noto Sans CJK KR Regular', 'Noto Sans Regular'];
|
||||
|
||||
export async function fetchEncStyle(signal: AbortSignal): Promise<StyleSpecification> {
|
||||
const res = await fetch(NAUTICAL_STYLE_URL, { signal });
|
||||
if (!res.ok) throw new Error(`ENC style fetch failed: ${res.status}`);
|
||||
const style = (await res.json()) as StyleSpecification;
|
||||
|
||||
for (const layer of style.layers) {
|
||||
const layout = (layer as { layout?: Record<string, unknown> }).layout;
|
||||
if (!layout) continue;
|
||||
const tf = layout['text-font'];
|
||||
if (Array.isArray(tf) && tf.every((v) => typeof v === 'string')) {
|
||||
layout['text-font'] = SERVER_FONTS;
|
||||
}
|
||||
}
|
||||
|
||||
return style;
|
||||
}
|
||||
82
frontend/src/features/encMap/types.ts
Normal file
82
frontend/src/features/encMap/types.ts
Normal file
@ -0,0 +1,82 @@
|
||||
export interface EncMapSettings {
|
||||
showBuoys: boolean;
|
||||
showBeacons: boolean;
|
||||
showLights: boolean;
|
||||
showDangers: boolean;
|
||||
showLandmarks: boolean;
|
||||
showSoundings: boolean;
|
||||
showPilot: boolean;
|
||||
showAnchorage: boolean;
|
||||
showRestricted: boolean;
|
||||
showDredged: boolean;
|
||||
showTSS: boolean;
|
||||
showContours: boolean;
|
||||
|
||||
landColor: string;
|
||||
coastlineColor: string;
|
||||
backgroundColor: string;
|
||||
|
||||
depthDrying: string;
|
||||
depthVeryShallow: string;
|
||||
depthSafetyZone: string;
|
||||
depthMedium: string;
|
||||
depthDeep: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_ENC_MAP_SETTINGS: EncMapSettings = {
|
||||
showBuoys: true,
|
||||
showBeacons: true,
|
||||
showLights: true,
|
||||
showDangers: true,
|
||||
showLandmarks: true,
|
||||
showSoundings: true,
|
||||
showPilot: true,
|
||||
showAnchorage: true,
|
||||
showRestricted: true,
|
||||
showDredged: true,
|
||||
showTSS: true,
|
||||
showContours: true,
|
||||
|
||||
landColor: '#BFBE8D',
|
||||
coastlineColor: '#4C5B62',
|
||||
backgroundColor: '#93AEBB',
|
||||
|
||||
depthDrying: '#58AF99',
|
||||
depthVeryShallow: '#61B7FF',
|
||||
depthSafetyZone: '#82CAFF',
|
||||
depthMedium: '#A7D9FA',
|
||||
depthDeep: '#C9EDFD',
|
||||
};
|
||||
|
||||
export const ENC_LAYER_CATEGORIES: Record<string, string[]> = {
|
||||
showBuoys: ['boylat', 'boycar', 'boyisd', 'boysaw', 'boyspp'],
|
||||
showBeacons: ['lndmrk'],
|
||||
showLights: ['lights', 'lights-catlit'],
|
||||
showDangers: ['uwtroc', 'obstrn', 'wrecks'],
|
||||
showLandmarks: ['lndmrk'],
|
||||
showSoundings: ['soundg', 'soundg-critical'],
|
||||
showPilot: ['pilbop'],
|
||||
showAnchorage: ['achare', 'achare-outline'],
|
||||
showRestricted: ['resare-outline', 'resare-symbol', 'mipare'],
|
||||
showDredged: [
|
||||
'drgare-drying', 'drgare-very-shallow', 'drgare-safety-zone',
|
||||
'drgare-medium', 'drgare-deep', 'drgare-pattern', 'drgare-outline', 'drgare-symbol',
|
||||
],
|
||||
showTSS: ['tsslpt', 'tsslpt-outline'],
|
||||
showContours: ['depcnt', 'depare-safety-edge', 'depare-safety-edge-label'],
|
||||
};
|
||||
|
||||
export const ENC_COLOR_TARGETS: [layerId: string, prop: string, settingsKey: keyof EncMapSettings][] = [
|
||||
['lndare', 'fill-color', 'landColor'],
|
||||
['globe-lndare', 'fill-color', 'landColor'],
|
||||
['coalne', 'line-color', 'coastlineColor'],
|
||||
['globe-coalne', 'line-color', 'coastlineColor'],
|
||||
];
|
||||
|
||||
export const ENC_DEPTH_COLOR_TARGETS: { key: keyof EncMapSettings; label: string; layerIds: string[] }[] = [
|
||||
{ key: 'depthDrying', label: '건출 (< 0m)', layerIds: ['depare-drying', 'drgare-drying'] },
|
||||
{ key: 'depthVeryShallow', label: '극천 (0~2m)', layerIds: ['depare-very-shallow', 'drgare-very-shallow'] },
|
||||
{ key: 'depthSafetyZone', label: '안전수심 (2~30m)', layerIds: ['depare-safety-zone', 'drgare-safety-zone'] },
|
||||
{ key: 'depthMedium', label: '중간 (30m~)', layerIds: ['depare-medium', 'drgare-medium'] },
|
||||
{ key: 'depthDeep', label: '심해', layerIds: ['depare-deep', 'drgare-deep'] },
|
||||
];
|
||||
64
frontend/src/features/encMap/useEncMapSettings.ts
Normal file
64
frontend/src/features/encMap/useEncMapSettings.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import { useEffect, useRef, type MutableRefObject } from 'react';
|
||||
import type maplibregl from 'maplibre-gl';
|
||||
import { applyEncVisibility, applyEncColors } from './encSettings';
|
||||
import type { EncMapSettings } from './types';
|
||||
|
||||
/**
|
||||
* 스타일 로드 완료를 안정적으로 감지하여 callback 실행.
|
||||
* gc-wing-dev onMapStyleReady 패턴 이식.
|
||||
*/
|
||||
function onStyleReady(map: maplibregl.Map, callback: () => void): () => void {
|
||||
if (map.isStyleLoaded()) {
|
||||
callback();
|
||||
return () => {};
|
||||
}
|
||||
let fired = false;
|
||||
const runOnce = () => {
|
||||
if (fired || !map.isStyleLoaded()) return;
|
||||
fired = true;
|
||||
callback();
|
||||
try { map.off('style.load', runOnce); map.off('styledata', runOnce); } catch { /* ignore */ }
|
||||
};
|
||||
map.on('style.load', runOnce);
|
||||
map.on('styledata', runOnce);
|
||||
return () => {
|
||||
try { map.off('style.load', runOnce); map.off('styledata', runOnce); } catch { /* ignore */ }
|
||||
};
|
||||
}
|
||||
|
||||
export function useEncMapSettings(
|
||||
mapRef: MutableRefObject<maplibregl.Map | null>,
|
||||
mapMode: 'satellite' | 'enc',
|
||||
settings: EncMapSettings,
|
||||
syncEpoch = 0,
|
||||
) {
|
||||
// settings를 ref로 유지 — style.load 콜백에서 최신값 참조
|
||||
const settingsRef = useRef(settings);
|
||||
settingsRef.current = settings;
|
||||
|
||||
// syncEpoch 변경 = 맵 로드 완료 → 전체 설정 재적용
|
||||
// mapMode 변경 = 위성↔ENC 전환 → style.load 대기 후 적용
|
||||
useEffect(() => {
|
||||
if (mapMode !== 'enc') return;
|
||||
const map = mapRef.current;
|
||||
if (!map) return;
|
||||
|
||||
const applyAll = () => {
|
||||
const s = settingsRef.current;
|
||||
applyEncVisibility(map, s);
|
||||
applyEncColors(map, s);
|
||||
};
|
||||
|
||||
const stop = onStyleReady(map, applyAll);
|
||||
return stop;
|
||||
}, [mapMode, syncEpoch, mapRef]);
|
||||
|
||||
// settings 변경 시 즉시 적용 (스타일이 이미 로드된 상태에서)
|
||||
useEffect(() => {
|
||||
if (mapMode !== 'enc') return;
|
||||
const map = mapRef.current;
|
||||
if (!map || !map.isStyleLoaded()) return;
|
||||
applyEncVisibility(map, settings);
|
||||
applyEncColors(map, settings);
|
||||
}, [settings, mapMode, mapRef]);
|
||||
}
|
||||
415
frontend/src/flow/GearParentFlowViewer.css
Normal file
415
frontend/src/flow/GearParentFlowViewer.css
Normal file
@ -0,0 +1,415 @@
|
||||
.gear-flow-app {
|
||||
min-height: 100vh;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(43, 108, 176, 0.16), transparent 28%),
|
||||
radial-gradient(circle at bottom right, rgba(15, 118, 110, 0.14), transparent 24%),
|
||||
#07111f;
|
||||
color: #dce7f3;
|
||||
}
|
||||
|
||||
.gear-flow-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 332px minmax(880px, 1fr) 392px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.gear-flow-sidebar,
|
||||
.gear-flow-detail {
|
||||
backdrop-filter: blur(18px);
|
||||
background: rgba(7, 17, 31, 0.86);
|
||||
border-color: rgba(148, 163, 184, 0.16);
|
||||
}
|
||||
|
||||
.gear-flow-sidebar {
|
||||
border-right-width: 1px;
|
||||
}
|
||||
|
||||
.gear-flow-detail {
|
||||
border-left-width: 1px;
|
||||
}
|
||||
|
||||
.gear-flow-hero {
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.12);
|
||||
}
|
||||
|
||||
.gear-flow-sidebar .gear-flow-hero,
|
||||
.gear-flow-detail .gear-flow-hero {
|
||||
padding-right: 1.75rem;
|
||||
padding-left: 1.75rem;
|
||||
}
|
||||
|
||||
.gear-flow-panel-heading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.gear-flow-panel-kicker {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.gear-flow-panel-title {
|
||||
margin: 0;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.22;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.gear-flow-panel-description {
|
||||
margin: 0;
|
||||
font-size: 0.94rem;
|
||||
line-height: 1.72;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.gear-flow-meta-card {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.14);
|
||||
background: rgba(15, 23, 42, 0.64);
|
||||
padding: 1rem 1.05rem;
|
||||
}
|
||||
|
||||
.gear-flow-meta-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
font-size: 0.9rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.gear-flow-meta-row span {
|
||||
color: #e2e8f0;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.gear-flow-input,
|
||||
.gear-flow-select {
|
||||
width: 100%;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
background: rgba(15, 23, 42, 0.84);
|
||||
padding: 0.72rem 0.9rem;
|
||||
color: #f8fafc;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.gear-flow-input:focus,
|
||||
.gear-flow-select:focus {
|
||||
border-color: rgba(96, 165, 250, 0.7);
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.18);
|
||||
}
|
||||
|
||||
.gear-flow-node-card {
|
||||
border: 1px solid rgba(148, 163, 184, 0.16);
|
||||
border-radius: 16px;
|
||||
background: rgba(15, 23, 42, 0.68);
|
||||
overflow: hidden;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.gear-flow-node-card[data-active="true"] {
|
||||
border-color: rgba(96, 165, 250, 0.7);
|
||||
box-shadow: 0 0 0 1px rgba(96, 165, 250, 0.4);
|
||||
background: rgba(20, 35, 59, 0.86);
|
||||
}
|
||||
|
||||
.gear-flow-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999px;
|
||||
padding: 0.42rem 1rem;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.03em;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.gear-flow-chip[data-tone="implemented"] {
|
||||
background: rgba(34, 197, 94, 0.14);
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
.gear-flow-chip[data-tone="proposed"] {
|
||||
background: rgba(245, 158, 11, 0.16);
|
||||
color: #fcd34d;
|
||||
}
|
||||
|
||||
.gear-flow-chip[data-tone="neutral"] {
|
||||
background: rgba(148, 163, 184, 0.14);
|
||||
color: #cbd5e1;
|
||||
}
|
||||
|
||||
.gear-flow-section {
|
||||
border-top: 1px solid rgba(148, 163, 184, 0.12);
|
||||
}
|
||||
|
||||
.gear-flow-list {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.gear-flow-list-item {
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.16);
|
||||
background: rgba(15, 23, 42, 0.7);
|
||||
padding: 0.75rem 0.88rem;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.55;
|
||||
color: #cbd5e1;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.gear-flow-canvas {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.gear-flow-topbar {
|
||||
position: absolute;
|
||||
left: 24px;
|
||||
right: 24px;
|
||||
top: 20px;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.gear-flow-topbar-card {
|
||||
pointer-events: auto;
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
border-radius: 18px;
|
||||
background: rgba(7, 17, 31, 0.82);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.gear-flow-topbar-card--wrap {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.6rem;
|
||||
max-width: min(1080px, calc(100vw - 840px));
|
||||
}
|
||||
|
||||
.gear-flow-topbar-title {
|
||||
font-weight: 700;
|
||||
color: #f8fafc;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.gear-flow-topbar-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.14);
|
||||
background: rgba(15, 23, 42, 0.58);
|
||||
padding: 0.34rem 0.72rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.gear-flow-react-node {
|
||||
overflow: visible !important;
|
||||
transition:
|
||||
transform 160ms ease,
|
||||
box-shadow 160ms ease,
|
||||
border-color 160ms ease;
|
||||
}
|
||||
|
||||
.gear-flow-react-node.is-selected {
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.gear-flow-react-node--proposal {
|
||||
border-style: dashed !important;
|
||||
}
|
||||
|
||||
.gear-flow-node {
|
||||
--gear-flow-node-text-offset: 0.98rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.02rem;
|
||||
padding: 1.78rem 2.08rem 1.68rem;
|
||||
position: relative;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.gear-flow-node--function,
|
||||
.gear-flow-node--table,
|
||||
.gear-flow-node--component,
|
||||
.gear-flow-node--artifact,
|
||||
.gear-flow-node--proposal {
|
||||
padding-right: 2rem;
|
||||
padding-left: 2rem;
|
||||
}
|
||||
|
||||
.gear-flow-node__accent {
|
||||
position: absolute;
|
||||
left: 1.18rem;
|
||||
top: 1.18rem;
|
||||
bottom: 1.18rem;
|
||||
width: 5px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--gear-flow-accent) 82%, white 18%);
|
||||
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.gear-flow-node__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1.1rem;
|
||||
padding-left: var(--gear-flow-node-text-offset);
|
||||
}
|
||||
|
||||
.gear-flow-node__heading {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.gear-flow-node__stage {
|
||||
font-size: 1.04rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.16em;
|
||||
line-height: 1.2;
|
||||
color: #94a3b8;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.gear-flow-node__title {
|
||||
margin-top: 0;
|
||||
font-size: 1.54rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.32;
|
||||
color: #f8fafc;
|
||||
padding-right: 0.25rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.gear-flow-node__symbol {
|
||||
font-size: 1.14rem;
|
||||
line-height: 1.55;
|
||||
color: #cbd5e1;
|
||||
overflow-wrap: anywhere;
|
||||
padding-right: 0.2rem;
|
||||
padding-left: var(--gear-flow-node-text-offset);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.gear-flow-node__role {
|
||||
font-size: 1.25rem;
|
||||
line-height: 1.62;
|
||||
color: #94a3b8;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
padding-right: 0.18rem;
|
||||
padding-left: var(--gear-flow-node-text-offset);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.gear-flow-summary {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.gear-flow-detail-empty {
|
||||
border: 1px dashed rgba(148, 163, 184, 0.2);
|
||||
border-radius: 18px;
|
||||
background: rgba(15, 23, 42, 0.42);
|
||||
padding: 1rem 1.1rem;
|
||||
}
|
||||
|
||||
.gear-flow-detail-card {
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.14);
|
||||
background: rgba(15, 23, 42, 0.62);
|
||||
padding: 1.15rem 1.2rem;
|
||||
}
|
||||
|
||||
.gear-flow-detail-title {
|
||||
font-size: 1.45rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.34;
|
||||
color: #f8fafc;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.gear-flow-detail-symbol {
|
||||
font-size: 0.86rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.6;
|
||||
color: #7dd3fc;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.gear-flow-detail-text {
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.78;
|
||||
color: #cbd5e1;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.gear-flow-detail-file {
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.65;
|
||||
color: #94a3b8;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.gear-flow-section-title {
|
||||
margin-bottom: 0.78rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.gear-flow-link {
|
||||
color: #93c5fd;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.gear-flow-link:hover {
|
||||
color: #bfdbfe;
|
||||
}
|
||||
|
||||
.gear-flow-detail .gear-flow-section {
|
||||
padding-right: 0.15rem;
|
||||
}
|
||||
|
||||
.gear-flow-detail .space-y-6 {
|
||||
padding-right: 0.25rem;
|
||||
}
|
||||
|
||||
@media (max-width: 1680px) {
|
||||
.gear-flow-shell {
|
||||
grid-template-columns: 304px minmax(760px, 1fr) 348px;
|
||||
}
|
||||
|
||||
.gear-flow-topbar-card--wrap {
|
||||
max-width: min(860px, calc(100vw - 740px));
|
||||
}
|
||||
}
|
||||
565
frontend/src/flow/GearParentFlowViewer.tsx
Normal file
565
frontend/src/flow/GearParentFlowViewer.tsx
Normal file
@ -0,0 +1,565 @@
|
||||
import { useMemo, useState, type CSSProperties } from 'react';
|
||||
import {
|
||||
ReactFlow,
|
||||
Background,
|
||||
Controls,
|
||||
MiniMap,
|
||||
MarkerType,
|
||||
Position,
|
||||
type Edge,
|
||||
type Node,
|
||||
type NodeMouseHandler,
|
||||
type EdgeMouseHandler,
|
||||
} from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import manifest from './gearParentFlowManifest.json';
|
||||
import './GearParentFlowViewer.css';
|
||||
|
||||
type FlowStatus = 'implemented' | 'proposed';
|
||||
|
||||
type FlowNodeMeta = {
|
||||
id: string;
|
||||
label: string;
|
||||
stage: string;
|
||||
kind: string;
|
||||
position: { x: number; y: number };
|
||||
file: string;
|
||||
symbol: string;
|
||||
role: string;
|
||||
params: string[];
|
||||
rules: string[];
|
||||
storageReads: string[];
|
||||
storageWrites: string[];
|
||||
outputs: string[];
|
||||
impacts: string[];
|
||||
status: FlowStatus;
|
||||
};
|
||||
|
||||
type FlowEdgeMeta = {
|
||||
id: string;
|
||||
source: string;
|
||||
target: string;
|
||||
label?: string;
|
||||
detail?: string;
|
||||
};
|
||||
|
||||
type FlowManifest = {
|
||||
meta: {
|
||||
title: string;
|
||||
version: string;
|
||||
updatedAt: string;
|
||||
description: string;
|
||||
};
|
||||
nodes: FlowNodeMeta[];
|
||||
edges: FlowEdgeMeta[];
|
||||
};
|
||||
|
||||
const flowManifest = manifest as FlowManifest;
|
||||
|
||||
const stageColors: Record<string, string> = {
|
||||
'원천': '#38bdf8',
|
||||
'시간 모델': '#60a5fa',
|
||||
'적재': '#818cf8',
|
||||
'캐시': '#a78bfa',
|
||||
'정규화': '#c084fc',
|
||||
'그룹핑': '#f472b6',
|
||||
'후보 추적': '#fb7185',
|
||||
'검토 워크플로우': '#f97316',
|
||||
'최종 추론': '#f59e0b',
|
||||
'조회 계층': '#22c55e',
|
||||
'프론트': '#14b8a6',
|
||||
'문서': '#06b6d4',
|
||||
'미래 설계': '#eab308',
|
||||
};
|
||||
|
||||
const stageOrder = [
|
||||
'원천',
|
||||
'시간 모델',
|
||||
'적재',
|
||||
'캐시',
|
||||
'정규화',
|
||||
'그룹핑',
|
||||
'후보 추적',
|
||||
'검토 워크플로우',
|
||||
'최종 추론',
|
||||
'조회 계층',
|
||||
'프론트',
|
||||
'문서',
|
||||
'미래 설계',
|
||||
] as const;
|
||||
|
||||
const layoutConfig = {
|
||||
startX: 52,
|
||||
startY: 88,
|
||||
columnGap: 816,
|
||||
rowGap: 309,
|
||||
};
|
||||
|
||||
const semanticSlots: Record<string, { col: number; row: number; yOffset?: number }> = {
|
||||
source_tracks: { col: 0, row: 0 },
|
||||
safe_window: { col: 0, row: 1, yOffset: 36 },
|
||||
snpdb_fetch: { col: 0, row: 2, yOffset: 92 },
|
||||
vessel_store: { col: 0, row: 3, yOffset: 156 },
|
||||
gear_identity: { col: 1, row: 0 },
|
||||
detect_groups: { col: 1, row: 1 },
|
||||
group_snapshots: { col: 1, row: 2 },
|
||||
gear_correlation: { col: 1, row: 3 },
|
||||
workflow_exclusions: { col: 1, row: 4 },
|
||||
score_breakdown: { col: 2, row: 0 },
|
||||
parent_inference: { col: 2, row: 1 },
|
||||
backend_read_model: { col: 2, row: 2 },
|
||||
workflow_api: { col: 2, row: 3 },
|
||||
review_ui: { col: 2, row: 4 },
|
||||
future_episode: { col: 2, row: 5 },
|
||||
mermaid_docs: { col: 0, row: 5, yOffset: 240 },
|
||||
react_flow_viewer: { col: 1, row: 5, yOffset: 182 },
|
||||
};
|
||||
|
||||
function summarizeNode(node: FlowNodeMeta): string {
|
||||
return [node.symbol, node.role].filter(Boolean).join(' · ');
|
||||
}
|
||||
|
||||
function matchesQuery(node: FlowNodeMeta, query: string): boolean {
|
||||
if (!query) return true;
|
||||
const normalizedQuery = query.replace(/\s+/g, '').toLowerCase();
|
||||
const haystack = [
|
||||
node.label,
|
||||
node.stage,
|
||||
node.kind,
|
||||
node.file,
|
||||
node.symbol,
|
||||
node.role,
|
||||
...(node.params ?? []),
|
||||
...(node.rules ?? []),
|
||||
...(node.outputs ?? []),
|
||||
...(node.impacts ?? []),
|
||||
]
|
||||
.join(' ')
|
||||
.replace(/\s+/g, '')
|
||||
.toLowerCase();
|
||||
return haystack.includes(normalizedQuery);
|
||||
}
|
||||
|
||||
function stageTone(stage: string): string {
|
||||
return stageColors[stage] ?? '#94a3b8';
|
||||
}
|
||||
|
||||
function shapeClipPath(kind: string): string | undefined {
|
||||
switch (kind) {
|
||||
case 'function':
|
||||
return 'polygon(4% 0, 100% 0, 96% 100%, 0 100%)';
|
||||
case 'table':
|
||||
return 'polygon(0 6%, 8% 0, 100% 0, 100% 94%, 92% 100%, 0 100%)';
|
||||
case 'component':
|
||||
return 'polygon(7% 0, 93% 0, 100% 16%, 100% 84%, 93% 100%, 7% 100%, 0 84%, 0 16%)';
|
||||
case 'artifact':
|
||||
return 'polygon(0 0, 86% 0, 100% 14%, 100% 100%, 0 100%)';
|
||||
case 'proposal':
|
||||
return 'polygon(7% 0, 93% 0, 100% 20%, 100% 80%, 93% 100%, 7% 100%, 0 80%, 0 20%)';
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function compareNodeOrder(a: FlowNodeMeta, b: FlowNodeMeta): number {
|
||||
const stageGap = stageOrder.indexOf(a.stage as (typeof stageOrder)[number])
|
||||
- stageOrder.indexOf(b.stage as (typeof stageOrder)[number]);
|
||||
if (stageGap !== 0) return stageGap;
|
||||
if (a.position.y !== b.position.y) return a.position.y - b.position.y;
|
||||
if (a.position.x !== b.position.x) return a.position.x - b.position.x;
|
||||
return a.label.localeCompare(b.label);
|
||||
}
|
||||
|
||||
function layoutNodeMeta(nodes: FlowNodeMeta[], _edges: FlowEdgeMeta[]): FlowNodeMeta[] {
|
||||
const sortedNodes = [...nodes].sort(compareNodeOrder);
|
||||
const fallbackSlots = new Map<number, number>();
|
||||
const positioned = new Map<string, { x: number; y: number }>();
|
||||
|
||||
for (const node of sortedNodes) {
|
||||
const semantic = semanticSlots[node.id];
|
||||
if (semantic) {
|
||||
positioned.set(node.id, {
|
||||
x: layoutConfig.startX + semantic.col * layoutConfig.columnGap,
|
||||
y: layoutConfig.startY + semantic.row * layoutConfig.rowGap + (semantic.yOffset ?? 0),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const fallbackCol = Math.min(3, stageOrder.indexOf(node.stage as (typeof stageOrder)[number]) % 4);
|
||||
const fallbackRow = fallbackSlots.get(fallbackCol) ?? 5;
|
||||
fallbackSlots.set(fallbackCol, fallbackRow + 1);
|
||||
positioned.set(node.id, {
|
||||
x: layoutConfig.startX + fallbackCol * layoutConfig.columnGap,
|
||||
y: layoutConfig.startY + fallbackRow * layoutConfig.rowGap,
|
||||
});
|
||||
}
|
||||
|
||||
return nodes.map((node) => ({ ...node, position: positioned.get(node.id) ?? node.position }));
|
||||
}
|
||||
|
||||
function buildNodes(nodes: FlowNodeMeta[], selectedNodeId: string | null): Node[] {
|
||||
return nodes.map((node) => {
|
||||
const color = stageTone(node.stage);
|
||||
const clipPath = shapeClipPath(node.kind);
|
||||
const style = {
|
||||
'--gear-flow-accent': color,
|
||||
'--gear-flow-node-text-offset': '0.98rem',
|
||||
width: 380,
|
||||
borderRadius: node.kind === 'component' || node.kind === 'proposal' ? 22 : 18,
|
||||
padding: 0,
|
||||
color: '#e2e8f0',
|
||||
border: `2px solid ${selectedNodeId === node.id ? color : `${color}88`}`,
|
||||
background: 'linear-gradient(180deg, rgba(15,23,42,0.98), rgba(15,23,42,0.84))',
|
||||
boxShadow: selectedNodeId === node.id
|
||||
? `0 0 0 4px ${color}33, 0 26px 52px rgba(2, 6, 23, 0.4)`
|
||||
: '0 18px 36px rgba(2, 6, 23, 0.24)',
|
||||
overflow: 'visible',
|
||||
...(clipPath ? { clipPath } : {}),
|
||||
} as CSSProperties;
|
||||
|
||||
return {
|
||||
id: node.id,
|
||||
position: node.position,
|
||||
draggable: true,
|
||||
selectable: true,
|
||||
className: `gear-flow-react-node gear-flow-react-node--${node.kind}${selectedNodeId === node.id ? ' is-selected' : ''}`,
|
||||
style,
|
||||
data: { label: (
|
||||
<div className={`gear-flow-node gear-flow-node--${node.kind}`}>
|
||||
<div className="gear-flow-node__accent" />
|
||||
<div className="gear-flow-node__header">
|
||||
<div className="gear-flow-node__heading">
|
||||
<div className="gear-flow-node__stage">{node.stage}</div>
|
||||
<div className="gear-flow-node__title">{node.label}</div>
|
||||
</div>
|
||||
<span
|
||||
className="gear-flow-chip shrink-0"
|
||||
data-tone={node.status === 'implemented' ? 'implemented' : 'proposed'}
|
||||
>
|
||||
{node.status === 'implemented' ? '구현됨' : '제안됨'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="gear-flow-node__symbol">{node.symbol}</div>
|
||||
<div className="gear-flow-node__role">{node.role}</div>
|
||||
</div>
|
||||
)},
|
||||
sourcePosition: Position.Right,
|
||||
targetPosition: Position.Left,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function buildEdges(edges: FlowEdgeMeta[], selectedEdgeId: string | null): Edge[] {
|
||||
return edges.map((edge) => ({
|
||||
id: edge.id,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
type: 'smoothstep',
|
||||
pathOptions: { borderRadius: 18, offset: 18 },
|
||||
label: edge.label,
|
||||
animated: selectedEdgeId === edge.id,
|
||||
markerEnd: {
|
||||
type: MarkerType.ArrowClosed,
|
||||
color: selectedEdgeId === edge.id ? '#f8fafc' : '#94a3b8',
|
||||
width: 20,
|
||||
height: 20,
|
||||
},
|
||||
style: {
|
||||
stroke: selectedEdgeId === edge.id ? '#f8fafc' : '#94a3b8',
|
||||
strokeWidth: selectedEdgeId === edge.id ? 2.8 : 1.9,
|
||||
},
|
||||
labelStyle: {
|
||||
fill: '#e2e8f0',
|
||||
fontSize: 18,
|
||||
fontWeight: 700,
|
||||
letterSpacing: 0.2,
|
||||
},
|
||||
labelBgStyle: {
|
||||
fill: 'rgba(7, 17, 31, 0.94)',
|
||||
fillOpacity: 0.96,
|
||||
stroke: 'rgba(148, 163, 184, 0.24)',
|
||||
strokeWidth: 1,
|
||||
},
|
||||
labelBgPadding: [8, 5],
|
||||
labelBgBorderRadius: 12,
|
||||
}));
|
||||
}
|
||||
|
||||
function DetailList({ items }: { items: string[] }) {
|
||||
if (!items.length) {
|
||||
return <div className="text-sm text-slate-500">없음</div>;
|
||||
}
|
||||
return (
|
||||
<div className="gear-flow-list">
|
||||
{items.map((item) => (
|
||||
<div key={item} className="gear-flow-list-item">{item}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GearParentFlowViewer() {
|
||||
const [search, setSearch] = useState('');
|
||||
const [stageFilter, setStageFilter] = useState('전체');
|
||||
const [statusFilter, setStatusFilter] = useState<'전체' | FlowStatus>('전체');
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(flowManifest.nodes[0]?.id ?? null);
|
||||
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(null);
|
||||
|
||||
const filteredNodeMeta = useMemo(() => {
|
||||
return flowManifest.nodes.filter((node) => {
|
||||
if (stageFilter !== '전체' && node.stage !== stageFilter) return false;
|
||||
if (statusFilter !== '전체' && node.status !== statusFilter) return false;
|
||||
return matchesQuery(node, search);
|
||||
});
|
||||
}, [search, stageFilter, statusFilter]);
|
||||
|
||||
const visibleNodeIds = useMemo(() => new Set(filteredNodeMeta.map((node) => node.id)), [filteredNodeMeta]);
|
||||
|
||||
const filteredEdgeMeta = useMemo(() => {
|
||||
return flowManifest.edges.filter((edge) => visibleNodeIds.has(edge.source) && visibleNodeIds.has(edge.target));
|
||||
}, [visibleNodeIds]);
|
||||
|
||||
const layoutedNodeMeta = useMemo(
|
||||
() => layoutNodeMeta(filteredNodeMeta, filteredEdgeMeta),
|
||||
[filteredNodeMeta, filteredEdgeMeta],
|
||||
);
|
||||
|
||||
const reactFlowNodes = useMemo(
|
||||
() => buildNodes(layoutedNodeMeta, selectedNodeId),
|
||||
[layoutedNodeMeta, selectedNodeId],
|
||||
);
|
||||
const reactFlowEdges = useMemo(
|
||||
() => buildEdges(filteredEdgeMeta, selectedEdgeId),
|
||||
[filteredEdgeMeta, selectedEdgeId],
|
||||
);
|
||||
|
||||
const selectedNode = useMemo(
|
||||
() => flowManifest.nodes.find((node) => node.id === selectedNodeId) ?? null,
|
||||
[selectedNodeId],
|
||||
);
|
||||
const selectedEdge = useMemo(
|
||||
() => flowManifest.edges.find((edge) => edge.id === selectedEdgeId) ?? null,
|
||||
[selectedEdgeId],
|
||||
);
|
||||
|
||||
const onNodeClick: NodeMouseHandler = (_event, node) => {
|
||||
setSelectedEdgeId(null);
|
||||
setSelectedNodeId(node.id);
|
||||
};
|
||||
|
||||
const onEdgeClick: EdgeMouseHandler = (_event, edge) => {
|
||||
setSelectedNodeId(null);
|
||||
setSelectedEdgeId(edge.id);
|
||||
};
|
||||
|
||||
const onNodeMouseEnter: NodeMouseHandler = (_event, node) => {
|
||||
if (!selectedNodeId) setSelectedNodeId(node.id);
|
||||
};
|
||||
|
||||
const stageOptions = useMemo(
|
||||
() => ['전체', ...Array.from(new Set(flowManifest.nodes.map((node) => node.stage)))],
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="gear-flow-app">
|
||||
<div className="gear-flow-shell">
|
||||
<aside className="gear-flow-sidebar flex flex-col">
|
||||
<div className="gear-flow-hero space-y-4 px-6 py-6">
|
||||
<div className="gear-flow-panel-heading">
|
||||
<div className="gear-flow-panel-kicker">Flow Source</div>
|
||||
<h1 className="gear-flow-panel-title">{flowManifest.meta.title}</h1>
|
||||
<p className="gear-flow-panel-description">{flowManifest.meta.description}</p>
|
||||
</div>
|
||||
<div className="gear-flow-meta-card">
|
||||
<div className="gear-flow-meta-row">버전 <span>{flowManifest.meta.version}</span></div>
|
||||
<div className="gear-flow-meta-row">갱신일 <span>{flowManifest.meta.updatedAt}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 px-6 py-5">
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">검색</label>
|
||||
<input
|
||||
className="gear-flow-input"
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder="모듈, 메서드, 규칙, 파일 검색"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">단계</label>
|
||||
<select className="gear-flow-select" value={stageFilter} onChange={(event) => setStageFilter(event.target.value)}>
|
||||
{stageOptions.map((option) => (
|
||||
<option key={option} value={option}>{option}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">상태</label>
|
||||
<select
|
||||
className="gear-flow-select"
|
||||
value={statusFilter}
|
||||
onChange={(event) => setStatusFilter(event.target.value as '전체' | FlowStatus)}
|
||||
>
|
||||
<option value="전체">전체</option>
|
||||
<option value="implemented">구현됨</option>
|
||||
<option value="proposed">제안됨</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 pb-6">
|
||||
<div className="mb-3 flex items-center justify-between text-xs font-semibold uppercase tracking-[0.18em] text-slate-500">
|
||||
<span>노드 목록</span>
|
||||
<span>{filteredNodeMeta.length}</span>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{layoutedNodeMeta.map((node) => (
|
||||
<button
|
||||
key={node.id}
|
||||
type="button"
|
||||
className="gear-flow-node-card w-full p-4 text-left transition"
|
||||
data-active={selectedNodeId === node.id}
|
||||
onClick={() => {
|
||||
setSelectedEdgeId(null);
|
||||
setSelectedNodeId(node.id);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-slate-500">{node.stage}</div>
|
||||
<div className="mt-1 text-base font-semibold text-slate-50">{node.label}</div>
|
||||
<div className="gear-flow-summary mt-2 text-sm leading-6 text-slate-400">{summarizeNode(node)}</div>
|
||||
</div>
|
||||
<span className="gear-flow-chip shrink-0" data-tone={node.status === 'implemented' ? 'implemented' : 'proposed'}>
|
||||
{node.status === 'implemented' ? '구현됨' : '제안됨'}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="gear-flow-canvas">
|
||||
<div className="gear-flow-topbar">
|
||||
<div className="gear-flow-topbar-card gear-flow-topbar-card--wrap px-5 py-3 text-sm text-slate-300">
|
||||
<span className="gear-flow-topbar-title">React Flow Viewer</span>
|
||||
<span className="gear-flow-topbar-pill">노드 클릭 시 상세 스펙</span>
|
||||
<span className="gear-flow-topbar-pill">엣지 클릭 시 전달 의미</span>
|
||||
<span className="gear-flow-topbar-pill">검색/단계/상태 필터</span>
|
||||
</div>
|
||||
</div>
|
||||
<ReactFlow
|
||||
nodes={reactFlowNodes}
|
||||
edges={reactFlowEdges}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.03, minZoom: 0.7, maxZoom: 1.2 }}
|
||||
onNodeClick={onNodeClick}
|
||||
onNodeMouseEnter={onNodeMouseEnter}
|
||||
onEdgeClick={onEdgeClick}
|
||||
nodesConnectable={false}
|
||||
elementsSelectable
|
||||
minZoom={0.35}
|
||||
maxZoom={1.8}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
>
|
||||
<MiniMap
|
||||
pannable
|
||||
zoomable
|
||||
nodeStrokeColor={(node) => {
|
||||
const meta = flowManifest.nodes.find((item) => item.id === node.id);
|
||||
return meta ? stageTone(meta.stage) : '#94a3b8';
|
||||
}}
|
||||
nodeColor={(node) => {
|
||||
const meta = flowManifest.nodes.find((item) => item.id === node.id);
|
||||
return meta ? `${stageTone(meta.stage)}55` : '#334155';
|
||||
}}
|
||||
maskColor="rgba(7, 17, 31, 0.68)"
|
||||
/>
|
||||
<Controls showInteractive={false} />
|
||||
<Background color="#20324d" gap={24} size={1.2} />
|
||||
</ReactFlow>
|
||||
</main>
|
||||
|
||||
<aside className="gear-flow-detail flex flex-col">
|
||||
<div className="gear-flow-hero px-6 py-6">
|
||||
<div className="gear-flow-panel-heading">
|
||||
<div className="gear-flow-panel-kicker">Detail</div>
|
||||
<h2 className="gear-flow-panel-title">상세 정보</h2>
|
||||
<p className="gear-flow-panel-description">
|
||||
노드를 클릭하면 역할, 파라미터, 판단 기준, 저장소 영향과 downstream 관계를 확인할 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-6 py-6">
|
||||
{selectedNode ? (
|
||||
<div className="space-y-6">
|
||||
<div className="gear-flow-detail-card">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className="gear-flow-chip" data-tone={selectedNode.status === 'implemented' ? 'implemented' : 'proposed'}>
|
||||
{selectedNode.status === 'implemented' ? '구현됨' : '제안됨'}
|
||||
</span>
|
||||
<span className="gear-flow-chip" data-tone="neutral">{selectedNode.stage}</span>
|
||||
<span className="gear-flow-chip" data-tone="neutral">{selectedNode.kind}</span>
|
||||
</div>
|
||||
<div className="gear-flow-detail-title">{selectedNode.label}</div>
|
||||
<div className="gear-flow-detail-symbol">{selectedNode.symbol}</div>
|
||||
<div className="gear-flow-detail-text">{selectedNode.role}</div>
|
||||
<div className="gear-flow-detail-file">{selectedNode.file}</div>
|
||||
</div>
|
||||
|
||||
<section className="gear-flow-section pt-5">
|
||||
<div className="gear-flow-section-title">파라미터</div>
|
||||
<DetailList items={selectedNode.params} />
|
||||
</section>
|
||||
|
||||
<section className="gear-flow-section pt-5">
|
||||
<div className="gear-flow-section-title">판단 기준</div>
|
||||
<DetailList items={selectedNode.rules} />
|
||||
</section>
|
||||
|
||||
<section className="gear-flow-section pt-5">
|
||||
<div className="gear-flow-section-title">읽는 저장소</div>
|
||||
<DetailList items={selectedNode.storageReads} />
|
||||
</section>
|
||||
|
||||
<section className="gear-flow-section pt-5">
|
||||
<div className="gear-flow-section-title">쓰는 저장소</div>
|
||||
<DetailList items={selectedNode.storageWrites} />
|
||||
</section>
|
||||
|
||||
<section className="gear-flow-section pt-5">
|
||||
<div className="gear-flow-section-title">출력</div>
|
||||
<DetailList items={selectedNode.outputs} />
|
||||
</section>
|
||||
|
||||
<section className="gear-flow-section pt-5">
|
||||
<div className="gear-flow-section-title">영향 관계</div>
|
||||
<DetailList items={selectedNode.impacts} />
|
||||
</section>
|
||||
</div>
|
||||
) : selectedEdge ? (
|
||||
<div className="space-y-6">
|
||||
<div className="gear-flow-detail-card">
|
||||
<span className="gear-flow-chip" data-tone="neutral">엣지</span>
|
||||
<div className="gear-flow-detail-title">{selectedEdge.label || selectedEdge.id}</div>
|
||||
<div className="gear-flow-detail-text">{selectedEdge.detail || '설명 없음'}</div>
|
||||
<div className="gear-flow-detail-file">{selectedEdge.source} → {selectedEdge.target}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="gear-flow-detail-empty px-5 py-6 text-sm leading-7 text-slate-400">
|
||||
노드나 엣지를 선택하면 상세 정보가 여기에 표시됩니다.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default GearParentFlowViewer;
|
||||
325
frontend/src/flow/gearParentFlowManifest.json
Normal file
325
frontend/src/flow/gearParentFlowManifest.json
Normal file
@ -0,0 +1,325 @@
|
||||
{
|
||||
"meta": {
|
||||
"title": "어구 모선 추적 데이터 흐름",
|
||||
"version": "2026-04-03",
|
||||
"updatedAt": "2026-04-03",
|
||||
"description": "snpdb 적재부터 review/label workflow와 episode continuity + prior bonus까지의 전체 흐름"
|
||||
},
|
||||
"nodes": [
|
||||
{
|
||||
"id": "source_tracks",
|
||||
"label": "5분 원천 궤적",
|
||||
"stage": "원천",
|
||||
"kind": "table",
|
||||
"position": { "x": 0, "y": 20 },
|
||||
"file": "signal.t_vessel_tracks_5min",
|
||||
"symbol": "signal.t_vessel_tracks_5min",
|
||||
"role": "5분 bucket 단위 AIS 궤적 원천 테이블",
|
||||
"params": ["1 row = 1 MMSI = 5분 linestringM"],
|
||||
"rules": ["bbox 122,31,132,39", "LineStringM dump 후 point timestamp 사용"],
|
||||
"storageReads": [],
|
||||
"storageWrites": [],
|
||||
"outputs": ["mmsi", "time_bucket", "timestamp", "lat", "lon", "raw_sog"],
|
||||
"impacts": ["모든 그룹/점수 계산의 원천 입력"],
|
||||
"status": "implemented"
|
||||
},
|
||||
{
|
||||
"id": "safe_window",
|
||||
"label": "safe watermark",
|
||||
"stage": "시간 모델",
|
||||
"kind": "function",
|
||||
"position": { "x": 260, "y": 20 },
|
||||
"file": "prediction/time_bucket.py",
|
||||
"symbol": "compute_safe_bucket / compute_incremental_window_start",
|
||||
"role": "미완결 bucket 차단과 overlap backfill 시작점 계산",
|
||||
"params": ["SNPDB_SAFE_DELAY_MIN", "SNPDB_BACKFILL_BUCKETS"],
|
||||
"rules": ["safe bucket까지만 조회", "last_bucket보다 과거도 일부 재조회"],
|
||||
"storageReads": [],
|
||||
"storageWrites": [],
|
||||
"outputs": ["safe_bucket", "window_start", "from_bucket"],
|
||||
"impacts": ["live cache drift 완화", "재기동 spike 억제"],
|
||||
"status": "implemented"
|
||||
},
|
||||
{
|
||||
"id": "snpdb_fetch",
|
||||
"label": "snpdb 적재",
|
||||
"stage": "적재",
|
||||
"kind": "module",
|
||||
"position": { "x": 520, "y": 20 },
|
||||
"file": "prediction/db/snpdb.py",
|
||||
"symbol": "fetch_all_tracks / fetch_incremental",
|
||||
"role": "safe bucket까지 초기/증분 궤적 적재",
|
||||
"params": ["hours=24", "last_bucket"],
|
||||
"rules": ["time_bucket > from_bucket", "time_bucket <= safe_bucket"],
|
||||
"storageReads": ["signal.t_vessel_tracks_5min"],
|
||||
"storageWrites": [],
|
||||
"outputs": ["DataFrame of points"],
|
||||
"impacts": ["VesselStore 초기화와 증분 merge 입력"],
|
||||
"status": "implemented"
|
||||
},
|
||||
{
|
||||
"id": "vessel_store",
|
||||
"label": "VesselStore 캐시",
|
||||
"stage": "캐시",
|
||||
"kind": "module",
|
||||
"position": { "x": 800, "y": 20 },
|
||||
"file": "prediction/cache/vessel_store.py",
|
||||
"symbol": "load_initial / merge_incremental / evict_stale",
|
||||
"role": "24시간 sliding in-memory cache 유지",
|
||||
"params": ["_tracks", "_last_bucket"],
|
||||
"rules": ["timestamp dedupe", "safe bucket 기준 24h eviction"],
|
||||
"storageReads": [],
|
||||
"storageWrites": [],
|
||||
"outputs": ["latest positions", "tracks by MMSI"],
|
||||
"impacts": ["identity, grouping, correlation, inference 공통 입력"],
|
||||
"status": "implemented"
|
||||
},
|
||||
{
|
||||
"id": "gear_identity",
|
||||
"label": "어구 identity",
|
||||
"stage": "정규화",
|
||||
"kind": "module",
|
||||
"position": { "x": 1080, "y": 20 },
|
||||
"file": "prediction/fleet_tracker.py",
|
||||
"symbol": "track_gear_identity",
|
||||
"role": "어구 이름 패턴 파싱과 gear_identity_log 유지",
|
||||
"params": ["parent_name", "gear_index_1", "gear_index_2"],
|
||||
"rules": ["정규화 길이 4 미만 제외", "같은 이름 다른 MMSI면 identity migration"],
|
||||
"storageReads": ["fleet_vessels"],
|
||||
"storageWrites": ["gear_identity_log", "gear_correlation_scores(target_mmsi transfer)"],
|
||||
"outputs": ["active gear identity rows"],
|
||||
"impacts": ["grouping과 parent_mmsi 보조 입력"],
|
||||
"status": "implemented"
|
||||
},
|
||||
{
|
||||
"id": "detect_groups",
|
||||
"label": "어구 그룹 검출",
|
||||
"stage": "그룹핑",
|
||||
"kind": "function",
|
||||
"position": { "x": 260, "y": 220 },
|
||||
"file": "prediction/algorithms/polygon_builder.py",
|
||||
"symbol": "detect_gear_groups",
|
||||
"role": "이름 기반 raw group과 거리 기반 sub-cluster 생성",
|
||||
"params": ["MAX_DIST_DEG=0.15", "STALE_SEC", "is_trackable_parent_name"],
|
||||
"rules": ["440/441 제외", "single cluster면 sc#0", "multi cluster면 sc#1..N", "재병합 시 sc#0"],
|
||||
"storageReads": [],
|
||||
"storageWrites": [],
|
||||
"outputs": ["gear_groups[]"],
|
||||
"impacts": ["sub_cluster_id는 순간 라벨일 뿐 영구 ID가 아님"],
|
||||
"status": "implemented"
|
||||
},
|
||||
{
|
||||
"id": "group_snapshots",
|
||||
"label": "그룹 스냅샷 생성",
|
||||
"stage": "그룹핑",
|
||||
"kind": "function",
|
||||
"position": { "x": 520, "y": 220 },
|
||||
"file": "prediction/algorithms/polygon_builder.py",
|
||||
"symbol": "build_all_group_snapshots",
|
||||
"role": "1h/1h-fb/6h polygon snapshot 생성",
|
||||
"params": ["parent_active_1h", "MIN_GEAR_GROUP_SIZE"],
|
||||
"rules": ["1h 활성<2이면 1h-fb", "수역 외 소수 멤버 제외", "parent nearby면 isParent=true"],
|
||||
"storageReads": [],
|
||||
"storageWrites": ["group_polygon_snapshots"],
|
||||
"outputs": ["group snapshots"],
|
||||
"impacts": ["backend live 현황과 parent inference center track 입력"],
|
||||
"status": "implemented"
|
||||
},
|
||||
{
|
||||
"id": "gear_correlation",
|
||||
"label": "correlation 모델",
|
||||
"stage": "후보 추적",
|
||||
"kind": "module",
|
||||
"position": { "x": 800, "y": 220 },
|
||||
"file": "prediction/algorithms/gear_correlation.py",
|
||||
"symbol": "run_gear_correlation",
|
||||
"role": "후보 선박/어구 raw metric과 EMA score 계산",
|
||||
"params": ["active models", "track_threshold", "decay_fast", "candidate max=30"],
|
||||
"rules": ["선박은 track 기반", "어구 후보는 GEAR_BUOY", "후보 이탈 시 fast decay"],
|
||||
"storageReads": ["group snapshots", "vessel_store", "correlation_param_models"],
|
||||
"storageWrites": ["gear_correlation_raw_metrics", "gear_correlation_scores"],
|
||||
"outputs": ["raw metrics", "EMA score rows"],
|
||||
"impacts": ["parent inference 후보 seed"],
|
||||
"status": "implemented"
|
||||
},
|
||||
{
|
||||
"id": "workflow_exclusions",
|
||||
"label": "후보 제외 / 라벨",
|
||||
"stage": "검토 워크플로우",
|
||||
"kind": "table",
|
||||
"position": { "x": 1080, "y": 220 },
|
||||
"file": "database/migration/014_gear_parent_workflow_v2_phase1.sql",
|
||||
"symbol": "gear_parent_candidate_exclusions / gear_parent_label_sessions",
|
||||
"role": "사람 판단 데이터를 자동 추론과 분리 저장",
|
||||
"params": ["scope=GROUP|GLOBAL", "duration=1|3|5d"],
|
||||
"rules": ["GLOBAL은 모든 그룹에서 제거", "ACTIVE label session만 tracking"],
|
||||
"storageReads": [],
|
||||
"storageWrites": ["gear_parent_candidate_exclusions", "gear_parent_label_sessions"],
|
||||
"outputs": ["active exclusions", "active label sessions"],
|
||||
"impacts": ["parent inference candidate pruning", "label tracking"],
|
||||
"status": "implemented"
|
||||
},
|
||||
{
|
||||
"id": "parent_inference",
|
||||
"label": "모선 추론",
|
||||
"stage": "최종 추론",
|
||||
"kind": "module",
|
||||
"position": { "x": 260, "y": 420 },
|
||||
"file": "prediction/algorithms/gear_parent_inference.py",
|
||||
"symbol": "run_gear_parent_inference",
|
||||
"role": "후보 생성, coverage-aware scoring, 상태 전이, resolution 저장",
|
||||
"params": ["auto score 0.72/0.15/3", "review threshold 0.60", "412/413 bonus +15%"],
|
||||
"rules": ["DIRECT_PARENT_MATCH", "SKIPPED_SHORT_NAME", "NO_CANDIDATE", "AUTO_PROMOTED", "REVIEW_REQUIRED", "UNRESOLVED"],
|
||||
"storageReads": ["gear_correlation_scores", "gear_correlation_raw_metrics", "group_polygon_snapshots", "active exclusions", "active label sessions"],
|
||||
"storageWrites": ["gear_group_parent_candidate_snapshots", "gear_group_parent_resolution", "gear_parent_label_tracking_cycles"],
|
||||
"outputs": ["candidate snapshots", "resolution current state", "label tracking rows"],
|
||||
"impacts": ["review queue", "group detail", "future prior feature source"],
|
||||
"status": "implemented"
|
||||
},
|
||||
{
|
||||
"id": "score_breakdown",
|
||||
"label": "점수 보정",
|
||||
"stage": "최종 추론",
|
||||
"kind": "function",
|
||||
"position": { "x": 520, "y": 420 },
|
||||
"file": "prediction/algorithms/gear_parent_inference.py",
|
||||
"symbol": "_build_candidate_scores / _build_track_coverage_metrics",
|
||||
"role": "이름, 궤적, 방문, 근접, 활동, 안정성, bonus를 합산",
|
||||
"params": ["name 1.0/0.8/0.5/0.3", "coverage factors", "registry +0.05", "china prefix +0.15"],
|
||||
"rules": ["raw->effective 보정", "preBonusScore>=0.30일 때만 412/413 bonus"],
|
||||
"storageReads": [],
|
||||
"storageWrites": ["candidate evidence JSON"],
|
||||
"outputs": ["final_score", "coverage metrics", "evidenceConfidence"],
|
||||
"impacts": ["review UI 설명력", "future signal/prior 분리 설계"],
|
||||
"status": "implemented"
|
||||
},
|
||||
{
|
||||
"id": "backend_read_model",
|
||||
"label": "backend read model",
|
||||
"stage": "조회 계층",
|
||||
"kind": "module",
|
||||
"position": { "x": 800, "y": 420 },
|
||||
"file": "backend/src/main/java/gc/mda/kcg/domain/fleet/GroupPolygonService.java",
|
||||
"symbol": "group list / review queue / detail SQL",
|
||||
"role": "최신 전역 1h live snapshot과 fresh inference만 노출",
|
||||
"params": ["snapshot_time max where resolution=1h"],
|
||||
"rules": ["last_evaluated_at >= snapshot_time", "사라진 과거 sub-cluster 숨김"],
|
||||
"storageReads": ["group_polygon_snapshots", "gear_group_parent_resolution", "gear_group_parent_candidate_snapshots"],
|
||||
"storageWrites": [],
|
||||
"outputs": ["GroupPolygonDto", "GroupParentInferenceDto", "review queue rows"],
|
||||
"impacts": ["stale inference 차단", "프론트 live 상세 일관성"],
|
||||
"status": "implemented"
|
||||
},
|
||||
{
|
||||
"id": "workflow_api",
|
||||
"label": "workflow API",
|
||||
"stage": "조회 계층",
|
||||
"kind": "module",
|
||||
"position": { "x": 1080, "y": 420 },
|
||||
"file": "backend/src/main/java/gc/mda/kcg/domain/fleet/ParentInferenceWorkflowController.java",
|
||||
"symbol": "candidate-exclusions / label-sessions endpoints",
|
||||
"role": "그룹 제외, 전역 제외, 라벨 세션, tracking 조회 API",
|
||||
"params": ["POST/GET workflow actions"],
|
||||
"rules": ["activeOnly query", "release/cancel action"],
|
||||
"storageReads": ["workflow tables"],
|
||||
"storageWrites": ["workflow tables", "review log"],
|
||||
"outputs": ["workflow DTO responses"],
|
||||
"impacts": ["human-in-the-loop 데이터 축적"],
|
||||
"status": "implemented"
|
||||
},
|
||||
{
|
||||
"id": "review_ui",
|
||||
"label": "모선 검토 UI",
|
||||
"stage": "프론트",
|
||||
"kind": "component",
|
||||
"position": { "x": 520, "y": 620 },
|
||||
"file": "frontend/src/components/korea/ParentReviewPanel.tsx",
|
||||
"symbol": "ParentReviewPanel",
|
||||
"role": "후보 비교, 필터, 라벨/제외 액션, coverage evidence 표시",
|
||||
"params": ["min score", "min gear count", "search", "spatial filter"],
|
||||
"rules": ["30% 미만 후보 비표시", "검색/범위/어구수 필터 AND", "hover 기반 overlay 강조"],
|
||||
"storageReads": ["review/detail API", "localStorage filters"],
|
||||
"storageWrites": ["workflow API actions", "localStorage filters"],
|
||||
"outputs": ["review decisions", "candidate interpretation"],
|
||||
"impacts": ["사람 판단 백데이터 생성"],
|
||||
"status": "implemented"
|
||||
},
|
||||
{
|
||||
"id": "mermaid_docs",
|
||||
"label": "Mermaid 산출물",
|
||||
"stage": "문서",
|
||||
"kind": "artifact",
|
||||
"position": { "x": 800, "y": 620 },
|
||||
"file": "docs/generated/gear-parent-flow-overview.md",
|
||||
"symbol": "generated Mermaid docs",
|
||||
"role": "정적 흐름도와 노드 인덱스 문서",
|
||||
"params": ["manifest JSON"],
|
||||
"rules": ["generator 재실행 시 갱신"],
|
||||
"storageReads": ["flow manifest"],
|
||||
"storageWrites": ["docs/generated/*.md", "docs/generated/*.mmd"],
|
||||
"outputs": ["overview flowchart", "node index"],
|
||||
"impacts": ["정적 문서 기반 리뷰/공유"],
|
||||
"status": "implemented"
|
||||
},
|
||||
{
|
||||
"id": "react_flow_viewer",
|
||||
"label": "React Flow viewer",
|
||||
"stage": "문서",
|
||||
"kind": "component",
|
||||
"position": { "x": 1080, "y": 620 },
|
||||
"file": "frontend/src/flow/GearParentFlowViewer.tsx",
|
||||
"symbol": "GearParentFlowViewer",
|
||||
"role": "노드 클릭/검색/필터/상세 패널이 있는 인터랙티브 흐름 뷰어",
|
||||
"params": ["stage filter", "search", "node detail"],
|
||||
"rules": ["별도 HTML entry", "manifest를 단일 source로 사용"],
|
||||
"storageReads": ["flow manifest"],
|
||||
"storageWrites": [],
|
||||
"outputs": ["interactive HTML graph"],
|
||||
"impacts": ["개발/검토/설명 자료"],
|
||||
"status": "implemented"
|
||||
},
|
||||
{
|
||||
"id": "future_episode",
|
||||
"label": "episode continuity",
|
||||
"stage": "후보 추적",
|
||||
"kind": "module",
|
||||
"position": { "x": 260, "y": 620 },
|
||||
"file": "prediction/algorithms/gear_parent_episode.py",
|
||||
"symbol": "build_episode_plan / compute_prior_bonus_components",
|
||||
"role": "sub_cluster continuity와 episode/lineage/label prior bonus를 계산하는 계층",
|
||||
"params": ["split", "merge", "expire", "24h/7d/30d prior windows"],
|
||||
"rules": ["small member change는 same episode", "true merge는 new episode", "prior bonus는 weak carry-over + cap 0.20"],
|
||||
"storageReads": ["gear_group_episodes", "gear_group_episode_snapshots", "candidate snapshots", "label history"],
|
||||
"storageWrites": ["gear_group_episodes", "gear_group_episode_snapshots"],
|
||||
"outputs": ["episode assignment", "continuity source/score", "prior bonus components"],
|
||||
"impacts": ["장기 기억 기반 추론", "split/merge 이후 후보 관성 완화"],
|
||||
"status": "implemented"
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{ "id": "e1", "source": "source_tracks", "target": "safe_window", "label": "bucket window", "detail": "원천 5분 bucket에 safe delay와 overlap backfill 적용" },
|
||||
{ "id": "e2", "source": "safe_window", "target": "snpdb_fetch", "label": "fetch bounds", "detail": "safe_bucket, from_bucket, window_start 전달" },
|
||||
{ "id": "e3", "source": "snpdb_fetch", "target": "vessel_store", "label": "points", "detail": "초기/증분 point DataFrame 적재" },
|
||||
{ "id": "e4", "source": "vessel_store", "target": "gear_identity", "label": "latest positions", "detail": "어구 이름 패턴과 parent_name 파싱" },
|
||||
{ "id": "e5", "source": "vessel_store", "target": "detect_groups", "label": "latest positions", "detail": "어구 raw group과 서브클러스터 생성" },
|
||||
{ "id": "e6", "source": "detect_groups", "target": "group_snapshots", "label": "gear_groups", "detail": "1h/1h-fb/6h polygon snapshot 생성" },
|
||||
{ "id": "e7", "source": "vessel_store", "target": "gear_correlation", "label": "tracks", "detail": "후보 선박 6h track과 latest positions 입력" },
|
||||
{ "id": "e8", "source": "detect_groups", "target": "gear_correlation", "label": "groups", "detail": "그룹 중심, 반경, active ratio 계산 입력" },
|
||||
{ "id": "e9", "source": "group_snapshots", "target": "backend_read_model", "label": "snapshots", "detail": "최신 1h live group read model 구성" },
|
||||
{ "id": "e10", "source": "group_snapshots", "target": "parent_inference", "label": "center tracks", "detail": "최근 6h 그룹 중심 이동과 live parent membership 입력" },
|
||||
{ "id": "e11", "source": "gear_correlation", "target": "parent_inference", "label": "scores + raw", "detail": "correlation score와 raw metrics 사용" },
|
||||
{ "id": "e11a", "source": "detect_groups", "target": "future_episode", "label": "current clusters", "detail": "현재 gear group 멤버/중심점으로 episode continuity 계산" },
|
||||
{ "id": "e11b", "source": "workflow_exclusions", "target": "future_episode", "label": "label history", "detail": "label session lineage를 label prior 입력으로 사용" },
|
||||
{ "id": "e11c", "source": "future_episode", "target": "parent_inference", "label": "episode assignment", "detail": "episode_id, continuity source, prior aggregate를 candidate build에 반영" },
|
||||
{ "id": "e12", "source": "workflow_exclusions", "target": "parent_inference", "label": "active gates", "detail": "group/global exclusion과 label session을 candidate build에 반영" },
|
||||
{ "id": "e13", "source": "parent_inference", "target": "score_breakdown", "label": "candidate scoring", "detail": "이름/track/visit/proximity/activity/stability와 bonus 계산" },
|
||||
{ "id": "e13a", "source": "future_episode", "target": "score_breakdown", "label": "prior bonus", "detail": "episode/lineage/label prior bonus를 final score 마지막 단계에 가산" },
|
||||
{ "id": "e14", "source": "score_breakdown", "target": "backend_read_model", "label": "fresh candidate/resolution", "detail": "fresh inference만 group detail과 review queue에 노출" },
|
||||
{ "id": "e15", "source": "workflow_api", "target": "workflow_exclusions", "label": "CRUD", "detail": "exclusion/label 생성, 취소, 조회" },
|
||||
{ "id": "e16", "source": "backend_read_model", "target": "review_ui", "label": "review/detail API", "detail": "모선 검토 UI의 기본 데이터 소스" },
|
||||
{ "id": "e17", "source": "workflow_api", "target": "review_ui", "label": "actions", "detail": "라벨/그룹 제외/전체 제외/해제 액션 처리" },
|
||||
{ "id": "e18", "source": "review_ui", "target": "mermaid_docs", "label": "human-readable spec", "detail": "정적 문서와 UI 해석 흐름 연결" },
|
||||
{ "id": "e19", "source": "review_ui", "target": "react_flow_viewer", "label": "same manifest", "detail": "문서와 viewer가 같은 구조 설명을 공유" },
|
||||
{ "id": "e20", "source": "parent_inference", "target": "future_episode", "label": "episode snapshots", "detail": "current resolution과 top candidate를 episode snapshot/history에 기록" }
|
||||
]
|
||||
}
|
||||
14
frontend/src/gearParentFlowMain.tsx
Normal file
14
frontend/src/gearParentFlowMain.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import '@fontsource-variable/inter';
|
||||
import '@fontsource-variable/noto-sans-kr';
|
||||
import '@fontsource-variable/fira-code';
|
||||
import './styles/tailwind.css';
|
||||
import './index.css';
|
||||
import GearParentFlowViewer from './flow/GearParentFlowViewer';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<GearParentFlowViewer />
|
||||
</StrictMode>,
|
||||
);
|
||||
@ -12,7 +12,7 @@ import { clusterLabels } from '../utils/labelCluster';
|
||||
export interface FleetClusterDeckConfig {
|
||||
selectedGearGroup: string | null;
|
||||
hoveredMmsi: string | null;
|
||||
hoveredGearGroup: string | null; // gear polygon hover highlight
|
||||
hoveredGearCompositeKey: string | null;
|
||||
enabledModels: Set<string>;
|
||||
historyActive: boolean;
|
||||
hasCorrelationTracks: boolean;
|
||||
@ -21,13 +21,24 @@ export interface FleetClusterDeckConfig {
|
||||
fontScale?: number; // fontScale.analysis (default 1)
|
||||
focusMode?: boolean; // 집중 모드 — 라이브 폴리곤/마커 숨김
|
||||
onPolygonClick?: (features: PickedPolygonFeature[], coordinate: [number, number]) => void;
|
||||
onPolygonHover?: (info: { lng: number; lat: number; type: 'fleet' | 'gear'; id: string | number } | null) => void;
|
||||
onPolygonHover?: (info: {
|
||||
lng: number;
|
||||
lat: number;
|
||||
type: 'fleet' | 'gear';
|
||||
id: string | number;
|
||||
groupKey?: string;
|
||||
subClusterId?: number;
|
||||
compositeKey?: string;
|
||||
} | null) => void;
|
||||
}
|
||||
|
||||
export interface PickedPolygonFeature {
|
||||
type: 'fleet' | 'gear';
|
||||
clusterId?: number;
|
||||
name?: string;
|
||||
groupKey?: string;
|
||||
subClusterId?: number;
|
||||
compositeKey?: string;
|
||||
gearCount?: number;
|
||||
inZone?: boolean;
|
||||
}
|
||||
@ -112,6 +123,9 @@ function findPolygonsAtPoint(
|
||||
results.push({
|
||||
type: 'gear',
|
||||
name: f.properties?.name,
|
||||
groupKey: f.properties?.groupKey,
|
||||
subClusterId: f.properties?.subClusterId,
|
||||
compositeKey: f.properties?.compositeKey,
|
||||
gearCount: f.properties?.gearCount,
|
||||
inZone: f.properties?.inZone === 1,
|
||||
});
|
||||
@ -136,7 +150,7 @@ export function useFleetClusterDeckLayers(
|
||||
const {
|
||||
selectedGearGroup,
|
||||
hoveredMmsi,
|
||||
hoveredGearGroup,
|
||||
hoveredGearCompositeKey,
|
||||
enabledModels,
|
||||
historyActive,
|
||||
zoomScale,
|
||||
@ -243,7 +257,15 @@ export function useFleetClusterDeckLayers(
|
||||
const f = info.object as GeoJSON.Feature;
|
||||
const name = f.properties?.name;
|
||||
if (name) {
|
||||
onPolygonHover?.({ lng: info.coordinate![0], lat: info.coordinate![1], type: 'gear', id: name });
|
||||
onPolygonHover?.({
|
||||
lng: info.coordinate![0],
|
||||
lat: info.coordinate![1],
|
||||
type: 'gear',
|
||||
id: f.properties?.groupKey ?? name,
|
||||
groupKey: f.properties?.groupKey ?? name,
|
||||
subClusterId: f.properties?.subClusterId,
|
||||
compositeKey: f.properties?.compositeKey,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
onPolygonHover?.(null);
|
||||
@ -258,25 +280,20 @@ export function useFleetClusterDeckLayers(
|
||||
}
|
||||
|
||||
// ── 4b. Gear hover highlight ──────────────────────────────────────────
|
||||
if (hoveredGearGroup && gearFc.features.length > 0) {
|
||||
const hoveredGearFeatures = gearFc.features.filter(
|
||||
f => f.properties?.name === hoveredGearGroup,
|
||||
);
|
||||
if (hoveredGearFeatures.length > 0) {
|
||||
layers.push(new GeoJsonLayer({
|
||||
id: 'gear-hover-highlight',
|
||||
data: { type: 'FeatureCollection' as const, features: hoveredGearFeatures },
|
||||
getFillColor: (f: GeoJSON.Feature) =>
|
||||
f.properties?.inZone === 1 ? [220, 38, 38, 64] : [249, 115, 22, 64],
|
||||
getLineColor: (f: GeoJSON.Feature) =>
|
||||
f.properties?.inZone === 1 ? [220, 38, 38, 255] : [249, 115, 22, 255],
|
||||
getLineWidth: 2.5,
|
||||
lineWidthUnits: 'pixels',
|
||||
filled: true,
|
||||
stroked: true,
|
||||
pickable: false,
|
||||
}));
|
||||
}
|
||||
if (hoveredGearCompositeKey && geo.hoveredGearHighlightGeoJson && geo.hoveredGearHighlightGeoJson.features.length > 0) {
|
||||
layers.push(new GeoJsonLayer({
|
||||
id: 'gear-hover-highlight',
|
||||
data: geo.hoveredGearHighlightGeoJson,
|
||||
getFillColor: (f: GeoJSON.Feature) =>
|
||||
f.properties?.inZone === 1 ? [220, 38, 38, 72] : [249, 115, 22, 72],
|
||||
getLineColor: (f: GeoJSON.Feature) =>
|
||||
f.properties?.inZone === 1 ? [220, 38, 38, 255] : [249, 115, 22, 255],
|
||||
getLineWidth: 2.5,
|
||||
lineWidthUnits: 'pixels',
|
||||
filled: true,
|
||||
stroked: true,
|
||||
pickable: false,
|
||||
}));
|
||||
}
|
||||
|
||||
// ── 5. Selected gear highlight (selectedGearHighlightGeoJson) ────────────
|
||||
@ -539,7 +556,7 @@ export function useFleetClusterDeckLayers(
|
||||
geo,
|
||||
selectedGearGroup,
|
||||
hoveredMmsi,
|
||||
hoveredGearGroup,
|
||||
hoveredGearCompositeKey,
|
||||
enabledModels,
|
||||
historyActive,
|
||||
zoomScale,
|
||||
|
||||
@ -6,6 +6,7 @@ import { useGearReplayStore } from '../stores/gearReplayStore';
|
||||
import { findFrameAtTime, interpolateMemberPositions, interpolateSubFrameMembers } from '../stores/gearReplayPreprocess';
|
||||
import type { MemberPosition } from '../stores/gearReplayPreprocess';
|
||||
import { MODEL_ORDER, MODEL_COLORS } from '../components/korea/fleetClusterConstants';
|
||||
import { getParentReviewCandidateColor } from '../components/korea/parentReviewCandidateColors';
|
||||
import { buildInterpPolygon } from '../components/korea/fleetClusterUtils';
|
||||
import type { GearCorrelationItem } from '../services/vesselAnalysis';
|
||||
import { SHIP_ICON_MAPPING } from '../utils/shipIconSvg';
|
||||
@ -41,6 +42,80 @@ interface CorrPosition {
|
||||
isVessel: boolean;
|
||||
}
|
||||
|
||||
interface TripDatumLike {
|
||||
id: string;
|
||||
path: [number, number][];
|
||||
timestamps: number[];
|
||||
color: [number, number, number, number];
|
||||
}
|
||||
|
||||
function interpolateTripPosition(
|
||||
trip: TripDatumLike,
|
||||
relTime: number,
|
||||
): { lon: number; lat: number; cog: number } | null {
|
||||
const ts = trip.timestamps;
|
||||
const path = trip.path;
|
||||
if (path.length === 0 || ts.length === 0) return null;
|
||||
if (relTime < ts[0] || relTime > ts[ts.length - 1]) return null;
|
||||
|
||||
if (path.length === 1 || ts.length === 1) {
|
||||
return { lon: path[0][0], lat: path[0][1], cog: 0 };
|
||||
}
|
||||
|
||||
if (relTime <= ts[0]) {
|
||||
const dx = path[1][0] - path[0][0];
|
||||
const dy = path[1][1] - path[0][1];
|
||||
return {
|
||||
lon: path[0][0],
|
||||
lat: path[0][1],
|
||||
cog: (dx === 0 && dy === 0) ? 0 : (Math.atan2(dx, dy) * 180 / Math.PI + 360) % 360,
|
||||
};
|
||||
}
|
||||
|
||||
if (relTime >= ts[ts.length - 1]) {
|
||||
const last = path.length - 1;
|
||||
const dx = path[last][0] - path[last - 1][0];
|
||||
const dy = path[last][1] - path[last - 1][1];
|
||||
return {
|
||||
lon: path[last][0],
|
||||
lat: path[last][1],
|
||||
cog: (dx === 0 && dy === 0) ? 0 : (Math.atan2(dx, dy) * 180 / Math.PI + 360) % 360,
|
||||
};
|
||||
}
|
||||
|
||||
let lo = 0;
|
||||
let hi = ts.length - 1;
|
||||
while (lo < hi - 1) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (ts[mid] <= relTime) lo = mid;
|
||||
else hi = mid;
|
||||
}
|
||||
const ratio = ts[hi] !== ts[lo] ? (relTime - ts[lo]) / (ts[hi] - ts[lo]) : 0;
|
||||
const dx = path[hi][0] - path[lo][0];
|
||||
const dy = path[hi][1] - path[lo][1];
|
||||
return {
|
||||
lon: path[lo][0] + dx * ratio,
|
||||
lat: path[lo][1] + (path[hi][1] - path[lo][1]) * ratio,
|
||||
cog: (dx === 0 && dy === 0) ? 0 : (Math.atan2(dx, dy) * 180 / Math.PI + 360) % 360,
|
||||
};
|
||||
}
|
||||
|
||||
function clipTripPathToTime(trip: TripDatumLike, relTime: number): [number, number][] {
|
||||
const ts = trip.timestamps;
|
||||
if (trip.path.length < 2 || ts.length < 2) return [];
|
||||
if (relTime < ts[0]) return [];
|
||||
if (relTime >= ts[ts.length - 1]) return trip.path;
|
||||
|
||||
let hi = ts.findIndex(value => value > relTime);
|
||||
if (hi <= 0) hi = 1;
|
||||
const clipped = trip.path.slice(0, hi);
|
||||
const interpolated = interpolateTripPosition(trip, relTime);
|
||||
if (interpolated) {
|
||||
clipped.push([interpolated.lon, interpolated.lat]);
|
||||
}
|
||||
return clipped;
|
||||
}
|
||||
|
||||
// ── Hook ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@ -67,8 +142,8 @@ export function useGearReplayLayers(
|
||||
const enabledModels = useGearReplayStore(s => s.enabledModels);
|
||||
const enabledVessels = useGearReplayStore(s => s.enabledVessels);
|
||||
const hoveredMmsi = useGearReplayStore(s => s.hoveredMmsi);
|
||||
const reviewCandidates = useGearReplayStore(s => s.reviewCandidates);
|
||||
const correlationByModel = useGearReplayStore(s => s.correlationByModel);
|
||||
const modelCenterTrails = useGearReplayStore(s => s.modelCenterTrails);
|
||||
const showTrails = useGearReplayStore(s => s.showTrails);
|
||||
const showLabels = useGearReplayStore(s => s.showLabels);
|
||||
const show1hPolygon = useGearReplayStore(s => s.show1hPolygon);
|
||||
@ -217,6 +292,11 @@ export function useGearReplayLayers(
|
||||
// Member positions (interpolated) — 항상 계산 (배지/오퍼레이셔널에서 사용)
|
||||
const members = interpolateMemberPositions(state.historyFrames, frameIdx, ct);
|
||||
const memberPts: [number, number][] = members.map(m => [m.lon, m.lat]);
|
||||
const relTime = ct - st;
|
||||
const visibleMemberMmsis = new Set(members.map(m => m.mmsi));
|
||||
const reviewCandidateMap = new Map(reviewCandidates.map(candidate => [candidate.mmsi, candidate]));
|
||||
const reviewCandidateSet = new Set(reviewCandidateMap.keys());
|
||||
const corrTrackMap = new Map(correlationTripsData.map(d => [d.id, d]));
|
||||
|
||||
// 서브클러스터 프레임 (identity 폴리곤 + operational 폴리곤에서 공유)
|
||||
const subFrames = frame.subFrames ?? [{ subClusterId: 0, centerLon: frame.centerLon, centerLat: frame.centerLat, members: frame.members, memberCount: frame.memberCount }];
|
||||
@ -226,7 +306,7 @@ export function useGearReplayLayers(
|
||||
// 멤버 전체 항적 (identity — 항상 ON)
|
||||
if (memberTripsData.length > 0) {
|
||||
for (const trip of memberTripsData) {
|
||||
if (trip.path.length < 2) continue;
|
||||
if (!visibleMemberMmsis.has(trip.id) || trip.path.length < 2) continue;
|
||||
layers.push(new PathLayer({
|
||||
id: `replay-member-path-${trip.id}`,
|
||||
data: [{ path: trip.path }],
|
||||
@ -236,6 +316,7 @@ export function useGearReplayLayers(
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// 연관 선박 전체 항적 (correlation)
|
||||
if (correlationTripsData.length > 0) {
|
||||
const activeMmsis = new Set<string>();
|
||||
@ -246,7 +327,7 @@ export function useGearReplayLayers(
|
||||
}
|
||||
}
|
||||
for (const trip of correlationTripsData) {
|
||||
if (!activeMmsis.has(trip.id) || trip.path.length < 2) continue;
|
||||
if (!activeMmsis.has(trip.id) || reviewCandidateSet.has(trip.id) || trip.path.length < 2) continue;
|
||||
layers.push(new PathLayer({
|
||||
id: `replay-corr-path-${trip.id}`,
|
||||
data: [{ path: trip.path }],
|
||||
@ -256,31 +337,29 @@ export function useGearReplayLayers(
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Correlation TripsLayer (GPU animated, 항상 ON, 고채도)
|
||||
if (correlationTripsData.length > 0) {
|
||||
const activeMmsis = new Set<string>();
|
||||
for (const [mn, items] of correlationByModel) {
|
||||
if (!enabledModels.has(mn)) continue;
|
||||
for (const c of items as GearCorrelationItem[]) {
|
||||
if (enabledVessels.has(c.targetMmsi)) activeMmsis.add(c.targetMmsi);
|
||||
if (reviewCandidates.length > 0) {
|
||||
for (const candidate of reviewCandidates) {
|
||||
const trip = corrTrackMap.get(candidate.mmsi);
|
||||
if (!trip || trip.path.length < 2) continue;
|
||||
const [r, g, b] = hexToRgb(getParentReviewCandidateColor(candidate.rank));
|
||||
const hovered = hoveredMmsi === candidate.mmsi;
|
||||
layers.push(new PathLayer({
|
||||
id: `replay-review-path-glow-${candidate.mmsi}`,
|
||||
data: [{ path: trip.path }],
|
||||
getPath: (d: { path: [number, number][] }) => d.path,
|
||||
getColor: hovered ? [255, 255, 255, 110] : [255, 255, 255, 45],
|
||||
widthMinPixels: hovered ? 7 : 4,
|
||||
}));
|
||||
layers.push(new PathLayer({
|
||||
id: `replay-review-path-${candidate.mmsi}`,
|
||||
data: [{ path: trip.path }],
|
||||
getPath: (d: { path: [number, number][] }) => d.path,
|
||||
getColor: [r, g, b, hovered ? 230 : 160],
|
||||
widthMinPixels: hovered ? 4.5 : 2.5,
|
||||
}));
|
||||
}
|
||||
}
|
||||
const enabledTrips = correlationTripsData.filter(d => activeMmsis.has(d.id));
|
||||
if (enabledTrips.length > 0) {
|
||||
layers.push(new TripsLayer({
|
||||
id: 'replay-corr-trails',
|
||||
data: enabledTrips,
|
||||
getPath: d => d.path,
|
||||
getTimestamps: d => d.timestamps,
|
||||
getColor: [100, 180, 255, 220], // 고채도 파랑 (항적보다 밝게)
|
||||
widthMinPixels: 2.5,
|
||||
fadeTrail: true,
|
||||
trailLength: TRAIL_LENGTH_MS,
|
||||
currentTime: ct - st,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// (identity 레이어는 최하단 — 최상위 z-index로 이동됨)
|
||||
@ -329,11 +408,10 @@ export function useGearReplayLayers(
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Correlation vessel positions (트랙 보간 → 끝점 clamp → live fallback)
|
||||
// 6. Correlation vessel positions (현재 리플레이 시점에 실제로 보이는 대상만)
|
||||
const corrPositions: CorrPosition[] = [];
|
||||
const corrTrackMap = new Map(correlationTripsData.map(d => [d.id, d]));
|
||||
const liveShips = shipsRef.current;
|
||||
const relTime = ct - st;
|
||||
const reviewPositions: CorrPosition[] = [];
|
||||
void shipsRef;
|
||||
|
||||
for (const [mn, items] of correlationByModel) {
|
||||
if (!enabledModels.has(mn)) continue;
|
||||
@ -342,80 +420,44 @@ export function useGearReplayLayers(
|
||||
|
||||
for (const c of items as GearCorrelationItem[]) {
|
||||
if (!enabledVessels.has(c.targetMmsi)) continue; // OFF → 아이콘+트레일+폴리곤 모두 제외
|
||||
if (reviewCandidateSet.has(c.targetMmsi)) continue;
|
||||
if (corrPositions.some(p => p.mmsi === c.targetMmsi)) continue;
|
||||
|
||||
let lon: number | undefined;
|
||||
let lat: number | undefined;
|
||||
let cog = 0;
|
||||
|
||||
// 방법 1: 트랙 데이터 (보간 + 범위 밖은 끝점 clamp)
|
||||
const tripData = corrTrackMap.get(c.targetMmsi);
|
||||
if (tripData && tripData.path.length > 0) {
|
||||
const ts = tripData.timestamps;
|
||||
const path = tripData.path;
|
||||
|
||||
if (relTime <= ts[0]) {
|
||||
// 트랙 시작 전 → 첫 점 사용
|
||||
lon = path[0][0]; lat = path[0][1];
|
||||
if (path.length > 1) {
|
||||
const dx = path[1][0] - path[0][0];
|
||||
const dy = path[1][1] - path[0][1];
|
||||
cog = (dx === 0 && dy === 0) ? 0 : (Math.atan2(dx, dy) * 180 / Math.PI + 360) % 360;
|
||||
}
|
||||
} else if (relTime >= ts[ts.length - 1]) {
|
||||
// 트랙 종료 후 → 마지막 점 사용
|
||||
const last = path.length - 1;
|
||||
lon = path[last][0]; lat = path[last][1];
|
||||
if (last > 0) {
|
||||
const dx = path[last][0] - path[last - 1][0];
|
||||
const dy = path[last][1] - path[last - 1][1];
|
||||
cog = (dx === 0 && dy === 0) ? 0 : (Math.atan2(dx, dy) * 180 / Math.PI + 360) % 360;
|
||||
}
|
||||
} else {
|
||||
// 범위 내 → 보간
|
||||
let lo = 0;
|
||||
let hi = ts.length - 1;
|
||||
while (lo < hi - 1) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (ts[mid] <= relTime) lo = mid; else hi = mid;
|
||||
}
|
||||
const ratio = ts[hi] !== ts[lo] ? (relTime - ts[lo]) / (ts[hi] - ts[lo]) : 0;
|
||||
lon = path[lo][0] + (path[hi][0] - path[lo][0]) * ratio;
|
||||
lat = path[lo][1] + (path[hi][1] - path[lo][1]) * ratio;
|
||||
const dx = path[hi][0] - path[lo][0];
|
||||
const dy = path[hi][1] - path[lo][1];
|
||||
cog = (dx === 0 && dy === 0) ? 0 : (Math.atan2(dx, dy) * 180 / Math.PI + 360) % 360;
|
||||
}
|
||||
}
|
||||
|
||||
// 방법 2: live 선박 위치 fallback
|
||||
if (lon === undefined) {
|
||||
const ship = liveShips.get(c.targetMmsi);
|
||||
if (ship) {
|
||||
lon = ship.lng;
|
||||
lat = ship.lat;
|
||||
cog = ship.course ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (lon === undefined || lat === undefined) continue;
|
||||
const position = tripData ? interpolateTripPosition(tripData, relTime) : null;
|
||||
if (!position) continue;
|
||||
|
||||
corrPositions.push({
|
||||
mmsi: c.targetMmsi,
|
||||
name: c.targetName || c.targetMmsi,
|
||||
lon,
|
||||
lat,
|
||||
cog,
|
||||
lon: position.lon,
|
||||
lat: position.lat,
|
||||
cog: position.cog,
|
||||
color: [r, g, b, 230],
|
||||
isVessel: c.targetType === 'VESSEL',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const candidate of reviewCandidates) {
|
||||
const tripData = corrTrackMap.get(candidate.mmsi);
|
||||
const position = tripData ? interpolateTripPosition(tripData, relTime) : null;
|
||||
if (!position) continue;
|
||||
const [r, g, b] = hexToRgb(getParentReviewCandidateColor(candidate.rank));
|
||||
reviewPositions.push({
|
||||
mmsi: candidate.mmsi,
|
||||
name: candidate.name,
|
||||
lon: position.lon,
|
||||
lat: position.lat,
|
||||
cog: position.cog,
|
||||
color: [r, g, b, hoveredMmsi === candidate.mmsi ? 255 : 235],
|
||||
isVessel: true,
|
||||
});
|
||||
}
|
||||
|
||||
// 디버그: 첫 프레임에서 전체 상태 출력
|
||||
if (shouldLog) {
|
||||
const trackHit = corrPositions.filter(p => corrTrackMap.has(p.mmsi)).length;
|
||||
const liveHit = corrPositions.length - trackHit;
|
||||
const sampleTrip = memberTripsData[0];
|
||||
console.log('[GearReplay] renderFrame:', {
|
||||
historyFrames: state.historyFrames.length,
|
||||
@ -427,7 +469,8 @@ export function useGearReplayLayers(
|
||||
currentTime: Math.round((ct - st) / 60000) + 'min (rel)',
|
||||
members: members.length,
|
||||
corrPositions: corrPositions.length,
|
||||
posSource: `track:${trackHit} live:${liveHit}`,
|
||||
reviewPositions: reviewPositions.length,
|
||||
posSource: `track:${trackHit}`,
|
||||
memberTrip0: sampleTrip ? { id: sampleTrip.id, pts: sampleTrip.path.length, tsRange: `${Math.round(sampleTrip.timestamps[0]/60000)}~${Math.round(sampleTrip.timestamps[sampleTrip.timestamps.length-1]/60000)}min` } : 'none',
|
||||
});
|
||||
// 모델별 상세
|
||||
@ -440,6 +483,73 @@ export function useGearReplayLayers(
|
||||
}
|
||||
}
|
||||
|
||||
const visibleCorrMmsis = new Set(corrPositions.map(position => position.mmsi));
|
||||
const visibleReviewMmsis = new Set(reviewPositions.map(position => position.mmsi));
|
||||
const visibleMemberTrips = memberTripsData.filter(d => visibleMemberMmsis.has(d.id));
|
||||
const enabledCorrTrips = correlationTripsData.filter(d => visibleCorrMmsis.has(d.id) && !reviewCandidateSet.has(d.id));
|
||||
const reviewVisibleTrips = correlationTripsData
|
||||
.filter(d => visibleReviewMmsis.has(d.id))
|
||||
.map(d => {
|
||||
const candidate = reviewCandidateMap.get(d.id);
|
||||
const [r, g, b] = hexToRgb(getParentReviewCandidateColor(candidate?.rank ?? 1));
|
||||
return { ...d, color: [r, g, b, hoveredMmsi === d.id ? 255 : 230] as [number, number, number, number] };
|
||||
});
|
||||
const hoveredReviewTrips = reviewVisibleTrips.filter(d => d.id === hoveredMmsi);
|
||||
const defaultReviewTrips = reviewVisibleTrips.filter(d => d.id !== hoveredMmsi);
|
||||
|
||||
if (enabledCorrTrips.length > 0) {
|
||||
layers.push(new TripsLayer({
|
||||
id: 'replay-corr-trails',
|
||||
data: enabledCorrTrips,
|
||||
getPath: d => d.path,
|
||||
getTimestamps: d => d.timestamps,
|
||||
getColor: [100, 180, 255, 220],
|
||||
widthMinPixels: 2.5,
|
||||
fadeTrail: true,
|
||||
trailLength: TRAIL_LENGTH_MS,
|
||||
currentTime: ct - st,
|
||||
}));
|
||||
}
|
||||
|
||||
if (defaultReviewTrips.length > 0) {
|
||||
layers.push(new TripsLayer({
|
||||
id: 'replay-review-trails',
|
||||
data: defaultReviewTrips,
|
||||
getPath: d => d.path,
|
||||
getTimestamps: d => d.timestamps,
|
||||
getColor: d => d.color,
|
||||
widthMinPixels: 4,
|
||||
fadeTrail: true,
|
||||
trailLength: TRAIL_LENGTH_MS,
|
||||
currentTime: ct - st,
|
||||
}));
|
||||
}
|
||||
|
||||
if (hoveredReviewTrips.length > 0) {
|
||||
layers.push(new TripsLayer({
|
||||
id: 'replay-review-hover-trails-glow',
|
||||
data: hoveredReviewTrips,
|
||||
getPath: d => d.path,
|
||||
getTimestamps: d => d.timestamps,
|
||||
getColor: [255, 255, 255, 190],
|
||||
widthMinPixels: 7,
|
||||
fadeTrail: true,
|
||||
trailLength: TRAIL_LENGTH_MS,
|
||||
currentTime: ct - st,
|
||||
}));
|
||||
layers.push(new TripsLayer({
|
||||
id: 'replay-review-hover-trails',
|
||||
data: hoveredReviewTrips,
|
||||
getPath: d => d.path,
|
||||
getTimestamps: d => d.timestamps,
|
||||
getColor: d => d.color,
|
||||
widthMinPixels: 5,
|
||||
fadeTrail: true,
|
||||
trailLength: TRAIL_LENGTH_MS,
|
||||
currentTime: ct - st,
|
||||
}));
|
||||
}
|
||||
|
||||
if (corrPositions.length > 0) {
|
||||
layers.push(new IconLayer<CorrPosition>({
|
||||
id: 'replay-corr-vessels',
|
||||
@ -470,10 +580,57 @@ export function useGearReplayLayers(
|
||||
}
|
||||
}
|
||||
|
||||
if (reviewPositions.length > 0) {
|
||||
layers.push(new ScatterplotLayer({
|
||||
id: 'replay-review-vessel-glow',
|
||||
data: reviewPositions,
|
||||
getPosition: d => [d.lon, d.lat],
|
||||
getFillColor: d => {
|
||||
const alpha = hoveredMmsi === d.mmsi ? 90 : 40;
|
||||
return [255, 255, 255, alpha] as [number, number, number, number];
|
||||
},
|
||||
getRadius: d => hoveredMmsi === d.mmsi ? 420 : 260,
|
||||
radiusUnits: 'meters',
|
||||
radiusMinPixels: 10,
|
||||
}));
|
||||
|
||||
layers.push(new IconLayer<CorrPosition>({
|
||||
id: 'replay-review-vessels',
|
||||
data: reviewPositions,
|
||||
getPosition: d => [d.lon, d.lat],
|
||||
getIcon: () => SHIP_ICON_MAPPING['ship-triangle'],
|
||||
getSize: d => hoveredMmsi === d.mmsi ? 24 : 20,
|
||||
getAngle: d => -(d.cog || 0),
|
||||
getColor: d => d.color,
|
||||
sizeUnits: 'pixels',
|
||||
billboard: false,
|
||||
}));
|
||||
|
||||
if (showLabels) {
|
||||
const clusteredReview = clusterLabels(reviewPositions, d => [d.lon, d.lat], zoomLevel);
|
||||
layers.push(new TextLayer<CorrPosition>({
|
||||
id: 'replay-review-labels',
|
||||
data: clusteredReview,
|
||||
getPosition: d => [d.lon, d.lat],
|
||||
getText: d => {
|
||||
const candidate = reviewCandidateMap.get(d.mmsi);
|
||||
return candidate ? `#${candidate.rank} ${d.name}` : d.name;
|
||||
},
|
||||
getColor: d => d.color,
|
||||
getSize: d => hoveredMmsi === d.mmsi ? 10 * fs : 9 * fs,
|
||||
getPixelOffset: [0, 17],
|
||||
background: true,
|
||||
getBackgroundColor: [0, 0, 0, 215],
|
||||
backgroundPadding: [3, 2],
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Hover highlight
|
||||
if (hoveredMmsi) {
|
||||
const hoveredMember = members.find(m => m.mmsi === hoveredMmsi);
|
||||
const hoveredCorr = corrPositions.find(c => c.mmsi === hoveredMmsi);
|
||||
const hoveredCorr = corrPositions.find(c => c.mmsi === hoveredMmsi)
|
||||
?? reviewPositions.find(c => c.mmsi === hoveredMmsi);
|
||||
const hoveredPos: [number, number] | null = hoveredMember
|
||||
? [hoveredMember.lon, hoveredMember.lat]
|
||||
: hoveredCorr
|
||||
@ -506,16 +663,8 @@ export function useGearReplayLayers(
|
||||
|
||||
// Hover trail (from correlation track)
|
||||
const hoveredTrack = correlationTripsData.find(d => d.id === hoveredMmsi);
|
||||
if (hoveredTrack) {
|
||||
const relTime = ct - st;
|
||||
let clipIdx = hoveredTrack.timestamps.length;
|
||||
for (let i = 0; i < hoveredTrack.timestamps.length; i++) {
|
||||
if (hoveredTrack.timestamps[i] > relTime) {
|
||||
clipIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const clippedPath = hoveredTrack.path.slice(0, clipIdx);
|
||||
if (hoveredTrack && !reviewCandidateSet.has(hoveredMmsi) && (visibleCorrMmsis.has(hoveredMmsi) || visibleMemberMmsis.has(hoveredMmsi))) {
|
||||
const clippedPath = clipTripPathToTime(hoveredTrack, relTime);
|
||||
if (clippedPath.length >= 2) {
|
||||
layers.push(new PathLayer({
|
||||
id: 'replay-hover-trail',
|
||||
@ -537,6 +686,9 @@ export function useGearReplayLayers(
|
||||
for (const c of corrPositions) {
|
||||
if (state.pinnedMmsis.has(c.mmsi)) pinnedPositions.push({ position: [c.lon, c.lat] });
|
||||
}
|
||||
for (const c of reviewPositions) {
|
||||
if (state.pinnedMmsis.has(c.mmsi)) pinnedPositions.push({ position: [c.lon, c.lat] });
|
||||
}
|
||||
if (pinnedPositions.length > 0) {
|
||||
// glow
|
||||
layers.push(new ScatterplotLayer({
|
||||
@ -566,12 +718,8 @@ export function useGearReplayLayers(
|
||||
// pinned trails (correlation tracks)
|
||||
const relTime = ct - st;
|
||||
for (const trip of correlationTripsData) {
|
||||
if (!state.pinnedMmsis.has(trip.id)) continue;
|
||||
let clipIdx = trip.timestamps.length;
|
||||
for (let i = 0; i < trip.timestamps.length; i++) {
|
||||
if (trip.timestamps[i] > relTime) { clipIdx = i; break; }
|
||||
}
|
||||
const clippedPath = trip.path.slice(0, clipIdx);
|
||||
if (!state.pinnedMmsis.has(trip.id) || !visibleCorrMmsis.has(trip.id)) continue;
|
||||
const clippedPath = clipTripPathToTime(trip, relTime);
|
||||
if (clippedPath.length >= 2) {
|
||||
layers.push(new PathLayer({
|
||||
id: `replay-pinned-trail-${trip.id}`,
|
||||
@ -583,14 +731,24 @@ export function useGearReplayLayers(
|
||||
}
|
||||
}
|
||||
|
||||
for (const trip of reviewVisibleTrips) {
|
||||
if (!state.pinnedMmsis.has(trip.id)) continue;
|
||||
const clippedPath = clipTripPathToTime(trip, relTime);
|
||||
if (clippedPath.length >= 2) {
|
||||
layers.push(new PathLayer({
|
||||
id: `replay-pinned-review-trail-${trip.id}`,
|
||||
data: [{ path: clippedPath }],
|
||||
getPath: (d: { path: [number, number][] }) => d.path,
|
||||
getColor: trip.color,
|
||||
widthMinPixels: 3.5,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// pinned member trails (identity tracks)
|
||||
for (const trip of memberTripsData) {
|
||||
if (!state.pinnedMmsis.has(trip.id)) continue;
|
||||
let clipIdx = trip.timestamps.length;
|
||||
for (let i = 0; i < trip.timestamps.length; i++) {
|
||||
if (trip.timestamps[i] > relTime) { clipIdx = i; break; }
|
||||
}
|
||||
const clippedPath = trip.path.slice(0, clipIdx);
|
||||
if (!state.pinnedMmsis.has(trip.id) || !visibleMemberMmsis.has(trip.id)) continue;
|
||||
const clippedPath = clipTripPathToTime(trip, relTime);
|
||||
if (clippedPath.length >= 2) {
|
||||
layers.push(new PathLayer({
|
||||
id: `replay-pinned-mtrail-${trip.id}`,
|
||||
@ -642,62 +800,6 @@ export function useGearReplayLayers(
|
||||
}
|
||||
}
|
||||
|
||||
// 8.5. Model center trails + current center point (모델×서브클러스터별 중심 경로)
|
||||
for (const trail of modelCenterTrails) {
|
||||
if (!enabledModels.has(trail.modelName)) continue;
|
||||
if (trail.path.length < 2) continue;
|
||||
const color = MODEL_COLORS[trail.modelName] ?? '#94a3b8';
|
||||
const [r, g, b] = hexToRgb(color);
|
||||
|
||||
// 중심 경로 (PathLayer, 연한 모델 색상)
|
||||
layers.push(new PathLayer({
|
||||
id: `replay-model-trail-${trail.modelName}-s${trail.subClusterId}`,
|
||||
data: [{ path: trail.path }],
|
||||
getPath: (d: { path: [number, number][] }) => d.path,
|
||||
getColor: [r, g, b, 100],
|
||||
widthMinPixels: 1.5,
|
||||
}));
|
||||
|
||||
// 현재 중심점 (보간)
|
||||
const ts = trail.timestamps;
|
||||
if (ts.length > 0 && relTime >= ts[0] && relTime <= ts[ts.length - 1]) {
|
||||
let lo = 0, hi = ts.length - 1;
|
||||
while (lo < hi - 1) { const mid = (lo + hi) >> 1; if (ts[mid] <= relTime) lo = mid; else hi = mid; }
|
||||
const ratio = ts[hi] !== ts[lo] ? (relTime - ts[lo]) / (ts[hi] - ts[lo]) : 0;
|
||||
const cx = trail.path[lo][0] + (trail.path[hi][0] - trail.path[lo][0]) * ratio;
|
||||
const cy = trail.path[lo][1] + (trail.path[hi][1] - trail.path[lo][1]) * ratio;
|
||||
|
||||
const centerData = [{ position: [cx, cy] as [number, number] }];
|
||||
layers.push(new ScatterplotLayer({
|
||||
id: `replay-model-center-${trail.modelName}-s${trail.subClusterId}`,
|
||||
data: centerData,
|
||||
getPosition: (d: { position: [number, number] }) => d.position,
|
||||
getFillColor: [r, g, b, 255],
|
||||
getRadius: 150,
|
||||
radiusUnits: 'meters',
|
||||
radiusMinPixels: 5,
|
||||
stroked: true,
|
||||
getLineColor: [255, 255, 255, 200],
|
||||
lineWidthMinPixels: 1.5,
|
||||
}));
|
||||
if (showLabels) {
|
||||
layers.push(new TextLayer({
|
||||
id: `replay-model-center-label-${trail.modelName}-s${trail.subClusterId}`,
|
||||
data: centerData,
|
||||
getPosition: (d: { position: [number, number] }) => d.position,
|
||||
getText: () => trail.modelName,
|
||||
getColor: [r, g, b, 255],
|
||||
getSize: 9 * fs,
|
||||
getPixelOffset: [0, -12],
|
||||
background: true,
|
||||
getBackgroundColor: [0, 0, 0, 200],
|
||||
backgroundPadding: [3, 1],
|
||||
fontFamily: '"Fira Code Variable", monospace',
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 9. Model badges (small colored dots next to each vessel/gear per model)
|
||||
{
|
||||
const badgeTargets = new Map<string, { lon: number; lat: number; models: Set<string> }>();
|
||||
@ -797,10 +899,10 @@ export function useGearReplayLayers(
|
||||
}
|
||||
|
||||
// TripsLayer (멤버 트레일)
|
||||
if (memberTripsData.length > 0) {
|
||||
if (visibleMemberTrips.length > 0) {
|
||||
layers.push(new TripsLayer({
|
||||
id: 'replay-identity-trails',
|
||||
data: memberTripsData,
|
||||
data: visibleMemberTrips,
|
||||
getPath: d => d.path,
|
||||
getTimestamps: d => d.timestamps,
|
||||
getColor: [255, 200, 60, 220],
|
||||
@ -848,6 +950,8 @@ export function useGearReplayLayers(
|
||||
const frame6h = state.historyFrames6h[frameIdx6h];
|
||||
const subFrames6h = frame6h.subFrames ?? [{ subClusterId: 0, centerLon: frame6h.centerLon, centerLat: frame6h.centerLat, members: frame6h.members, memberCount: frame6h.memberCount }];
|
||||
const members6h = interpolateMemberPositions(state.historyFrames6h, frameIdx6h, ct);
|
||||
const visibleMemberMmsis6h = new Set(members6h.map(member => member.mmsi));
|
||||
const visibleMemberTrips6h = memberTripsData6h.filter(trip => visibleMemberMmsis6h.has(trip.id));
|
||||
|
||||
// 6h 폴리곤
|
||||
for (const sf of subFrames6h) {
|
||||
@ -908,10 +1012,10 @@ export function useGearReplayLayers(
|
||||
}
|
||||
|
||||
// 6h TripsLayer (항적 애니메이션)
|
||||
if (memberTripsData6h.length > 0) {
|
||||
if (visibleMemberTrips6h.length > 0) {
|
||||
layers.push(new TripsLayer({
|
||||
id: 'replay-6h-identity-trails',
|
||||
data: memberTripsData6h,
|
||||
data: visibleMemberTrips6h,
|
||||
getPath: d => d.path,
|
||||
getTimestamps: d => d.timestamps,
|
||||
getColor: [147, 197, 253, 180] as [number, number, number, number],
|
||||
@ -957,7 +1061,7 @@ export function useGearReplayLayers(
|
||||
centerTrailSegments, centerDotsPositions,
|
||||
centerTrailSegments6h, centerDotsPositions6h, subClusterCenters6h,
|
||||
enabledModels, enabledVessels, hoveredMmsi, correlationByModel,
|
||||
modelCenterTrails, subClusterCenters, showTrails, showLabels,
|
||||
reviewCandidates, subClusterCenters, showTrails, showLabels,
|
||||
show1hPolygon, show6hPolygon, pinnedMmsis, fs, zoomLevel,
|
||||
replayLayerRef, requestRender,
|
||||
]);
|
||||
|
||||
@ -38,8 +38,11 @@ export interface UseGroupPolygonsResult {
|
||||
allGroups: GroupPolygonDto[];
|
||||
isLoading: boolean;
|
||||
lastUpdated: number;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
const NOOP_REFRESH = async () => {};
|
||||
|
||||
const EMPTY: UseGroupPolygonsResult = {
|
||||
fleetGroups: [],
|
||||
gearInZoneGroups: [],
|
||||
@ -47,13 +50,14 @@ const EMPTY: UseGroupPolygonsResult = {
|
||||
allGroups: [],
|
||||
isLoading: false,
|
||||
lastUpdated: 0,
|
||||
refresh: NOOP_REFRESH,
|
||||
};
|
||||
|
||||
export function useGroupPolygons(enabled: boolean): UseGroupPolygonsResult {
|
||||
const [allGroups, setAllGroups] = useState<GroupPolygonDto[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [lastUpdated, setLastUpdated] = useState(0);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval>>();
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | undefined>(undefined);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
@ -92,5 +96,5 @@ export function useGroupPolygons(enabled: boolean): UseGroupPolygonsResult {
|
||||
|
||||
if (!enabled) return EMPTY;
|
||||
|
||||
return { fleetGroups, gearInZoneGroups, gearOutZoneGroups, allGroups, isLoading, lastUpdated };
|
||||
return { fleetGroups, gearInZoneGroups, gearOutZoneGroups, allGroups, isLoading, lastUpdated, refresh: load };
|
||||
}
|
||||
|
||||
@ -195,6 +195,202 @@
|
||||
"operator": "Operator",
|
||||
"yearSuffix": ""
|
||||
},
|
||||
"fleetGear": {
|
||||
"fleetSection": "Fleet Status ({{count}})",
|
||||
"fleetFallback": "Fleet #{{id}}",
|
||||
"inZoneSection": "Gear In Zone ({{count}})",
|
||||
"outZoneSection": "Unauthorized Gear ({{count}})",
|
||||
"toggleFleetSection": "Collapse or expand fleet status",
|
||||
"emptyFleet": "No fleet data",
|
||||
"vesselCountCompact": "({{count}} vessels)",
|
||||
"zoom": "Zoom",
|
||||
"moveToFleet": "Move map to this fleet",
|
||||
"moveToGroup": "Move map to this gear group",
|
||||
"moveToShip": "Move to ship",
|
||||
"moveToShipItem": "Move to ship {{name}}",
|
||||
"moveToGear": "Move to gear position",
|
||||
"moveToGearItem": "Move to {{name}} position",
|
||||
"shipList": "Ships",
|
||||
"gearList": "Gear List",
|
||||
"roleMain": "Main",
|
||||
"roleSub": "Sub"
|
||||
},
|
||||
"parentInference": {
|
||||
"title": "Parent Review",
|
||||
"actorLabel": "Review Actor",
|
||||
"actorPlaceholder": "lab-ui",
|
||||
"reviewQueue": "Review Queue ({{count}})",
|
||||
"reviewQueueFiltered": "Review Queue ({{filtered}} / {{total}})",
|
||||
"queueMeta": "sc#{{subClusterId}} · {{count}} candidates",
|
||||
"emptyQueue": "No items waiting for review.",
|
||||
"loading": "Loading...",
|
||||
"emptyState": "No parent inference data yet.",
|
||||
"filters": {
|
||||
"minScore": "Minimum score",
|
||||
"minScoreValue": "{{value}}%+",
|
||||
"minScoreAll": "All",
|
||||
"minMemberCount": "Minimum gear",
|
||||
"search": "Search",
|
||||
"searchPlaceholder": "Search name, zone, suggested parent",
|
||||
"clearSearch": "Clear",
|
||||
"resetFilters": "Reset filters",
|
||||
"sort": "Sort",
|
||||
"startSpatial": "Draw map range",
|
||||
"finishSpatial": "Apply range",
|
||||
"clearSpatial": "Clear range",
|
||||
"spatialIdle": "No map range filter is applied.",
|
||||
"spatialDrawing": "{{count}} points added on the map. Move the mouse for a live preview, and click near the start point after 3 or more points to close the polygon.",
|
||||
"spatialApplied": "Only gear groups inside the drawn map range are shown.",
|
||||
"queueFilterFallback": "Saved filters currently hide every item, so the full review queue is shown temporarily.",
|
||||
"queueTopScore": "Top Score {{score}}",
|
||||
"queueMemberCount": "{{count}} gear",
|
||||
"sortOptions": {
|
||||
"backend": "Default order",
|
||||
"topScore": "Highest score",
|
||||
"memberCount": "Most gear",
|
||||
"candidateCount": "Most candidates",
|
||||
"zoneDistance": "Closest to fishing zone",
|
||||
"name": "Name"
|
||||
}
|
||||
},
|
||||
"summary": {
|
||||
"label": "Inference",
|
||||
"recommendedParent": "Suggested Parent",
|
||||
"confidence": "Confidence",
|
||||
"topMargin": "Top Score/Margin",
|
||||
"stableCycles": "Stable Cycles",
|
||||
"statusReason": "Reason",
|
||||
"marginOnly": "Margin",
|
||||
"activeLabel": "Active Label",
|
||||
"activeUntil": "until {{value}}",
|
||||
"groupExclusions": "Group Exclusions"
|
||||
},
|
||||
"metrics": {
|
||||
"corr": "Corr",
|
||||
"name": "Name",
|
||||
"track": "Track",
|
||||
"visit": "Visit",
|
||||
"prox": "Prox",
|
||||
"activity": "Activity"
|
||||
},
|
||||
"actions": {
|
||||
"refresh": "Refresh",
|
||||
"duration": "Duration",
|
||||
"durationOption": "{{days}}d",
|
||||
"label": "Label",
|
||||
"jumpSubCluster": "Locate",
|
||||
"cancelLabel": "Clear Label",
|
||||
"groupExclude": "Group Excl.",
|
||||
"releaseGroupExclude": "Group Clear",
|
||||
"globalExclude": "Global Excl.",
|
||||
"releaseGlobalExclude": "Global Clear",
|
||||
"otherLabelActive": "Another candidate is already active as the labeled parent."
|
||||
},
|
||||
"badges": {
|
||||
"AUTO_PROMOTED": "AUTO",
|
||||
"MANUAL_CONFIRMED": "MANUAL",
|
||||
"DIRECT_PARENT_MATCH": "DIRECT",
|
||||
"REVIEW_REQUIRED": "REVIEW",
|
||||
"SKIPPED_SHORT_NAME": "SHORT",
|
||||
"NO_CANDIDATE": "NO CAND",
|
||||
"UNRESOLVED": "OPEN",
|
||||
"NONE": "NONE"
|
||||
},
|
||||
"status": {
|
||||
"AUTO_PROMOTED": "Auto Promoted",
|
||||
"MANUAL_CONFIRMED": "Manual Confirmed",
|
||||
"DIRECT_PARENT_MATCH": "Direct Parent Match",
|
||||
"REVIEW_REQUIRED": "Review Required",
|
||||
"SKIPPED_SHORT_NAME": "Skipped: Short Name",
|
||||
"NO_CANDIDATE": "No Candidate",
|
||||
"UNRESOLVED": "Unresolved"
|
||||
},
|
||||
"reasons": {
|
||||
"shortName": "Normalized name is shorter than 4 characters",
|
||||
"directParentMatch": "A direct parent vessel is already included in the group",
|
||||
"noCandidate": "No candidate could be generated"
|
||||
},
|
||||
"reference": {
|
||||
"shipOnly": "Only ship candidates are used for confirm and 24-hour exclusion. Gear remains reference-only for replay comparison.",
|
||||
"reviewDriven": "When parent review is active, this panel becomes reference-only. Actual overlay visibility follows the state of the right-side parent review panel.",
|
||||
"referenceGear": "Reference Gear"
|
||||
},
|
||||
"candidate": {
|
||||
"hoverHint": "Hover a candidate card to compare that vessel's full track and current replay movement more clearly.",
|
||||
"trackReady": "Track Ready",
|
||||
"trackMissing": "No Track",
|
||||
"totalScore": "Total",
|
||||
"nationalityBonusApplied": "Nationality +{{value}}%",
|
||||
"nationalityBonusNone": "No nationality bonus",
|
||||
"evidenceConfidence": "Evidence {{value}}%",
|
||||
"emptyThreshold": "No candidates at or above {{score}}%.",
|
||||
"labelActive": "Label",
|
||||
"groupExcludedUntil": "Group Excluded · {{value}}",
|
||||
"globalExcluded": "Global Excl.",
|
||||
"trackWindow": "Observed",
|
||||
"overlapWindow": "Overlap",
|
||||
"inZoneWindow": "In zone",
|
||||
"scoreWindow": "Score win.",
|
||||
"trackCoverage": "Track adj.",
|
||||
"visitCoverage": "Visit adj.",
|
||||
"activityCoverage": "Activity adj.",
|
||||
"proxCoverage": "Prox adj."
|
||||
},
|
||||
"help": {
|
||||
"title": "Parent Review Guide",
|
||||
"intro": "All scores are shown as 0-100%. The final candidate score is built from the components below, and reviewers should use both the candidate cards and replay comparison together.",
|
||||
"close": "Close",
|
||||
"scoreTitle": "Scoring",
|
||||
"scoreScaleLabel": "Display scale",
|
||||
"scoreScaleDesc": "Each candidate metric is stored as 0.0-1.0 internally and displayed as 0-100%.",
|
||||
"formulaLabel": "Final score formula",
|
||||
"formulaDesc": "Corr 40% + Name 15% + Track 15% + Visit 10% + Proximity 5% + Activity 5% + Stability 10% + Registry bonus 5%. After that, if the pre-bonus score is at least 30% and MMSI starts with 412/413, a +15% nationality bonus is added at the very end.",
|
||||
"nameScoreLabel": "Name score",
|
||||
"nameScoreDesc": "100% for raw uppercase exact match, 80% for normalized exact match after removing spaces/`_`/`-`/`%`, 50% for prefix or contains match, 30% when only the pure alphabetic portion matches after removing digits, and 0% otherwise. Normalized comparison uses the gear-group name against candidate AIS/registry names.",
|
||||
"corrLabel": "Corr",
|
||||
"corrDesc": "Uses the current_score from the default correlation model directly. This is the base linkage score between the group and the vessel candidate.",
|
||||
"trackLabel": "Track",
|
||||
"trackDesc": "Compares the last 6 hours of gear-polygon center movement and vessel track with DTW. Near 0m average distance approaches 100%; 10km or more approaches 0%. Short observations are reduced afterward by a coverage adjustment based on observed points and span.",
|
||||
"coverageLabel": "Coverage adjustment",
|
||||
"coverageDesc": "Track, visit, proximity, and activity are discounted when the observed track, overlap window, or in-zone stay is too short. The candidate card shows this as Observed/Overlap/In zone plus the adjustment rows.",
|
||||
"visitLabel": "Visit",
|
||||
"visitDesc": "Average visit_score from raw metrics over the last 6 hours. It rises when the vessel repeatedly visits the group area, but short in-zone coverage lowers the effective value.",
|
||||
"proxLabel": "Proximity",
|
||||
"proxDesc": "Average proximity_ratio from raw metrics over the last 6 hours. It rises when the vessel stays physically close over aligned observations, but very short tracks are reduced by the track coverage adjustment.",
|
||||
"activityLabel": "Activity",
|
||||
"activityDesc": "Average activity_sync from raw metrics over the last 6 hours. It reflects how similarly movement and working patterns evolve together, and is reduced when in-zone coverage is too short.",
|
||||
"stabilityLabel": "Stability",
|
||||
"stabilityDesc": "Computed as default correlation streak_count divided by 6, then clamped to 100%. It rises when the same top candidate persists across cycles.",
|
||||
"bonusLabel": "Bonuses",
|
||||
"bonusDesc": "A registry-matched vessel gets a fixed +5%. A 412/413 MMSI gets +15% only when the pre-bonus score is already at least 30%.",
|
||||
"summaryLabel": "Top / Margin / Stable cycles",
|
||||
"summaryDesc": "Top score is the final score of the #1 candidate for the group, margin is the gap between #1 and #2, and stable cycles counts how many consecutive cycles the same top MMSI remained on top.",
|
||||
"filterTitle": "Filters",
|
||||
"filterSortLabel": "Sort",
|
||||
"filterSortDesc": "Reorders the queue by default order, score, gear count, candidate count, fishing-zone distance, or name.",
|
||||
"filterMemberLabel": "Min gear count",
|
||||
"filterMemberDesc": "Only groups with at least this many gear members remain in the review queue. Default is 2.",
|
||||
"filterScoreLabel": "Min score",
|
||||
"filterScoreDesc": "Only groups whose top score is at or above this threshold remain in the list and on the map. Candidate cards are also limited to 30%+ scores in the current UI.",
|
||||
"filterSearchLabel": "Search",
|
||||
"filterSearchDesc": "Matches input text while ignoring spaces and case. Groups remain visible when the query is contained in the group name, zone name, or suggested parent name.",
|
||||
"filterSpatialLabel": "Map area",
|
||||
"filterSpatialDesc": "Start drawing, click the map to create a polygon, and finish to keep only groups inside that area. It combines with score/gear-count filters using AND logic.",
|
||||
"actionTitle": "Buttons and interactions",
|
||||
"actionRefreshLabel": "Refresh",
|
||||
"actionRefreshDesc": "Reloads the selected group's inference, active labels/exclusions, and the review queue.",
|
||||
"actionLocateLabel": "Locate",
|
||||
"actionLocateDesc": "Moves the map to the actual member bounds of that `sc#` subcluster, which helps when the same name is split into far-apart clusters.",
|
||||
"actionLabelLabel": "Label",
|
||||
"actionLabelDesc": "Stores the selected candidate as the answer label for this group. During the chosen duration (1/3/5 days), shadow tracking rows are accumulated for model evaluation.",
|
||||
"actionGroupExcludeLabel": "Group Excl.",
|
||||
"actionGroupExcludeDesc": "Excludes the selected candidate only from this gear group for the chosen duration. Other groups are unaffected.",
|
||||
"actionGlobalExcludeLabel": "Global Excl.",
|
||||
"actionGlobalExcludeDesc": "Excludes the selected MMSI from every gear group's candidate pool for the chosen duration. Use this for AIS targets that were misclassified as vessel candidates.",
|
||||
"actionHoverLabel": "Hover compare",
|
||||
"actionHoverDesc": "Hovering a candidate card strongly highlights that vessel's full track and current movement in replay so you can visually compare it against the gear polygon movement."
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"title": "KCG Monitoring Dashboard",
|
||||
"subtitle": "Maritime Situational Awareness",
|
||||
|
||||
@ -195,6 +195,202 @@
|
||||
"operator": "운영",
|
||||
"yearSuffix": "년"
|
||||
},
|
||||
"fleetGear": {
|
||||
"fleetSection": "선단 현황 ({{count}}개)",
|
||||
"fleetFallback": "선단 #{{id}}",
|
||||
"inZoneSection": "조업구역내 어구 ({{count}}개)",
|
||||
"outZoneSection": "비허가 어구 ({{count}}개)",
|
||||
"toggleFleetSection": "선단 현황 접기/펴기",
|
||||
"emptyFleet": "선단 데이터 없음",
|
||||
"vesselCountCompact": "({{count}}척)",
|
||||
"zoom": "이동",
|
||||
"moveToFleet": "이 선단으로 지도 이동",
|
||||
"moveToGroup": "이 어구 그룹으로 지도 이동",
|
||||
"moveToShip": "선박으로 이동",
|
||||
"moveToShipItem": "{{name}} 선박으로 이동",
|
||||
"moveToGear": "어구 위치로 이동",
|
||||
"moveToGearItem": "{{name}} 위치로 이동",
|
||||
"shipList": "선박",
|
||||
"gearList": "어구 목록",
|
||||
"roleMain": "주선",
|
||||
"roleSub": "구성"
|
||||
},
|
||||
"parentInference": {
|
||||
"title": "모선 검토",
|
||||
"actorLabel": "검토자",
|
||||
"actorPlaceholder": "lab-ui",
|
||||
"reviewQueue": "검토 대기 ({{count}}건)",
|
||||
"reviewQueueFiltered": "검토 대기 ({{filtered}} / {{total}}건)",
|
||||
"queueMeta": "sc#{{subClusterId}} · 후보 {{count}}건",
|
||||
"emptyQueue": "대기 중인 검토가 없습니다.",
|
||||
"loading": "불러오는 중...",
|
||||
"emptyState": "모선 추론 데이터가 아직 없습니다.",
|
||||
"filters": {
|
||||
"minScore": "최소 일치율",
|
||||
"minScoreValue": "{{value}}%+",
|
||||
"minScoreAll": "전체",
|
||||
"minMemberCount": "최소 어구 수",
|
||||
"search": "검색",
|
||||
"searchPlaceholder": "이름, 수역, 추천 모선 검색",
|
||||
"clearSearch": "초기화",
|
||||
"resetFilters": "필터 초기화",
|
||||
"sort": "정렬",
|
||||
"startSpatial": "지도 범위 그리기",
|
||||
"finishSpatial": "범위 확정",
|
||||
"clearSpatial": "범위 해제",
|
||||
"spatialIdle": "지도 범위 필터가 적용되지 않았습니다.",
|
||||
"spatialDrawing": "지도에서 점 {{count}}개를 찍었습니다. 마우스를 움직이면 미리보기가 보이고, 3개 이상이면 시작점 근처 클릭으로 바로 닫을 수 있습니다.",
|
||||
"spatialApplied": "사용자가 그린 지도 범위 안의 어구 그룹만 표시합니다.",
|
||||
"queueFilterFallback": "저장된 필터로 0건이 되어 전체 검토 대기 목록을 임시 표시 중입니다.",
|
||||
"queueTopScore": "Top 점수 {{score}}",
|
||||
"queueMemberCount": "{{count}}개",
|
||||
"sortOptions": {
|
||||
"backend": "기본 순서",
|
||||
"topScore": "일치율 높은순",
|
||||
"memberCount": "어구 수 많은순",
|
||||
"candidateCount": "후보 수 많은순",
|
||||
"zoneDistance": "조업구역 가까운순",
|
||||
"name": "이름순"
|
||||
}
|
||||
},
|
||||
"summary": {
|
||||
"label": "추론",
|
||||
"recommendedParent": "추천 모선",
|
||||
"confidence": "신뢰도",
|
||||
"topMargin": "Top/격차",
|
||||
"stableCycles": "연속 안정 주기",
|
||||
"statusReason": "사유",
|
||||
"marginOnly": "격차",
|
||||
"activeLabel": "활성 정답 라벨",
|
||||
"activeUntil": "{{value}}까지",
|
||||
"groupExclusions": "그룹 제외 후보"
|
||||
},
|
||||
"metrics": {
|
||||
"corr": "상관",
|
||||
"name": "이름",
|
||||
"track": "궤적",
|
||||
"visit": "방문",
|
||||
"prox": "근접",
|
||||
"activity": "활동"
|
||||
},
|
||||
"actions": {
|
||||
"refresh": "새로고침",
|
||||
"duration": "적용 기간",
|
||||
"durationOption": "{{days}}일",
|
||||
"label": "라벨",
|
||||
"jumpSubCluster": "이동",
|
||||
"cancelLabel": "라벨 해제",
|
||||
"groupExclude": "그룹 제외",
|
||||
"releaseGroupExclude": "그룹 해제",
|
||||
"globalExclude": "전체 제외",
|
||||
"releaseGlobalExclude": "전체 해제",
|
||||
"otherLabelActive": "다른 후보가 이미 정답 라벨로 활성화되어 있습니다."
|
||||
},
|
||||
"badges": {
|
||||
"AUTO_PROMOTED": "자동",
|
||||
"MANUAL_CONFIRMED": "수동",
|
||||
"DIRECT_PARENT_MATCH": "직접일치",
|
||||
"REVIEW_REQUIRED": "검토",
|
||||
"SKIPPED_SHORT_NAME": "짧음",
|
||||
"NO_CANDIDATE": "후보없음",
|
||||
"UNRESOLVED": "미해결",
|
||||
"NONE": "없음"
|
||||
},
|
||||
"status": {
|
||||
"AUTO_PROMOTED": "자동 승격",
|
||||
"MANUAL_CONFIRMED": "수동 확정",
|
||||
"DIRECT_PARENT_MATCH": "직접 모선 일치",
|
||||
"REVIEW_REQUIRED": "검토 필요",
|
||||
"SKIPPED_SHORT_NAME": "짧은 이름 제외",
|
||||
"NO_CANDIDATE": "후보 없음",
|
||||
"UNRESOLVED": "미해결"
|
||||
},
|
||||
"reasons": {
|
||||
"shortName": "정규화 이름 길이 4 미만",
|
||||
"directParentMatch": "그룹 멤버에 직접 모선이 포함됨",
|
||||
"noCandidate": "후보를 생성하지 못함"
|
||||
},
|
||||
"reference": {
|
||||
"shipOnly": "모선 확정과 24시간 제외 판단은 선박 후보만 사용합니다. 어구는 재생 비교용 참고 정보입니다.",
|
||||
"reviewDriven": "모선 검토가 선택되면 이 패널은 참고 정보만 보여주고, 실제 오버레이 표시는 우측 모선 검토 패널 상태를 그대로 따릅니다.",
|
||||
"referenceGear": "참고 어구"
|
||||
},
|
||||
"candidate": {
|
||||
"hoverHint": "후보 카드에 마우스를 올리면 리플레이에서 해당 선박 항적과 현재 움직임을 강하게 비교할 수 있습니다.",
|
||||
"trackReady": "항적 비교 가능",
|
||||
"trackMissing": "항적 없음",
|
||||
"totalScore": "전체",
|
||||
"nationalityBonusApplied": "국적 가산 +{{value}}%",
|
||||
"nationalityBonusNone": "국적 가산 없음",
|
||||
"evidenceConfidence": "증거 {{value}}%",
|
||||
"emptyThreshold": "{{score}}% 이상 후보가 없습니다.",
|
||||
"labelActive": "라벨 활성",
|
||||
"groupExcludedUntil": "그룹 제외 · {{value}}",
|
||||
"globalExcluded": "전체 제외",
|
||||
"trackWindow": "관측",
|
||||
"overlapWindow": "겹침",
|
||||
"inZoneWindow": "영역내",
|
||||
"scoreWindow": "점수창",
|
||||
"trackCoverage": "궤적 보정",
|
||||
"visitCoverage": "방문 보정",
|
||||
"activityCoverage": "활동 보정",
|
||||
"proxCoverage": "근접 보정"
|
||||
},
|
||||
"help": {
|
||||
"title": "모선 검토 가이드",
|
||||
"intro": "각 점수는 0~100%로 표시됩니다. 최종 후보 점수는 아래 항목을 합산해 계산하고, 검토자는 우측 후보 카드와 리플레이 비교를 함께 사용합니다.",
|
||||
"close": "닫기",
|
||||
"scoreTitle": "점수 기준",
|
||||
"scoreScaleLabel": "표시 단위",
|
||||
"scoreScaleDesc": "후보 카드의 각 수치는 0.0~1.0 내부 점수를 0~100%로 변환해 보여줍니다.",
|
||||
"formulaLabel": "전체 점수 산식",
|
||||
"formulaDesc": "상관 40% + 이름 15% + 궤적 15% + 방문 10% + 근접 5% + 활동 5% + 안정성 10% + 등록보너스 5%를 합산합니다. 그 뒤 pre-bonus 점수가 30% 이상이고 MMSI가 412/413으로 시작하면 국적 가산 +15%를 마지막에 후가산합니다.",
|
||||
"nameScoreLabel": "이름 점수",
|
||||
"nameScoreDesc": "원문을 대문자로 본 완전일치면 100%, 공백/`_`/`-`/`%` 제거 후 정규화 일치면 80%, prefix 또는 contains 일치면 50%, 숫자를 제거한 순수 문자 기준으로만 같으면 30%, 그 외는 0%입니다. 정규화 비교는 어구 그룹 이름과 후보 AIS/registry 이름을 기준으로 합니다.",
|
||||
"corrLabel": "상관",
|
||||
"corrDesc": "기본 correlation model의 current_score를 그대로 사용합니다. 해당 어구 그룹과 후보 선박이 기존 상관 모델에서 얼마나 강하게 연결됐는지의 기본 점수입니다.",
|
||||
"trackLabel": "궤적",
|
||||
"trackDesc": "최근 6시간의 어구 폴리곤 중심 이동과 선박 항적을 DTW 기반으로 비교합니다. 평균 거리 0m에 가까울수록 100%, 평균 거리 10km 이상이면 0%에 수렴합니다. 다만 관측 포인트 수와 관측 시간폭이 짧으면 coverage 보정으로 실제 반영치는 더 낮아집니다.",
|
||||
"coverageLabel": "Coverage 보정",
|
||||
"coverageDesc": "짧은 항적, 짧은 겹침, 짧은 영역내 체류가 과대평가되지 않도록 궤적/방문/근접/활동에 별도 보정 계수를 곱합니다. 후보 카드의 `관측/겹침/영역내`와 `XX 보정` 항목이 이 근거입니다.",
|
||||
"visitLabel": "방문",
|
||||
"visitDesc": "최근 6시간 raw metrics의 visit_score 평균입니다. 선박이 해당 어구 그룹 주변을 반복 방문할수록 높아집니다. 단, 영역내 포인트 수와 체류 시간이 짧으면 coverage 보정으로 낮아집니다.",
|
||||
"proxLabel": "근접",
|
||||
"proxDesc": "최근 6시간 raw metrics의 proximity_ratio 평균입니다. 같은 시계열 기준으로 가까이 붙어 있던 비율이 높을수록 올라갑니다. 단, 짧은 관측은 궤적 coverage 보정으로 그대로 100%를 유지하지 못합니다.",
|
||||
"activityLabel": "활동",
|
||||
"activityDesc": "최근 6시간 raw metrics의 activity_sync 평균입니다. 이동/조업 패턴이 함께 움직인 정도를 반영합니다. 영역내 관측이 짧으면 activity coverage 보정으로 반영치를 낮춥니다.",
|
||||
"stabilityLabel": "안정성",
|
||||
"stabilityDesc": "기본 correlation model의 streak_count를 6으로 나눈 뒤 100%로 clamp 합니다. 같은 후보가 여러 cycle 연속 유지될수록 올라갑니다.",
|
||||
"bonusLabel": "보너스",
|
||||
"bonusDesc": "registry 선박으로 식별되면 +5% 고정 가산, MMSI 412/413 후보는 pre-bonus 점수 30% 이상일 때만 +15%를 마지막에 추가합니다.",
|
||||
"summaryLabel": "Top/격차/안정 주기",
|
||||
"summaryDesc": "Top 점수는 현재 그룹의 1위 후보 최종 점수, 격차는 1위와 2위의 차이, 연속 안정 주기는 같은 1위 MMSI가 연속 유지된 cycle 수입니다.",
|
||||
"filterTitle": "필터 사용법",
|
||||
"filterSortLabel": "정렬",
|
||||
"filterSortDesc": "기본 순서, 일치율, 어구 수, 후보 수, 조업구역 거리, 이름 기준으로 검토 대기 목록을 재배열합니다.",
|
||||
"filterMemberLabel": "최소 어구 수",
|
||||
"filterMemberDesc": "해당 수 이상 멤버를 가진 어구 그룹만 검토 대기에 남깁니다. 기본값은 2입니다.",
|
||||
"filterScoreLabel": "최소 일치율",
|
||||
"filterScoreDesc": "Top 점수가 지정한 값 이상인 그룹만 목록과 지도에 남깁니다. 현재 UI 후보 카드도 30% 이상 후보만 보여줍니다.",
|
||||
"filterSearchLabel": "검색",
|
||||
"filterSearchDesc": "입력한 텍스트를 공백 무관, 대소문자 무관으로 비교합니다. 그룹 이름, 수역명, 추천 모선 이름에 포함되면 목록과 지도에 남깁니다.",
|
||||
"filterSpatialLabel": "지도 범위",
|
||||
"filterSpatialDesc": "범위 시작 후 지도를 클릭해 다각형을 그리고, 완료를 누르면 그 범위 안의 어구 그룹만 검토 대기에 남깁니다. 최소 일치율/어구 수와 AND 조건으로 함께 적용됩니다.",
|
||||
"actionTitle": "버튼과 동작",
|
||||
"actionRefreshLabel": "새로고침",
|
||||
"actionRefreshDesc": "현재 선택 그룹의 추론 결과, 활성 라벨/제외 상태, 검토 대기 목록을 다시 불러옵니다.",
|
||||
"actionLocateLabel": "이동",
|
||||
"actionLocateDesc": "해당 `sc#` 서브클러스터의 실제 멤버 bounds로 지도를 이동시켜, 멀리 떨어진 클러스터를 바로 찾을 수 있게 합니다.",
|
||||
"actionLabelLabel": "라벨",
|
||||
"actionLabelDesc": "선택한 후보를 이 어구 그룹의 정답 라벨로 기록합니다. 기간(1/3/5일) 동안 별도 tracking row가 쌓여 모델 평가용 백데이터로 사용됩니다.",
|
||||
"actionGroupExcludeLabel": "그룹 제외",
|
||||
"actionGroupExcludeDesc": "선택한 후보를 현재 어구 그룹에서만 기간 동안 제외합니다. 다른 어구 그룹의 후보군에는 영향을 주지 않습니다.",
|
||||
"actionGlobalExcludeLabel": "전체 제외",
|
||||
"actionGlobalExcludeDesc": "선택한 MMSI를 모든 어구 그룹의 후보군에서 기간 동안 제외합니다. 패턴 기반 이름이 아니어서 선박으로 오분류된 AIS를 제거할 때 사용합니다.",
|
||||
"actionHoverLabel": "호버 비교",
|
||||
"actionHoverDesc": "후보 카드에 마우스를 올리면 리플레이에서 해당 후보 선박의 전체 항적과 현재 움직임이 강하게 강조되어, 어구 폴리곤 중심 이동과 시각적으로 비교할 수 있습니다."
|
||||
}
|
||||
},
|
||||
"auth": {
|
||||
"title": "KCG 모니터링 대시보드",
|
||||
"subtitle": "해양 상황 인식 시스템",
|
||||
|
||||
@ -58,6 +58,20 @@ export interface MemberInfo {
|
||||
isParent: boolean;
|
||||
}
|
||||
|
||||
export interface ParentInferenceSummary {
|
||||
status: string;
|
||||
normalizedParentName: string | null;
|
||||
selectedParentMmsi: string | null;
|
||||
selectedParentName: string | null;
|
||||
confidence: number | null;
|
||||
decisionSource: string | null;
|
||||
topScore: number | null;
|
||||
scoreMargin: number | null;
|
||||
stableCycles: number | null;
|
||||
skipReason: string | null;
|
||||
statusReason: string | null;
|
||||
}
|
||||
|
||||
export interface GroupPolygonDto {
|
||||
groupType: 'FLEET' | 'GEAR_IN_ZONE' | 'GEAR_OUT_ZONE';
|
||||
groupKey: string;
|
||||
@ -73,7 +87,9 @@ export interface GroupPolygonDto {
|
||||
zoneName: string | null;
|
||||
members: MemberInfo[];
|
||||
color: string;
|
||||
resolution?: '1h' | '6h';
|
||||
resolution?: '1h' | '1h-fb' | '6h';
|
||||
candidateCount?: number | null;
|
||||
parentInference?: ParentInferenceSummary | null;
|
||||
}
|
||||
|
||||
export async function fetchGroupPolygons(): Promise<GroupPolygonDto[]> {
|
||||
@ -134,6 +150,376 @@ export async function fetchGroupCorrelations(
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/* ── Parent Inference Review Types ───────────────────────────── */
|
||||
|
||||
export interface ParentInferenceCandidate {
|
||||
candidateMmsi: string;
|
||||
candidateName: string;
|
||||
candidateVesselId: number | null;
|
||||
rank: number;
|
||||
candidateSource: string;
|
||||
finalScore: number | null;
|
||||
baseCorrScore: number | null;
|
||||
nameMatchScore: number | null;
|
||||
trackSimilarityScore: number | null;
|
||||
visitScore6h: number | null;
|
||||
proximityScore6h: number | null;
|
||||
activitySyncScore6h: number | null;
|
||||
stabilityScore: number | null;
|
||||
registryBonus: number | null;
|
||||
marginFromTop: number | null;
|
||||
trackAvailable: boolean | null;
|
||||
evidence: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface GroupParentInferenceItem {
|
||||
groupType: GroupPolygonDto['groupType'];
|
||||
groupKey: string;
|
||||
groupLabel: string;
|
||||
subClusterId: number;
|
||||
snapshotTime: string;
|
||||
zoneName: string | null;
|
||||
memberCount: number | null;
|
||||
resolution: GroupPolygonDto['resolution'];
|
||||
candidateCount: number | null;
|
||||
parentInference: ParentInferenceSummary | null;
|
||||
candidates?: ParentInferenceCandidate[];
|
||||
evidenceSummary?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ParentInferenceReviewResponse {
|
||||
count: number;
|
||||
items: GroupParentInferenceItem[];
|
||||
}
|
||||
|
||||
export interface GroupParentInferenceResponse {
|
||||
groupKey: string;
|
||||
count: number;
|
||||
items: GroupParentInferenceItem[];
|
||||
}
|
||||
|
||||
export interface ParentInferenceReviewRequest {
|
||||
action: 'CONFIRM' | 'REJECT' | 'RESET';
|
||||
selectedParentMmsi?: string;
|
||||
actor: string;
|
||||
comment?: string;
|
||||
}
|
||||
|
||||
export async function fetchParentInferenceReview(
|
||||
status = 'REVIEW_REQUIRED',
|
||||
limit = 100,
|
||||
): Promise<ParentInferenceReviewResponse> {
|
||||
const res = await fetch(
|
||||
`${API_BASE}/vessel-analysis/groups/parent-inference/review?status=${encodeURIComponent(status)}&limit=${limit}`,
|
||||
{ headers: { accept: 'application/json' } },
|
||||
);
|
||||
if (!res.ok) return { count: 0, items: [] };
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchGroupParentInference(groupKey: string): Promise<GroupParentInferenceResponse> {
|
||||
const res = await fetch(
|
||||
`${API_BASE}/vessel-analysis/groups/${encodeURIComponent(groupKey)}/parent-inference`,
|
||||
{ headers: { accept: 'application/json' } },
|
||||
);
|
||||
if (!res.ok) return { groupKey, count: 0, items: [] };
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function reviewGroupParentInference(
|
||||
groupKey: string,
|
||||
subClusterId: number,
|
||||
payload: ParentInferenceReviewRequest,
|
||||
): Promise<{ groupKey: string; subClusterId: number; action: string; item: GroupParentInferenceItem | null }> {
|
||||
const res = await fetch(
|
||||
`${API_BASE}/vessel-analysis/groups/${encodeURIComponent(groupKey)}/parent-inference/${subClusterId}/review`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
let message = `parent inference review failed: ${res.status}`;
|
||||
try {
|
||||
const data = await res.json() as { error?: string };
|
||||
if (data.error) message = data.error;
|
||||
} catch {
|
||||
// ignore JSON parse failure
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export interface ParentCandidateExclusion {
|
||||
id: number;
|
||||
scopeType: 'GROUP' | 'GLOBAL';
|
||||
groupKey: string | null;
|
||||
subClusterId: number | null;
|
||||
candidateMmsi: string;
|
||||
reasonType: 'GROUP_WRONG_PARENT' | 'GLOBAL_NOT_PARENT_TARGET';
|
||||
durationDays: number | null;
|
||||
activeFrom: string;
|
||||
activeUntil: string | null;
|
||||
releasedAt: string | null;
|
||||
releasedBy: string | null;
|
||||
actor: string;
|
||||
comment: string | null;
|
||||
active: boolean;
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ParentLabelSession {
|
||||
id: number;
|
||||
groupKey: string;
|
||||
subClusterId: number;
|
||||
labelParentMmsi: string;
|
||||
labelParentName: string | null;
|
||||
labelParentVesselId: number | null;
|
||||
durationDays: number;
|
||||
status: 'ACTIVE' | 'EXPIRED' | 'CANCELLED';
|
||||
activeFrom: string;
|
||||
activeUntil: string;
|
||||
actor: string;
|
||||
comment: string | null;
|
||||
anchorSnapshotTime: string | null;
|
||||
anchorCenterLat: number | null;
|
||||
anchorCenterLon: number | null;
|
||||
anchorMemberCount: number | null;
|
||||
active: boolean;
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ParentLabelTrackingCycle {
|
||||
id: number;
|
||||
labelSessionId: number;
|
||||
observedAt: string;
|
||||
candidateSnapshotObservedAt: string | null;
|
||||
autoStatus: string | null;
|
||||
topCandidateMmsi: string | null;
|
||||
topCandidateName: string | null;
|
||||
topCandidateScore: number | null;
|
||||
topCandidateMargin: number | null;
|
||||
candidateCount: number | null;
|
||||
labeledCandidatePresent: boolean;
|
||||
labeledCandidateRank: number | null;
|
||||
labeledCandidateScore: number | null;
|
||||
labeledCandidatePreBonusScore: number | null;
|
||||
labeledCandidateMarginFromTop: number | null;
|
||||
matchedTop1: boolean;
|
||||
matchedTop3: boolean;
|
||||
evidenceSummary: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface GroupParentLabelSessionRequest {
|
||||
selectedParentMmsi: string;
|
||||
durationDays: 1 | 3 | 5;
|
||||
actor: string;
|
||||
comment?: string;
|
||||
}
|
||||
|
||||
export interface GroupParentCandidateExclusionRequest {
|
||||
candidateMmsi: string;
|
||||
durationDays: 1 | 3 | 5;
|
||||
actor: string;
|
||||
comment?: string;
|
||||
}
|
||||
|
||||
export interface GlobalParentCandidateExclusionRequest {
|
||||
candidateMmsi: string;
|
||||
actor: string;
|
||||
comment?: string;
|
||||
}
|
||||
|
||||
export interface ParentWorkflowActionRequest {
|
||||
actor: string;
|
||||
comment?: string;
|
||||
}
|
||||
|
||||
export interface ParentCandidateExclusionListResponse {
|
||||
count: number;
|
||||
items: ParentCandidateExclusion[];
|
||||
}
|
||||
|
||||
export interface ParentLabelSessionListResponse {
|
||||
count: number;
|
||||
items: ParentLabelSession[];
|
||||
}
|
||||
|
||||
export interface ParentLabelTrackingResponse {
|
||||
labelSessionId: number;
|
||||
count: number;
|
||||
items: ParentLabelTrackingCycle[];
|
||||
}
|
||||
|
||||
async function parseWorkflowError(res: Response, fallback: string): Promise<never> {
|
||||
let message = fallback;
|
||||
try {
|
||||
const data = await res.json() as { error?: string };
|
||||
if (data.error) {
|
||||
message = data.error;
|
||||
}
|
||||
} catch {
|
||||
// ignore JSON parse failure
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
export async function createGroupParentLabelSession(
|
||||
groupKey: string,
|
||||
subClusterId: number,
|
||||
payload: GroupParentLabelSessionRequest,
|
||||
): Promise<{ groupKey: string; subClusterId: number; action: string; item: ParentLabelSession | null }> {
|
||||
const res = await fetch(
|
||||
`${API_BASE}/vessel-analysis/groups/${encodeURIComponent(groupKey)}/parent-inference/${subClusterId}/label-sessions`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
return parseWorkflowError(res, `parent label session failed: ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function createGroupCandidateExclusion(
|
||||
groupKey: string,
|
||||
subClusterId: number,
|
||||
payload: GroupParentCandidateExclusionRequest,
|
||||
): Promise<{ groupKey: string; subClusterId: number; action: string; item: ParentCandidateExclusion | null }> {
|
||||
const res = await fetch(
|
||||
`${API_BASE}/vessel-analysis/groups/${encodeURIComponent(groupKey)}/parent-inference/${subClusterId}/candidate-exclusions`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
return parseWorkflowError(res, `group candidate exclusion failed: ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function createGlobalCandidateExclusion(
|
||||
payload: GlobalParentCandidateExclusionRequest,
|
||||
): Promise<{ action: string; item: ParentCandidateExclusion | null }> {
|
||||
const res = await fetch(`${API_BASE}/vessel-analysis/parent-inference/candidate-exclusions/global`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) {
|
||||
return parseWorkflowError(res, `global candidate exclusion failed: ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function releaseCandidateExclusion(
|
||||
exclusionId: number,
|
||||
payload: ParentWorkflowActionRequest,
|
||||
): Promise<{ action: string; item: ParentCandidateExclusion | null }> {
|
||||
const res = await fetch(`${API_BASE}/vessel-analysis/parent-inference/candidate-exclusions/${exclusionId}/release`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) {
|
||||
return parseWorkflowError(res, `candidate exclusion release failed: ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchParentCandidateExclusions(params: {
|
||||
scopeType?: 'GROUP' | 'GLOBAL';
|
||||
groupKey?: string;
|
||||
subClusterId?: number;
|
||||
candidateMmsi?: string;
|
||||
activeOnly?: boolean;
|
||||
limit?: number;
|
||||
} = {}): Promise<ParentCandidateExclusionListResponse> {
|
||||
const search = new URLSearchParams();
|
||||
if (params.scopeType) search.set('scopeType', params.scopeType);
|
||||
if (params.groupKey) search.set('groupKey', params.groupKey);
|
||||
if (params.subClusterId != null) search.set('subClusterId', String(params.subClusterId));
|
||||
if (params.candidateMmsi) search.set('candidateMmsi', params.candidateMmsi);
|
||||
if (params.activeOnly != null) search.set('activeOnly', String(params.activeOnly));
|
||||
if (params.limit != null) search.set('limit', String(params.limit));
|
||||
const res = await fetch(`${API_BASE}/vessel-analysis/parent-inference/candidate-exclusions?${search.toString()}`, {
|
||||
headers: { accept: 'application/json' },
|
||||
});
|
||||
if (!res.ok) return { count: 0, items: [] };
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchParentLabelSessions(params: {
|
||||
groupKey?: string;
|
||||
subClusterId?: number;
|
||||
status?: 'ACTIVE' | 'EXPIRED' | 'CANCELLED';
|
||||
activeOnly?: boolean;
|
||||
limit?: number;
|
||||
} = {}): Promise<ParentLabelSessionListResponse> {
|
||||
const search = new URLSearchParams();
|
||||
if (params.groupKey) search.set('groupKey', params.groupKey);
|
||||
if (params.subClusterId != null) search.set('subClusterId', String(params.subClusterId));
|
||||
if (params.status) search.set('status', params.status);
|
||||
if (params.activeOnly != null) search.set('activeOnly', String(params.activeOnly));
|
||||
if (params.limit != null) search.set('limit', String(params.limit));
|
||||
const res = await fetch(`${API_BASE}/vessel-analysis/parent-inference/label-sessions?${search.toString()}`, {
|
||||
headers: { accept: 'application/json' },
|
||||
});
|
||||
if (!res.ok) return { count: 0, items: [] };
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function cancelParentLabelSession(
|
||||
labelSessionId: number,
|
||||
payload: ParentWorkflowActionRequest,
|
||||
): Promise<{ action: string; item: ParentLabelSession | null }> {
|
||||
const res = await fetch(`${API_BASE}/vessel-analysis/parent-inference/label-sessions/${labelSessionId}/cancel`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
accept: 'application/json',
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) {
|
||||
return parseWorkflowError(res, `label session cancel failed: ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchParentLabelTracking(
|
||||
labelSessionId: number,
|
||||
limit = 200,
|
||||
): Promise<ParentLabelTrackingResponse> {
|
||||
const res = await fetch(
|
||||
`${API_BASE}/vessel-analysis/parent-inference/label-sessions/${labelSessionId}/tracking?limit=${limit}`,
|
||||
{ headers: { accept: 'application/json' } },
|
||||
);
|
||||
if (!res.ok) return { labelSessionId, count: 0, items: [] };
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/* ── Correlation Tracks (Prediction API) ──────────────────────── */
|
||||
|
||||
export interface CorrelationTrackPoint {
|
||||
|
||||
@ -37,8 +37,18 @@ export interface CenterTrailSegment {
|
||||
isInterpolated: boolean;
|
||||
}
|
||||
|
||||
export interface ReplayReviewCandidate {
|
||||
mmsi: string;
|
||||
name: string;
|
||||
rank: number;
|
||||
score: number | null;
|
||||
trackAvailable: boolean;
|
||||
subClusterId: number;
|
||||
}
|
||||
|
||||
// ── Speed factor: 1x = 30 real seconds covers 12 timeline hours ──
|
||||
const SPEED_FACTOR = (12 * 60 * 60 * 1000) / (30 * 1000); // 1440
|
||||
const DEFAULT_AB_RANGE_MS = 2 * 60 * 60 * 1000;
|
||||
|
||||
// ── Module-level rAF state (outside React) ───────────────────────
|
||||
let animationFrameId: number | null = null;
|
||||
@ -52,6 +62,8 @@ interface GearReplayState {
|
||||
currentTime: number;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
dataStartTime: number;
|
||||
dataEndTime: number;
|
||||
playbackSpeed: number;
|
||||
|
||||
// Source data (1h = primary identity polygon)
|
||||
@ -84,6 +96,7 @@ interface GearReplayState {
|
||||
enabledModels: Set<string>;
|
||||
enabledVessels: Set<string>;
|
||||
hoveredMmsi: string | null;
|
||||
reviewCandidates: ReplayReviewCandidate[];
|
||||
correlationByModel: Map<string, GearCorrelationItem[]>;
|
||||
showTrails: boolean;
|
||||
showLabels: boolean;
|
||||
@ -111,6 +124,7 @@ interface GearReplayState {
|
||||
setEnabledModels: (models: Set<string>) => void;
|
||||
setEnabledVessels: (vessels: Set<string>) => void;
|
||||
setHoveredMmsi: (mmsi: string | null) => void;
|
||||
setReviewCandidates: (candidates: ReplayReviewCandidate[]) => void;
|
||||
setShowTrails: (show: boolean) => void;
|
||||
setShowLabels: (show: boolean) => void;
|
||||
setFocusMode: (focus: boolean) => void;
|
||||
@ -169,6 +183,8 @@ export const useGearReplayStore = create<GearReplayState>()(
|
||||
currentTime: 0,
|
||||
startTime: 0,
|
||||
endTime: 0,
|
||||
dataStartTime: 0,
|
||||
dataEndTime: 0,
|
||||
playbackSpeed: 1,
|
||||
|
||||
// Source data
|
||||
@ -198,6 +214,7 @@ export const useGearReplayStore = create<GearReplayState>()(
|
||||
enabledModels: new Set<string>(),
|
||||
enabledVessels: new Set<string>(),
|
||||
hoveredMmsi: null,
|
||||
reviewCandidates: [],
|
||||
showTrails: true,
|
||||
showLabels: true,
|
||||
focusMode: false,
|
||||
@ -216,6 +233,52 @@ export const useGearReplayStore = create<GearReplayState>()(
|
||||
const endTime = Date.now();
|
||||
const frameTimes = frames.map(f => new Date(f.snapshotTime).getTime());
|
||||
const frameTimes6h = (frames6h ?? []).map(f => new Date(f.snapshotTime).getTime());
|
||||
let primaryDataStartTime = Number.POSITIVE_INFINITY;
|
||||
let primaryDataEndTime = 0;
|
||||
let fallbackDataStartTime = Number.POSITIVE_INFINITY;
|
||||
let fallbackDataEndTime = 0;
|
||||
|
||||
const pushPrimaryTime = (value: number) => {
|
||||
if (!Number.isFinite(value) || value <= 0) return;
|
||||
primaryDataStartTime = Math.min(primaryDataStartTime, value);
|
||||
primaryDataEndTime = Math.max(primaryDataEndTime, value);
|
||||
};
|
||||
|
||||
const pushFallbackTime = (value: number) => {
|
||||
if (!Number.isFinite(value) || value <= 0) return;
|
||||
fallbackDataStartTime = Math.min(fallbackDataStartTime, value);
|
||||
fallbackDataEndTime = Math.max(fallbackDataEndTime, value);
|
||||
};
|
||||
|
||||
frameTimes.forEach(pushPrimaryTime);
|
||||
frameTimes6h.forEach(pushPrimaryTime);
|
||||
for (const track of corrTracks) {
|
||||
for (const point of track.track) {
|
||||
pushFallbackTime(point.ts);
|
||||
}
|
||||
}
|
||||
|
||||
let dataStartTime = Number.isFinite(primaryDataStartTime)
|
||||
? primaryDataStartTime
|
||||
: fallbackDataStartTime;
|
||||
let dataEndTime = Number.isFinite(primaryDataStartTime)
|
||||
? primaryDataEndTime
|
||||
: fallbackDataEndTime;
|
||||
|
||||
if (!Number.isFinite(dataStartTime) || dataStartTime <= 0) {
|
||||
dataStartTime = startTime;
|
||||
dataEndTime = endTime;
|
||||
} else if (dataEndTime <= dataStartTime) {
|
||||
const paddedStart = Math.max(startTime, dataStartTime - DEFAULT_AB_RANGE_MS / 2);
|
||||
const paddedEnd = Math.min(endTime, dataStartTime + DEFAULT_AB_RANGE_MS / 2);
|
||||
if (paddedEnd > paddedStart) {
|
||||
dataStartTime = paddedStart;
|
||||
dataEndTime = paddedEnd;
|
||||
} else {
|
||||
dataStartTime = startTime;
|
||||
dataEndTime = endTime;
|
||||
}
|
||||
}
|
||||
|
||||
const memberTrips = buildMemberTripsData(frames, startTime);
|
||||
const corrTrips = buildCorrelationTripsData(corrTracks, startTime);
|
||||
@ -248,6 +311,8 @@ export const useGearReplayStore = create<GearReplayState>()(
|
||||
snapshotRanges6h: ranges6h,
|
||||
startTime,
|
||||
endTime,
|
||||
dataStartTime,
|
||||
dataEndTime,
|
||||
currentTime: startTime,
|
||||
rawCorrelationTracks: corrTracks,
|
||||
memberTripsData: memberTrips,
|
||||
@ -305,17 +370,29 @@ export const useGearReplayStore = create<GearReplayState>()(
|
||||
},
|
||||
|
||||
setHoveredMmsi: (mmsi) => set({ hoveredMmsi: mmsi }),
|
||||
setReviewCandidates: (candidates) => set({ reviewCandidates: candidates }),
|
||||
setShowTrails: (show) => set({ showTrails: show }),
|
||||
setShowLabels: (show) => set({ showLabels: show }),
|
||||
setFocusMode: (focus) => set({ focusMode: focus }),
|
||||
setShow1hPolygon: (show) => set({ show1hPolygon: show }),
|
||||
setShow6hPolygon: (show) => set({ show6hPolygon: show }),
|
||||
setAbLoop: (on) => {
|
||||
const { startTime, endTime } = get();
|
||||
const { startTime, endTime, dataStartTime, dataEndTime } = get();
|
||||
if (on && startTime > 0) {
|
||||
// 기본 A-B: 전체 구간의 마지막 4시간
|
||||
const dur = endTime - startTime;
|
||||
set({ abLoop: true, abA: endTime - Math.min(dur, 4 * 3600_000), abB: endTime });
|
||||
let rangeStart = dataStartTime > 0 ? Math.max(startTime, dataStartTime) : startTime;
|
||||
let rangeEnd = dataEndTime > rangeStart ? Math.min(endTime, dataEndTime) : endTime;
|
||||
if (rangeEnd <= rangeStart) {
|
||||
const fallbackStart = Math.max(startTime, rangeStart - DEFAULT_AB_RANGE_MS / 2);
|
||||
const fallbackEnd = Math.min(endTime, rangeStart + DEFAULT_AB_RANGE_MS / 2);
|
||||
if (fallbackEnd > fallbackStart) {
|
||||
rangeStart = fallbackStart;
|
||||
rangeEnd = fallbackEnd;
|
||||
} else {
|
||||
rangeStart = startTime;
|
||||
rangeEnd = endTime;
|
||||
}
|
||||
}
|
||||
set({ abLoop: true, abA: rangeStart, abB: rangeEnd });
|
||||
} else {
|
||||
set({ abLoop: false, abA: 0, abB: 0 });
|
||||
}
|
||||
@ -358,6 +435,8 @@ export const useGearReplayStore = create<GearReplayState>()(
|
||||
currentTime: 0,
|
||||
startTime: 0,
|
||||
endTime: 0,
|
||||
dataStartTime: 0,
|
||||
dataEndTime: 0,
|
||||
playbackSpeed: 1,
|
||||
historyFrames: [],
|
||||
historyFrames6h: [],
|
||||
@ -381,6 +460,7 @@ export const useGearReplayStore = create<GearReplayState>()(
|
||||
enabledModels: new Set<string>(),
|
||||
enabledVessels: new Set<string>(),
|
||||
hoveredMmsi: null,
|
||||
reviewCandidates: [],
|
||||
showTrails: true,
|
||||
showLabels: true,
|
||||
focusMode: false,
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { resolve } from 'node:path'
|
||||
import { defineConfig, type UserConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
@ -6,6 +7,14 @@ import tailwindcss from '@tailwindcss/vite'
|
||||
export default defineConfig(({ mode }): UserConfig => ({
|
||||
plugins: [tailwindcss(), react()],
|
||||
esbuild: mode === 'production' ? { drop: ['console', 'debugger'] } : {},
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input: {
|
||||
main: resolve(__dirname, 'index.html'),
|
||||
gearParentFlow: resolve(__dirname, 'gear-parent-flow.html'),
|
||||
},
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api/ais': {
|
||||
@ -116,9 +125,9 @@ export default defineConfig(({ mode }): UserConfig => ({
|
||||
secure: true,
|
||||
},
|
||||
'/api/prediction/': {
|
||||
target: 'http://192.168.1.18:8001',
|
||||
target: 'https://kcg.gc-si.dev',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api\/prediction/, '/api'),
|
||||
secure: true,
|
||||
},
|
||||
'/ollama': {
|
||||
target: 'http://localhost:11434',
|
||||
|
||||
@ -19,6 +19,7 @@ from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from algorithms.polygon_builder import _get_time_bucket_age
|
||||
from config import qualified_table
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -26,6 +27,9 @@ logger = logging.getLogger(__name__)
|
||||
# ── 상수 ──────────────────────────────────────────────────────────
|
||||
_EARTH_RADIUS_NM = 3440.065
|
||||
_NM_TO_M = 1852.0
|
||||
CORRELATION_PARAM_MODELS = qualified_table('correlation_param_models')
|
||||
GEAR_CORRELATION_SCORES = qualified_table('gear_correlation_scores')
|
||||
GEAR_CORRELATION_RAW_METRICS = qualified_table('gear_correlation_raw_metrics')
|
||||
|
||||
|
||||
# ── 파라미터 모델 ─────────────────────────────────────────────────
|
||||
@ -469,10 +473,11 @@ def _get_vessel_track(vessel_store, mmsi: str, hours: int = 6) -> list[dict]:
|
||||
else recent.get('raw_sog', pd.Series(dtype=float))).fillna(0).values
|
||||
cogs = (recent['cog'] if 'cog' in recent.columns
|
||||
else pd.Series(0, index=recent.index)).fillna(0).values
|
||||
timestamps = recent['timestamp'].tolist()
|
||||
|
||||
return [
|
||||
{'lat': float(lats[i]), 'lon': float(lons[i]),
|
||||
'sog': float(sogs[i]), 'cog': float(cogs[i])}
|
||||
'sog': float(sogs[i]), 'cog': float(cogs[i]), 'timestamp': timestamps[i]}
|
||||
for i in range(len(lats))
|
||||
]
|
||||
|
||||
@ -724,7 +729,7 @@ def _load_active_models(conn) -> list[ModelParams]:
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
cur.execute(
|
||||
"SELECT id, name, params FROM kcg.correlation_param_models "
|
||||
f"SELECT id, name, params FROM {CORRELATION_PARAM_MODELS} "
|
||||
"WHERE is_active = TRUE ORDER BY is_default DESC, id ASC"
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
@ -751,7 +756,7 @@ def _load_all_scores(conn) -> dict[tuple, dict]:
|
||||
"SELECT model_id, group_key, sub_cluster_id, target_mmsi, "
|
||||
"current_score, streak_count, last_observed_at, "
|
||||
"target_type, target_name "
|
||||
"FROM kcg.gear_correlation_scores"
|
||||
f"FROM {GEAR_CORRELATION_SCORES}"
|
||||
)
|
||||
result = {}
|
||||
for row in cur.fetchall():
|
||||
@ -780,7 +785,7 @@ def _batch_insert_raw(conn, batch: list[tuple]):
|
||||
from psycopg2.extras import execute_values
|
||||
execute_values(
|
||||
cur,
|
||||
"""INSERT INTO kcg.gear_correlation_raw_metrics
|
||||
f"""INSERT INTO {GEAR_CORRELATION_RAW_METRICS}
|
||||
(observed_at, group_key, sub_cluster_id, target_mmsi, target_type, target_name,
|
||||
proximity_ratio, visit_score, activity_sync,
|
||||
dtw_similarity, speed_correlation, heading_coherence,
|
||||
@ -805,7 +810,7 @@ def _batch_upsert_scores(conn, batch: list[tuple]):
|
||||
from psycopg2.extras import execute_values
|
||||
execute_values(
|
||||
cur,
|
||||
"""INSERT INTO kcg.gear_correlation_scores
|
||||
f"""INSERT INTO {GEAR_CORRELATION_SCORES}
|
||||
(model_id, group_key, sub_cluster_id, target_mmsi, target_type, target_name,
|
||||
current_score, streak_count, freeze_state,
|
||||
first_observed_at, last_observed_at, updated_at)
|
||||
@ -817,7 +822,7 @@ def _batch_upsert_scores(conn, batch: list[tuple]):
|
||||
current_score = EXCLUDED.current_score,
|
||||
streak_count = EXCLUDED.streak_count,
|
||||
freeze_state = EXCLUDED.freeze_state,
|
||||
observation_count = kcg.gear_correlation_scores.observation_count + 1,
|
||||
observation_count = {GEAR_CORRELATION_SCORES}.observation_count + 1,
|
||||
last_observed_at = EXCLUDED.last_observed_at,
|
||||
updated_at = EXCLUDED.updated_at""",
|
||||
batch,
|
||||
|
||||
19
prediction/algorithms/gear_name_rules.py
Normal file
19
prediction/algorithms/gear_name_rules.py
Normal file
@ -0,0 +1,19 @@
|
||||
"""어구 parent name 정규화/필터 규칙."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
_TRACKABLE_PARENT_MIN_LENGTH = 4
|
||||
_REMOVE_TOKENS = (' ', '_', '-', '%')
|
||||
|
||||
|
||||
def normalize_parent_name(name: Optional[str]) -> str:
|
||||
value = (name or '').upper().strip()
|
||||
for token in _REMOVE_TOKENS:
|
||||
value = value.replace(token, '')
|
||||
return value
|
||||
|
||||
|
||||
def is_trackable_parent_name(name: Optional[str]) -> bool:
|
||||
return len(normalize_parent_name(name)) >= _TRACKABLE_PARENT_MIN_LENGTH
|
||||
631
prediction/algorithms/gear_parent_episode.py
Normal file
631
prediction/algorithms/gear_parent_episode.py
Normal file
@ -0,0 +1,631 @@
|
||||
"""어구 모선 추론 episode continuity + prior bonus helper."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Iterable, Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from config import qualified_table
|
||||
|
||||
GEAR_GROUP_EPISODES = qualified_table('gear_group_episodes')
|
||||
GEAR_GROUP_EPISODE_SNAPSHOTS = qualified_table('gear_group_episode_snapshots')
|
||||
GEAR_GROUP_PARENT_CANDIDATE_SNAPSHOTS = qualified_table('gear_group_parent_candidate_snapshots')
|
||||
GEAR_PARENT_LABEL_SESSIONS = qualified_table('gear_parent_label_sessions')
|
||||
|
||||
_ACTIVE_EPISODE_WINDOW_HOURS = 6
|
||||
_EPISODE_PRIOR_WINDOW_HOURS = 24
|
||||
_LINEAGE_PRIOR_WINDOW_DAYS = 7
|
||||
_LABEL_PRIOR_WINDOW_DAYS = 30
|
||||
_CONTINUITY_SCORE_THRESHOLD = 0.45
|
||||
_MERGE_SCORE_THRESHOLD = 0.35
|
||||
_CENTER_DISTANCE_THRESHOLD_NM = 12.0
|
||||
_EPISODE_PRIOR_MAX = 0.05
|
||||
_LINEAGE_PRIOR_MAX = 0.03
|
||||
_LABEL_PRIOR_MAX = 0.07
|
||||
_TOTAL_PRIOR_CAP = 0.10
|
||||
|
||||
|
||||
def _clamp(value: float, floor: float = 0.0, ceil: float = 1.0) -> float:
|
||||
return max(floor, min(ceil, value))
|
||||
|
||||
|
||||
def _haversine_nm(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||
earth_radius_nm = 3440.065
|
||||
phi1 = math.radians(lat1)
|
||||
phi2 = math.radians(lat2)
|
||||
dphi = math.radians(lat2 - lat1)
|
||||
dlam = math.radians(lon2 - lon1)
|
||||
a = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlam / 2) ** 2
|
||||
return earth_radius_nm * 2 * math.atan2(math.sqrt(a), math.sqrt(max(0.0, 1 - a)))
|
||||
|
||||
|
||||
def _json_list(value: Any) -> list[str]:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, list):
|
||||
return [str(item) for item in value if item]
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
except Exception:
|
||||
return []
|
||||
if isinstance(parsed, list):
|
||||
return [str(item) for item in parsed if item]
|
||||
return []
|
||||
|
||||
|
||||
@dataclass
|
||||
class GroupEpisodeInput:
|
||||
group_key: str
|
||||
normalized_parent_name: str
|
||||
sub_cluster_id: int
|
||||
member_mmsis: list[str]
|
||||
member_count: int
|
||||
center_lat: float
|
||||
center_lon: float
|
||||
|
||||
@property
|
||||
def key(self) -> tuple[str, int]:
|
||||
return (self.group_key, self.sub_cluster_id)
|
||||
|
||||
|
||||
@dataclass
|
||||
class EpisodeState:
|
||||
episode_id: str
|
||||
lineage_key: str
|
||||
group_key: str
|
||||
normalized_parent_name: str
|
||||
current_sub_cluster_id: int
|
||||
member_mmsis: list[str]
|
||||
member_count: int
|
||||
center_lat: float
|
||||
center_lon: float
|
||||
last_snapshot_time: datetime
|
||||
status: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class EpisodeAssignment:
|
||||
group_key: str
|
||||
sub_cluster_id: int
|
||||
normalized_parent_name: str
|
||||
episode_id: str
|
||||
continuity_source: str
|
||||
continuity_score: float
|
||||
split_from_episode_id: Optional[str]
|
||||
merged_from_episode_ids: list[str]
|
||||
member_mmsis: list[str]
|
||||
member_count: int
|
||||
center_lat: float
|
||||
center_lon: float
|
||||
|
||||
@property
|
||||
def key(self) -> tuple[str, int]:
|
||||
return (self.group_key, self.sub_cluster_id)
|
||||
|
||||
|
||||
@dataclass
|
||||
class EpisodePlan:
|
||||
assignments: dict[tuple[str, int], EpisodeAssignment]
|
||||
expired_episode_ids: set[str]
|
||||
merged_episode_targets: dict[str, str]
|
||||
|
||||
|
||||
def _member_jaccard(left: Iterable[str], right: Iterable[str]) -> tuple[float, int]:
|
||||
left_set = {item for item in left if item}
|
||||
right_set = {item for item in right if item}
|
||||
if not left_set and not right_set:
|
||||
return 0.0, 0
|
||||
overlap = len(left_set & right_set)
|
||||
union = len(left_set | right_set)
|
||||
return (overlap / union if union else 0.0), overlap
|
||||
|
||||
|
||||
def continuity_score(current: GroupEpisodeInput, previous: EpisodeState) -> tuple[float, int, float]:
|
||||
jaccard, overlap_count = _member_jaccard(current.member_mmsis, previous.member_mmsis)
|
||||
distance_nm = _haversine_nm(current.center_lat, current.center_lon, previous.center_lat, previous.center_lon)
|
||||
center_support = _clamp(1.0 - (distance_nm / _CENTER_DISTANCE_THRESHOLD_NM))
|
||||
score = _clamp((0.75 * jaccard) + (0.25 * center_support))
|
||||
return round(score, 6), overlap_count, round(distance_nm, 3)
|
||||
|
||||
|
||||
def load_active_episode_states(conn, lineage_keys: list[str]) -> dict[str, list[EpisodeState]]:
|
||||
if not lineage_keys:
|
||||
return {}
|
||||
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT episode_id, lineage_key, group_key, normalized_parent_name,
|
||||
current_sub_cluster_id, current_member_mmsis, current_member_count,
|
||||
ST_Y(current_center_point) AS center_lat,
|
||||
ST_X(current_center_point) AS center_lon,
|
||||
last_snapshot_time, status
|
||||
FROM {GEAR_GROUP_EPISODES}
|
||||
WHERE lineage_key = ANY(%s)
|
||||
AND status = 'ACTIVE'
|
||||
AND last_snapshot_time >= NOW() - (%s * INTERVAL '1 hour')
|
||||
ORDER BY lineage_key, last_snapshot_time DESC, episode_id ASC
|
||||
""",
|
||||
(lineage_keys, _ACTIVE_EPISODE_WINDOW_HOURS),
|
||||
)
|
||||
result: dict[str, list[EpisodeState]] = {}
|
||||
for row in cur.fetchall():
|
||||
state = EpisodeState(
|
||||
episode_id=row[0],
|
||||
lineage_key=row[1],
|
||||
group_key=row[2],
|
||||
normalized_parent_name=row[3],
|
||||
current_sub_cluster_id=int(row[4] or 0),
|
||||
member_mmsis=_json_list(row[5]),
|
||||
member_count=int(row[6] or 0),
|
||||
center_lat=float(row[7] or 0.0),
|
||||
center_lon=float(row[8] or 0.0),
|
||||
last_snapshot_time=row[9],
|
||||
status=row[10],
|
||||
)
|
||||
result.setdefault(state.lineage_key, []).append(state)
|
||||
return result
|
||||
finally:
|
||||
cur.close()
|
||||
|
||||
|
||||
def group_to_episode_input(group: dict[str, Any], normalized_parent_name: str) -> GroupEpisodeInput:
|
||||
members = group.get('members') or []
|
||||
member_mmsis = sorted({str(member.get('mmsi')) for member in members if member.get('mmsi')})
|
||||
member_count = len(member_mmsis)
|
||||
if members:
|
||||
center_lat = sum(float(member['lat']) for member in members) / len(members)
|
||||
center_lon = sum(float(member['lon']) for member in members) / len(members)
|
||||
else:
|
||||
center_lat = 0.0
|
||||
center_lon = 0.0
|
||||
return GroupEpisodeInput(
|
||||
group_key=group['parent_name'],
|
||||
normalized_parent_name=normalized_parent_name,
|
||||
sub_cluster_id=int(group.get('sub_cluster_id', 0)),
|
||||
member_mmsis=member_mmsis,
|
||||
member_count=member_count,
|
||||
center_lat=center_lat,
|
||||
center_lon=center_lon,
|
||||
)
|
||||
|
||||
|
||||
def build_episode_plan(
|
||||
groups: list[GroupEpisodeInput],
|
||||
previous_by_lineage: dict[str, list[EpisodeState]],
|
||||
) -> EpisodePlan:
|
||||
assignments: dict[tuple[str, int], EpisodeAssignment] = {}
|
||||
expired_episode_ids: set[str] = set()
|
||||
merged_episode_targets: dict[str, str] = {}
|
||||
|
||||
groups_by_lineage: dict[str, list[GroupEpisodeInput]] = {}
|
||||
for group in groups:
|
||||
groups_by_lineage.setdefault(group.normalized_parent_name, []).append(group)
|
||||
|
||||
for lineage_key, current_groups in groups_by_lineage.items():
|
||||
previous_groups = previous_by_lineage.get(lineage_key, [])
|
||||
qualified_matches: dict[tuple[str, int], list[tuple[EpisodeState, float, int, float]]] = {}
|
||||
prior_to_currents: dict[str, list[tuple[GroupEpisodeInput, float, int, float]]] = {}
|
||||
|
||||
for current in current_groups:
|
||||
for previous in previous_groups:
|
||||
score, overlap_count, distance_nm = continuity_score(current, previous)
|
||||
if score >= _CONTINUITY_SCORE_THRESHOLD or (
|
||||
overlap_count > 0 and distance_nm <= _CENTER_DISTANCE_THRESHOLD_NM
|
||||
):
|
||||
qualified_matches.setdefault(current.key, []).append((previous, score, overlap_count, distance_nm))
|
||||
prior_to_currents.setdefault(previous.episode_id, []).append((current, score, overlap_count, distance_nm))
|
||||
|
||||
consumed_previous_ids: set[str] = set()
|
||||
assigned_current_keys: set[tuple[str, int]] = set()
|
||||
|
||||
for current in current_groups:
|
||||
matches = sorted(
|
||||
qualified_matches.get(current.key, []),
|
||||
key=lambda item: (item[1], item[2], -item[3], item[0].last_snapshot_time),
|
||||
reverse=True,
|
||||
)
|
||||
merge_candidates = [
|
||||
item for item in matches
|
||||
if item[1] >= _MERGE_SCORE_THRESHOLD
|
||||
]
|
||||
if len(merge_candidates) >= 2:
|
||||
episode_id = f"ep-{uuid4().hex[:12]}"
|
||||
merged_ids = [item[0].episode_id for item in merge_candidates]
|
||||
assignments[current.key] = EpisodeAssignment(
|
||||
group_key=current.group_key,
|
||||
sub_cluster_id=current.sub_cluster_id,
|
||||
normalized_parent_name=current.normalized_parent_name,
|
||||
episode_id=episode_id,
|
||||
continuity_source='MERGE_NEW',
|
||||
continuity_score=round(max(item[1] for item in merge_candidates), 6),
|
||||
split_from_episode_id=None,
|
||||
merged_from_episode_ids=merged_ids,
|
||||
member_mmsis=current.member_mmsis,
|
||||
member_count=current.member_count,
|
||||
center_lat=current.center_lat,
|
||||
center_lon=current.center_lon,
|
||||
)
|
||||
assigned_current_keys.add(current.key)
|
||||
for merged_id in merged_ids:
|
||||
consumed_previous_ids.add(merged_id)
|
||||
merged_episode_targets[merged_id] = episode_id
|
||||
|
||||
previous_ranked = sorted(
|
||||
previous_groups,
|
||||
key=lambda item: item.last_snapshot_time,
|
||||
reverse=True,
|
||||
)
|
||||
for previous in previous_ranked:
|
||||
if previous.episode_id in consumed_previous_ids:
|
||||
continue
|
||||
matches = [
|
||||
item for item in prior_to_currents.get(previous.episode_id, [])
|
||||
if item[0].key not in assigned_current_keys
|
||||
]
|
||||
if not matches:
|
||||
continue
|
||||
matches.sort(key=lambda item: (item[1], item[2], -item[3]), reverse=True)
|
||||
current, score, _, _ = matches[0]
|
||||
split_candidate_count = len(prior_to_currents.get(previous.episode_id, []))
|
||||
assignments[current.key] = EpisodeAssignment(
|
||||
group_key=current.group_key,
|
||||
sub_cluster_id=current.sub_cluster_id,
|
||||
normalized_parent_name=current.normalized_parent_name,
|
||||
episode_id=previous.episode_id,
|
||||
continuity_source='SPLIT_CONTINUE' if split_candidate_count > 1 else 'CONTINUED',
|
||||
continuity_score=score,
|
||||
split_from_episode_id=None,
|
||||
merged_from_episode_ids=[],
|
||||
member_mmsis=current.member_mmsis,
|
||||
member_count=current.member_count,
|
||||
center_lat=current.center_lat,
|
||||
center_lon=current.center_lon,
|
||||
)
|
||||
assigned_current_keys.add(current.key)
|
||||
consumed_previous_ids.add(previous.episode_id)
|
||||
|
||||
for current in current_groups:
|
||||
if current.key in assigned_current_keys:
|
||||
continue
|
||||
|
||||
matches = sorted(
|
||||
qualified_matches.get(current.key, []),
|
||||
key=lambda item: (item[1], item[2], -item[3], item[0].last_snapshot_time),
|
||||
reverse=True,
|
||||
)
|
||||
split_from_episode_id = None
|
||||
continuity_source = 'NEW'
|
||||
continuity_score_value = 0.0
|
||||
if matches:
|
||||
best_previous, score, _, _ = matches[0]
|
||||
split_from_episode_id = best_previous.episode_id
|
||||
continuity_source = 'SPLIT_NEW'
|
||||
continuity_score_value = score
|
||||
|
||||
assignments[current.key] = EpisodeAssignment(
|
||||
group_key=current.group_key,
|
||||
sub_cluster_id=current.sub_cluster_id,
|
||||
normalized_parent_name=current.normalized_parent_name,
|
||||
episode_id=f"ep-{uuid4().hex[:12]}",
|
||||
continuity_source=continuity_source,
|
||||
continuity_score=continuity_score_value,
|
||||
split_from_episode_id=split_from_episode_id,
|
||||
merged_from_episode_ids=[],
|
||||
member_mmsis=current.member_mmsis,
|
||||
member_count=current.member_count,
|
||||
center_lat=current.center_lat,
|
||||
center_lon=current.center_lon,
|
||||
)
|
||||
assigned_current_keys.add(current.key)
|
||||
|
||||
current_previous_ids = {assignment.episode_id for assignment in assignments.values() if assignment.normalized_parent_name == lineage_key}
|
||||
for previous in previous_groups:
|
||||
if previous.episode_id in merged_episode_targets:
|
||||
continue
|
||||
if previous.episode_id not in current_previous_ids:
|
||||
expired_episode_ids.add(previous.episode_id)
|
||||
|
||||
return EpisodePlan(
|
||||
assignments=assignments,
|
||||
expired_episode_ids=expired_episode_ids,
|
||||
merged_episode_targets=merged_episode_targets,
|
||||
)
|
||||
|
||||
|
||||
def load_episode_prior_stats(conn, episode_ids: list[str]) -> dict[tuple[str, str], dict[str, Any]]:
|
||||
if not episode_ids:
|
||||
return {}
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT episode_id, candidate_mmsi,
|
||||
COUNT(*) AS seen_count,
|
||||
SUM(CASE WHEN rank = 1 THEN 1 ELSE 0 END) AS top1_count,
|
||||
AVG(final_score) AS avg_score,
|
||||
MAX(observed_at) AS last_seen_at
|
||||
FROM {GEAR_GROUP_PARENT_CANDIDATE_SNAPSHOTS}
|
||||
WHERE episode_id = ANY(%s)
|
||||
AND observed_at >= NOW() - (%s * INTERVAL '1 hour')
|
||||
GROUP BY episode_id, candidate_mmsi
|
||||
""",
|
||||
(episode_ids, _EPISODE_PRIOR_WINDOW_HOURS),
|
||||
)
|
||||
result: dict[tuple[str, str], dict[str, Any]] = {}
|
||||
for episode_id, candidate_mmsi, seen_count, top1_count, avg_score, last_seen_at in cur.fetchall():
|
||||
result[(episode_id, candidate_mmsi)] = {
|
||||
'seen_count': int(seen_count or 0),
|
||||
'top1_count': int(top1_count or 0),
|
||||
'avg_score': float(avg_score or 0.0),
|
||||
'last_seen_at': last_seen_at,
|
||||
}
|
||||
return result
|
||||
finally:
|
||||
cur.close()
|
||||
|
||||
|
||||
def load_lineage_prior_stats(conn, lineage_keys: list[str]) -> dict[tuple[str, str], dict[str, Any]]:
|
||||
if not lineage_keys:
|
||||
return {}
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT normalized_parent_name, candidate_mmsi,
|
||||
COUNT(*) AS seen_count,
|
||||
SUM(CASE WHEN rank = 1 THEN 1 ELSE 0 END) AS top1_count,
|
||||
SUM(CASE WHEN rank <= 3 THEN 1 ELSE 0 END) AS top3_count,
|
||||
AVG(final_score) AS avg_score,
|
||||
MAX(observed_at) AS last_seen_at
|
||||
FROM {GEAR_GROUP_PARENT_CANDIDATE_SNAPSHOTS}
|
||||
WHERE normalized_parent_name = ANY(%s)
|
||||
AND observed_at >= NOW() - (%s * INTERVAL '1 day')
|
||||
GROUP BY normalized_parent_name, candidate_mmsi
|
||||
""",
|
||||
(lineage_keys, _LINEAGE_PRIOR_WINDOW_DAYS),
|
||||
)
|
||||
result: dict[tuple[str, str], dict[str, Any]] = {}
|
||||
for lineage_key, candidate_mmsi, seen_count, top1_count, top3_count, avg_score, last_seen_at in cur.fetchall():
|
||||
result[(lineage_key, candidate_mmsi)] = {
|
||||
'seen_count': int(seen_count or 0),
|
||||
'top1_count': int(top1_count or 0),
|
||||
'top3_count': int(top3_count or 0),
|
||||
'avg_score': float(avg_score or 0.0),
|
||||
'last_seen_at': last_seen_at,
|
||||
}
|
||||
return result
|
||||
finally:
|
||||
cur.close()
|
||||
|
||||
|
||||
def load_label_prior_stats(conn, lineage_keys: list[str]) -> dict[tuple[str, str], dict[str, Any]]:
|
||||
if not lineage_keys:
|
||||
return {}
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT normalized_parent_name, label_parent_mmsi,
|
||||
COUNT(*) AS session_count,
|
||||
MAX(active_from) AS last_labeled_at
|
||||
FROM {GEAR_PARENT_LABEL_SESSIONS}
|
||||
WHERE normalized_parent_name = ANY(%s)
|
||||
AND active_from >= NOW() - (%s * INTERVAL '1 day')
|
||||
GROUP BY normalized_parent_name, label_parent_mmsi
|
||||
""",
|
||||
(lineage_keys, _LABEL_PRIOR_WINDOW_DAYS),
|
||||
)
|
||||
result: dict[tuple[str, str], dict[str, Any]] = {}
|
||||
for lineage_key, candidate_mmsi, session_count, last_labeled_at in cur.fetchall():
|
||||
result[(lineage_key, candidate_mmsi)] = {
|
||||
'session_count': int(session_count or 0),
|
||||
'last_labeled_at': last_labeled_at,
|
||||
}
|
||||
return result
|
||||
finally:
|
||||
cur.close()
|
||||
|
||||
|
||||
def _recency_support(observed_at: Optional[datetime], now: datetime, hours: float) -> float:
|
||||
if observed_at is None:
|
||||
return 0.0
|
||||
if observed_at.tzinfo is None:
|
||||
observed_at = observed_at.replace(tzinfo=timezone.utc)
|
||||
delta_hours = max(0.0, (now - observed_at.astimezone(timezone.utc)).total_seconds() / 3600.0)
|
||||
return _clamp(1.0 - (delta_hours / hours))
|
||||
|
||||
|
||||
def compute_prior_bonus_components(
|
||||
observed_at: datetime,
|
||||
normalized_parent_name: str,
|
||||
episode_id: str,
|
||||
candidate_mmsi: str,
|
||||
episode_prior_stats: dict[tuple[str, str], dict[str, Any]],
|
||||
lineage_prior_stats: dict[tuple[str, str], dict[str, Any]],
|
||||
label_prior_stats: dict[tuple[str, str], dict[str, Any]],
|
||||
) -> dict[str, float]:
|
||||
episode_stats = episode_prior_stats.get((episode_id, candidate_mmsi), {})
|
||||
lineage_stats = lineage_prior_stats.get((normalized_parent_name, candidate_mmsi), {})
|
||||
label_stats = label_prior_stats.get((normalized_parent_name, candidate_mmsi), {})
|
||||
|
||||
episode_bonus = 0.0
|
||||
if episode_stats:
|
||||
episode_bonus = _EPISODE_PRIOR_MAX * (
|
||||
0.35 * min(1.0, episode_stats.get('seen_count', 0) / 6.0)
|
||||
+ 0.35 * min(1.0, episode_stats.get('top1_count', 0) / 3.0)
|
||||
+ 0.15 * _clamp(float(episode_stats.get('avg_score', 0.0)))
|
||||
+ 0.15 * _recency_support(episode_stats.get('last_seen_at'), observed_at, _EPISODE_PRIOR_WINDOW_HOURS)
|
||||
)
|
||||
|
||||
lineage_bonus = 0.0
|
||||
if lineage_stats:
|
||||
lineage_bonus = _LINEAGE_PRIOR_MAX * (
|
||||
0.30 * min(1.0, lineage_stats.get('seen_count', 0) / 12.0)
|
||||
+ 0.25 * min(1.0, lineage_stats.get('top3_count', 0) / 6.0)
|
||||
+ 0.20 * min(1.0, lineage_stats.get('top1_count', 0) / 3.0)
|
||||
+ 0.15 * _clamp(float(lineage_stats.get('avg_score', 0.0)))
|
||||
+ 0.10 * _recency_support(lineage_stats.get('last_seen_at'), observed_at, _LINEAGE_PRIOR_WINDOW_DAYS * 24.0)
|
||||
)
|
||||
|
||||
label_bonus = 0.0
|
||||
if label_stats:
|
||||
label_bonus = _LABEL_PRIOR_MAX * (
|
||||
0.70 * min(1.0, label_stats.get('session_count', 0) / 3.0)
|
||||
+ 0.30 * _recency_support(label_stats.get('last_labeled_at'), observed_at, _LABEL_PRIOR_WINDOW_DAYS * 24.0)
|
||||
)
|
||||
|
||||
total = min(_TOTAL_PRIOR_CAP, episode_bonus + lineage_bonus + label_bonus)
|
||||
return {
|
||||
'episodePriorBonus': round(episode_bonus, 6),
|
||||
'lineagePriorBonus': round(lineage_bonus, 6),
|
||||
'labelPriorBonus': round(label_bonus, 6),
|
||||
'priorBonusTotal': round(total, 6),
|
||||
}
|
||||
|
||||
|
||||
def sync_episode_states(conn, observed_at: datetime, plan: EpisodePlan) -> None:
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
if plan.expired_episode_ids:
|
||||
cur.execute(
|
||||
f"""
|
||||
UPDATE {GEAR_GROUP_EPISODES}
|
||||
SET status = 'EXPIRED',
|
||||
updated_at = %s
|
||||
WHERE episode_id = ANY(%s)
|
||||
""",
|
||||
(observed_at, list(plan.expired_episode_ids)),
|
||||
)
|
||||
|
||||
for previous_episode_id, merged_into_episode_id in plan.merged_episode_targets.items():
|
||||
cur.execute(
|
||||
f"""
|
||||
UPDATE {GEAR_GROUP_EPISODES}
|
||||
SET status = 'MERGED',
|
||||
merged_into_episode_id = %s,
|
||||
updated_at = %s
|
||||
WHERE episode_id = %s
|
||||
""",
|
||||
(merged_into_episode_id, observed_at, previous_episode_id),
|
||||
)
|
||||
|
||||
for assignment in plan.assignments.values():
|
||||
cur.execute(
|
||||
f"""
|
||||
INSERT INTO {GEAR_GROUP_EPISODES} (
|
||||
episode_id, lineage_key, group_key, normalized_parent_name,
|
||||
current_sub_cluster_id, status, continuity_source, continuity_score,
|
||||
first_seen_at, last_seen_at, last_snapshot_time,
|
||||
current_member_count, current_member_mmsis, current_center_point,
|
||||
split_from_episode_id, merged_from_episode_ids, metadata, updated_at
|
||||
) VALUES (
|
||||
%s, %s, %s, %s,
|
||||
%s, 'ACTIVE', %s, %s,
|
||||
%s, %s, %s,
|
||||
%s, %s::jsonb, ST_SetSRID(ST_MakePoint(%s, %s), 4326),
|
||||
%s, %s::jsonb, '{{}}'::jsonb, %s
|
||||
)
|
||||
ON CONFLICT (episode_id)
|
||||
DO UPDATE SET
|
||||
group_key = EXCLUDED.group_key,
|
||||
normalized_parent_name = EXCLUDED.normalized_parent_name,
|
||||
current_sub_cluster_id = EXCLUDED.current_sub_cluster_id,
|
||||
status = 'ACTIVE',
|
||||
continuity_source = EXCLUDED.continuity_source,
|
||||
continuity_score = EXCLUDED.continuity_score,
|
||||
last_seen_at = EXCLUDED.last_seen_at,
|
||||
last_snapshot_time = EXCLUDED.last_snapshot_time,
|
||||
current_member_count = EXCLUDED.current_member_count,
|
||||
current_member_mmsis = EXCLUDED.current_member_mmsis,
|
||||
current_center_point = EXCLUDED.current_center_point,
|
||||
split_from_episode_id = COALESCE(EXCLUDED.split_from_episode_id, {GEAR_GROUP_EPISODES}.split_from_episode_id),
|
||||
merged_from_episode_ids = EXCLUDED.merged_from_episode_ids,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
""",
|
||||
(
|
||||
assignment.episode_id,
|
||||
assignment.normalized_parent_name,
|
||||
assignment.group_key,
|
||||
assignment.normalized_parent_name,
|
||||
assignment.sub_cluster_id,
|
||||
assignment.continuity_source,
|
||||
assignment.continuity_score,
|
||||
observed_at,
|
||||
observed_at,
|
||||
observed_at,
|
||||
assignment.member_count,
|
||||
json.dumps(assignment.member_mmsis, ensure_ascii=False),
|
||||
assignment.center_lon,
|
||||
assignment.center_lat,
|
||||
assignment.split_from_episode_id,
|
||||
json.dumps(assignment.merged_from_episode_ids, ensure_ascii=False),
|
||||
observed_at,
|
||||
),
|
||||
)
|
||||
finally:
|
||||
cur.close()
|
||||
|
||||
|
||||
def insert_episode_snapshots(
|
||||
conn,
|
||||
observed_at: datetime,
|
||||
plan: EpisodePlan,
|
||||
snapshot_payloads: dict[tuple[str, int], dict[str, Any]],
|
||||
) -> int:
|
||||
if not snapshot_payloads:
|
||||
return 0
|
||||
rows: list[tuple[Any, ...]] = []
|
||||
for key, payload in snapshot_payloads.items():
|
||||
assignment = plan.assignments.get(key)
|
||||
if assignment is None:
|
||||
continue
|
||||
rows.append((
|
||||
assignment.episode_id,
|
||||
assignment.normalized_parent_name,
|
||||
assignment.group_key,
|
||||
assignment.normalized_parent_name,
|
||||
assignment.sub_cluster_id,
|
||||
observed_at,
|
||||
assignment.member_count,
|
||||
json.dumps(assignment.member_mmsis, ensure_ascii=False),
|
||||
assignment.center_lon,
|
||||
assignment.center_lat,
|
||||
assignment.continuity_source,
|
||||
assignment.continuity_score,
|
||||
json.dumps(payload.get('parentEpisodeIds') or assignment.merged_from_episode_ids, ensure_ascii=False),
|
||||
payload.get('topCandidateMmsi'),
|
||||
payload.get('topCandidateScore'),
|
||||
payload.get('resolutionStatus'),
|
||||
json.dumps(payload.get('metadata') or {}, ensure_ascii=False),
|
||||
))
|
||||
|
||||
if not rows:
|
||||
return 0
|
||||
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
from psycopg2.extras import execute_values
|
||||
execute_values(
|
||||
cur,
|
||||
f"""
|
||||
INSERT INTO {GEAR_GROUP_EPISODE_SNAPSHOTS} (
|
||||
episode_id, lineage_key, group_key, normalized_parent_name, sub_cluster_id,
|
||||
observed_at, member_count, member_mmsis, center_point,
|
||||
continuity_source, continuity_score, parent_episode_ids,
|
||||
top_candidate_mmsi, top_candidate_score, resolution_status, metadata
|
||||
) VALUES %s
|
||||
ON CONFLICT (episode_id, observed_at) DO NOTHING
|
||||
""",
|
||||
rows,
|
||||
template="(%s, %s, %s, %s, %s, %s, %s, %s::jsonb, ST_SetSRID(ST_MakePoint(%s, %s), 4326), %s, %s, %s::jsonb, %s, %s, %s, %s::jsonb)",
|
||||
page_size=200,
|
||||
)
|
||||
return len(rows)
|
||||
finally:
|
||||
cur.close()
|
||||
1428
prediction/algorithms/gear_parent_inference.py
Normal file
1428
prediction/algorithms/gear_parent_inference.py
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
@ -15,6 +15,8 @@ from zoneinfo import ZoneInfo
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from algorithms.gear_name_rules import is_trackable_parent_name
|
||||
|
||||
try:
|
||||
from shapely.geometry import MultiPoint, Point
|
||||
from shapely import wkt as shapely_wkt
|
||||
@ -197,6 +199,8 @@ def detect_gear_groups(
|
||||
continue
|
||||
|
||||
parent_raw = (m.group(1) or name).strip()
|
||||
if not is_trackable_parent_name(parent_raw):
|
||||
continue
|
||||
parent_key = _normalize_parent(parent_raw)
|
||||
# 대표 이름: 공백 없는 버전 우선 (더 정규화된 형태)
|
||||
if parent_key not in parent_display or ' ' not in parent_raw:
|
||||
@ -399,6 +403,7 @@ def build_all_group_snapshots(
|
||||
'group_type': 'FLEET',
|
||||
'group_key': str(company_id),
|
||||
'group_label': group_label,
|
||||
'resolution': '1h',
|
||||
'snapshot_time': now,
|
||||
'polygon_wkt': polygon_wkt,
|
||||
'center_wkt': center_wkt,
|
||||
@ -413,6 +418,16 @@ def build_all_group_snapshots(
|
||||
# ── GEAR 타입: detect_gear_groups 결과 → 1h/6h 듀얼 스냅샷 ────
|
||||
gear_groups = detect_gear_groups(vessel_store, now=now)
|
||||
|
||||
# parent_name 기준 전체 1h 활성 멤버 합산 (서브클러스터 분리 전)
|
||||
parent_active_1h: dict[str, int] = {}
|
||||
for group in gear_groups:
|
||||
pn = group['parent_name']
|
||||
cnt = sum(
|
||||
1 for gm in group['members']
|
||||
if _get_time_bucket_age(gm.get('mmsi'), all_positions, now) <= DISPLAY_STALE_SEC
|
||||
)
|
||||
parent_active_1h[pn] = parent_active_1h.get(pn, 0) + cnt
|
||||
|
||||
for group in gear_groups:
|
||||
parent_name: str = group['parent_name']
|
||||
parent_mmsi: Optional[str] = group['parent_mmsi']
|
||||
@ -421,13 +436,15 @@ def build_all_group_snapshots(
|
||||
if not gear_members:
|
||||
continue
|
||||
|
||||
# ── 1h 활성 멤버 필터 ──
|
||||
display_members_1h = [
|
||||
# ── 1h 활성 멤버 필터 (이 서브클러스터 내) ──
|
||||
active_members_1h = [
|
||||
gm for gm in gear_members
|
||||
if _get_time_bucket_age(gm.get('mmsi'), all_positions, now) <= DISPLAY_STALE_SEC
|
||||
]
|
||||
# fallback: 1h < 2이면 time_bucket 최신 2개 유지 (폴리곤 형태 보존)
|
||||
if len(display_members_1h) < 2 and len(gear_members) >= 2:
|
||||
|
||||
# fallback: 서브클러스터 내 1h < 2이면 time_bucket 최신 2개 유지
|
||||
display_members_1h = active_members_1h
|
||||
if len(active_members_1h) < 2 and len(gear_members) >= 2:
|
||||
sorted_by_age = sorted(
|
||||
gear_members,
|
||||
key=lambda gm: _get_time_bucket_age(gm.get('mmsi'), all_positions, now),
|
||||
@ -442,7 +459,10 @@ def build_all_group_snapshots(
|
||||
display_members_6h = gear_members
|
||||
|
||||
# ── resolution별 스냅샷 생성 ──
|
||||
for resolution, members_for_snap in [('1h', display_members_1h), ('6h', display_members_6h)]:
|
||||
# 1h-fb: parent_name 전체 1h 활성 < 2 → 리플레이/일치율 추적용, 라이브 현황에서 제외
|
||||
# parent_name 전체 기준으로 판단 (서브클러스터 분리로 개별 멤버가 적어져도 그룹 전체가 활성이면 1h)
|
||||
res_1h = '1h' if parent_active_1h.get(parent_name, 0) >= 2 else '1h-fb'
|
||||
for resolution, members_for_snap in [(res_1h, display_members_1h), ('6h', display_members_6h)]:
|
||||
if len(members_for_snap) < 2:
|
||||
continue
|
||||
# 6h: 최신 적재가 STALE_SEC(6h) 초과 시 스킵
|
||||
|
||||
42
prediction/cache/vessel_store.py
vendored
42
prediction/cache/vessel_store.py
vendored
@ -1,9 +1,13 @@
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import numpy as np
|
||||
|
||||
_KST = ZoneInfo('Asia/Seoul')
|
||||
import pandas as pd
|
||||
from time_bucket import compute_initial_window_start, compute_safe_bucket
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -114,19 +118,21 @@ class VesselStore:
|
||||
self._tracks[str(mmsi)] = group.reset_index(drop=True)
|
||||
|
||||
# last_bucket 설정 — incremental fetch 시작점
|
||||
# snpdb time_bucket은 tz-naive KST이므로 UTC 변환하지 않고 그대로 유지
|
||||
if 'time_bucket' in df_all.columns and not df_all['time_bucket'].dropna().empty:
|
||||
max_bucket = pd.to_datetime(df_all['time_bucket'].dropna()).max()
|
||||
if hasattr(max_bucket, 'to_pydatetime'):
|
||||
max_bucket = max_bucket.to_pydatetime()
|
||||
if isinstance(max_bucket, datetime) and max_bucket.tzinfo is None:
|
||||
max_bucket = max_bucket.replace(tzinfo=timezone.utc)
|
||||
if isinstance(max_bucket, datetime) and max_bucket.tzinfo is not None:
|
||||
max_bucket = max_bucket.replace(tzinfo=None)
|
||||
self._last_bucket = max_bucket
|
||||
elif 'timestamp' in df_all.columns and not df_all['timestamp'].dropna().empty:
|
||||
max_ts = pd.to_datetime(df_all['timestamp'].dropna()).max()
|
||||
if hasattr(max_ts, 'to_pydatetime'):
|
||||
max_ts = max_ts.to_pydatetime()
|
||||
if isinstance(max_ts, datetime) and max_ts.tzinfo is None:
|
||||
max_ts = max_ts.replace(tzinfo=timezone.utc)
|
||||
# timestamp는 UTC aware → KST wall-clock naive로 변환
|
||||
if isinstance(max_ts, datetime) and max_ts.tzinfo is not None:
|
||||
max_ts = max_ts.astimezone(_KST).replace(tzinfo=None)
|
||||
self._last_bucket = max_ts
|
||||
|
||||
vessel_count = len(self._tracks)
|
||||
@ -159,10 +165,11 @@ class VesselStore:
|
||||
mmsi_str = str(mmsi)
|
||||
if mmsi_str in self._tracks:
|
||||
combined = pd.concat([self._tracks[mmsi_str], group], ignore_index=True)
|
||||
combined = combined.drop_duplicates(subset=['timestamp'])
|
||||
combined = combined.sort_values(['timestamp', 'time_bucket'])
|
||||
combined = combined.drop_duplicates(subset=['timestamp'], keep='last')
|
||||
self._tracks[mmsi_str] = combined.reset_index(drop=True)
|
||||
else:
|
||||
self._tracks[mmsi_str] = group.reset_index(drop=True)
|
||||
self._tracks[mmsi_str] = group.sort_values(['timestamp', 'time_bucket']).reset_index(drop=True)
|
||||
|
||||
if 'time_bucket' in group.columns and not group['time_bucket'].empty:
|
||||
bucket_vals = pd.to_datetime(group['time_bucket'].dropna())
|
||||
@ -171,8 +178,8 @@ class VesselStore:
|
||||
|
||||
if new_buckets:
|
||||
latest = max(new_buckets)
|
||||
if isinstance(latest, datetime) and latest.tzinfo is None:
|
||||
latest = latest.replace(tzinfo=timezone.utc)
|
||||
if isinstance(latest, datetime) and latest.tzinfo is not None:
|
||||
latest = latest.replace(tzinfo=None)
|
||||
if self._last_bucket is None or latest > self._last_bucket:
|
||||
self._last_bucket = latest
|
||||
|
||||
@ -186,6 +193,8 @@ class VesselStore:
|
||||
"""Remove track points older than N hours and evict empty MMSI entries."""
|
||||
import datetime as _dt
|
||||
|
||||
safe_bucket = compute_safe_bucket()
|
||||
cutoff_bucket = compute_initial_window_start(hours, safe_bucket)
|
||||
now = datetime.now(timezone.utc)
|
||||
cutoff_aware = now - _dt.timedelta(hours=hours)
|
||||
cutoff_naive = cutoff_aware.replace(tzinfo=None)
|
||||
@ -195,12 +204,16 @@ class VesselStore:
|
||||
|
||||
for mmsi in list(self._tracks.keys()):
|
||||
df = self._tracks[mmsi]
|
||||
ts_col = df['timestamp']
|
||||
# Handle tz-aware and tz-naive timestamps uniformly
|
||||
if hasattr(ts_col.dtype, 'tz') and ts_col.dtype.tz is not None:
|
||||
mask = ts_col >= pd.Timestamp(cutoff_aware)
|
||||
if 'time_bucket' in df.columns and not df['time_bucket'].dropna().empty:
|
||||
bucket_col = pd.to_datetime(df['time_bucket'], errors='coerce')
|
||||
mask = bucket_col >= pd.Timestamp(cutoff_bucket)
|
||||
else:
|
||||
mask = ts_col >= pd.Timestamp(cutoff_naive)
|
||||
ts_col = df['timestamp']
|
||||
# Handle tz-aware and tz-naive timestamps uniformly
|
||||
if hasattr(ts_col.dtype, 'tz') and ts_col.dtype.tz is not None:
|
||||
mask = ts_col >= pd.Timestamp(cutoff_aware)
|
||||
else:
|
||||
mask = ts_col >= pd.Timestamp(cutoff_naive)
|
||||
filtered = df[mask].reset_index(drop=True)
|
||||
if filtered.empty:
|
||||
del self._tracks[mmsi]
|
||||
@ -210,10 +223,11 @@ class VesselStore:
|
||||
|
||||
after_total = sum(len(v) for v in self._tracks.values())
|
||||
logger.info(
|
||||
'eviction complete: removed %d points, evicted %d mmsis (threshold=%dh)',
|
||||
'eviction complete: removed %d points, evicted %d mmsis (threshold=%dh, cutoff_bucket=%s)',
|
||||
before_total - after_total,
|
||||
len(evicted_mmsis),
|
||||
hours,
|
||||
cutoff_bucket,
|
||||
)
|
||||
|
||||
def refresh_static_info(self) -> None:
|
||||
|
||||
@ -9,6 +9,8 @@
|
||||
- MarineTraffic AIS/GNSS 스푸핑 가이드
|
||||
"""
|
||||
|
||||
from config import settings
|
||||
|
||||
# ── 역할 정의 ──
|
||||
ROLE_DEFINITION = """당신은 대한민국 해양경찰청의 **해양상황 분석 AI 어시스턴트**입니다.
|
||||
Python AI 분석 파이프라인(7단계 + 8개 알고리즘)의 실시간 결과를 기반으로,
|
||||
@ -406,6 +408,8 @@ snpdb (AIS 원본 항적) → vessel_store (인메모리 24h) → 7단계 파이
|
||||
- 집계 데이터(몇 척인지)는 이미 시스템 프롬프트에 있으므로 도구 불필요
|
||||
- 대부분의 질문은 kcgdb로 충분 — snpdb 직접 조회는 특수한 항적 분석에만 사용"""
|
||||
|
||||
DB_SCHEMA_AND_TOOLS = DB_SCHEMA_AND_TOOLS.replace('kcg.', f'{settings.KCGDB_SCHEMA}.')
|
||||
|
||||
|
||||
# ── 지식 섹션 레지스트리 (키워드 → 상세 텍스트) ──
|
||||
KNOWLEDGE_SECTIONS: dict[str, str] = {
|
||||
|
||||
@ -5,7 +5,14 @@ import logging
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
from config import qualified_table
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
VESSEL_ANALYSIS_RESULTS = qualified_table('vessel_analysis_results')
|
||||
FLEET_VESSELS = qualified_table('fleet_vessels')
|
||||
GROUP_POLYGON_SNAPSHOTS = qualified_table('group_polygon_snapshots')
|
||||
GEAR_CORRELATION_SCORES = qualified_table('gear_correlation_scores')
|
||||
CORRELATION_PARAM_MODELS = qualified_table('correlation_param_models')
|
||||
|
||||
# ── 사전 쿼리 패턴 (키워드 기반, 1회 왕복으로 해결) ──
|
||||
|
||||
@ -117,8 +124,8 @@ def execute_prequery(params: dict) -> str:
|
||||
v.cluster_id, v.cluster_size, v.dist_to_baseline_nm,
|
||||
v.is_transship_suspect, v.transship_pair_mmsi,
|
||||
fv.permit_no, fv.name_cn, fv.gear_code
|
||||
FROM kcg.vessel_analysis_results v
|
||||
LEFT JOIN kcg.fleet_vessels fv ON v.mmsi = fv.mmsi
|
||||
FROM {VESSEL_ANALYSIS_RESULTS} v
|
||||
LEFT JOIN {FLEET_VESSELS} fv ON v.mmsi = fv.mmsi
|
||||
WHERE {where}
|
||||
ORDER BY v.risk_score DESC
|
||||
LIMIT 30
|
||||
@ -217,7 +224,7 @@ def _query_fleet_group(params: dict) -> str:
|
||||
try:
|
||||
from db import kcgdb
|
||||
|
||||
conditions = ["snapshot_time = (SELECT MAX(snapshot_time) FROM kcg.group_polygon_snapshots)"]
|
||||
conditions = [f"snapshot_time = (SELECT MAX(snapshot_time) FROM {GROUP_POLYGON_SNAPSHOTS})"]
|
||||
bind_params: list = []
|
||||
|
||||
if 'group_type' in params:
|
||||
@ -230,7 +237,7 @@ def _query_fleet_group(params: dict) -> str:
|
||||
where = ' AND '.join(conditions)
|
||||
query = f"""
|
||||
SELECT group_type, group_key, group_label, member_count, zone_name, members
|
||||
FROM kcg.group_polygon_snapshots
|
||||
FROM {GROUP_POLYGON_SNAPSHOTS}
|
||||
WHERE {where}
|
||||
ORDER BY member_count DESC
|
||||
LIMIT 20
|
||||
@ -376,8 +383,8 @@ def _query_gear_correlation(params: dict) -> str:
|
||||
'SELECT target_name, target_mmsi, target_type, current_score, '
|
||||
'streak_count, observation_count, proximity_ratio, visit_score, '
|
||||
'heading_coherence, freeze_state '
|
||||
'FROM kcg.gear_correlation_scores s '
|
||||
'JOIN kcg.correlation_param_models m ON s.model_id = m.id '
|
||||
f'FROM {GEAR_CORRELATION_SCORES} s '
|
||||
f'JOIN {CORRELATION_PARAM_MODELS} m ON s.model_id = m.id '
|
||||
'WHERE s.group_key = %s AND m.is_default = TRUE AND s.current_score >= 0.3 '
|
||||
'ORDER BY s.current_score DESC LIMIT %s',
|
||||
(group_key, limit),
|
||||
|
||||
@ -1,3 +1,6 @@
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
@ -25,6 +28,8 @@ class Settings(BaseSettings):
|
||||
INITIAL_LOAD_HOURS: int = 24
|
||||
STATIC_INFO_REFRESH_MIN: int = 60
|
||||
PERMIT_REFRESH_MIN: int = 30
|
||||
SNPDB_SAFE_DELAY_MIN: int = 12
|
||||
SNPDB_BACKFILL_BUCKETS: int = 3
|
||||
|
||||
# 파이프라인
|
||||
TRAJECTORY_HOURS: int = 6
|
||||
@ -48,3 +53,14 @@ class Settings(BaseSettings):
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
_SQL_IDENTIFIER = re.compile(r'^[A-Za-z_][A-Za-z0-9_]*$')
|
||||
|
||||
|
||||
def qualified_table(table_name: str, schema: Optional[str] = None) -> str:
|
||||
resolved_schema = schema or settings.KCGDB_SCHEMA
|
||||
if not _SQL_IDENTIFIER.fullmatch(resolved_schema):
|
||||
raise ValueError(f'Invalid schema name: {resolved_schema!r}')
|
||||
if not _SQL_IDENTIFIER.fullmatch(table_name):
|
||||
raise ValueError(f'Invalid table name: {table_name!r}')
|
||||
return f'{resolved_schema}.{table_name}'
|
||||
|
||||
@ -7,7 +7,7 @@ import psycopg2
|
||||
from psycopg2 import pool
|
||||
from psycopg2.extras import execute_values
|
||||
|
||||
from config import settings
|
||||
from config import qualified_table, settings
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from models.result import AnalysisResult
|
||||
@ -15,6 +15,7 @@ if TYPE_CHECKING:
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_pool: Optional[pool.ThreadedConnectionPool] = None
|
||||
GROUP_POLYGON_SNAPSHOTS = qualified_table('group_polygon_snapshots')
|
||||
|
||||
|
||||
def init_pool():
|
||||
@ -152,8 +153,8 @@ def save_group_snapshots(snapshots: list[dict]) -> int:
|
||||
if not snapshots:
|
||||
return 0
|
||||
|
||||
insert_sql = """
|
||||
INSERT INTO kcg.group_polygon_snapshots (
|
||||
insert_sql = f"""
|
||||
INSERT INTO {GROUP_POLYGON_SNAPSHOTS} (
|
||||
group_type, group_key, group_label, sub_cluster_id, resolution, snapshot_time,
|
||||
polygon, center_point, area_sq_nm, member_count,
|
||||
zone_id, zone_name, members, color
|
||||
@ -280,11 +281,11 @@ def fetch_polygon_summary() -> dict:
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("""
|
||||
cur.execute(f"""
|
||||
SELECT group_type, COUNT(*), SUM(member_count)
|
||||
FROM kcg.group_polygon_snapshots
|
||||
FROM {GROUP_POLYGON_SNAPSHOTS}
|
||||
WHERE snapshot_time = (
|
||||
SELECT MAX(snapshot_time) FROM kcg.group_polygon_snapshots
|
||||
SELECT MAX(snapshot_time) FROM {GROUP_POLYGON_SNAPSHOTS}
|
||||
)
|
||||
GROUP BY group_type
|
||||
""")
|
||||
@ -315,7 +316,9 @@ def cleanup_group_snapshots(days: int = 7) -> int:
|
||||
with get_conn() as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(
|
||||
f"DELETE FROM kcg.group_polygon_snapshots WHERE snapshot_time < NOW() - INTERVAL '{days} days'",
|
||||
f"DELETE FROM {GROUP_POLYGON_SNAPSHOTS} "
|
||||
"WHERE snapshot_time < NOW() - (%s * INTERVAL '1 day')",
|
||||
(days,),
|
||||
)
|
||||
deleted = cur.rowcount
|
||||
conn.commit()
|
||||
|
||||
@ -10,15 +10,21 @@ APScheduler 일별 작업으로 실행:
|
||||
import logging
|
||||
from datetime import date, datetime, timedelta
|
||||
|
||||
from config import qualified_table, settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SYSTEM_CONFIG = qualified_table('system_config')
|
||||
GEAR_CORRELATION_RAW_METRICS = qualified_table('gear_correlation_raw_metrics')
|
||||
GEAR_CORRELATION_SCORES = qualified_table('gear_correlation_scores')
|
||||
|
||||
|
||||
def _get_config_int(conn, key: str, default: int) -> int:
|
||||
"""system_config에서 설정값 조회. 없으면 default."""
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
cur.execute(
|
||||
"SELECT value::text FROM kcg.system_config WHERE key = %s",
|
||||
f"SELECT value::text FROM {SYSTEM_CONFIG} WHERE key = %s",
|
||||
(key,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
@ -40,18 +46,18 @@ def _create_future_partitions(conn, days_ahead: int) -> int:
|
||||
cur.execute(
|
||||
"SELECT 1 FROM pg_class c "
|
||||
"JOIN pg_namespace n ON n.oid = c.relnamespace "
|
||||
"WHERE c.relname = %s AND n.nspname = 'kcg'",
|
||||
(partition_name,),
|
||||
"WHERE c.relname = %s AND n.nspname = %s",
|
||||
(partition_name, settings.KCGDB_SCHEMA),
|
||||
)
|
||||
if cur.fetchone() is None:
|
||||
next_d = d + timedelta(days=1)
|
||||
cur.execute(
|
||||
f"CREATE TABLE IF NOT EXISTS kcg.{partition_name} "
|
||||
f"PARTITION OF kcg.gear_correlation_raw_metrics "
|
||||
f"CREATE TABLE IF NOT EXISTS {qualified_table(partition_name)} "
|
||||
f"PARTITION OF {GEAR_CORRELATION_RAW_METRICS} "
|
||||
f"FOR VALUES FROM ('{d.isoformat()}') TO ('{next_d.isoformat()}')"
|
||||
)
|
||||
created += 1
|
||||
logger.info('created partition: kcg.%s', partition_name)
|
||||
logger.info('created partition: %s.%s', settings.KCGDB_SCHEMA, partition_name)
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
@ -71,7 +77,8 @@ def _drop_expired_partitions(conn, retention_days: int) -> int:
|
||||
"SELECT c.relname FROM pg_class c "
|
||||
"JOIN pg_namespace n ON n.oid = c.relnamespace "
|
||||
"WHERE c.relname LIKE 'gear_correlation_raw_metrics_%%' "
|
||||
"AND n.nspname = 'kcg' AND c.relkind = 'r'"
|
||||
"AND n.nspname = %s AND c.relkind = 'r'",
|
||||
(settings.KCGDB_SCHEMA,),
|
||||
)
|
||||
for (name,) in cur.fetchall():
|
||||
date_str = name.rsplit('_', 1)[-1]
|
||||
@ -80,9 +87,9 @@ def _drop_expired_partitions(conn, retention_days: int) -> int:
|
||||
except ValueError:
|
||||
continue
|
||||
if partition_date < cutoff:
|
||||
cur.execute(f'DROP TABLE IF EXISTS kcg.{name}')
|
||||
cur.execute(f'DROP TABLE IF EXISTS {qualified_table(name)}')
|
||||
dropped += 1
|
||||
logger.info('dropped expired partition: kcg.%s', name)
|
||||
logger.info('dropped expired partition: %s.%s', settings.KCGDB_SCHEMA, name)
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
@ -97,7 +104,7 @@ def _cleanup_stale_scores(conn, cleanup_days: int) -> int:
|
||||
cur = conn.cursor()
|
||||
try:
|
||||
cur.execute(
|
||||
"DELETE FROM kcg.gear_correlation_scores "
|
||||
f"DELETE FROM {GEAR_CORRELATION_SCORES} "
|
||||
"WHERE last_observed_at < NOW() - make_interval(days => %s)",
|
||||
(cleanup_days,),
|
||||
)
|
||||
|
||||
@ -8,6 +8,7 @@ import psycopg2
|
||||
from psycopg2 import pool
|
||||
|
||||
from config import settings
|
||||
from time_bucket import compute_incremental_window_start, compute_initial_window_start, compute_safe_bucket
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -62,7 +63,10 @@ def fetch_all_tracks(hours: int = 24) -> pd.DataFrame:
|
||||
LineStringM 지오메트리에서 개별 포인트를 추출하며,
|
||||
한국 해역(122-132E, 31-39N) 내 최근 N시간 데이터를 반환한다.
|
||||
"""
|
||||
query = f"""
|
||||
safe_bucket = compute_safe_bucket()
|
||||
window_start = compute_initial_window_start(hours, safe_bucket)
|
||||
|
||||
query = """
|
||||
SELECT
|
||||
t.mmsi,
|
||||
to_timestamp(ST_M((dp).geom)) as timestamp,
|
||||
@ -75,18 +79,21 @@ def fetch_all_tracks(hours: int = 24) -> pd.DataFrame:
|
||||
END as raw_sog
|
||||
FROM signal.t_vessel_tracks_5min t,
|
||||
LATERAL ST_DumpPoints(t.track_geom) dp
|
||||
WHERE t.time_bucket >= NOW() - INTERVAL '{hours} hours'
|
||||
WHERE t.time_bucket >= %s
|
||||
AND t.time_bucket <= %s
|
||||
AND t.track_geom && ST_MakeEnvelope(122, 31, 132, 39, 4326)
|
||||
ORDER BY t.mmsi, to_timestamp(ST_M((dp).geom))
|
||||
"""
|
||||
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
df = pd.read_sql_query(query, conn)
|
||||
df = pd.read_sql_query(query, conn, params=(window_start, safe_bucket))
|
||||
logger.info(
|
||||
'fetch_all_tracks: %d rows, %d vessels (last %dh)',
|
||||
'fetch_all_tracks: %d rows, %d vessels (window=%s..%s, last %dh safe)',
|
||||
len(df),
|
||||
df['mmsi'].nunique() if len(df) > 0 else 0,
|
||||
window_start,
|
||||
safe_bucket,
|
||||
hours,
|
||||
)
|
||||
return df
|
||||
@ -101,6 +108,17 @@ def fetch_incremental(last_bucket: datetime) -> pd.DataFrame:
|
||||
스케줄러 증분 업데이트에 사용되며, time_bucket > last_bucket 조건으로
|
||||
이미 처리한 버킷을 건너뛴다.
|
||||
"""
|
||||
safe_bucket = compute_safe_bucket()
|
||||
from_bucket = compute_incremental_window_start(last_bucket)
|
||||
if safe_bucket <= from_bucket:
|
||||
logger.info(
|
||||
'fetch_incremental skipped: safe_bucket=%s, from_bucket=%s, last_bucket=%s',
|
||||
safe_bucket,
|
||||
from_bucket,
|
||||
last_bucket,
|
||||
)
|
||||
return pd.DataFrame(columns=['mmsi', 'timestamp', 'lat', 'lon', 'raw_sog'])
|
||||
|
||||
query = """
|
||||
SELECT
|
||||
t.mmsi,
|
||||
@ -115,17 +133,20 @@ def fetch_incremental(last_bucket: datetime) -> pd.DataFrame:
|
||||
FROM signal.t_vessel_tracks_5min t,
|
||||
LATERAL ST_DumpPoints(t.track_geom) dp
|
||||
WHERE t.time_bucket > %s
|
||||
AND t.time_bucket <= %s
|
||||
AND t.track_geom && ST_MakeEnvelope(122, 31, 132, 39, 4326)
|
||||
ORDER BY t.mmsi, to_timestamp(ST_M((dp).geom))
|
||||
"""
|
||||
|
||||
try:
|
||||
with get_conn() as conn:
|
||||
df = pd.read_sql_query(query, conn, params=(last_bucket,))
|
||||
df = pd.read_sql_query(query, conn, params=(from_bucket, safe_bucket))
|
||||
logger.info(
|
||||
'fetch_incremental: %d rows, %d vessels (since %s)',
|
||||
'fetch_incremental: %d rows, %d vessels (from %s, safe %s, last %s)',
|
||||
len(df),
|
||||
df['mmsi'].nunique() if len(df) > 0 else 0,
|
||||
from_bucket.isoformat(),
|
||||
safe_bucket.isoformat(),
|
||||
last_bucket.isoformat(),
|
||||
)
|
||||
return df
|
||||
|
||||
@ -7,6 +7,9 @@ from typing import Optional
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from algorithms.gear_name_rules import is_trackable_parent_name
|
||||
from config import qualified_table
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 어구 이름 패턴 — 공백/영숫자 인덱스/끝_ 허용
|
||||
@ -14,6 +17,11 @@ GEAR_PATTERN = re.compile(r'^(.+?)_(?=\S*\d)\S+(?:[_ ]\S*)*[_ ]*$|^(\d+)$')
|
||||
GEAR_PATTERN_PCT = re.compile(r'^(.+?)%$')
|
||||
|
||||
_REGISTRY_CACHE_SEC = 3600
|
||||
FLEET_COMPANIES = qualified_table('fleet_companies')
|
||||
FLEET_VESSELS = qualified_table('fleet_vessels')
|
||||
GEAR_IDENTITY_LOG = qualified_table('gear_identity_log')
|
||||
GEAR_CORRELATION_SCORES = qualified_table('gear_correlation_scores')
|
||||
FLEET_TRACKING_SNAPSHOT = qualified_table('fleet_tracking_snapshot')
|
||||
|
||||
|
||||
class FleetTracker:
|
||||
@ -32,13 +40,13 @@ class FleetTracker:
|
||||
return
|
||||
|
||||
cur = conn.cursor()
|
||||
cur.execute('SELECT id, name_cn, name_en FROM kcg.fleet_companies')
|
||||
cur.execute(f'SELECT id, name_cn, name_en FROM {FLEET_COMPANIES}')
|
||||
self._companies = {r[0]: {'name_cn': r[1], 'name_en': r[2]} for r in cur.fetchall()}
|
||||
|
||||
cur.execute(
|
||||
"""SELECT id, company_id, permit_no, name_cn, name_en, tonnage,
|
||||
gear_code, fleet_role, pair_vessel_id, mmsi
|
||||
FROM kcg.fleet_vessels"""
|
||||
f"""SELECT id, company_id, permit_no, name_cn, name_en, tonnage,
|
||||
gear_code, fleet_role, pair_vessel_id, mmsi
|
||||
FROM {FLEET_VESSELS}"""
|
||||
)
|
||||
self._vessels = {}
|
||||
self._name_cn_map = {}
|
||||
@ -92,7 +100,7 @@ class FleetTracker:
|
||||
# 이미 매칭됨 → last_seen_at 업데이트
|
||||
if mmsi in self._mmsi_to_vid:
|
||||
cur.execute(
|
||||
'UPDATE kcg.fleet_vessels SET last_seen_at = NOW() WHERE id = %s',
|
||||
f'UPDATE {FLEET_VESSELS} SET last_seen_at = NOW() WHERE id = %s',
|
||||
(self._mmsi_to_vid[mmsi],),
|
||||
)
|
||||
continue
|
||||
@ -104,7 +112,7 @@ class FleetTracker:
|
||||
|
||||
if vid:
|
||||
cur.execute(
|
||||
"""UPDATE kcg.fleet_vessels
|
||||
f"""UPDATE {FLEET_VESSELS}
|
||||
SET mmsi = %s, match_confidence = 0.95, match_method = 'NAME_EXACT',
|
||||
last_seen_at = NOW(), updated_at = NOW()
|
||||
WHERE id = %s AND (mmsi IS NULL OR mmsi = %s)""",
|
||||
@ -154,6 +162,10 @@ class FleetTracker:
|
||||
if m2:
|
||||
parent_name = m2.group(1).strip()
|
||||
|
||||
effective_parent_name = parent_name or name
|
||||
if not is_trackable_parent_name(effective_parent_name):
|
||||
continue
|
||||
|
||||
# 모선 매칭
|
||||
parent_mmsi: Optional[str] = None
|
||||
parent_vid: Optional[int] = None
|
||||
@ -170,7 +182,7 @@ class FleetTracker:
|
||||
|
||||
# 기존 활성 행 조회
|
||||
cur.execute(
|
||||
"""SELECT id, name FROM kcg.gear_identity_log
|
||||
f"""SELECT id, name FROM {GEAR_IDENTITY_LOG}
|
||||
WHERE mmsi = %s AND is_active = TRUE""",
|
||||
(mmsi,),
|
||||
)
|
||||
@ -180,7 +192,7 @@ class FleetTracker:
|
||||
if existing[1] == name:
|
||||
# 같은 MMSI + 같은 이름 → 위치/시간 업데이트
|
||||
cur.execute(
|
||||
"""UPDATE kcg.gear_identity_log
|
||||
f"""UPDATE {GEAR_IDENTITY_LOG}
|
||||
SET last_seen_at = %s, lat = %s, lon = %s
|
||||
WHERE id = %s""",
|
||||
(now, lat, lon, existing[0]),
|
||||
@ -188,11 +200,11 @@ class FleetTracker:
|
||||
else:
|
||||
# 같은 MMSI + 다른 이름 → 이전 비활성화 + 새 행
|
||||
cur.execute(
|
||||
'UPDATE kcg.gear_identity_log SET is_active = FALSE WHERE id = %s',
|
||||
f'UPDATE {GEAR_IDENTITY_LOG} SET is_active = FALSE WHERE id = %s',
|
||||
(existing[0],),
|
||||
)
|
||||
cur.execute(
|
||||
"""INSERT INTO kcg.gear_identity_log
|
||||
f"""INSERT INTO {GEAR_IDENTITY_LOG}
|
||||
(mmsi, name, parent_name, parent_mmsi, parent_vessel_id,
|
||||
gear_index_1, gear_index_2, lat, lon,
|
||||
match_method, match_confidence, first_seen_at, last_seen_at)
|
||||
@ -204,7 +216,7 @@ class FleetTracker:
|
||||
else:
|
||||
# 새 MMSI → 같은 이름이 다른 MMSI로 있는지 확인
|
||||
cur.execute(
|
||||
"""SELECT id, mmsi FROM kcg.gear_identity_log
|
||||
f"""SELECT id, mmsi FROM {GEAR_IDENTITY_LOG}
|
||||
WHERE name = %s AND is_active = TRUE AND mmsi != %s""",
|
||||
(name, mmsi),
|
||||
)
|
||||
@ -212,7 +224,7 @@ class FleetTracker:
|
||||
if old_mmsi_row:
|
||||
# 같은 이름 + 다른 MMSI → MMSI 변경
|
||||
cur.execute(
|
||||
'UPDATE kcg.gear_identity_log SET is_active = FALSE WHERE id = %s',
|
||||
f'UPDATE {GEAR_IDENTITY_LOG} SET is_active = FALSE WHERE id = %s',
|
||||
(old_mmsi_row[0],),
|
||||
)
|
||||
logger.info('gear MMSI change: %s → %s (name=%s)', old_mmsi_row[1], mmsi, name)
|
||||
@ -220,7 +232,7 @@ class FleetTracker:
|
||||
# 어피니티 점수 이전 (이전 MMSI → 새 MMSI)
|
||||
try:
|
||||
cur.execute(
|
||||
"UPDATE kcg.gear_correlation_scores "
|
||||
f"UPDATE {GEAR_CORRELATION_SCORES} "
|
||||
"SET target_mmsi = %s, updated_at = NOW() "
|
||||
"WHERE target_mmsi = %s",
|
||||
(mmsi, old_mmsi_row[1]),
|
||||
@ -234,7 +246,7 @@ class FleetTracker:
|
||||
logger.warning('affinity score transfer failed: %s', e)
|
||||
|
||||
cur.execute(
|
||||
"""INSERT INTO kcg.gear_identity_log
|
||||
f"""INSERT INTO {GEAR_IDENTITY_LOG}
|
||||
(mmsi, name, parent_name, parent_mmsi, parent_vessel_id,
|
||||
gear_index_1, gear_index_2, lat, lon,
|
||||
match_method, match_confidence, first_seen_at, last_seen_at)
|
||||
@ -329,7 +341,7 @@ class FleetTracker:
|
||||
center_lon = sum(lons) / len(lons) if lons else None
|
||||
|
||||
cur.execute(
|
||||
"""INSERT INTO kcg.fleet_tracking_snapshot
|
||||
f"""INSERT INTO {FLEET_TRACKING_SNAPSHOT}
|
||||
(company_id, snapshot_time, total_vessels, active_vessels,
|
||||
center_lat, center_lon)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)""",
|
||||
|
||||
@ -4,7 +4,7 @@ from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import BackgroundTasks, FastAPI
|
||||
|
||||
from config import settings
|
||||
from config import qualified_table, settings
|
||||
from db import kcgdb, snpdb
|
||||
from scheduler import get_last_run, run_analysis_cycle, start_scheduler, stop_scheduler
|
||||
|
||||
@ -14,6 +14,8 @@ logging.basicConfig(
|
||||
stream=sys.stdout,
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
GEAR_CORRELATION_SCORES = qualified_table('gear_correlation_scores')
|
||||
CORRELATION_PARAM_MODELS = qualified_table('correlation_param_models')
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@ -89,11 +91,11 @@ def get_correlation_tracks(
|
||||
cur = conn.cursor()
|
||||
|
||||
# Get correlated vessels from ALL active models
|
||||
cur.execute("""
|
||||
cur.execute(f"""
|
||||
SELECT s.target_mmsi, s.target_type, s.target_name,
|
||||
s.current_score, m.name AS model_name
|
||||
FROM kcg.gear_correlation_scores s
|
||||
JOIN kcg.correlation_param_models m ON s.model_id = m.id
|
||||
FROM {GEAR_CORRELATION_SCORES} s
|
||||
JOIN {CORRELATION_PARAM_MODELS} m ON s.model_id = m.id
|
||||
WHERE s.group_key = %s
|
||||
AND s.current_score >= %s
|
||||
AND m.is_active = TRUE
|
||||
|
||||
@ -7,5 +7,6 @@ pandas>=2.2
|
||||
scikit-learn>=1.5
|
||||
apscheduler>=3.10
|
||||
shapely>=2.0
|
||||
tzdata
|
||||
httpx>=0.27
|
||||
redis>=5.0
|
||||
|
||||
@ -121,6 +121,7 @@ def run_analysis_cycle():
|
||||
# 4.7 어구 연관성 분석 (멀티모델 패턴 추적)
|
||||
try:
|
||||
from algorithms.gear_correlation import run_gear_correlation
|
||||
from algorithms.gear_parent_inference import run_gear_parent_inference
|
||||
|
||||
corr_result = run_gear_correlation(
|
||||
vessel_store=vessel_store,
|
||||
@ -132,6 +133,21 @@ def run_analysis_cycle():
|
||||
corr_result['updated'], corr_result['raw_inserted'],
|
||||
corr_result['models'],
|
||||
)
|
||||
|
||||
inference_result = run_gear_parent_inference(
|
||||
vessel_store=vessel_store,
|
||||
gear_groups=gear_groups,
|
||||
conn=kcg_conn,
|
||||
)
|
||||
logger.info(
|
||||
'gear parent inference: %d groups, %d direct-match, %d candidates, %d promoted, %d review, %d skipped',
|
||||
inference_result['groups'],
|
||||
inference_result.get('direct_matched', 0),
|
||||
inference_result['candidates'],
|
||||
inference_result['promoted'],
|
||||
inference_result['review_required'],
|
||||
inference_result['skipped'],
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning('gear correlation failed: %s', e)
|
||||
|
||||
|
||||
177
prediction/tests/test_gear_parent_episode.py
Normal file
177
prediction/tests/test_gear_parent_episode.py
Normal file
@ -0,0 +1,177 @@
|
||||
import unittest
|
||||
import sys
|
||||
import types
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
stub = types.ModuleType('pydantic_settings')
|
||||
|
||||
|
||||
class BaseSettings:
|
||||
def __init__(self, **kwargs):
|
||||
for name, value in self.__class__.__dict__.items():
|
||||
if name.isupper():
|
||||
setattr(self, name, kwargs.get(name, value))
|
||||
|
||||
|
||||
stub.BaseSettings = BaseSettings
|
||||
sys.modules.setdefault('pydantic_settings', stub)
|
||||
|
||||
from algorithms.gear_parent_episode import (
|
||||
GroupEpisodeInput,
|
||||
EpisodeState,
|
||||
build_episode_plan,
|
||||
compute_prior_bonus_components,
|
||||
continuity_score,
|
||||
)
|
||||
|
||||
|
||||
class GearParentEpisodeTest(unittest.TestCase):
|
||||
def test_continuity_score_prefers_member_overlap_and_near_center(self):
|
||||
current = GroupEpisodeInput(
|
||||
group_key='ZHEDAIYU02394',
|
||||
normalized_parent_name='ZHEDAIYU02394',
|
||||
sub_cluster_id=1,
|
||||
member_mmsis=['100', '200', '300'],
|
||||
member_count=3,
|
||||
center_lat=35.0,
|
||||
center_lon=129.0,
|
||||
)
|
||||
previous = EpisodeState(
|
||||
episode_id='ep-prev',
|
||||
lineage_key='ZHEDAIYU02394',
|
||||
group_key='ZHEDAIYU02394',
|
||||
normalized_parent_name='ZHEDAIYU02394',
|
||||
current_sub_cluster_id=0,
|
||||
member_mmsis=['100', '200', '400'],
|
||||
member_count=3,
|
||||
center_lat=35.02,
|
||||
center_lon=129.01,
|
||||
last_snapshot_time=datetime.now(timezone.utc),
|
||||
status='ACTIVE',
|
||||
)
|
||||
score, overlap_count, distance_nm = continuity_score(current, previous)
|
||||
self.assertGreaterEqual(overlap_count, 2)
|
||||
self.assertGreater(score, 0.45)
|
||||
self.assertLess(distance_nm, 12.0)
|
||||
|
||||
def test_build_episode_plan_creates_merge_episode(self):
|
||||
now = datetime.now(timezone.utc)
|
||||
current = GroupEpisodeInput(
|
||||
group_key='JINSHI',
|
||||
normalized_parent_name='JINSHI',
|
||||
sub_cluster_id=0,
|
||||
member_mmsis=['a', 'b', 'c', 'd'],
|
||||
member_count=4,
|
||||
center_lat=35.0,
|
||||
center_lon=129.0,
|
||||
)
|
||||
previous_a = EpisodeState(
|
||||
episode_id='ep-a',
|
||||
lineage_key='JINSHI',
|
||||
group_key='JINSHI',
|
||||
normalized_parent_name='JINSHI',
|
||||
current_sub_cluster_id=1,
|
||||
member_mmsis=['a', 'b'],
|
||||
member_count=2,
|
||||
center_lat=35.0,
|
||||
center_lon=129.0,
|
||||
last_snapshot_time=now - timedelta(minutes=5),
|
||||
status='ACTIVE',
|
||||
)
|
||||
previous_b = EpisodeState(
|
||||
episode_id='ep-b',
|
||||
lineage_key='JINSHI',
|
||||
group_key='JINSHI',
|
||||
normalized_parent_name='JINSHI',
|
||||
current_sub_cluster_id=2,
|
||||
member_mmsis=['c', 'd'],
|
||||
member_count=2,
|
||||
center_lat=35.01,
|
||||
center_lon=129.01,
|
||||
last_snapshot_time=now - timedelta(minutes=5),
|
||||
status='ACTIVE',
|
||||
)
|
||||
plan = build_episode_plan([current], {'JINSHI': [previous_a, previous_b]})
|
||||
assignment = plan.assignments[current.key]
|
||||
self.assertEqual(assignment.continuity_source, 'MERGE_NEW')
|
||||
self.assertEqual(set(assignment.merged_from_episode_ids), {'ep-a', 'ep-b'})
|
||||
self.assertEqual(plan.merged_episode_targets['ep-a'], assignment.episode_id)
|
||||
self.assertEqual(plan.merged_episode_targets['ep-b'], assignment.episode_id)
|
||||
|
||||
def test_build_episode_plan_marks_split_continue_and_split_new(self):
|
||||
now = datetime.now(timezone.utc)
|
||||
previous = EpisodeState(
|
||||
episode_id='ep-prev',
|
||||
lineage_key='A01859',
|
||||
group_key='A01859',
|
||||
normalized_parent_name='A01859',
|
||||
current_sub_cluster_id=0,
|
||||
member_mmsis=['a', 'b', 'c', 'd'],
|
||||
member_count=4,
|
||||
center_lat=35.0,
|
||||
center_lon=129.0,
|
||||
last_snapshot_time=now - timedelta(minutes=5),
|
||||
status='ACTIVE',
|
||||
)
|
||||
current_a = GroupEpisodeInput(
|
||||
group_key='A01859',
|
||||
normalized_parent_name='A01859',
|
||||
sub_cluster_id=1,
|
||||
member_mmsis=['a', 'b', 'c'],
|
||||
member_count=3,
|
||||
center_lat=35.0,
|
||||
center_lon=129.0,
|
||||
)
|
||||
current_b = GroupEpisodeInput(
|
||||
group_key='A01859',
|
||||
normalized_parent_name='A01859',
|
||||
sub_cluster_id=2,
|
||||
member_mmsis=['c', 'd'],
|
||||
member_count=2,
|
||||
center_lat=35.02,
|
||||
center_lon=129.02,
|
||||
)
|
||||
plan = build_episode_plan([current_a, current_b], {'A01859': [previous]})
|
||||
sources = {plan.assignments[current_a.key].continuity_source, plan.assignments[current_b.key].continuity_source}
|
||||
self.assertIn('SPLIT_CONTINUE', sources)
|
||||
self.assertIn('SPLIT_NEW', sources)
|
||||
|
||||
def test_compute_prior_bonus_components_caps_total_bonus(self):
|
||||
observed_at = datetime.now(timezone.utc)
|
||||
bonuses = compute_prior_bonus_components(
|
||||
observed_at=observed_at,
|
||||
normalized_parent_name='JINSHI',
|
||||
episode_id='ep-1',
|
||||
candidate_mmsi='412333326',
|
||||
episode_prior_stats={
|
||||
('ep-1', '412333326'): {
|
||||
'seen_count': 12,
|
||||
'top1_count': 5,
|
||||
'avg_score': 0.88,
|
||||
'last_seen_at': observed_at - timedelta(hours=1),
|
||||
},
|
||||
},
|
||||
lineage_prior_stats={
|
||||
('JINSHI', '412333326'): {
|
||||
'seen_count': 24,
|
||||
'top1_count': 6,
|
||||
'top3_count': 10,
|
||||
'avg_score': 0.82,
|
||||
'last_seen_at': observed_at - timedelta(hours=3),
|
||||
},
|
||||
},
|
||||
label_prior_stats={
|
||||
('JINSHI', '412333326'): {
|
||||
'session_count': 4,
|
||||
'last_labeled_at': observed_at - timedelta(days=1),
|
||||
},
|
||||
},
|
||||
)
|
||||
self.assertGreater(bonuses['episodePriorBonus'], 0.0)
|
||||
self.assertGreater(bonuses['lineagePriorBonus'], 0.0)
|
||||
self.assertGreater(bonuses['labelPriorBonus'], 0.0)
|
||||
self.assertLessEqual(bonuses['priorBonusTotal'], 0.20)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
279
prediction/tests/test_gear_parent_inference.py
Normal file
279
prediction/tests/test_gear_parent_inference.py
Normal file
@ -0,0 +1,279 @@
|
||||
import unittest
|
||||
import sys
|
||||
import types
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
stub = types.ModuleType('pydantic_settings')
|
||||
|
||||
|
||||
class BaseSettings:
|
||||
def __init__(self, **kwargs):
|
||||
for name, value in self.__class__.__dict__.items():
|
||||
if name.isupper():
|
||||
setattr(self, name, kwargs.get(name, value))
|
||||
|
||||
|
||||
stub.BaseSettings = BaseSettings
|
||||
sys.modules.setdefault('pydantic_settings', stub)
|
||||
|
||||
from algorithms.gear_parent_inference import (
|
||||
RegistryVessel,
|
||||
CandidateScore,
|
||||
_AUTO_PROMOTED_STATUS,
|
||||
_apply_final_score_bonus,
|
||||
_build_track_coverage_metrics,
|
||||
_build_candidate_scores,
|
||||
_china_mmsi_prefix_bonus,
|
||||
_direct_parent_member,
|
||||
_direct_parent_stable_cycles,
|
||||
_label_tracking_row,
|
||||
_NO_CANDIDATE_STATUS,
|
||||
_REVIEW_REQUIRED_STATUS,
|
||||
_UNRESOLVED_STATUS,
|
||||
_name_match_score,
|
||||
_select_status,
|
||||
_top_candidate_stable_cycles,
|
||||
is_trackable_parent_name,
|
||||
normalize_parent_name,
|
||||
)
|
||||
|
||||
|
||||
class GearParentInferenceRuleTest(unittest.TestCase):
|
||||
def _candidate(self, *, mmsi='123456789', score=0.8, sources=None):
|
||||
return CandidateScore(
|
||||
mmsi=mmsi,
|
||||
name='TEST',
|
||||
vessel_id=1,
|
||||
target_type='VESSEL',
|
||||
candidate_source=','.join(sources or ['CORRELATION']),
|
||||
base_corr_score=0.7,
|
||||
name_match_score=0.1,
|
||||
track_similarity_score=0.8,
|
||||
visit_score_6h=0.4,
|
||||
proximity_score_6h=0.3,
|
||||
activity_sync_score_6h=0.2,
|
||||
stability_score=0.9,
|
||||
registry_bonus=0.05,
|
||||
episode_prior_bonus=0.0,
|
||||
lineage_prior_bonus=0.0,
|
||||
label_prior_bonus=0.0,
|
||||
final_score=score,
|
||||
streak_count=6,
|
||||
model_id=1,
|
||||
model_name='default',
|
||||
evidence={'sources': sources or ['CORRELATION']},
|
||||
)
|
||||
|
||||
def test_normalize_parent_name_removes_space_symbols(self):
|
||||
self.assertEqual(normalize_parent_name(' A_B-C% 12 '), 'ABC12')
|
||||
|
||||
def test_trackable_parent_name_requires_length_four_after_normalize(self):
|
||||
self.assertFalse(is_trackable_parent_name('A-1%'))
|
||||
self.assertFalse(is_trackable_parent_name('ZSY'))
|
||||
self.assertFalse(is_trackable_parent_name('991'))
|
||||
self.assertTrue(is_trackable_parent_name(' AB_12 '))
|
||||
|
||||
def test_name_match_score_prefers_raw_exact(self):
|
||||
self.assertEqual(_name_match_score('LUWENYU 53265', 'LUWENYU 53265', None), 1.0)
|
||||
|
||||
def test_name_match_score_supports_compact_exact_and_prefix(self):
|
||||
registry = RegistryVessel(
|
||||
vessel_id=1,
|
||||
mmsi='412327765',
|
||||
name_cn='LUWENYU53265',
|
||||
name_en='LUWENYU 53265',
|
||||
)
|
||||
self.assertEqual(_name_match_score('LUWENYU 53265', 'LUWENYU53265', None), 0.8)
|
||||
self.assertEqual(_name_match_score('LUWENYU 532', 'LUWENYU53265', None), 0.5)
|
||||
self.assertEqual(_name_match_score('LUWENYU 53265', 'DIFFERENT', registry), 1.0)
|
||||
self.assertEqual(_name_match_score('ZHEDAIYU02433', 'ZHEDAIYU06178', None), 0.3)
|
||||
|
||||
def test_name_match_score_does_not_use_candidate_registry_self_match(self):
|
||||
registry = RegistryVessel(
|
||||
vessel_id=1,
|
||||
mmsi='412413545',
|
||||
name_cn='ZHEXIANGYU55005',
|
||||
name_en='ZHEXIANGYU55005',
|
||||
)
|
||||
self.assertEqual(_name_match_score('JINSHI', 'ZHEXIANGYU55005', registry), 0.0)
|
||||
|
||||
def test_direct_parent_member_prefers_parent_member_then_parent_mmsi(self):
|
||||
all_positions = {'412420673': {'name': 'ZHEDAIYU02433'}}
|
||||
from_members = _direct_parent_member(
|
||||
{
|
||||
'parent_name': 'ZHEDAIYU02433',
|
||||
'members': [
|
||||
{'mmsi': '412420673', 'name': 'ZHEDAIYU02433', 'isParent': True},
|
||||
{'mmsi': '24330082', 'name': 'ZHEDAIYU02433_82_99_', 'isParent': False},
|
||||
],
|
||||
},
|
||||
all_positions,
|
||||
)
|
||||
self.assertEqual(from_members['mmsi'], '412420673')
|
||||
|
||||
from_parent_mmsi = _direct_parent_member(
|
||||
{
|
||||
'parent_name': 'ZHEDAIYU02433',
|
||||
'parent_mmsi': '412420673',
|
||||
'members': [],
|
||||
},
|
||||
all_positions,
|
||||
)
|
||||
self.assertEqual(from_parent_mmsi['mmsi'], '412420673')
|
||||
self.assertEqual(from_parent_mmsi['name'], 'ZHEDAIYU02433')
|
||||
|
||||
def test_direct_parent_stable_cycles_reuses_same_parent(self):
|
||||
existing = {
|
||||
'selected_parent_mmsi': '412420673',
|
||||
'stable_cycles': 4,
|
||||
'evidence_summary': {'directParentMmsi': '412420673'},
|
||||
}
|
||||
self.assertEqual(_direct_parent_stable_cycles(existing, '412420673'), 5)
|
||||
self.assertEqual(_direct_parent_stable_cycles(existing, '412000000'), 1)
|
||||
|
||||
def test_china_prefix_bonus_requires_threshold(self):
|
||||
self.assertEqual(_china_mmsi_prefix_bonus('412327765', 0.30), 0.15)
|
||||
self.assertEqual(_china_mmsi_prefix_bonus('413987654', 0.65), 0.15)
|
||||
self.assertEqual(_china_mmsi_prefix_bonus('412327765', 0.29), 0.0)
|
||||
self.assertEqual(_china_mmsi_prefix_bonus('440123456', 0.75), 0.0)
|
||||
|
||||
def test_apply_final_score_bonus_adds_bonus_after_weighted_score(self):
|
||||
pre_bonus_score, china_bonus, final_score = _apply_final_score_bonus('412333326', 0.66)
|
||||
self.assertIsInstance(pre_bonus_score, float)
|
||||
self.assertIsInstance(china_bonus, float)
|
||||
self.assertIsInstance(final_score, float)
|
||||
self.assertEqual(pre_bonus_score, 0.66)
|
||||
self.assertEqual(china_bonus, 0.15)
|
||||
self.assertEqual(final_score, 0.81)
|
||||
|
||||
def test_top_candidate_stable_cycles_resets_on_candidate_change(self):
|
||||
existing = {
|
||||
'stable_cycles': 5,
|
||||
'evidence_summary': {'topCandidateMmsi': '111111111'},
|
||||
}
|
||||
self.assertEqual(_top_candidate_stable_cycles(existing, self._candidate(mmsi='111111111')), 6)
|
||||
self.assertEqual(_top_candidate_stable_cycles(existing, self._candidate(mmsi='222222222')), 1)
|
||||
|
||||
def test_select_status_requires_recent_stability_and_correlation_for_auto(self):
|
||||
self.assertEqual(
|
||||
_select_status(self._candidate(score=0.8, sources=['CORRELATION']), margin=0.2, stable_cycles=3),
|
||||
(_AUTO_PROMOTED_STATUS, 'AUTO_PROMOTION'),
|
||||
)
|
||||
self.assertEqual(
|
||||
_select_status(self._candidate(score=0.8, sources=['PREVIOUS_SELECTION']), margin=0.2, stable_cycles=3),
|
||||
(_REVIEW_REQUIRED_STATUS, 'AUTO_REVIEW'),
|
||||
)
|
||||
self.assertEqual(
|
||||
_select_status(self._candidate(score=0.8, sources=['CORRELATION']), margin=0.2, stable_cycles=2),
|
||||
(_REVIEW_REQUIRED_STATUS, 'AUTO_REVIEW'),
|
||||
)
|
||||
|
||||
def test_select_status_marks_candidate_gaps_explicitly(self):
|
||||
self.assertEqual(_select_status(None, margin=0.0, stable_cycles=0), (_NO_CANDIDATE_STATUS, 'AUTO_NO_CANDIDATE'))
|
||||
self.assertEqual(
|
||||
_select_status(self._candidate(score=0.45, sources=['CORRELATION']), margin=0.1, stable_cycles=1),
|
||||
(_UNRESOLVED_STATUS, 'AUTO_SCORE'),
|
||||
)
|
||||
|
||||
def test_build_candidate_scores_applies_active_exclusions_before_scoring(self):
|
||||
class FakeStore:
|
||||
_tracks = {}
|
||||
|
||||
candidates = _build_candidate_scores(
|
||||
vessel_store=FakeStore(),
|
||||
observed_at=datetime(2026, 4, 3, 0, 0, tzinfo=timezone.utc),
|
||||
group={'parent_name': 'AB1234', 'sub_cluster_id': 1},
|
||||
episode_assignment=types.SimpleNamespace(
|
||||
episode_id='ep-test',
|
||||
continuity_source='NEW',
|
||||
continuity_score=0.0,
|
||||
),
|
||||
default_model_id=1,
|
||||
default_model_name='default',
|
||||
score_rows=[
|
||||
{
|
||||
'target_mmsi': '412111111',
|
||||
'target_type': 'VESSEL',
|
||||
'target_name': 'AB1234',
|
||||
'current_score': 0.8,
|
||||
'streak_count': 4,
|
||||
},
|
||||
{
|
||||
'target_mmsi': '440222222',
|
||||
'target_type': 'VESSEL',
|
||||
'target_name': 'AB1234',
|
||||
'current_score': 0.7,
|
||||
'streak_count': 3,
|
||||
},
|
||||
],
|
||||
raw_metrics={},
|
||||
center_track=[],
|
||||
all_positions={},
|
||||
registry_by_mmsi={},
|
||||
registry_by_name={},
|
||||
existing=None,
|
||||
excluded_candidate_mmsis={'412111111'},
|
||||
episode_prior_stats={},
|
||||
lineage_prior_stats={},
|
||||
label_prior_stats={},
|
||||
)
|
||||
self.assertEqual([candidate.mmsi for candidate in candidates], ['440222222'])
|
||||
|
||||
def test_track_coverage_metrics_penalize_short_track_support(self):
|
||||
now = datetime(2026, 4, 3, 0, 0, tzinfo=timezone.utc)
|
||||
center_track = [
|
||||
{'timestamp': now - timedelta(hours=5), 'lat': 35.0, 'lon': 129.0},
|
||||
{'timestamp': now - timedelta(hours=1), 'lat': 35.1, 'lon': 129.1},
|
||||
]
|
||||
short_track = [
|
||||
{'timestamp': now - timedelta(minutes=10), 'lat': 35.1, 'lon': 129.1, 'sog': 0.5},
|
||||
]
|
||||
long_track = [
|
||||
{'timestamp': now - timedelta(minutes=90) + timedelta(minutes=10 * idx), 'lat': 35.0, 'lon': 129.0 + (0.01 * idx), 'sog': 0.5}
|
||||
for idx in range(10)
|
||||
]
|
||||
|
||||
short_metrics = _build_track_coverage_metrics(center_track, short_track, 35.05, 129.05)
|
||||
long_metrics = _build_track_coverage_metrics(center_track, long_track, 35.05, 129.05)
|
||||
|
||||
self.assertEqual(short_metrics['trackPointCount'], 1)
|
||||
self.assertEqual(short_metrics['trackCoverageFactor'], 0.0)
|
||||
self.assertGreater(long_metrics['trackCoverageFactor'], 0.0)
|
||||
self.assertGreater(long_metrics['coverageFactor'], short_metrics['coverageFactor'])
|
||||
|
||||
def test_label_tracking_row_tracks_rank_and_match_flags(self):
|
||||
top_candidate = self._candidate(mmsi='412333326', score=0.81, sources=['CORRELATION'])
|
||||
top_candidate.evidence = {
|
||||
'sources': ['CORRELATION'],
|
||||
'scoreBreakdown': {'preBonusScore': 0.66},
|
||||
}
|
||||
labeled_candidate = self._candidate(mmsi='440123456', score=0.62, sources=['CORRELATION'])
|
||||
labeled_candidate.evidence = {
|
||||
'sources': ['CORRELATION'],
|
||||
'scoreBreakdown': {'preBonusScore': 0.62},
|
||||
}
|
||||
|
||||
row = _label_tracking_row(
|
||||
observed_at='2026-04-03T00:00:00Z',
|
||||
label_session={
|
||||
'id': 10,
|
||||
'label_parent_mmsi': '440123456',
|
||||
'label_parent_name': 'TARGET',
|
||||
},
|
||||
auto_status='REVIEW_REQUIRED',
|
||||
top_candidate=top_candidate,
|
||||
margin=0.19,
|
||||
candidates=[top_candidate, labeled_candidate],
|
||||
)
|
||||
self.assertEqual(row[0], 10)
|
||||
self.assertEqual(row[8], 2)
|
||||
self.assertTrue(row[9])
|
||||
self.assertEqual(row[10], 2)
|
||||
self.assertEqual(row[11], 0.62)
|
||||
self.assertEqual(row[12], 0.62)
|
||||
self.assertFalse(row[14])
|
||||
self.assertTrue(row[15])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
90
prediction/tests/test_time_bucket.py
Normal file
90
prediction/tests/test_time_bucket.py
Normal file
@ -0,0 +1,90 @@
|
||||
import unittest
|
||||
import sys
|
||||
import types
|
||||
from datetime import datetime, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import pandas as pd
|
||||
|
||||
stub = types.ModuleType('pydantic_settings')
|
||||
|
||||
|
||||
class BaseSettings:
|
||||
def __init__(self, **kwargs):
|
||||
for name, value in self.__class__.__dict__.items():
|
||||
if name.isupper():
|
||||
setattr(self, name, kwargs.get(name, value))
|
||||
|
||||
|
||||
stub.BaseSettings = BaseSettings
|
||||
sys.modules.setdefault('pydantic_settings', stub)
|
||||
|
||||
from cache.vessel_store import VesselStore
|
||||
from time_bucket import compute_incremental_window_start, compute_initial_window_start, compute_safe_bucket
|
||||
|
||||
|
||||
class TimeBucketRuleTest(unittest.TestCase):
|
||||
def test_safe_bucket_uses_delay_then_floors_to_5m(self):
|
||||
now = datetime(2026, 4, 2, 15, 14, 0, tzinfo=ZoneInfo('Asia/Seoul'))
|
||||
self.assertEqual(compute_safe_bucket(now), datetime(2026, 4, 2, 15, 0, 0))
|
||||
|
||||
def test_incremental_window_includes_overlap_buckets(self):
|
||||
last_bucket = datetime(2026, 4, 2, 15, 0, 0)
|
||||
self.assertEqual(compute_incremental_window_start(last_bucket), datetime(2026, 4, 2, 14, 45, 0))
|
||||
|
||||
def test_initial_window_start_anchors_to_safe_bucket(self):
|
||||
safe_bucket = datetime(2026, 4, 2, 15, 0, 0)
|
||||
self.assertEqual(compute_initial_window_start(24, safe_bucket), datetime(2026, 4, 1, 15, 0, 0))
|
||||
|
||||
def test_merge_incremental_prefers_newer_overlap_rows(self):
|
||||
store = VesselStore()
|
||||
store._tracks = {
|
||||
'412000001': pd.DataFrame([
|
||||
{
|
||||
'mmsi': '412000001',
|
||||
'timestamp': pd.Timestamp('2026-04-02T00:01:00Z'),
|
||||
'time_bucket': datetime(2026, 4, 2, 9, 0, 0),
|
||||
'lat': 30.0,
|
||||
'lon': 120.0,
|
||||
'raw_sog': 1.0,
|
||||
},
|
||||
{
|
||||
'mmsi': '412000001',
|
||||
'timestamp': pd.Timestamp('2026-04-02T00:02:00Z'),
|
||||
'time_bucket': datetime(2026, 4, 2, 9, 0, 0),
|
||||
'lat': 30.1,
|
||||
'lon': 120.1,
|
||||
'raw_sog': 1.0,
|
||||
},
|
||||
])
|
||||
}
|
||||
df_new = pd.DataFrame([
|
||||
{
|
||||
'mmsi': '412000001',
|
||||
'timestamp': pd.Timestamp('2026-04-02T00:02:00Z'),
|
||||
'time_bucket': datetime(2026, 4, 2, 9, 0, 0),
|
||||
'lat': 30.2,
|
||||
'lon': 120.2,
|
||||
'raw_sog': 2.0,
|
||||
},
|
||||
{
|
||||
'mmsi': '412000001',
|
||||
'timestamp': pd.Timestamp('2026-04-02T00:03:00Z'),
|
||||
'time_bucket': datetime(2026, 4, 2, 9, 5, 0),
|
||||
'lat': 30.3,
|
||||
'lon': 120.3,
|
||||
'raw_sog': 2.0,
|
||||
},
|
||||
])
|
||||
|
||||
store.merge_incremental(df_new)
|
||||
|
||||
merged = store._tracks['412000001']
|
||||
self.assertEqual(len(merged), 3)
|
||||
replacement = merged.loc[merged['timestamp'] == pd.Timestamp('2026-04-02T00:02:00Z')].iloc[0]
|
||||
self.assertEqual(float(replacement['lat']), 30.2)
|
||||
self.assertEqual(float(replacement['lon']), 120.2)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
42
prediction/time_bucket.py
Normal file
42
prediction/time_bucket.py
Normal file
@ -0,0 +1,42 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from config import settings
|
||||
|
||||
_KST = ZoneInfo('Asia/Seoul')
|
||||
_BUCKET_MINUTES = 5
|
||||
|
||||
|
||||
def normalize_bucket_kst(bucket: datetime) -> datetime:
|
||||
if bucket.tzinfo is None:
|
||||
return bucket
|
||||
return bucket.astimezone(_KST).replace(tzinfo=None)
|
||||
|
||||
|
||||
def floor_bucket_kst(value: datetime, bucket_minutes: int = _BUCKET_MINUTES) -> datetime:
|
||||
if value.tzinfo is None:
|
||||
localized = value.replace(tzinfo=_KST)
|
||||
else:
|
||||
localized = value.astimezone(_KST)
|
||||
floored_minute = (localized.minute // bucket_minutes) * bucket_minutes
|
||||
return localized.replace(minute=floored_minute, second=0, microsecond=0)
|
||||
|
||||
|
||||
def compute_safe_bucket(now: datetime | None = None) -> datetime:
|
||||
current = now or datetime.now(timezone.utc)
|
||||
if current.tzinfo is None:
|
||||
current = current.replace(tzinfo=timezone.utc)
|
||||
safe_point = current.astimezone(_KST) - timedelta(minutes=settings.SNPDB_SAFE_DELAY_MIN)
|
||||
return floor_bucket_kst(safe_point).replace(tzinfo=None)
|
||||
|
||||
|
||||
def compute_initial_window_start(hours: int, safe_bucket: datetime | None = None) -> datetime:
|
||||
anchor = normalize_bucket_kst(safe_bucket or compute_safe_bucket())
|
||||
return anchor - timedelta(hours=hours)
|
||||
|
||||
|
||||
def compute_incremental_window_start(last_bucket: datetime) -> datetime:
|
||||
normalized = normalize_bucket_kst(last_bucket)
|
||||
return normalized - timedelta(minutes=settings.SNPDB_BACKFILL_BUCKETS * _BUCKET_MINUTES)
|
||||
불러오는 중...
Reference in New Issue
Block a user